399 行
16 KiB
TypeScript
399 行
16 KiB
TypeScript
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
|
||
import { useEffect } from "react";
|
||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { toast } from "sonner";
|
||
import { fetchNotifications, markNotificationRead, type NotificationItem } from "../api/notifications";
|
||
import { fetchPortalHome } from "../api/portal";
|
||
import { fetchWorkflowTasks, fetchWorkflowTaskSummary } from "../api/workflow";
|
||
import { useAuth } from "../auth/AuthContext";
|
||
import { hasAnyPermission, hasBusinessApproverRole, hasFinanceRole } from "../auth/permissions";
|
||
import { businessEntityLabel, compactBusinessReference, joinBusinessMeta } from "../lib/businessDisplay";
|
||
import { resolveMobileNotificationTargetLink } from "../lib/notificationLinks";
|
||
import { PageHeader } from "../components/common/PageHeader";
|
||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||
import { MobileIcon } from "../components/mobile/MobileIcon";
|
||
import { Button } from "../components/ui/button";
|
||
import {
|
||
MOBILE_CAPTURE_PERMISSIONS,
|
||
MOBILE_ASSET_SCAN_PERMISSIONS,
|
||
MOBILE_HOME_ASSET_SCAN_PERMISSIONS,
|
||
MOBILE_LEAVE_PERMISSIONS,
|
||
MOBILE_PURCHASE_PERMISSIONS,
|
||
MOBILE_REIMBURSEMENT_PERMISSIONS,
|
||
} from "../features/mobile/navigation";
|
||
|
||
type MobileAction = {
|
||
key: string;
|
||
label: string;
|
||
meta: string;
|
||
icon: string;
|
||
to: string;
|
||
tone?: "primary" | "warning";
|
||
};
|
||
|
||
function resolveErrorMessage(error: unknown) {
|
||
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
|
||
}
|
||
|
||
function formatDateTime(value: string | null) {
|
||
if (!value) {
|
||
return "-";
|
||
}
|
||
try {
|
||
return new Date(value).toLocaleString("zh-CN", {
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
hour12: false,
|
||
});
|
||
} catch {
|
||
return value;
|
||
}
|
||
}
|
||
|
||
function notificationTypeLabel(type: string) {
|
||
if (type.startsWith("workflow.")) return "审批事件";
|
||
if (type.startsWith("reimbursement.")) return "报销事件";
|
||
if (type.startsWith("leave.")) return "请假事件";
|
||
if (type.startsWith("purchase_request.")) return "采购事件";
|
||
if (type.startsWith("travel_request.")) return "差旅事件";
|
||
if (type.startsWith("asset.")) return "资产事件";
|
||
return "系统事件";
|
||
}
|
||
|
||
function ActionTile({ action }: { action: MobileAction }) {
|
||
const isPrimary = action.tone === "primary";
|
||
const isWarning = action.tone === "warning";
|
||
|
||
return (
|
||
<Link
|
||
to={action.to}
|
||
className={
|
||
isPrimary
|
||
? "mobile-icon-action mobile-icon-action--primary"
|
||
: isWarning
|
||
? "mobile-icon-action mobile-icon-action--warning"
|
||
: "mobile-icon-action"
|
||
}
|
||
data-testid={`mobile-action-${action.key}`}
|
||
title={`${action.label}:${action.meta}`}
|
||
aria-label={`${action.label},${action.meta}`}
|
||
>
|
||
<MobileIcon
|
||
className="mobile-icon-action__icon"
|
||
data-testid={`mobile-action-${action.key}-icon`}
|
||
name={action.icon}
|
||
/>
|
||
<span className="mobile-icon-action__label" data-testid={`mobile-action-${action.key}-label`}>
|
||
{action.label}
|
||
</span>
|
||
<span className="sr-only">{action.meta}</span>
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
export function MobilePortalPage() {
|
||
const queryClient = useQueryClient();
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
const { me, permissions } = useAuth();
|
||
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
|
||
const canUseFinance = hasFinanceRole(me?.roleCodes, permissions);
|
||
const canAccessCapture = hasAnyPermission(permissions, [...MOBILE_CAPTURE_PERMISSIONS]);
|
||
const canAccessAssetScan = hasAnyPermission(permissions, [...MOBILE_ASSET_SCAN_PERMISSIONS]);
|
||
const canShowAssetScanOnHome = hasAnyPermission(permissions, [...MOBILE_HOME_ASSET_SCAN_PERMISSIONS]);
|
||
const canAccessLeave = hasAnyPermission(permissions, [...MOBILE_LEAVE_PERMISSIONS]);
|
||
const canAccessPurchase = hasAnyPermission(permissions, [...MOBILE_PURCHASE_PERMISSIONS]);
|
||
const canAccessReimbursements = hasAnyPermission(permissions, [...MOBILE_REIMBURSEMENT_PERMISSIONS]);
|
||
const canCreateExpense = hasAnyPermission(permissions, ["expense.create"]);
|
||
const currentMobileSource = `${location.pathname}${location.search}`;
|
||
|
||
const pendingCountQuery = useQuery({
|
||
enabled: canAccessApprovals,
|
||
queryKey: ["mobile", "workflow", "pending-items"],
|
||
queryFn: () => fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 3 }),
|
||
});
|
||
|
||
const workflowSummaryQuery = useQuery({
|
||
enabled: canAccessApprovals,
|
||
queryKey: ["mobile", "workflow", "summary"],
|
||
queryFn: () => fetchWorkflowTaskSummary(),
|
||
});
|
||
|
||
const notificationsQuery = useQuery({
|
||
queryKey: ["mobile", "portal", "notifications"],
|
||
queryFn: () => fetchNotifications({ page: 1, pageSize: 6 }),
|
||
});
|
||
|
||
const portalHomeQuery = useQuery({
|
||
enabled: Boolean(me?.id),
|
||
queryKey: ["mobile", "portal", "home", me?.id],
|
||
queryFn: ({ signal }) => fetchPortalHome(signal),
|
||
staleTime: 0,
|
||
refetchOnMount: true,
|
||
refetchOnWindowFocus: true,
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (location.hash !== "#finance" || !canUseFinance) {
|
||
return;
|
||
}
|
||
|
||
const frameId = window.requestAnimationFrame(() => {
|
||
const financeSection = document.getElementById("finance");
|
||
financeSection?.scrollIntoView({ block: "start", behavior: "smooth" });
|
||
financeSection?.focus({ preventScroll: true });
|
||
});
|
||
|
||
return () => window.cancelAnimationFrame(frameId);
|
||
}, [canUseFinance, location.hash, portalHomeQuery.isError, portalHomeQuery.isLoading]);
|
||
|
||
const markHomeNotificationReadMutation = useMutation({
|
||
mutationFn: (notificationId: string) => markNotificationRead(notificationId),
|
||
onSuccess: async () => {
|
||
await Promise.all([
|
||
queryClient.invalidateQueries({ queryKey: ["mobile", "portal", "notifications"] }),
|
||
queryClient.invalidateQueries({ queryKey: ["notifications"] }),
|
||
]);
|
||
},
|
||
});
|
||
|
||
const pendingApprovalCount = canAccessApprovals
|
||
? workflowSummaryQuery.data?.pendingTotal ?? pendingCountQuery.data?.total ?? 0
|
||
: 0;
|
||
const pendingTasks = pendingCountQuery.data?.items ?? [];
|
||
const urgentPendingTask = pendingTasks[0] ?? null;
|
||
const notifications = notificationsQuery.data?.items ?? [];
|
||
const homeNotifications = notifications
|
||
.filter((item) => !(canAccessApprovals && item.type.startsWith("workflow.task_")))
|
||
.slice(0, 3);
|
||
const financeSummary = portalHomeQuery.data?.financeSummary ?? null;
|
||
const businessHandoffs = portalHomeQuery.data?.businessHandoffs;
|
||
const paymentBatchEnabled = Boolean(financeSummary?.paymentBatchEnabled);
|
||
const financePendingCount = financeSummary?.pendingBatchCount ?? 0;
|
||
const financeExceptionCount = (businessHandoffs ? 0 : financeSummary?.failedItemCount ?? 0) + (financeSummary?.riskCount ?? 0);
|
||
const shouldShowFinanceCard = canUseFinance &&
|
||
!portalHomeQuery.isLoading &&
|
||
!portalHomeQuery.isError &&
|
||
(businessHandoffs ? financeExceptionCount > 0 : paymentBatchEnabled || financePendingCount > 0 || financeExceptionCount > 0);
|
||
const financeCardBadge = paymentBatchEnabled ? "复核与付款" : financePendingCount > 0 ? "待付款" : "异常与复核";
|
||
|
||
async function openHomeNotification(item: NotificationItem) {
|
||
const targetLink = resolveMobileNotificationTargetLink(item);
|
||
if (!targetLink) {
|
||
navigate("/mobile/notifications", { state: { from: currentMobileSource } });
|
||
return;
|
||
}
|
||
|
||
try {
|
||
if (!item.readAt) {
|
||
await markHomeNotificationReadMutation.mutateAsync(item.id);
|
||
}
|
||
navigate(targetLink, { state: { from: currentMobileSource } });
|
||
} catch (error) {
|
||
toast.error(resolveErrorMessage(error));
|
||
}
|
||
}
|
||
|
||
const actions = [
|
||
canCreateExpense && canAccessReimbursements
|
||
? {
|
||
key: "new-reimbursement",
|
||
label: "新建报销",
|
||
meta: "填写明细",
|
||
icon: "request_quote",
|
||
to: "/mobile/reimbursements?intent=create",
|
||
tone: "primary",
|
||
}
|
||
: null,
|
||
canAccessCapture
|
||
? {
|
||
key: "capture",
|
||
label: "上传票据",
|
||
meta: "上传 PDF",
|
||
icon: "receipt_long",
|
||
to: "/mobile/capture",
|
||
}
|
||
: null,
|
||
canAccessLeave
|
||
? {
|
||
key: "leave",
|
||
label: "请假记录",
|
||
meta: "企微主办",
|
||
icon: "event_note",
|
||
to: "/mobile/leave",
|
||
}
|
||
: null,
|
||
canAccessPurchase
|
||
? {
|
||
key: "purchase",
|
||
label: "采购",
|
||
meta: "查看申请",
|
||
icon: "shopping_cart",
|
||
to: "/mobile/purchase-requests",
|
||
}
|
||
: null,
|
||
canAccessAssetScan && canShowAssetScanOnHome
|
||
? {
|
||
key: "asset-scan",
|
||
label: "资产扫码",
|
||
meta: "扫码盘点",
|
||
icon: "qr_code_scanner",
|
||
to: "/mobile/assets/scan",
|
||
}
|
||
: null,
|
||
].filter((item): item is MobileAction => Boolean(item)).slice(0, 4);
|
||
|
||
return (
|
||
<PageScaffold className="mobile-page-stack">
|
||
<PageHeader
|
||
title="今日待处理"
|
||
meta={<span className="badge-neutral">移动工作台</span>}
|
||
/>
|
||
|
||
{canAccessApprovals && pendingApprovalCount > 0 ? (
|
||
<section className="mobile-card mobile-focus-card mobile-page-stack" data-testid="mobile-pending-summary">
|
||
<div className="mobile-section-header mobile-section-header--top">
|
||
<div className="min-w-0">
|
||
<div className="mobile-section-title">待我审批</div>
|
||
<div className="mobile-list-card__subtitle mt-1">今日待处理</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="text-3xl font-semibold leading-none text-zinc-900" data-testid="mobile-pending-summary-count">
|
||
{pendingApprovalCount}
|
||
</div>
|
||
{workflowSummaryQuery.data?.overdueTotal ? (
|
||
<div className="mt-1 text-xs font-medium text-amber-700">超时 {workflowSummaryQuery.data.overdueTotal}</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
{urgentPendingTask ? (
|
||
<div className="mobile-page-stack">
|
||
{(() => {
|
||
const task = urgentPendingTask;
|
||
const entityType = task.workflowInstance?.entityType;
|
||
const reference = compactBusinessReference(task.businessSummary?.reference ?? task.workflowInstance?.entityId);
|
||
return (
|
||
<Link
|
||
key={task.id}
|
||
to={`/mobile/approvals/${encodeURIComponent(task.id)}`}
|
||
state={{ from: currentMobileSource }}
|
||
className="mobile-list-card block text-sm"
|
||
data-testid="mobile-pending-summary-primary"
|
||
>
|
||
<div className="mobile-list-card__header">
|
||
<div className="mobile-list-card__title">
|
||
{reference ? `${businessEntityLabel(entityType, { noun: true })} ${reference}` : businessEntityLabel(entityType, { noun: true })}
|
||
</div>
|
||
</div>
|
||
<div className="mobile-list-card__subtitle mt-1">
|
||
{joinBusinessMeta([task.businessSummary?.applicantName ? `申请人:${task.businessSummary.applicantName}` : null, task.businessSummary?.amount ?? task.businessSummary?.duration, `节点:${task.stepName}`])}
|
||
</div>
|
||
</Link>
|
||
);
|
||
})()}
|
||
</div>
|
||
) : null}
|
||
<Button asChild className="mt-3 w-full" size="sm" type="button" variant="secondary">
|
||
<Link to="/mobile/approvals">查看全部审批</Link>
|
||
</Button>
|
||
</section>
|
||
) : null}
|
||
|
||
{portalHomeQuery.isError ? <section className="mobile-card space-y-3" role="alert"><p>业务待办加载失败,请重试。</p><Button onClick={() => void portalHomeQuery.refetch()} type="button" variant="secondary">重试加载待办</Button></section> : null}
|
||
|
||
{!portalHomeQuery.isError && businessHandoffs && businessHandoffs.total > 0 ? (
|
||
<section className="mobile-card mobile-page-stack" data-testid="mobile-business-handoffs">
|
||
<div className="mobile-section-header"><h2 className="mobile-section-title">业务待办</h2><span>{businessHandoffs.total} 项</span></div>
|
||
{businessHandoffs.items.map((item) => (
|
||
<Link className="mobile-list-card block" key={item.key} to={item.mobileHref}>
|
||
<div className="mobile-list-card__title">{item.title}{item.reference ? ` · ${item.reference}` : ""}</div>
|
||
<p className="mobile-list-card__subtitle mt-1">{item.nextStep}</p>
|
||
<p className="mt-1 text-xs text-zinc-500">{item.ownerLabel} · 查看处理</p>
|
||
</Link>
|
||
))}
|
||
</section>
|
||
) : null}
|
||
|
||
{shouldShowFinanceCard ? (
|
||
<section id="finance" tabIndex={-1} className="mobile-card mobile-page-stack" data-testid="mobile-finance-summary">
|
||
<div className="mobile-section-header">
|
||
<div className="mobile-section-title">财务</div>
|
||
<span className="badge-neutral">{financeCardBadge}</span>
|
||
</div>
|
||
<div className="mobile-metric-grid text-sm">
|
||
{!businessHandoffs ? <div className="rounded-lg bg-zinc-50 px-3 py-2">
|
||
<div className="text-xs text-zinc-500">待付款</div>
|
||
<div className="mt-1 text-xl font-semibold text-zinc-900">{financePendingCount}</div>
|
||
</div> : null}
|
||
<div className="rounded-lg bg-zinc-50 px-3 py-2">
|
||
<div className="text-xs text-zinc-500">异常</div>
|
||
<div className="mt-1 text-xl font-semibold text-zinc-900">{financeExceptionCount}</div>
|
||
</div>
|
||
</div>
|
||
{paymentBatchEnabled ? (
|
||
<Button asChild className="mt-3 w-full" size="sm" type="button" variant="secondary">
|
||
<Link to="/mobile/payment-batches">进入付款</Link>
|
||
</Button>
|
||
) : null}
|
||
</section>
|
||
) : null}
|
||
|
||
{actions.length ? (
|
||
<section className="mobile-card mobile-page-stack" data-testid="mobile-today-focus">
|
||
<div className="mobile-section-header">
|
||
<div className="mobile-section-title">常用动作</div>
|
||
</div>
|
||
<div className="mobile-icon-grid" data-testid="mobile-action-grid">
|
||
{actions.map((action) => (
|
||
<ActionTile key={action.key} action={action} />
|
||
))}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
<section className="mobile-card mobile-page-stack" data-testid="mobile-notifications">
|
||
<div className="mobile-section-header">
|
||
<div className="mobile-section-title">通知</div>
|
||
<Button asChild size="sm" type="button" variant="secondary">
|
||
<Link to="/mobile/notifications">全部</Link>
|
||
</Button>
|
||
</div>
|
||
|
||
{notificationsQuery.isLoading ? <p className="text-sm text-zinc-500">读取中...</p> : null}
|
||
{notificationsQuery.isError ? (
|
||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||
{resolveErrorMessage(notificationsQuery.error)}
|
||
</p>
|
||
) : null}
|
||
{!notificationsQuery.isLoading && !homeNotifications.length ? (
|
||
<p className="text-sm text-zinc-500">暂无通知。</p>
|
||
) : null}
|
||
|
||
<div className="mobile-page-stack">
|
||
{homeNotifications.map((item) => (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
className="mobile-list-card w-full text-left text-sm"
|
||
onClick={() => void openHomeNotification(item)}
|
||
data-testid="mobile-home-notification-card"
|
||
>
|
||
<div className="mobile-list-card__header">
|
||
<div className="min-w-0">
|
||
<div className="text-xs font-medium text-primary">{notificationTypeLabel(item.type)}</div>
|
||
<div className="mobile-list-card__title mt-0.5">{item.title}</div>
|
||
</div>
|
||
{!item.readAt ? <span className="badge-warning mobile-status-slot">新</span> : null}
|
||
</div>
|
||
<div className="mobile-list-card__subtitle mt-1">{formatDateTime(item.createdAt)}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
</PageScaffold>
|
||
);
|
||
}
|