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 { mapApiErrorMessage } from "../api/errorMessages"; import { fetchNotifications, markNotificationRead, type NotificationItem } from "../api/notifications"; import { fetchPortalHome } from "../api/portal"; import { isApiError } from "../api/types"; 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) { if (isApiError(error)) { return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; } if (error instanceof Error) { return error.message; } return "请求失败,请稍后重试。"; } 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 canAccessWecomApprovalArchive = hasAnyPermission(permissions, [ "leave.view_own", "attendance.view_own", "leave.view_all", "attendance.view_all", "*", ]); 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: canUseFinance, queryKey: ["mobile", "portal", "home"], queryFn: () => fetchPortalHome(), }); 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 paymentBatchEnabled = Boolean(financeSummary?.paymentBatchEnabled); 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_available", to: "/mobile/leave?view=form", } : 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} {canUseFinance ? (
财务
{paymentBatchEnabled ? "复核与付款" : "审批与台账"}
{portalHomeQuery.isLoading ?

读取中...

: null} {portalHomeQuery.isError ? (

{resolveErrorMessage(portalHomeQuery.error)}

) : null} {!portalHomeQuery.isLoading && !portalHomeQuery.isError && paymentBatchEnabled ? (
待付款
{financeSummary?.pendingBatchCount ?? 0}
异常
{(financeSummary?.failedItemCount ?? 0) + (financeSummary?.riskCount ?? 0)}
台账/导出
可用
) : null} {!portalHomeQuery.isLoading && !portalHomeQuery.isError && !paymentBatchEnabled ? (
付款批次未启用
可查看已流转到财务审批的报销单,待付款和异常批次暂不展示。
) : null}
) : null}
常用动作
{actions.map((action) => ( ))}
考勤
企业微信打卡
日常打卡、请假和补卡在企业微信完成;OA 保留已归档历史和考勤汇总。
{canAccessWecomApprovalArchive ? ( ) : null}
通知
{notificationsQuery.isLoading ?

读取中...

: null} {notificationsQuery.isError ? (

{resolveErrorMessage(notificationsQuery.error)}

) : null} {!notificationsQuery.isLoading && !homeNotifications.length ? (

暂无通知。

) : null}
{homeNotifications.map((item) => ( ))}
); }