import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link, useLocation } from "react-router-dom"; import { toast } from "sonner"; import type { WorkflowTaskDetail } from "../../api/workflow"; import { approveWorkflowTask, fetchWorkflowApprovers, rejectWorkflowTask, returnWorkflowTask, transferWorkflowTask, } from "../../api/workflow"; import { downloadReimbursementAttachment, fetchReimbursementAttachmentPreviewUrl, fetchReimbursementDetail, fetchReimbursementMaterialPreviewBundle, } from "../../api/reimbursements"; import { fetchInvoiceSignedUrl } from "../../api/invoicePool"; import { isApiError } from "../../api/types"; import { useAuth } from "../../auth/AuthContext"; import { hasPermission } from "../../auth/permissions"; import { ConfirmDialog } from "../../components/common/ConfirmDialog"; import { StatusBadge } from "../../components/common/StatusBadge"; import { useAttachmentDownloadReasonDialog } from "../../components/common/useAttachmentDownloadReasonDialog"; import { ReimbursementDesktopWorkspaceShell } from "../../components/reimbursements/ReimbursementDesktopWorkspaceShell"; import { ReimbursementMobileWorkspaceShell } from "../../components/reimbursements/ReimbursementMobileWorkspaceShell"; import { ReimbursementReturnReferencePicker } from "../../components/reimbursements/ReimbursementReturnReferencePicker"; import { BusinessDetailActionPanel } from "../../components/patterns/BusinessDetailTemplate"; import { MobileStickyActionBar } from "../../components/patterns/MobileStickyActionBar"; import { SectionPanel } from "../../components/patterns/SectionPanel"; import { Button } from "../../components/ui/button"; import { Label } from "../../components/ui/label"; import { Textarea } from "../../components/ui/textarea"; import { approvalRoleLabel, workflowActionLabel } from "../../lib/approvalDisplay"; import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../../lib/attachmentGovernance"; import { formatDisplayDateTime } from "../../lib/displayFormat"; import { buildReimbursementApprovalEvidence } from "../../lib/reimbursementApprovalProjection"; import { buildReimbursementWorkspaceModel, reimbursementAmountIntegrityMessage } from "../../lib/reimbursementWorkspace"; import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage"; import { isWecomWebView } from "../../lib/runtimeContext"; import { getWorkflowConfirmCopy, type WorkflowConfirmAction } from "../../lib/workflowActionConfirm"; import { openFileInBrowser, previewFileInWecom } from "../../lib/wecomFilePreview"; type SecondaryWorkflowAction = "reject" | "return" | "transfer"; type Props = { detail: WorkflowTaskDetail; layout: "desktop" | "mobile"; refetchTask: () => Promise; taskId: string; online?: boolean; }; function isAlreadyProcessedTaskError(error: unknown) { return isApiError(error) && error.details?.task === "already_processed"; } function workflowInstanceStatusLabel(status: string | undefined | null) { const labels: Record = { pending: "审批中", active: "审批中", reviewing: "审批中", approved: "已通过", rejected: "已驳回", terminated: "已终止", cancelled: "已取消", completed: "已完成", }; return status ? labels[status] ?? status : "-"; } function workflowTaskStatusLabel(status: string) { const labels: Record = { pending: "待处理", completed: "已处理", rejected: "已驳回", cancelled: "已取消", }; return labels[status] ?? "未知状态"; } function resolveApprovalBackTarget(value: unknown, layout: "desktop" | "mobile") { const fallback = layout === "mobile" ? "/mobile/approvals" : "/workflow/tasks"; if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//") || value.includes("\\") || value.includes("#")) { return fallback; } if (layout === "mobile") { return value === "/mobile" || value.startsWith("/mobile/approvals?") ? value : fallback; } return value === "/workflow/tasks" || value.startsWith("/workflow/tasks?") ? value : fallback; } export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, taskId, online = true }: Props) { const queryClient = useQueryClient(); const location = useLocation(); const { permissions } = useAuth(); const backTarget = resolveApprovalBackTarget((location.state as { from?: unknown } | null)?.from, layout); const reimbursementId = detail.reimbursement?.id ?? ""; const [rejectComment, setRejectComment] = useState(""); const [returnComment, setReturnComment] = useState(""); const [transferReason, setTransferReason] = useState(""); const [selectedApprover, setSelectedApprover] = useState(""); const [actionError, setActionError] = useState(""); const [activeSecondaryAction, setActiveSecondaryAction] = useState(null); const [confirmAction, setConfirmAction] = useState(null); const [transferConfirmOpen, setTransferConfirmOpen] = useState(false); const reimbursementDetailQuery = useQuery({ enabled: Boolean(reimbursementId) && online, queryKey: ["reimbursements", reimbursementId], queryFn: () => fetchReimbursementDetail(reimbursementId), }); const snapshot = detail.workflowInstance?.contextData ?? null; const evidence = useMemo(() => { if (!reimbursementDetailQuery.data || !snapshot) return null; return buildReimbursementApprovalEvidence(reimbursementDetailQuery.data, snapshot); }, [reimbursementDetailQuery.data, snapshot]); const model = useMemo(() => { if (!reimbursementDetailQuery.data || !evidence) return null; return buildReimbursementWorkspaceModel(reimbursementDetailQuery.data, evidence, { snapshotInvalid: snapshot?.parseStatus === "invalid", snapshotMissing: snapshot?.budgetCheckStatus === "missing", }); }, [evidence, reimbursementDetailQuery.data, snapshot?.budgetCheckStatus, snapshot?.parseStatus]); const decisionReady = Boolean( online && model && model.amountSummary.integrity === "ready" && snapshot?.parseStatus === "ready", ); const allowedActions = useMemo(() => new Set(detail.allowedActions), [detail.allowedActions]); const canTransfer = allowedActions.has("transfer") && hasPermission(permissions, "expense.transfer"); const approverQuery = useQuery({ enabled: canTransfer && online, queryKey: ["workflow", "approvers"], queryFn: () => fetchWorkflowApprovers({ page: 1, pageSize: 50 }), }); 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: (error) => toast.error(resolveUserErrorMessage(error, "打开发票失败,请重试")), }); const openAttachmentMutation = useMutation({ mutationFn: async ({ attachment, reason }: { attachment: { id: string; originalName: string; fileSize: number } & AttachmentGovernanceFields; reason?: string; }) => { if (isWecomWebView()) { const result = await fetchReimbursementAttachmentPreviewUrl(reimbursementId, attachment.id, reason); await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize }); return null; } return downloadReimbursementAttachment(reimbursementId, 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: (error) => toast.error(resolveUserErrorMessage(error, "打开附件失败,请重试")), }); const materialPreviewMutation = useMutation({ mutationFn: (reason?: string) => fetchReimbursementMaterialPreviewBundle(reimbursementId, reason), onSuccess: (bundle) => openFileInBrowser(bundle.signedUrl), onError: (error) => toast.error(resolveUserErrorMessage(error, "打开完整材料失败,可逐份查看原件")), }); const actionMutation = useMutation({ mutationFn: async (action: "approve" | "reject" | "return" | "transfer") => { if (!decisionReady) { throw new Error( model ? reimbursementAmountIntegrityMessage(model.amountSummary.integrity) ?? "完整报销内容或提交时检查尚未就绪,暂时不能处理" : "完整报销内容或提交时检查尚未就绪,暂时不能处理", ); } if (action === "approve") return approveWorkflowTask(taskId); if (action === "reject") return rejectWorkflowTask(taskId, rejectComment.trim()); if (action === "return") return returnWorkflowTask(taskId, returnComment.trim()); if (!selectedApprover) throw new Error("请选择转审目标"); return transferWorkflowTask(taskId, selectedApprover, transferReason.trim()); }, onSuccess: async (_data, action) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }), queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }), refetchTask(), ]); setActionError(""); setActiveSecondaryAction(null); setConfirmAction(null); setTransferConfirmOpen(false); toast.success({ approve: "已通过", reject: "已驳回", return: "已退回", transfer: "已转审" }[action]); }, onError: (error) => { if (isAlreadyProcessedTaskError(error)) void refetchTask(); const message = resolveUserErrorMessage(error, "动作执行失败,请稍后重试"); setActionError(message); toast.error(message); }, }); function requestAction(action: WorkflowConfirmAction) { if (!decisionReady) { setActionError(!online ? "报销审批需联网核对完整报销内容后才能处理。" : model ? reimbursementAmountIntegrityMessage(model.amountSummary.integrity) ?? "完整报销内容或提交时检查尚未就绪,请重试加载后再决定。" : "完整报销内容或提交时检查尚未就绪,请重试加载后再决定。"); return; } const comment = action === "reject" ? rejectComment : action === "return" ? returnComment : ""; if ((action === "reject" || action === "return") && !comment.trim()) { setActionError(`${action === "reject" ? "驳回" : "退回"}必须填写意见`); return; } setActionError(""); setConfirmAction(action); } function appendReturnReference(reference: string) { if (!reference) return; setReturnComment((current) => current.includes(reference) ? current : `${current.trim()}\n${reference}`.trim()); setActiveSecondaryAction("return"); } async function openAttachment(attachmentId: string) { const attachment = reimbursementDetailQuery.data?.attachments.find((item) => item.id === attachmentId); if (!attachment) return toast.error("附件信息已变化,请刷新后重试"); const reason = await requestDownloadReason(attachment); if (reason !== null) openAttachmentMutation.mutate({ attachment, reason }); } async function openAllMaterials() { const governed = reimbursementDetailQuery.data?.attachments.find(attachmentRequiresDownloadReason); const reason = governed ? await requestDownloadReason(governed) : undefined; if (reason !== null) materialPreviewMutation.mutate(reason); } if (!model) { const message = !online ? "报销审批需联网核对完整报销内容;当前不能通过、退回、驳回或转审。" : snapshot?.parseStatus === "invalid" ? "提交时检查记录无法解析,暂时不能审批;请联系系统管理员核对。" : reimbursementDetailQuery.isError ? resolveUserErrorMessage(reimbursementDetailQuery.error, "完整报销内容加载失败,暂时不能审批。") : "正在加载完整报销内容和票据;加载完成前不能作出审批决定。"; const disabledActions = (
); return (

{message}

{disabledActions} {online && reimbursementDetailQuery.isError ? : null}
); } const confirmCopy = confirmAction ? getWorkflowConfirmCopy(confirmAction) : null; const actionDisabled = actionMutation.isPending || !decisionReady; const issueAnchors = model.issueAnchors.length ? (

需要关注

{model.issueAnchors.map((issue) => ( {issue.label} ))}
) : null; const statusSummary = ( <>
节点:{detail.task.stepName}
状态:{workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus)}
审批角色:{approvalRoleLabel(detail.task.assigneeRole)}
{decisionReady ? "完整内容已就绪" : "内容未就绪,审批动作已停用"}
); const offlineNotice = !online ? (

报销审批需联网核对完整报销内容;当前不能通过、退回、驳回或转审。

) : null; const commentEditor = ( <> {actionError ?

{actionError}

: null} {activeSecondaryAction === "return" ? (

退回后原单可修改并重新提交,单号和审批历史会保留。