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, 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 { workflowActionLabel } from "../../lib/approvalDisplay"; import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../../lib/attachmentGovernance"; import { formatDisplayDateTime } from "../../lib/displayFormat"; import { buildReimbursementApprovalEvidence } from "../../lib/reimbursementApprovalProjection"; import { resolveReimbursementApprovalBlockReason } from "../../lib/reimbursementApprovalReadiness"; import { buildReimbursementWorkspaceModel } from "../../lib/reimbursementWorkspace"; import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage"; import { isWecomWebView } from "../../lib/runtimeContext"; import { getWorkflowConfirmCopy } from "../../lib/workflowActionConfirm"; import { openFileInBrowser, previewFileInWecom } from "../../lib/wecomFilePreview"; type SecondaryWorkflowAction = "return" | "transfer"; type ReimbursementConfirmAction = "approve" | "return"; 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 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 [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 projectedDecisionBlockReason = resolveReimbursementApprovalBlockReason({ amountIntegrity: model?.amountSummary.integrity ?? null, detailState: reimbursementDetailQuery.isError ? "error" : reimbursementDetailQuery.data ? "ready" : "loading", online, snapshotParseStatus: snapshot?.parseStatus, }); const decisionBlockReason = projectedDecisionBlockReason ?? "完整报销内容或提交时检查尚未就绪,请重试加载后再决定。"; 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" | "return" | "transfer") => { if (!decisionReady) { throw new Error(decisionBlockReason); } if (action === "approve") return approveWorkflowTask(taskId); 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: "已通过", return: "已退回", transfer: "已转审" }[action]); }, onError: (error) => { if (isAlreadyProcessedTaskError(error)) void refetchTask(); const message = resolveUserErrorMessage(error, "动作执行失败,请稍后重试"); setActionError(message); toast.error(message); }, }); function requestAction(action: ReimbursementConfirmAction) { if (!decisionReady) { setActionError(decisionBlockReason); return; } const comment = action === "return" ? returnComment : ""; if (action === "return" && !comment.trim()) { setActionError("退回修改必须填写意见"); 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 disabledActions = (
); return (

{decisionBlockReason}

{disabledActions} {online && reimbursementDetailQuery.isError ? : null}
); } const baseConfirmCopy = confirmAction ? getWorkflowConfirmCopy(confirmAction) : null; const confirmCopy = confirmAction === "return" && baseConfirmCopy ? { ...baseConfirmCopy, title: "确认退回修改?", confirmLabel: "确认退回修改" } : baseConfirmCopy; const actionDisabled = actionMutation.isPending || !decisionReady; const visibleIssueAnchors = model.issueAnchors.filter((issue) => issue.label !== decisionBlockReason); const issueAnchors = visibleIssueAnchors.length ? (

需要关注

{visibleIssueAnchors.map((issue) => ( {issue.label} ))}
) : null; const decisionBlockNotice = !decisionReady && decisionBlockReason ? (

{decisionBlockReason}

) : null; const actionPanelBeforeActions = issueAnchors || decisionBlockNotice ? (
{issueAnchors}{decisionBlockNotice}
) : null; const offlineNotice = !online ? (

{decisionBlockReason}

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

{actionError}

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

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