import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { mapApiErrorMessage } from "../api/errorMessages"; 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 { 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 { 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 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; }; 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: "差旅" }, ]; 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 businessStatusLabel(status: string | null | undefined) { const labels: Record = { draft: "草稿", pending: "待处理", reviewing: "审批中", submitted: "审批中", approved: "已通过", rejected: "已驳回", returned: "已退回", cancelled: "已取消", completed: "已完成", paid: "已付款", }; return status ? labels[status] ?? status : "-"; } function isInternalId(value: string | null | undefined) { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value ?? ""); } function publicReference(value: string | null | undefined) { if (!value || isInternalId(value)) { return null; } return 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; return { id: task.id, title: summary?.title ?? task.stepName, subtitle: task.status === "completed" ? `业务:${entityLabel(entityType)} · ${reference ?? "未生成业务编号"} · 完成于 ${formatDateTime(task.completedAt)}` : `业务:${entityLabel(entityType)} · ${reference ?? "未生成业务编号"}`, 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); } export function MobileApprovalsPage() { const queryClient = useQueryClient(); const { me, permissions, accessToken, offlineAccessMode } = useAuth(); const [searchParams, setSearchParams] = useSearchParams(); const deepLinkTaskId = searchParams.get("taskId")?.trim() ?? ""; const [selectedTaskId, setSelectedTaskId] = useState(deepLinkTaskId); const [taskTab, setTaskTab] = useState("pending"); 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 detailSectionRef = useRef(null); const ownerEmployeeId = me?.id ?? null; const canAccessRemoteApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]); const canUseRemote = isOnline && Boolean(accessToken) && canAccessRemoteApprovals; const remoteTaskStatus = taskTab === "completed" ? "completed" : "pending"; useEffect(() => { setSelectedTaskId(deepLinkTaskId); }, [deepLinkTaskId]); useEffect(() => { if (taskTab === "completed") { setBatchMode(false); setSelectedBatchTaskIds([]); setBatchRejectComment(""); } }, [taskTab]); function updateTaskSelection(taskId: string) { setSelectedTaskId(taskId); const nextParams = new URLSearchParams(searchParams); if (taskId) { nextParams.set("taskId", taskId); } else { nextParams.delete("taskId"); } setSearchParams(nextParams, { replace: true }); } function scrollDetailIntoViewOnMobile() { if (typeof window === "undefined" || !window.matchMedia("(max-width: 767px)").matches) { return; } window.requestAnimationFrame(() => { detailSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); }); } function handleTaskSelection(taskId: string) { updateTaskSelection(taskId); scrollDetailIntoViewOnMobile(); } 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: item.taskSnapshot.stepName ?? item.detail.task.stepName, subtitle: `业务:${entityLabel(item.taskSnapshot.entityType ?? item.detail.workflowInstance?.entityType)} · 缓存于 ${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 selectedTaskAllowsActions = Boolean(taskTab === "pending" && selectedTask?.task.status === "pending"); const isOfflineUncachedDeepLink = Boolean(selectedTaskId) && !selectedTask && !canUseRemote; useEffect(() => { const visibleIds = new Set(filteredTaskCards.map((task) => task.id)); setSelectedBatchTaskIds((current) => current.filter((taskId) => visibleIds.has(taskId))); }, [filteredTaskCards]); useEffect(() => { if (selectedTaskId || !filteredTaskCards.length) { return; } updateTaskSelection(filteredTaskCards[0].id); }, [filteredTaskCards, selectedTaskId]); 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(""); updateTaskSelection(""); 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)) { updateTaskSelection(""); } 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)); return ( {!isOnline ? (

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

) : null} {error ? (

{error}

) : null} {message ? (

{message}

) : null}

{listTitle}

{filteredTaskCards.length} 条

{isOnline ? "在线" : "离线"}
setTaskTab(value as MobileTaskTab)}> 待处理 已处理
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} 条