import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { mapApiErrorMessage } from "../api/errorMessages"; import { fetchLeaveRequests, type LeaveRequestItem } from "../api/leave"; import { fetchPurchaseRequests, type PurchaseRequestSummary } from "../api/purchaseRequests"; import { fetchReimbursements, type ReimbursementListItem } from "../api/reimbursements"; import { isApiError } from "../api/types"; import { approveWorkflowTask, fetchWorkflowApprovers, fetchWorkflowTaskDetail, fetchWorkflowTasks, rejectWorkflowTask, returnWorkflowTask, transferWorkflowTask, type WorkflowTaskDetail, type WorkflowTaskSummary, } from "../api/workflow"; import { useAuth } from "../auth/AuthContext"; import { hasAnyPermission } from "../auth/permissions"; import { PageHeader } from "../components/common/PageHeader"; import { StatusBadge } from "../components/common/StatusBadge"; import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader"; import { PageScaffold } from "../components/patterns/PageScaffold"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Spinner } from "../components/ui/spinner"; import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs"; import { Textarea } from "../components/ui/textarea"; import { MOBILE_APPROVAL_PERMISSIONS } from "../features/mobile/navigation"; import { resolvePurchaseDisplayCode } from "../lib/approvalDisplay"; import { businessPrimaryLabel, compactBusinessReference, joinBusinessMeta } from "../lib/businessDisplay"; import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay"; import { cacheWorkflowTaskDetail, enqueueWorkflowActionDraft, getCachedWorkflowTaskDetail, listCachedWorkflowTaskDetails, listWorkflowActionDrafts, removeWorkflowActionDraft, resolveWorkflowTaskReferenceSnapshot, updateWorkflowActionDraftStatus, type WorkflowDraftAction, type WorkflowDraftQueueItem, } from "../mobile/workflowActionQueue"; type MobileAction = "approve" | "reject" | "return" | "transfer"; type MobileTaskTab = "pending" | "completed"; type EmployeeProgressTab = "draft" | "reviewing" | "approved" | "returned"; type EmployeeProgressQueryStatus = "draft" | "reviewing" | "approved" | "rejected"; type EntityFilter = "all" | "leave" | "reimbursement" | "purchase_request" | "travel_request"; type ReplayClassification = "failed" | "conflicted"; type VisibleTaskCard = { id: string; title: string; subtitle: string; status: string; entityType: string | null; entityId: string | null; reference: string | null; applicantName: string | null; metric: string | null; businessStatus: string | null; allowedActions: MobileAction[]; completedAt: string | null; isCached: boolean; detail: WorkflowTaskDetail | null; }; type EmployeeProgressCard = { id: string; title: string; subtitle: string; status: string; to: string; }; const ENTITY_FILTER_OPTIONS: Array<{ value: EntityFilter; label: string }> = [ { value: "all", label: "全部" }, { value: "leave", label: "请假" }, { value: "reimbursement", label: "报销" }, { value: "purchase_request", label: "采购" }, { value: "travel_request", label: "差旅" }, ]; const EMPLOYEE_PROGRESS_TABS: Array<{ value: EmployeeProgressTab; label: string }> = [ { value: "draft", label: "草稿" }, { value: "reviewing", label: "审批中" }, { value: "approved", label: "已通过" }, { value: "returned", label: "退回/驳回" }, ]; function resolveProgressTab(value: string | null | undefined): EmployeeProgressTab { if (value === "draft" || value === "reviewing" || value === "approved" || value === "returned") { return value; } return "reviewing"; } function resolveTaskTab(value: string | null | undefined): MobileTaskTab { return value === "completed" ? "completed" : "pending"; } function progressStatusQuery(tab: EmployeeProgressTab): EmployeeProgressQueryStatus { return tab === "returned" ? "rejected" : tab; } function entityLabel(entityType: string | undefined | null) { if (entityType === "leave") { return "请假"; } if (entityType === "reimbursement") { return "报销"; } if (entityType === "purchase_request") { return "采购"; } if (entityType === "travel_request") { return "差旅"; } return entityType || "未知"; } function workflowStatusLabel(status: string) { if (status === "pending") { return "待处理"; } if (status === "completed") { return "已处理"; } if (status === "rejected") { return "已驳回"; } if (status === "cancelled") { return "已取消"; } if (status === "skipped") { return "已跳过"; } return status; } function workflowActionLabel(action: string | null | undefined) { if (!action) { return "-"; } const labels: Record = { submit: "提交", approve: "通过", reject: "驳回", return: "退回", transfer: "转审", withdraw: "撤回", remind: "催办", overdue_remind: "超时提醒", cancel: "取消", skip: "跳过", }; return labels[action] ?? action; } function businessStatusLabel(status: string | null | undefined) { const labels: Record = { draft: "草稿", pending: "待处理", reviewing: "审批中", submitted: "审批中", approved: "已通过", rejected: "已驳回", returned: "已退回", cancelled: "已取消", completed: "已完成", paid: "已付款", }; return status ? labels[status] ?? status : "-"; } function publicReference(value: string | null | undefined) { return compactBusinessReference(value); } 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 isAlreadyProcessedTaskError(error: unknown) { return isApiError(error) && error.details?.task === "already_processed"; } function classifyReplayError(error: unknown): ReplayClassification { if (!isApiError(error)) { return "failed"; } if ( (error.status === 409 && error.details?.task === "already_processed") || error.status === 403 || error.status === 404 ) { return "conflicted"; } return "failed"; } function queueStatusLabel(status: WorkflowDraftQueueItem["status"]) { if (status === "queued") { return "待同步"; } if (status === "failed") { return "同步失败"; } return "存在冲突"; } function formatDateTime(value: string | null | undefined) { if (!value) { return "-"; } try { return new Date(value).toLocaleString("zh-CN"); } catch { return value; } } function normalizeSearchValue(value: string | null | undefined) { return String(value ?? "").trim().toLowerCase(); } function isWithinRecentDays(value: string | null | undefined, days: number) { if (!value) { return false; } const timestamp = new Date(value).getTime(); if (Number.isNaN(timestamp)) { return false; } return timestamp >= Date.now() - days * 24 * 60 * 60 * 1000; } function matchesEntityFilter(entityType: string | null, filter: EntityFilter) { return filter === "all" || entityType === filter; } function toVisibleRemoteTask(task: WorkflowTaskSummary): VisibleTaskCard { const entityType = task.workflowInstance?.entityType ?? null; const summary = task.businessSummary; const reference = publicReference(summary?.reference ?? task.workflowInstance?.entityId); const metric = summary?.amount ?? summary?.duration ?? null; const title = businessPrimaryLabel({ entityType, reference, title: summary?.title ?? task.stepName, fallback: task.stepName, }); const subtitle = task.status === "completed" ? joinBusinessMeta([`节点:${task.stepName}`, `完成于 ${formatDateTime(task.completedAt)}`]) : joinBusinessMeta([`节点:${task.stepName}`, summary?.applicantName ? `申请人:${summary.applicantName}` : null]); return { id: task.id, title, subtitle, status: task.status, entityType, entityId: task.workflowInstance?.entityId ?? null, reference, applicantName: summary?.applicantName ?? null, metric, businessStatus: summary?.businessStatus ?? task.workflowInstance?.currentStatus ?? null, allowedActions: task.allowedActions, completedAt: task.completedAt, isCached: false, detail: null, }; } function matchesSearch(card: VisibleTaskCard, keyword: string) { if (!keyword) { return true; } const searchValue = normalizeSearchValue(keyword); const haystack = [ card.title, entityLabel(card.entityType), card.id, card.entityId, card.reference, card.applicantName, card.metric, card.businessStatus, ] .map((item) => normalizeSearchValue(item)) .join(" "); return haystack.includes(searchValue); } function toEmployeeProgressCards(input: { reimbursements: ReimbursementListItem[]; leaveRequests: LeaveRequestItem[]; purchaseRequests: PurchaseRequestSummary[]; }): EmployeeProgressCard[] { const reimbursementCards = input.reimbursements.map((item) => ({ id: `reimbursement-${item.id}`, title: businessPrimaryLabel({ entityType: "reimbursement", reference: item.formNumber, title: resolveReimbursementDisplayCode(item), }), subtitle: joinBusinessMeta([ item.owner?.name ? `报销人:${item.owner.name}` : null, item.totalAmount ? `金额:${item.totalAmount}` : null, item.pendingWorkflow?.task?.stepName ? `节点:${item.pendingWorkflow.task.stepName}` : null, ]), status: item.status, to: `/mobile/reimbursements/${encodeURIComponent(item.id)}`, })); const leaveCards = input.leaveRequests.map((item) => ({ id: `leave-${item.id}`, title: joinBusinessMeta(["请假", item.leaveType?.name]) || "请假", subtitle: joinBusinessMeta([ `${item.startDate} 至 ${item.endDate}`, `${item.requestedDays} 天`, item.pendingWorkflow?.task?.stepName ? `节点:${item.pendingWorkflow.task.stepName}` : null, ]), status: item.status, to: `/mobile/leave/${encodeURIComponent(item.id)}`, })); const purchaseCards = input.purchaseRequests.map((item) => ({ id: `purchase-${item.id}`, title: businessPrimaryLabel({ entityType: "purchase_request", reference: item.requestNo, title: resolvePurchaseDisplayCode(item), }), subtitle: joinBusinessMeta([ item.applicant?.name ? `申请人:${item.applicant.name}` : null, item.totalAmount ? `金额:${item.totalAmount}` : null, item.updatedAt ? `更新:${formatDateTime(item.updatedAt)}` : null, ]), status: item.status, to: `/mobile/purchase-requests/${encodeURIComponent(item.id)}`, })); return [...reimbursementCards, ...leaveCards, ...purchaseCards].slice(0, 8); } export function MobileApprovalsPage() { const queryClient = useQueryClient(); const navigate = useNavigate(); const location = useLocation(); const routeParams = useParams(); const { me, permissions, accessToken, offlineAccessMode } = useAuth(); const [searchParams, setSearchParams] = useSearchParams(); const deepLinkTaskId = routeParams.taskId?.trim() || searchParams.get("taskId")?.trim() || ""; const isPushDetail = Boolean(deepLinkTaskId); const [selectedTaskId, setSelectedTaskId] = useState(deepLinkTaskId); const [taskTab, setTaskTab] = useState(() => resolveTaskTab(searchParams.get("taskTab"))); const [progressTab, setProgressTab] = useState(() => resolveProgressTab(searchParams.get("progress")), ); const [entityFilter, setEntityFilter] = useState("all"); const [searchInput, setSearchInput] = useState(""); const deferredSearch = useDeferredValue(searchInput); const [batchMode, setBatchMode] = useState(false); const [selectedBatchTaskIds, setSelectedBatchTaskIds] = useState([]); const [batchRejectComment, setBatchRejectComment] = useState(""); const [error, setError] = useState(""); const [message, setMessage] = useState(""); const [comment, setComment] = useState(""); const [transferToApproverId, setTransferToApproverId] = useState(""); const [transferReason, setTransferReason] = useState(""); const [offlineDraftSubmitting, setOfflineDraftSubmitting] = useState(false); const [isOnline, setIsOnline] = useState(true); const autoSyncRequestedRef = useRef(false); const ownerEmployeeId = me?.id ?? null; const canAccessRemoteApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]); const canUseRemote = isOnline && Boolean(accessToken) && canAccessRemoteApprovals; const remoteTaskStatus = taskTab === "completed" ? "completed" : "pending"; const currentMobileSource = `${location.pathname}${location.search}`; const employeeProgressStatus = progressStatusQuery(progressTab); const reimbursementProgressQuery = useQuery({ enabled: !canAccessRemoteApprovals, queryKey: ["mobile", "approvals", "progress", "reimbursements", employeeProgressStatus], queryFn: () => fetchReimbursements({ scope: "own", status: employeeProgressStatus, page: 1, pageSize: 8 }), }); const leaveProgressQuery = useQuery({ enabled: !canAccessRemoteApprovals, queryKey: ["mobile", "approvals", "progress", "leave", employeeProgressStatus], queryFn: () => fetchLeaveRequests({ scope: "own", status: employeeProgressStatus, page: 1, pageSize: 8 }), }); const purchaseProgressQuery = useQuery({ enabled: !canAccessRemoteApprovals, queryKey: ["mobile", "approvals", "progress", "purchase", employeeProgressStatus], queryFn: () => fetchPurchaseRequests({ mine: true, status: employeeProgressStatus, page: 1, pageSize: 8 }), }); useEffect(() => { setSelectedTaskId(deepLinkTaskId); }, [deepLinkTaskId]); useEffect(() => { setProgressTab(resolveProgressTab(searchParams.get("progress"))); }, [searchParams]); useEffect(() => { setTaskTab(resolveTaskTab(searchParams.get("taskTab"))); }, [searchParams]); useEffect(() => { if (taskTab === "completed") { setBatchMode(false); setSelectedBatchTaskIds([]); setBatchRejectComment(""); } }, [taskTab]); function handleTaskSelection(taskId: string) { navigate(`/mobile/approvals/${encodeURIComponent(taskId)}`, { state: { from: currentMobileSource } }); } function handleProgressTabChange(value: string) { const nextTab = value as EmployeeProgressTab; setProgressTab(nextTab); const nextParams = new URLSearchParams(searchParams); nextParams.set("progress", nextTab); setSearchParams(nextParams, { replace: true }); } function handleTaskTabChange(value: string) { const nextTab = resolveTaskTab(value); setTaskTab(nextTab); const nextParams = new URLSearchParams(searchParams); nextParams.set("taskTab", nextTab); setSearchParams(nextParams, { replace: true }); } async function invalidateWorkflowQueries(taskId: string | null = selectedTaskId) { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }), queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-detail", taskId] }), queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId] }), queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId, taskId] }), queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "draft-queue", ownerEmployeeId] }), queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "pending-count"] }), ]); } const tasksQuery = useQuery({ enabled: canUseRemote, queryKey: ["mobile", "workflow", "tasks", remoteTaskStatus], queryFn: () => fetchWorkflowTasks({ status: remoteTaskStatus, page: 1, pageSize: 100 }), }); const detailQuery = useQuery({ enabled: Boolean(selectedTaskId) && canUseRemote, queryKey: ["mobile", "workflow", "task-detail", selectedTaskId], queryFn: () => fetchWorkflowTaskDetail(selectedTaskId), }); const cachedTasksQuery = useQuery({ enabled: Boolean(ownerEmployeeId), queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId], queryFn: () => listCachedWorkflowTaskDetails(ownerEmployeeId), }); const cachedDetailQuery = useQuery({ enabled: Boolean(selectedTaskId) && Boolean(ownerEmployeeId), queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId, selectedTaskId], queryFn: () => getCachedWorkflowTaskDetail(selectedTaskId, ownerEmployeeId), }); const queueQuery = useQuery({ enabled: Boolean(ownerEmployeeId), queryKey: ["mobile", "workflow", "draft-queue", ownerEmployeeId], queryFn: () => listWorkflowActionDrafts(ownerEmployeeId), }); useEffect(() => { if (!detailQuery.data || !ownerEmployeeId) { return; } void cacheWorkflowTaskDetail(detailQuery.data, ownerEmployeeId).then(async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId] }), queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId, detailQuery.data.task.id], }), ]); }); }, [detailQuery.data, ownerEmployeeId, queryClient]); const baseTaskCards = useMemo(() => { if (canUseRemote) { const remoteItems = (tasksQuery.data?.items ?? []).filter((item) => taskTab === "completed" ? isWithinRecentDays(item.completedAt, 30) : true, ); return remoteItems.map(toVisibleRemoteTask); } if (taskTab === "completed") { return []; } return (cachedTasksQuery.data ?? []) .filter((item) => item.detail.task.status === "pending") .map((item) => ({ id: item.detail.task.id, title: businessPrimaryLabel({ entityType: item.taskSnapshot.entityType ?? item.detail.workflowInstance?.entityType, reference: item.taskSnapshot.reference, title: item.taskSnapshot.stepName ?? item.detail.task.stepName, }), subtitle: joinBusinessMeta([`节点:${item.taskSnapshot.stepName ?? item.detail.task.stepName}`, `缓存于 ${formatDateTime(item.savedAt)}`]), status: item.detail.task.status, entityType: item.taskSnapshot.entityType ?? item.detail.workflowInstance?.entityType ?? null, entityId: item.taskSnapshot.entityId, reference: publicReference(item.taskSnapshot.reference), applicantName: null, metric: null, businessStatus: item.detail.workflowInstance?.currentStatus ?? null, allowedActions: item.detail.allowedActions, completedAt: item.detail.task.completedAt, isCached: true, detail: item.detail, })); }, [cachedTasksQuery.data, canUseRemote, taskTab, tasksQuery.data?.items]); const filteredTaskCards = useMemo( () => baseTaskCards.filter( (card) => matchesEntityFilter(card.entityType, entityFilter) && matchesSearch(card, deferredSearch), ), [baseTaskCards, deferredSearch, entityFilter], ); const pendingTaskMap = useMemo( () => new Map(filteredTaskCards.map((task) => [task.id, task])), [filteredTaskCards], ); const selectedCachedTaskDetail = useMemo( () => filteredTaskCards.find((task) => task.id === selectedTaskId)?.detail ?? null, [filteredTaskCards, selectedTaskId], ); const selectedTaskCard = useMemo( () => filteredTaskCards.find((task) => task.id === selectedTaskId) ?? null, [filteredTaskCards, selectedTaskId], ); const selectedTask = detailQuery.data ?? cachedDetailQuery.data?.detail ?? selectedCachedTaskDetail; const selectedReference = selectedTask ? resolveWorkflowTaskReferenceSnapshot(selectedTask) : null; const selectedDisplayReference = selectedTaskCard?.reference ?? publicReference(selectedReference?.reference); const selectedDetailReference = selectedDisplayReference ?? publicReference(selectedTask?.reimbursement?.formNumber) ?? publicReference(selectedTask?.purchaseRequest?.requestNo) ?? publicReference(selectedTask?.travelRequest?.requestNo); const selectedDetailTitle = selectedTask ? businessPrimaryLabel({ entityType: selectedTask.workflowInstance?.entityType, reference: selectedDetailReference, title: selectedTaskCard?.title ?? selectedTask.task.stepName, }) : ""; const selectedApplicantName = selectedTaskCard?.applicantName ?? selectedTask?.reimbursement?.submitter?.name ?? selectedTask?.leaveRequest?.owner?.name ?? selectedTask?.purchaseRequest?.applicant?.name ?? selectedTask?.travelRequest?.applicant?.name ?? null; const selectedMetric = selectedTaskCard?.metric ?? selectedTask?.reimbursement?.totalAmount ?? (selectedTask?.leaveRequest ? `${selectedTask.leaveRequest.requestedDays} 天` : null) ?? selectedTask?.purchaseRequest?.totalAmount ?? selectedTask?.travelRequest?.estimatedAmount ?? null; const selectedTaskAllowsActions = Boolean(taskTab === "pending" && selectedTask?.task.status === "pending"); const isOfflineUncachedDeepLink = Boolean(selectedTaskId) && !selectedTask && !canUseRemote; const employeeProgressCards = useMemo( () => toEmployeeProgressCards({ reimbursements: reimbursementProgressQuery.data?.items ?? [], leaveRequests: leaveProgressQuery.data?.items ?? [], purchaseRequests: purchaseProgressQuery.data?.items ?? [], }), [leaveProgressQuery.data?.items, purchaseProgressQuery.data?.items, reimbursementProgressQuery.data?.items], ); const employeeProgressLoading = reimbursementProgressQuery.isLoading || leaveProgressQuery.isLoading || purchaseProgressQuery.isLoading; const employeeProgressError = reimbursementProgressQuery.error ?? leaveProgressQuery.error ?? purchaseProgressQuery.error ?? null; useEffect(() => { const visibleIds = new Set(filteredTaskCards.map((task) => task.id)); setSelectedBatchTaskIds((current) => current.filter((taskId) => visibleIds.has(taskId))); }, [filteredTaskCards]); const canTransfer = Boolean( canUseRemote && selectedTaskAllowsActions && selectedTask?.allowedActions.includes("transfer"), ); const approversQuery = useQuery({ enabled: Boolean(canTransfer), queryKey: ["mobile", "workflow", "approvers"], queryFn: () => fetchWorkflowApprovers({ page: 1, pageSize: 100 }), }); async function refreshDraftQueueCache() { if (!ownerEmployeeId) { return []; } const drafts = await listWorkflowActionDrafts(ownerEmployeeId); queryClient.setQueryData(["mobile", "workflow", "draft-queue", ownerEmployeeId], drafts); return drafts; } const currentQueueItems = queueQuery.data ?? []; const retryableQueueItems = useMemo( () => currentQueueItems.filter((item) => item.status !== "conflicted"), [currentQueueItems], ); async function queueOfflineDraft(action: WorkflowDraftAction) { try { if (!selectedTaskId) { throw new Error("请选择任务"); } if (!selectedTask) { throw new Error("当前待办尚未缓存,请联网加载后再操作"); } if (!selectedTask.allowedActions.includes(action)) { throw new Error("当前待办不允许该动作"); } if (action === "reject" && !comment.trim()) { throw new Error("驳回必须填写意见"); } setOfflineDraftSubmitting(true); await enqueueWorkflowActionDraft({ taskId: selectedTaskId, action, comment: action === "reject" ? comment.trim() : null, ownerEmployeeId, detail: selectedTask, }); await refreshDraftQueueCache(); await queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId] }); setComment(""); setError(""); setMessage(action === "approve" ? "当前离线,通过草稿已加入待同步队列。" : "当前离线,驳回草稿已加入待同步队列。"); toast.success(action === "approve" ? "已加入通过草稿队列" : "已加入驳回草稿队列"); } catch (actionError) { const nextError = resolveErrorMessage(actionError); setError(nextError); setMessage(""); toast.error(nextError); } finally { setOfflineDraftSubmitting(false); } } const actionMutation = useMutation({ mutationFn: async (action: MobileAction) => { if (!canUseRemote) { throw new Error("当前离线,请使用离线草稿动作"); } if (!selectedTaskId) { throw new Error("请选择任务"); } if (!selectedTask) { throw new Error("当前待办尚未缓存,请联网加载后再操作"); } if (!selectedTask.allowedActions.includes(action)) { throw new Error("当前待办不允许该动作"); } if (action === "approve") { await approveWorkflowTask(selectedTaskId); return { action }; } if (action === "reject") { if (!comment.trim()) { throw new Error("驳回必须填写意见"); } await rejectWorkflowTask(selectedTaskId, comment.trim()); return { action }; } if (action === "return") { if (!comment.trim()) { throw new Error("退回必须填写意见"); } await returnWorkflowTask(selectedTaskId, comment.trim()); return { action }; } if (!transferToApproverId) { throw new Error("请选择转审目标审批人"); } if (transferReason.trim().length < 10) { throw new Error("转审理由至少 10 个字"); } await transferWorkflowTask(selectedTaskId, transferToApproverId, transferReason.trim()); return { action }; }, onSuccess: async () => { await invalidateWorkflowQueries(selectedTaskId); setComment(""); setTransferReason(""); setTransferToApproverId(""); setError(""); setMessage(""); navigate("/mobile/approvals", { replace: true }); toast.success("操作成功"); }, onError: (actionError) => { if (isAlreadyProcessedTaskError(actionError)) { void invalidateWorkflowQueries(selectedTaskId); } const nextError = resolveErrorMessage(actionError); setError(nextError); setMessage(""); toast.error(nextError); }, }); const batchActionMutation = useMutation({ mutationFn: async (input: { action: WorkflowDraftAction; taskIds: string[]; comment?: string }) => { if (!input.taskIds.length) { throw new Error("请先选择至少一条待办"); } if (input.action === "reject" && !input.comment?.trim()) { throw new Error("批量驳回必须填写共享意见"); } let succeeded = 0; let failed = 0; let skipped = 0; const failures: string[] = []; for (const taskId of input.taskIds) { const taskCard = pendingTaskMap.get(taskId); if (!taskCard) { skipped += 1; failures.push(`${taskId}: 当前筛选结果中找不到该待办`); continue; } if (!taskCard.allowedActions.includes(input.action)) { skipped += 1; failures.push(`${taskId}: 当前待办不允许${input.action === "approve" ? "通过" : "驳回"}`); continue; } try { if (canUseRemote) { if (input.action === "approve") { await approveWorkflowTask(taskId); } else { await rejectWorkflowTask(taskId, input.comment!.trim()); } } else { if (!taskCard.detail) { throw new Error("当前待办尚未缓存,请联网后重试"); } await enqueueWorkflowActionDraft({ taskId, action: input.action, comment: input.action === "reject" ? input.comment?.trim() ?? null : null, ownerEmployeeId, detail: taskCard.detail, }); } succeeded += 1; } catch (batchError) { failed += 1; failures.push(`${taskId}: ${resolveErrorMessage(batchError)}`); } } return { ...input, succeeded, failed, skipped, failures }; }, onSuccess: async (result) => { if (canUseRemote) { await invalidateWorkflowQueries(selectedTaskId); } else { await refreshDraftQueueCache(); await queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId] }); } const actionLabel = result.action === "approve" ? "通过" : "驳回"; const summary = canUseRemote ? `批量${actionLabel}完成:成功 ${result.succeeded},失败 ${result.failed},跳过 ${result.skipped}` : `离线批量${actionLabel}已入队:成功 ${result.succeeded},失败 ${result.failed},跳过 ${result.skipped}`; setBatchMode(false); setSelectedBatchTaskIds([]); setBatchRejectComment(""); setComment(""); setError(result.failures.length ? result.failures.slice(0, 3).join(";") : ""); setMessage(summary); if (canUseRemote && result.taskIds.includes(selectedTaskId)) { navigate("/mobile/approvals", { replace: true }); } if (result.failed > 0) { toast.error(summary); return; } toast.success(summary); }, onError: (batchError) => { const nextError = resolveErrorMessage(batchError); setError(nextError); setMessage(""); toast.error(nextError); }, }); const isActionSubmitting = actionMutation.isPending || offlineDraftSubmitting; const syncMutation = useMutation({ mutationFn: async (items: WorkflowDraftQueueItem[]) => { let succeeded = 0; let failed = 0; let conflicted = 0; for (const item of items.sort((a, b) => a.updatedAt.localeCompare(b.updatedAt))) { try { if (item.action === "approve") { await approveWorkflowTask(item.taskId); } else { await rejectWorkflowTask(item.taskId, item.comment ?? ""); } await removeWorkflowActionDraft(item.taskId, ownerEmployeeId); succeeded += 1; } catch (syncError) { const classification = classifyReplayError(syncError); const lastError = resolveErrorMessage(syncError); await updateWorkflowActionDraftStatus({ taskId: item.taskId, ownerEmployeeId, status: classification, lastError, }); if (classification === "conflicted") { conflicted += 1; } else { failed += 1; } } } return { succeeded, failed, conflicted }; }, onSuccess: async (result) => { await invalidateWorkflowQueries(selectedTaskId); setError(""); setMessage(`草稿同步完成:成功 ${result.succeeded},失败 ${result.failed},冲突 ${result.conflicted}`); if (result.succeeded || result.failed || result.conflicted) { toast.success(`草稿同步完成:成功 ${result.succeeded},失败 ${result.failed},冲突 ${result.conflicted}`); } }, onError: (syncError) => { const nextError = resolveErrorMessage(syncError); setError(nextError); setMessage(""); toast.error(nextError); }, }); useEffect(() => { const onOnline = () => { setIsOnline(true); autoSyncRequestedRef.current = true; }; const onOffline = () => { setIsOnline(false); }; window.addEventListener("online", onOnline); window.addEventListener("offline", onOffline); return () => { window.removeEventListener("online", onOnline); window.removeEventListener("offline", onOffline); }; }, []); useEffect(() => { if (accessToken) { setIsOnline(true); } }, [accessToken]); useEffect(() => { if (!autoSyncRequestedRef.current || !canUseRemote || syncMutation.isPending) { return; } autoSyncRequestedRef.current = false; if (retryableQueueItems.length) { syncMutation.mutate(retryableQueueItems); } }, [canUseRemote, retryableQueueItems, syncMutation]); function toggleBatchTask(taskId: string) { setSelectedBatchTaskIds((current) => current.includes(taskId) ? current.filter((id) => id !== taskId) : [...current, taskId], ); } function toggleSelectAllVisibleTasks() { const visibleIds = filteredTaskCards.map((task) => task.id); const allSelected = visibleIds.length > 0 && visibleIds.every((taskId) => selectedBatchTaskIds.includes(taskId)); if (allSelected) { setSelectedBatchTaskIds([]); return; } setSelectedBatchTaskIds(visibleIds); } function handleBatchAction(action: WorkflowDraftAction) { batchActionMutation.mutate({ action, taskIds: selectedBatchTaskIds, comment: action === "reject" ? batchRejectComment : undefined, }); } const listTitle = taskTab === "pending" ? "待我审批" : "已处理"; const allVisibleSelected = filteredTaskCards.length > 0 && filteredTaskCards.every((task) => selectedBatchTaskIds.includes(task.id)); const shouldShowDraftQueue = !isOnline || currentQueueItems.length > 0; return ( {isPushDetail ? ( ) : null } fallbackTo={`/mobile/approvals?taskTab=${taskTab}`} testId="mobile-approvals-detail-header" /> ) : ( )} {!isOnline && canAccessRemoteApprovals ? (

当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}仅支持查看已缓存待办,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。

) : null} {error ? (

{error}

) : null} {message ? (

{message}

) : null} {!isPushDetail && !canAccessRemoteApprovals ? (

我发起的进度

报销、请假、采购进度

{employeeProgressCards.length} 条
{EMPLOYEE_PROGRESS_TABS.map((item) => ( {item.label} ))} {employeeProgressLoading ?
加载中...
: null} {employeeProgressError ? (

{resolveErrorMessage(employeeProgressError)}

) : null} {!employeeProgressLoading && !employeeProgressError && !employeeProgressCards.length ? (
暂无申请进度
) : null}
{employeeProgressCards.map((item) => (
{item.title}
{item.subtitle ?
{item.subtitle}
: null}
))}
) : null} {!isPushDetail && canAccessRemoteApprovals ? (

{listTitle}

{filteredTaskCards.length} 条

{isOnline ? "在线" : "离线"}
待我审批 已处理
setSearchInput(event.target.value)} placeholder="搜索审批" />
筛选与批量
setEntityFilter(value as EntityFilter)}> {ENTITY_FILTER_OPTIONS.map((option) => ( {option.label} ))} {taskTab === "pending" ? (
{batchMode ? ( ) : null} 当前 {filteredTaskCards.length} 条 {batchMode ? `,已选 ${selectedBatchTaskIds.length}` : ""}
) : (

仅显示最近 30 天。

)}
{batchMode ? (
已选 {selectedBatchTaskIds.length} 条