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 ( {action.label} {action.meta} ); } 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 ( 移动工作台} /> {canAccessApprovals && pendingApprovalCount > 0 ? ( 待我审批 今日待处理 {pendingApprovalCount} {workflowSummaryQuery.data?.overdueTotal ? ( 超时 {workflowSummaryQuery.data.overdueTotal} ) : null} {urgentPendingTask ? ( {(() => { const task = urgentPendingTask; const entityType = task.workflowInstance?.entityType; const reference = compactBusinessReference(task.businessSummary?.reference ?? task.workflowInstance?.entityId); return ( {reference ? `${businessEntityLabel(entityType, { noun: true })} ${reference}` : businessEntityLabel(entityType, { noun: true })} {joinBusinessMeta([task.businessSummary?.applicantName ? `申请人:${task.businessSummary.applicantName}` : null, task.businessSummary?.amount ?? task.businessSummary?.duration, `节点:${task.stepName}`])} ); })()} ) : null} 查看全部审批 ) : null} {portalHomeQuery.isError ? 业务待办加载失败,请重试。 void portalHomeQuery.refetch()} type="button" variant="secondary">重试加载待办 : null} {!portalHomeQuery.isError && businessHandoffs && businessHandoffs.total > 0 ? ( 业务待办{businessHandoffs.total} 项 {businessHandoffs.items.map((item) => ( {item.title}{item.reference ? ` · ${item.reference}` : ""} {item.nextStep} {item.ownerLabel} · 查看处理 ))} ) : null} {shouldShowFinanceCard ? ( 财务 {financeCardBadge} {!businessHandoffs ? 待付款 {financePendingCount} : null} 异常 {financeExceptionCount} {paymentBatchEnabled ? ( 进入付款 ) : null} ) : null} {actions.length ? ( 常用动作 {actions.map((action) => ( ))} ) : null} 通知 全部 {notificationsQuery.isLoading ? 读取中... : null} {notificationsQuery.isError ? ( {resolveErrorMessage(notificationsQuery.error)} ) : null} {!notificationsQuery.isLoading && !homeNotifications.length ? ( 暂无通知。 ) : null} {homeNotifications.map((item) => ( void openHomeNotification(item)} data-testid="mobile-home-notification-card" > {notificationTypeLabel(item.type)} {item.title} {!item.readAt ? 新 : null} {formatDateTime(item.createdAt)} ))} ); }
业务待办加载失败,请重试。
{item.nextStep}
{item.ownerLabel} · 查看处理
读取中...
{resolveErrorMessage(notificationsQuery.error)}
暂无通知。