import { Link } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { clockInAttendance, clockOutAttendance, fetchMyTodayAttendance, type AttendanceTodayResponse, } from "../api/attendance"; import { mapApiErrorMessage } from "../api/errorMessages"; import { fetchNotifications } from "../api/notifications"; import { isApiError } from "../api/types"; import { fetchWorkflowTasks } from "../api/workflow"; import { useAuth } from "../auth/AuthContext"; import { hasAnyPermission } from "../auth/permissions"; import { PageHeader } from "../components/common/PageHeader"; import { PageScaffold } from "../components/patterns/PageScaffold"; import { Button } from "../components/ui/button"; import { MOBILE_APPROVAL_PERMISSIONS, MOBILE_CAPTURE_PERMISSIONS, MOBILE_LEAVE_PERMISSIONS, MOBILE_REIMBURSEMENT_PERMISSIONS, } from "../features/mobile/navigation"; import { buildMobileApprovalsPath } from "../pwa"; 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 isAttendanceFeatureDisabled(error: unknown) { return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_v1_disabled"; } 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 formatShortTime(value: string | null) { if (!value) { return "--:--"; } try { return new Date(value).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false, }); } catch { return value; } } function formatWorkMinutes(value: number | null | undefined) { if (value == null) { return "-"; } const hours = Math.floor(value / 60); const minutes = value % 60; return `${hours}h ${String(minutes).padStart(2, "0")}m`; } function attendanceExemptionLabel(reason: unknown) { if (reason === "leave") return "今日请假"; if (reason === "travel") return "今日差旅"; if (reason === "holiday") return "节假日"; if (reason === "weekend") return "周末"; if (reason === "policy_disabled") return "无需打卡"; return "无需打卡"; } function workflowEntityLabel(entityType: string | null | undefined) { if (entityType === "leave") return "请假"; if (entityType === "reimbursement") return "报销"; if (entityType === "purchase_request") return "采购"; if (entityType === "travel_request") return "差旅"; return "审批"; } function resolveAttendanceAction(attendanceToday: AttendanceTodayResponse | null) { if (!attendanceToday) { return null; } if (!attendanceToday.needClockIn) { return null; } if (!attendanceToday.item?.clockInAt) { return "clock-in" as const; } if (!attendanceToday.item.clockOutAt) { return "clock-out" as const; } return null; } function ActionTile({ action }: { action: MobileAction }) { const isPrimary = action.tone === "primary"; const isWarning = action.tone === "warning"; return (
{action.label} {action.icon}
{action.meta}
); } export function MobilePortalPage() { const queryClient = useQueryClient(); const { permissions } = useAuth(); const canAccessApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]); const canAccessCapture = hasAnyPermission(permissions, [...MOBILE_CAPTURE_PERMISSIONS]); const canAccessLeave = hasAnyPermission(permissions, [...MOBILE_LEAVE_PERMISSIONS]); const canAccessReimbursements = hasAnyPermission(permissions, [...MOBILE_REIMBURSEMENT_PERMISSIONS]); const attendanceTodayQuery = useQuery({ queryKey: ["mobile", "portal", "attendance", "today"], queryFn: () => fetchMyTodayAttendance(), retry: (failureCount, error) => !isAttendanceFeatureDisabled(error) && failureCount < 2, }); const pendingCountQuery = useQuery({ enabled: canAccessApprovals, queryKey: ["mobile", "workflow", "pending-items"], queryFn: () => fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 3 }), }); const notificationsQuery = useQuery({ queryKey: ["mobile", "portal", "notifications"], queryFn: () => fetchNotifications({ page: 1, pageSize: 3 }), }); const attendanceClockInMutation = useMutation({ mutationFn: () => clockInAttendance(), onSuccess: async (response) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["mobile", "portal", "attendance", "today"] }), queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }), queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }), ]); toast.success(response.item.isLate ? `上班打卡成功,迟到 ${response.item.lateMinutes} 分钟` : "上班打卡成功"); }, onError: (error) => { toast.error(resolveErrorMessage(error)); }, }); const attendanceClockOutMutation = useMutation({ mutationFn: () => clockOutAttendance(), onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["mobile", "portal", "attendance", "today"] }), queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }), queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }), ]); toast.success("下班打卡成功"); }, onError: (error) => { toast.error(resolveErrorMessage(error)); }, }); const attendanceToday = attendanceTodayQuery.data ?? null; const attendanceAction = resolveAttendanceAction(attendanceToday); const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error); const pendingApprovalCount = canAccessApprovals ? pendingCountQuery.data?.total ?? 0 : 0; const pendingTasks = pendingCountQuery.data?.items ?? []; const notifications = notificationsQuery.data?.items ?? []; const unreadNotificationCount = notifications.filter((item) => !item.readAt).length; const actions: MobileAction[] = [ canAccessApprovals ? { key: "approvals", label: "审批", meta: pendingApprovalCount > 0 ? `${pendingApprovalCount} 条待处理` : "暂无待办", icon: "fact_check", to: "/mobile/approvals", tone: pendingApprovalCount > 0 ? "warning" : "primary", } : null, canAccessCapture ? { key: "capture", label: "传票据", meta: "上传 PDF", icon: "receipt_long", to: "/mobile/capture", tone: "primary", } : null, canAccessReimbursements ? { key: "reimbursements", label: "报销", meta: "新建与进度", icon: "request_quote", to: "/mobile/reimbursements", } : null, canAccessLeave ? { key: "leave", label: "请假", meta: "提交申请", icon: "event_available", to: "/mobile/leave", } : null, { key: "me", label: "我的", meta: unreadNotificationCount > 0 ? `${unreadNotificationCount} 条新通知` : "资料和通知", icon: "person", to: "/mobile/me", }, ].filter((item): item is MobileAction => Boolean(item)).slice(0, 4); return (
待处理
{pendingApprovalCount > 0 ? {pendingApprovalCount} 待批 : null}
{actions.map((action) => ( ))}
{!attendanceFeatureDisabled ? (
打卡
{attendanceToday?.workDate ?? "今日"}
{attendanceTodayQuery.isLoading ? (

读取中...

) : attendanceTodayQuery.isError ? (

{resolveErrorMessage(attendanceTodayQuery.error)}

) : attendanceToday && !attendanceToday.needClockIn ? (
{attendanceExemptionLabel(attendanceToday.exemptionReason)}
) : attendanceToday?.item?.clockInAt && attendanceToday.item.clockOutAt ? (
{formatShortTime(attendanceToday.item.clockInAt)} - {formatShortTime(attendanceToday.item.clockOutAt)} 工时 {formatWorkMinutes(attendanceToday.item.workMinutes)}
) : attendanceToday?.item?.clockInAt ? (
已上班 {formatShortTime(attendanceToday.item.clockInAt)}
) : (
今天还没打卡
)} {attendanceAction ? ( ) : null}
) : null} {canAccessApprovals ? (
待审批
{pendingCountQuery.isLoading ?

读取中...

: null} {pendingCountQuery.isError ? (

{resolveErrorMessage(pendingCountQuery.error)}

) : null} {!pendingCountQuery.isLoading && !pendingTasks.length ? (

没有待处理审批。

) : null}
{pendingTasks.map((task) => (
{task.stepName}
{workflowEntityLabel(task.workflowInstance?.entityType)}
))}
) : null}
通知
{notificationsQuery.isLoading ?

读取中...

: null} {notificationsQuery.isError ? (

{resolveErrorMessage(notificationsQuery.error)}

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

暂无通知。

) : null}
{notifications.map((item) => (
{item.title}
{!item.readAt ? 新 : null}
{formatDateTime(item.createdAt)}
))}
); }