import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage"; 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 { fetchLeaveRequests, type LeaveRequestItem } from "../api/leave"; import { fetchPurchaseRequests, type PurchaseRequestSummary } from "../api/purchaseRequests"; import { downloadReimbursementAttachment, fetchReimbursementAttachmentPreviewUrl, fetchReimbursementDetail, fetchReimbursementMaterialPreviewBundle, fetchReimbursements, type ReimbursementListItem, } from "../api/reimbursements"; import { fetchInvoiceSignedUrl } from "../api/invoicePool"; 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 { ConfirmDialog } from "../components/common/ConfirmDialog"; import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog"; import { ReimbursementItemEvidenceList } from "../components/reimbursements/ReimbursementItemEvidenceList"; 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 { MOBILE_STICKY_ACTION_PAGE_CLASS, MobileStickyActionBar } from "../components/patterns/MobileStickyActionBar"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Label } from "../components/ui/label"; 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, workflowActionLabel } from "../lib/approvalDisplay"; import { businessPrimaryLabel, compactBusinessReference, joinBusinessMeta } from "../lib/businessDisplay"; import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay"; import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance"; import { buildReimbursementApprovalEvidence, buildReimbursementReturnReference, } from "../lib/reimbursementApprovalProjection"; import { isWecomWebView } from "../lib/runtimeContext"; import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview"; import { cn } from "../lib/utils"; import { getWorkflowConfirmCopy, type WorkflowConfirmAction } from "../lib/workflowActionConfirm"; 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" | "attendance_correction" | "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: "attendance_correction", 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 === "attendance_correction") { 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 publicReference(value: string | null | undefined) { return compactBusinessReference(value); } function resolveErrorMessage(error: unknown) { return resolveUserErrorMessage(error, "动作执行失败"); } 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 [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 [confirmAction, setConfirmAction] = useState(null); const [showSecondaryActions, setShowSecondaryActions] = 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 confirmCopy = confirmAction ? getWorkflowConfirmCopy(confirmAction, { offlineDraft: !canUseRemote && confirmAction !== "return" }) : null; 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]); 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 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 mobileReimbursementId = selectedTask?.reimbursement?.id ?? ""; const reimbursementDetailQuery = useQuery({ enabled: Boolean(mobileReimbursementId) && canUseRemote, queryKey: ["reimbursements", mobileReimbursementId], queryFn: () => fetchReimbursementDetail(mobileReimbursementId), }); const reimbursementEvidence = useMemo(() => { const snapshot = selectedTask?.workflowInstance?.contextData; if (!reimbursementDetailQuery.data || !snapshot) return null; return buildReimbursementApprovalEvidence(reimbursementDetailQuery.data, snapshot); }, [reimbursementDetailQuery.data, selectedTask?.workflowInstance?.contextData]); const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog(); const openInvoiceMutation = useMutation({ mutationFn: async (invoiceId: string) => { const result = await fetchInvoiceSignedUrl(invoiceId); const openedInWecom = await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize }); if (!openedInWecom) openFileInBrowser(result.signedUrl); }, onError: (actionError) => toast.error(resolveUserErrorMessage(actionError, "打开发票失败,请重试")), }); const openAttachmentMutation = useMutation({ mutationFn: async ({ attachment, reason }: { attachment: { id: string; originalName: string; fileSize: number } & AttachmentGovernanceFields; reason?: string; }) => { if (isWecomWebView()) { const result = await fetchReimbursementAttachmentPreviewUrl(mobileReimbursementId, attachment.id, reason); await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize }); return null; } return downloadReimbursementAttachment(mobileReimbursementId, attachment.id, reason); }, onSuccess: (result) => { if (!result) return; const url = URL.createObjectURL(result.blob); window.open(url, "_blank", "noopener,noreferrer"); window.setTimeout(() => URL.revokeObjectURL(url), 60_000); }, onError: (actionError) => toast.error(resolveUserErrorMessage(actionError, "打开附件失败,请重试")), }); const materialPreviewMutation = useMutation({ mutationFn: (reason?: string) => fetchReimbursementMaterialPreviewBundle(mobileReimbursementId, reason), onSuccess: (bundle) => openFileInBrowser(bundle.signedUrl), onError: (actionError) => toast.error(resolveUserErrorMessage(actionError, "打开完整材料失败,可逐份查看原件")), }); 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?.attendanceCorrection?.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?.attendanceCorrection ? selectedTask.attendanceCorrection.workDate : null) ?? selectedTask?.purchaseRequest?.totalAmount ?? selectedTask?.travelRequest?.estimatedAmount ?? null; const selectedTaskAllowsActions = Boolean(taskTab === "pending" && selectedTask?.task.status === "pending"); const isSelectedReimbursement = selectedTask?.workflowInstance?.entityType === "reimbursement"; const reimbursementDecisionReady = !isSelectedReimbursement || Boolean( canUseRemote && reimbursementEvidence && selectedTask?.workflowInstance?.contextData.parseStatus === "ready", ); 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; 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 }), }); function requestMobileWorkflowAction(action: WorkflowConfirmAction) { if (isSelectedReimbursement && !reimbursementDecisionReady) { setError("报销审批需联网加载完整明细、票据和提交时检查后才能处理。"); return; } if (!canUseRemote && action === "return") { setError("当前离线不支持退回,请联网后再处理。"); return; } if (action === "reject" && !comment.trim()) { setError("驳回必须填写意见"); return; } if (action === "return" && !comment.trim()) { setError("退回必须填写意见"); return; } setError(""); setConfirmAction(action); } function appendReturnReference(reference: string) { if (!reference) return; setComment((current) => current.includes(reference) ? current : `${current.trim()}\n${reference}`.trim()); setShowSecondaryActions(true); setError(""); } async function handleOpenAttachment(attachmentId: string) { const attachment = reimbursementDetailQuery.data?.attachments.find((item) => item.id === attachmentId); if (!attachment) { toast.error("附件信息已变化,请刷新后重试"); return; } const reason = await requestDownloadReason(attachment); if (reason === null) return; openAttachmentMutation.mutate({ attachment, reason }); } async function handleOpenAllMaterials() { const governedAttachment = reimbursementDetailQuery.data?.attachments.find(attachmentRequiresDownloadReason); const reason = governedAttachment ? await requestDownloadReason(governedAttachment) : undefined; if (reason === null) return; materialPreviewMutation.mutate(reason); } 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.workflowInstance?.entityType === "reimbursement") { 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.workflowInstance?.entityType === "reimbursement" && !reimbursementDecisionReady) { 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 isActionSubmitting = actionMutation.isPending || offlineDraftSubmitting; const transferDisabledReason = !transferToApproverId ? "请先选择目标审批人。" : transferReason.trim().length < 10 ? `请填写转审理由,至少 10 个字(还差 ${10 - transferReason.trim().length} 个字)。` : "转审理由已满足要求。"; 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]); const listTitle = taskTab === "pending" ? "待我审批" : "已处理"; const shouldShowDraftQueue = !isOnline || currentQueueItems.length > 0; const showMobileApprovalActions = Boolean(isPushDetail && selectedTask && selectedTaskAllowsActions); const approvalFocusText = taskTab === "pending" ? filteredTaskCards.length ? `当前有 ${filteredTaskCards.length} 条待处理,可优先查看金额、时效或退回风险最高的事项。` : "暂无待处理审批,收到企业微信或站内通知后可从这里直达详情。" : "最近 30 天已处理审批用于复核,不支持再次审批。"; const selectedActionText = selectedTask ? selectedTaskAllowsActions ? isSelectedReimbursement && !reimbursementDecisionReady ? "报销审批需联网加载完整明细、票据和提交时检查;内容就绪前不能通过、退回、驳回或保存草稿。" : canUseRemote ? "核对摘要和历史后,可在底部通过、驳回或退回。" : "当前离线,仅支持保存通过或驳回草稿,联网后再同步。" : "当前任务已处理,本页只展示业务摘要和处理历史。" : ""; return ( <> {isPushDetail ? ( ) : null } fallbackTo={`/mobile/approvals?taskTab=${taskTab}`} testId="mobile-approvals-detail-header" /> ) : ( )} {!isOnline && canAccessRemoteApprovals ? (

当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"} {isSelectedReimbursement ? "当前报销任务只显示摘要,不能保存审批草稿;请联网核对完整内容。" : "仅支持查看已缓存审批任务,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。"}

) : 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 ? (

当前办理焦点

{approvalFocusText}

{isOnline ? "在线" : "离线"}
当前筛选
{filteredTaskCards.length}
离线草稿
{currentQueueItems.length}
处理方式
逐条核对
) : 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" ? `当前 ${filteredTaskCards.length} 条` : "仅显示最近 30 天。"}

{canUseRemote && tasksQuery.isLoading ? (
加载中...
) : null} {canUseRemote && tasksQuery.isError ? (

{resolveErrorMessage(tasksQuery.error)}

) : null} {!canUseRemote && taskTab === "completed" ? (

离线模式仅缓存待处理任务详情;已处理历史需联网查看。

) : null} {!filteredTaskCards.length && !(canUseRemote && tasksQuery.isLoading) ? (

{taskTab === "completed" ? "最近 30 天暂无符合筛选条件的已处理任务。" : !canAccessRemoteApprovals && isOnline ? "暂无待我审批;收到企业微信审批消息后可从这里查看。" : canUseRemote ? "暂无符合筛选条件的审批任务。" : "暂无符合筛选条件的已缓存审批。"}

) : null}
{filteredTaskCards.map((task) => { const isSelected = selectedTaskId === task.id; return ( ); })}
) : null} {isPushDetail ? (

处理

{!selectedTaskId ?

请先选择任务。

: null} {selectedTaskId && canUseRemote && detailQuery.isLoading ? (

加载任务详情中...

) : null} {selectedTaskId && canUseRemote && detailQuery.isError ? (

{resolveErrorMessage(detailQuery.error)}

) : null} {isOfflineUncachedDeepLink ? (

离线未缓存该审批任务,请联网后重试。

) : null} {selectedTask ? ( <>
下一步
{selectedActionText}
{selectedDetailTitle}
{joinBusinessMeta([`节点:${selectedTask.task.stepName}`, `创建:${formatDateTime(selectedTask.task.createdAt)}`])}
类型
{entityLabel(selectedTask.workflowInstance?.entityType)}
业务状态
{businessStatusLabel(selectedTaskCard?.businessStatus ?? selectedTask.workflowInstance?.currentStatus)}
{selectedApplicantName ? (
申请人
{selectedApplicantName}
) : null} {selectedMetric ? (
金额/天数
{selectedMetric}
) : null} {selectedTask.task.completedAt ? (
完成时间
{formatDateTime(selectedTask.task.completedAt)}
) : null}
{selectedTask.reimbursement ? (
{joinBusinessMeta([`报销日期:${selectedTask.reimbursement.reimbursementDate ?? "-"}`, selectedTask.reimbursement.description])}
{!canUseRemote ? (

当前只显示允许离线保存的摘要。请联网加载完整明细和票据后再处理该报销审批。

) : reimbursementDetailQuery.isLoading ? (

正在加载完整报销内容;加载完成前所有审批动作均不可用。

) : reimbursementDetailQuery.isError ? (

{resolveUserErrorMessage(reimbursementDetailQuery.error, "完整报销内容加载失败,暂时不能审批。")}

) : null} {selectedTask.workflowInstance?.contextData.parseStatus === "invalid" ? (

提交时检查记录无法解析,暂时不能审批;请联系系统管理员核对。

) : null} {reimbursementEvidence ? ( <> {selectedTask.task.status !== "pending" ? (

当前版本可能包含申请人在退回后所做的修改;历史审批意见和提交时检查仍对应当时版本。

) : null} openInvoiceMutation.mutate(invoice.id), onOpenAttachment: (attachment) => void handleOpenAttachment(attachment.id), onOpenMaterialBundle: () => void handleOpenAllMaterials(), onReferenceItem: (item) => appendReturnReference(buildReimbursementReturnReference({ item })), onReferenceInvoice: (invoice) => appendReturnReference(buildReimbursementReturnReference({ invoice })), }} />

提交时检查

{selectedTask.workflowInstance?.contextData.budgetCheckStatus === "missing" ? (

该次提交未记录预算快照。

) : selectedTask.workflowInstance?.contextData.budgetCheckStatus === "not_applicable" ? (

提交时未匹配适用预算。

) : selectedTask.workflowInstance?.contextData.budgetCheck ? (

预算 {selectedTask.workflowInstance.contextData.budgetCheck.budgetCode},提交后使用率 {selectedTask.workflowInstance.contextData.budgetCheck.usagePercent}%

) : null}

费用标准提示 {selectedTask.workflowInstance?.contextData.standardViolations.length ?? 0} 条

当前票据检查

票据 {reimbursementEvidence.currentCheck.invoiceCount} 张,尚未对应明细 {reimbursementEvidence.currentCheck.unlinkedInvoiceCount} 张;需人工核对 {reimbursementEvidence.currentCheck.manualReviewItemCount} 条,申报额超出票面 {reimbursementEvidence.currentCheck.amountExceededItemCount} 条。

) : null}
) : null} {selectedTask.leaveRequest ? (
{joinBusinessMeta([`假期类型:${selectedTask.leaveRequest.leaveType?.name ?? "-"}`, `${selectedTask.leaveRequest.startDate ?? "-"} 至 ${selectedTask.leaveRequest.endDate ?? "-"}`])}
) : null} {selectedTask.attendanceCorrection ? (
{joinBusinessMeta([ `类型:${entityLabel("attendance_correction")}`, `日期:${selectedTask.attendanceCorrection.workDate ?? "-"}`, selectedTask.attendanceCorrection.reason ?? null, ])}
) : null} {selectedTask.purchaseRequest ? (
{joinBusinessMeta([`供应商:${selectedTask.purchaseRequest.supplier?.name ?? "-"}`, selectedTask.purchaseRequest.title])}
) : null} {selectedTask.travelRequest ? (
{joinBusinessMeta([`出差类型:${selectedTask.travelRequest.tripType === "international" ? "国际" : "国内"}`, selectedTask.travelRequest.purpose])}
) : null}

处理历史

{selectedTask.history.length} 条
{selectedTask.history.length ? ( selectedTask.history.map((item) => (
{workflowActionLabel(item.action)} · {item.actorName || item.actorEmployeeNo || item.actorId}
{item.comment || "-"}
{formatDateTime(item.createdAt)}
)) ) : (

暂无历史记录

)}
{!selectedTaskAllowsActions ? (

当前任务已处理,仅供查看,不支持再次审批。

) : ( <>
退回:原单回到申请人修改,保留单号和历史,修改后可重新提交。
驳回:本次流程终止或进入驳回态,申请人通常需要重新发起。