import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage"; import { ReimbursementBatchClassification } from "../components/reimbursements/ReimbursementBatchClassification"; import { useCallback, useEffect, useRef, useState } from "react"; import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft } from "lucide-react"; import { fetchExpenseTypes } from "../api/expenseTypes"; import type { ExpenseStandardCheck, PrecheckResponse } from "../api/feeControl"; import { createReimbursementItem, deleteReimbursementAttachment, deleteReimbursement, deleteReimbursementItem, downloadReimbursementAttachment, fetchReimbursementAttachmentPreviewUrl, fetchReimbursementDetail, fetchReimbursementMaterialPreviewBundle, MAX_REIMBURSEMENT_ATTACHMENT_FILES, precheckReimbursement, submitReimbursement, updateReimbursementItem, uploadReimbursementAttachments, withdrawReimbursement, linkReimbursementInvoiceItem, type ReimbursementItem, } from "../api/reimbursements"; import { remindWorkflowTask } from "../api/workflow"; import { bindInvoice, fetchBindableInvoices, fetchInvoiceSignedUrl, unbindInvoice, uploadInvoice, } from "../api/invoicePool"; import { toast } from "sonner"; import { isWecomWebView } from "../lib/runtimeContext"; import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview"; import { hasCompleteTravelSummary, isTravelReimbursement } from "../lib/reimbursementTravel"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; import { CardSkeleton } from "../components/common/CardSkeleton"; import { ConfirmDialog } from "../components/common/ConfirmDialog"; import { useUnsavedChangesGuard } from "../components/common/UnsavedChangesGuard"; import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog"; import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog"; import { RiskFlagsCard } from "../components/common/RiskFlagsCard"; import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary"; import { ReimbursementHeaderEditor } from "../components/reimbursements/ReimbursementHeaderEditor"; import { ReimbursementDesktopWorkspaceShell } from "../components/reimbursements/ReimbursementDesktopWorkspaceShell"; import { ProcessingProgress } from "../components/patterns/ProcessingProgress"; import { resolveReimbursementPaymentHandoff } from "../lib/reimbursementPaymentHandoff"; import { StatusBadge } from "../components/common/StatusBadge"; import { Button } from "../components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "../components/ui/dialog"; import { Input } from "../components/ui/input"; import { BusinessDetailActionPanel } from "../components/patterns/BusinessDetailTemplate"; import { SectionPanel } from "../components/patterns/SectionPanel"; import { formatInvoiceDisplayTitle, resolveInvoicePartyText, resolveInvoiceUserMessage, } from "../lib/invoiceStatus"; import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker"; import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay"; import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance"; import { formatDisplayDate, formatDisplayDateTime, formatMoney } from "../lib/displayFormat"; import { buildPcDetailSource, resolvePcDetailSource, type PcDetailNavigationState, } from "../lib/pcDetailNavigation"; import { TEST_IDS } from "../lib/testIds"; import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles"; import { ReimbursementActions } from "./reimbursement-detail/ReimbursementActions"; import { ReimbursementItemEditor, type ItemEditorInitialValues, type ItemEditorSaveData, } from "./reimbursement-detail/ReimbursementItemEditor"; import { buildPrecheckDialogMessage } from "../lib/precheck"; import { buildReimbursementApprovalEvidence } from "../lib/reimbursementApprovalProjection"; import { buildReimbursementWorkspaceModel } from "../lib/reimbursementWorkspace"; import { focusFirstInvoiceGuardIssue, parseReimbursementInvoiceGuardIssues, resolveInvoiceGuardIssuesAfterLink, type ReimbursementInvoiceGuardIssues, } from "../lib/reimbursementInvoiceGuard"; type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null; type SelectedInvoiceMap = Record; function buildActionErrorMessage(error: unknown, fallbackMessage: string) { return resolveUserErrorMessage(error, fallbackMessage); } export function ReimbursementDetailPage() { const { reimbursementId = "" } = useParams(); const location = useLocation(); const navigate = useNavigate(); const { permissions, me, accessToken } = useAuth(); const queryClient = useQueryClient(); const [actionError, setActionError] = useState(""); const [invoiceGuardIssues, setInvoiceGuardIssues] = useState(null); const [invoiceItemLinkErrors, setInvoiceItemLinkErrors] = useState>({}); const [itemEditor, setItemEditor] = useState(null); const [itemError, setItemError] = useState(""); const [confirmDeleteItemId, setConfirmDeleteItemId] = useState(""); const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false); const [showSecondaryDetail, setShowSecondaryDetail] = useState(false); const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState(null); const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false); const [invoiceSearchInput, setInvoiceSearchInput] = useState(""); const [invoiceSearch, setInvoiceSearch] = useState(""); const [selectedInvoices, setSelectedInvoices] = useState({}); const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState([]); const [invoiceUploadLinkedItemId, setInvoiceUploadLinkedItemId] = useState(""); const deselectedInvoiceIds = useRef(new Set()); const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState([]); const [deleteAttachmentTarget, setDeleteAttachmentTarget] = useState<{ id: string; originalName: string } | null>(null); const [invoiceActionError, setInvoiceActionError] = useState(""); const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState([]); const [pendingOcrLinkedItemIds, setPendingOcrLinkedItemIds] = useState>({}); const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 }); const [editorInitialValues, setEditorInitialValues] = useState({ expenseTypeId: "", expenseDate: "", amount: "", description: "", }); const currentDetailSource = buildPcDetailSource(location.pathname, location.search); const navigationState = location.state as PcDetailNavigationState | null; const backSource = resolvePcDetailSource(navigationState?.from, currentDetailSource) ?? "/reimbursements"; const backLabel = backSource.startsWith("/workflow/tasks/") ? "返回审批详情" : "返回报销列表"; const runGuarded = useUnsavedChangesGuard( Boolean(itemEditor) || selectedInvoiceFiles.length > 0 || selectedAttachmentFiles.length > 0 || Object.keys(selectedInvoices).length > 0, "报销表单", ); const detailQuery = useQuery({ enabled: Boolean(reimbursementId), queryKey: ["reimbursements", reimbursementId], queryFn: () => fetchReimbursementDetail(reimbursementId), staleTime: 0, refetchOnMount: true, refetchOnWindowFocus: (query) => Boolean(query.state.data && !["draft", "rejected"].includes(query.state.data.status)), }); const expenseTypesQuery = useQuery({ queryKey: ["expenseTypes", "enabled"], queryFn: () => fetchExpenseTypes({ enabled: true }), }); const canSubmitExpense = hasPermission(permissions, "expense.submit"); const canUploadInvoice = hasPermission(permissions, "invoice.upload"); const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own"); const canDeleteExpense = hasPermission(permissions, "expense.delete_own"); const canManagePaymentBatch = hasPermission(permissions, "payment.batch.manage"); const reimbursementPrecheckQuery = useQuery({ enabled: Boolean( reimbursementId && canSubmitExpense && detailQuery.data && me?.id && [detailQuery.data.ownerId, detailQuery.data.submitterId].includes(me.id) && ["draft", "rejected"].includes(detailQuery.data.status), ), queryKey: ["reimbursements", reimbursementId, "precheck"], queryFn: () => precheckReimbursement(reimbursementId), }); const bindableInvoicesQuery = useQuery({ enabled: Boolean(reimbursementId) && isInvoiceDialogOpen && canUploadInvoice && canViewOwnInvoice && Boolean(detailQuery.data) && ["draft", "rejected"].includes(detailQuery.data?.status ?? "") && Boolean(me?.id) && [detailQuery.data?.ownerId, detailQuery.data?.submitterId].includes(me?.id ?? ""), queryKey: ["invoice-pool", "bindable", reimbursementId, invoiceSearch], queryFn: () => fetchBindableInvoices({ reimbursementId, search: invoiceSearch || undefined, limit: 20, }), refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false, }); useEffect(() => { const timerId = window.setTimeout(() => { setInvoiceSearch(invoiceSearchInput.trim()); }, 300); return () => window.clearTimeout(timerId); }, [invoiceSearchInput]); useEffect(() => { const candidateIds = new Set((bindableInvoicesQuery.data?.items ?? []).map((item) => item.id)); setSelectedInvoices((previous) => Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => candidateIds.has(invoiceId))), ); }, [bindableInvoicesQuery.data?.items]); const mutation = useMutation({ mutationFn: async (params: { action: "submit" } | { action: "withdraw"; comment: string }) => { if (!reimbursementId) { throw new Error("missing reimbursementId"); } if (params.action === "submit") { return submitReimbursement(reimbursementId); } if (params.action === "withdraw") { return withdrawReimbursement(reimbursementId, params.comment); } }, onSuccess: async (data, params) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["reimbursements"] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }), ]); setActionError(""); setInvoiceGuardIssues(null); if (params.action === "submit") { setSubmitConfirmPrecheck(null); } const requiresManualReview = params.action === "submit" && data != null && typeof data === "object" && "invoiceAmountManualReviewRequired" in data && data.invoiceAmountManualReviewRequired === true; const actionMessages: Record = { submit: requiresManualReview ? "已提交,票面金额将由财务核对" : "已提交", withdraw: "已撤回", }; toast.success(actionMessages[params.action] ?? "操作成功"); }, onError: (error) => { const message = buildActionErrorMessage(error, "操作失败,请稍后重试"); const issues = parseReimbursementInvoiceGuardIssues(error); setSubmitConfirmPrecheck(null); setActionError(message); setInvoiceGuardIssues(issues); if (issues) window.requestAnimationFrame(focusFirstInvoiceGuardIssue); toast.error(message); }, }); const remindMutation = useMutation({ mutationFn: (taskId: string) => remindWorkflowTask(taskId), onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), queryClient.invalidateQueries({ queryKey: ["notifications"] }), queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }), queryClient.invalidateQueries({ queryKey: ["workflow", "tasks", "summary"] }), queryClient.invalidateQueries({ queryKey: ["portal-home"] }), ]); toast.success("已催办当前审批人"); }, onError: (error) => { toast.error(buildActionErrorMessage(error, "催办失败,请稍后重试")); }, }); const itemSaveMutation = useMutation({ mutationFn: async (data: ItemEditorSaveData) => { if (!reimbursementId) { throw new Error("missing reimbursementId"); } if (!itemEditor) { throw new Error("missing itemEditor"); } const payload = { expenseTypeId: data.expenseTypeId || null, expenseDate: data.expenseDate, amount: data.amount, description: data.description.trim(), }; if (itemEditor.mode === "create") { return createReimbursementItem(reimbursementId, payload); } return updateReimbursementItem(reimbursementId, itemEditor.itemId, payload); }, onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["reimbursements"] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }), ]); setItemEditor(null); setItemError(""); setInvoiceGuardIssues(null); toast.success("明细已保存"); }, onError: (error) => { const message = buildActionErrorMessage(error, "保存明细失败,请稍后重试"); setItemError(message); toast.error(message); }, }); const itemDeleteMutation = useMutation({ mutationFn: async (itemId: string) => { if (!reimbursementId) { throw new Error("missing reimbursementId"); } return deleteReimbursementItem(reimbursementId, itemId); }, onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["reimbursements"] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }), ]); setItemEditor(null); setConfirmDeleteItemId(""); setItemError(""); toast.success("明细已删除"); }, onError: (error) => { const message = buildActionErrorMessage(error, "删除明细失败,请稍后重试"); setItemError(message); toast.error(message); }, }); const deleteReimbursementMutation = useMutation({ mutationFn: async () => { if (!reimbursementId) { throw new Error("missing reimbursementId"); } return deleteReimbursement(reimbursementId); }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["reimbursements"] }); toast.success("报销单已删除"); navigate("/reimbursements?status=draft"); }, onError: (error) => { const message = buildActionErrorMessage(error, "删除报销单失败"); setActionError(message); toast.error(message); }, }); const bindInvoicesMutation = useMutation({ mutationFn: async (invoiceSelections: Array<{ invoiceId: string; linkedItemId?: string }>) => { if (!reimbursementId) { throw new Error("missing reimbursementId"); } const results: Array<{ invoiceId: string; success: boolean; message?: string }> = []; for (const selection of invoiceSelections) { try { await bindInvoice(selection.invoiceId, { reimbursementId, ...(selection.linkedItemId ? { linkedItemId: selection.linkedItemId } : { createItem: true as const }), }); results.push({ invoiceId: selection.invoiceId, success: true }); } catch (error) { results.push({ invoiceId: selection.invoiceId, success: false, message: buildActionErrorMessage(error, "添加发票失败"), }); } } return results; }, onSuccess: async (results) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["reimbursements"] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] }), ]); const failedResults = results.filter((item) => !item.success); const succeededIds = results.filter((item) => item.success).map((item) => item.invoiceId); setSelectedInvoices((previous) => Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))), ); setPendingOcrLinkedItemIds((previous) => Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))), ); if (succeededIds.length) setInvoiceGuardIssues(null); if (!failedResults.length) { setInvoiceActionError(""); setIsInvoiceDialogOpen(false); setInvoiceUploadLinkedItemId(""); setInvoiceSearchInput(""); setInvoiceSearch(""); setSelectedInvoices({}); setSelectedInvoiceFiles([]); setPendingOcrInvoiceIds((previous) => previous.filter((invoiceId) => !succeededIds.includes(invoiceId)), ); toast.success(`已添加 ${succeededIds.length} 张发票`); return; } const summary = failedResults .map((item) => { const invoice = (bindableInvoicesQuery.data?.items ?? []).find((candidate) => candidate.id === item.invoiceId); return `${formatInvoiceDisplayTitle(invoice)}:${item.message ?? "添加失败"}`; }) .join(";"); setInvoiceActionError(summary); if (succeededIds.length) { toast.success(`已添加 ${succeededIds.length} 张,${failedResults.length} 张失败`); } else { toast.error("发票添加失败"); } }, onError: (error) => { const message = buildActionErrorMessage(error, "发票添加失败"); setInvoiceActionError(message); toast.error(message); }, }); const handleTrackedInvoicesAvailable = useCallback( (invoiceIds: string[]) => { if (!invoiceIds.length) { return; } void queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); setSelectedInvoices((previous) => { const next = { ...previous }; for (const invoiceId of invoiceIds) { if (!deselectedInvoiceIds.current.has(invoiceId) && !(invoiceId in next)) { next[invoiceId] = pendingOcrLinkedItemIds[invoiceId] || ""; } } return next; }); }, [pendingOcrLinkedItemIds, queryClient], ); const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null; ocrLastError?: string | null }>) => { const message = items .map((item) => `${formatInvoiceDisplayTitle(item)}:${resolveInvoiceUserMessage(item)}`) .join(";"); setInvoiceActionError(message); const unavailableIds = new Set(items.map((item) => item.id)); setPendingOcrLinkedItemIds((previous) => Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !unavailableIds.has(invoiceId))), ); toast.error("部分发票需要处理"); }, []); const invoiceOcrTracker = useInvoiceOcrTracker({ accessToken, enabled: Boolean(reimbursementId), pendingInvoiceIds: pendingOcrInvoiceIds, setPendingInvoiceIds: setPendingOcrInvoiceIds, onAvailable: handleTrackedInvoicesAvailable, onUnavailable: handleTrackedInvoicesUnavailable, }); const unbindInvoiceMutation = useMutation({ mutationFn: (invoiceId: string) => unbindInvoice(invoiceId), onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["reimbursements"] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] }), ]); setInvoiceActionError(""); setInvoiceGuardIssues(null); toast.success("发票已移除"); }, onError: (error) => { const message = buildActionErrorMessage(error, "移除发票失败"); setInvoiceActionError(message); toast.error(message); }, }); const linkInvoiceItemMutation = useMutation({ mutationFn: ({ invoiceId, linkedItemId }: { invoiceId: string; linkedItemId: string }) => linkReimbursementInvoiceItem(reimbursementId, invoiceId, linkedItemId), onSuccess: async (_result, variables) => { await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }); setInvoiceGuardIssues((previous) => resolveInvoiceGuardIssuesAfterLink( previous, variables.invoiceId, variables.linkedItemId, )); setInvoiceItemLinkErrors((previous) => { const next = { ...previous }; delete next[variables.invoiceId]; return next; }); toast.success("发票已关联到报销明细"); }, onError: (error, variables) => { const message = buildActionErrorMessage(error, "关联明细失败,请刷新后重试"); setInvoiceItemLinkErrors((previous) => ({ ...previous, [variables.invoiceId]: message })); toast.error(message); }, }); const uploadInvoiceMutation = useMutation({ mutationFn: async (files: File[]) => { const ownerEmployeeId = detailQuery.data?.ownerId; if (!ownerEmployeeId) { throw new Error("missing ownerEmployeeId"); } if (!files.length) { throw new Error("请选择发票文件"); } const uploadFailures: Array<{ file: File; message: string }> = []; const pendingById = new Map(); const availableIds: string[] = []; const ocrFailures: string[] = []; const bindFailures: string[] = []; setInvoiceUploadProgress({ completed: 0, total: files.length }); for (const [index, file] of files.entries()) { try { const result = await uploadInvoice(file, "vat", ownerEmployeeId); if (result.uploadAction === "blocked" || !result.invoicePoolId) { uploadFailures.push({ file, message: resolveInvoiceUserMessage(result), }); } else if (result.status === "available") { availableIds.push(result.invoicePoolId); } else if (result.status === "pending_ocr") { pendingById.set(result.invoicePoolId, file.name); } else { uploadFailures.push({ file, message: resolveInvoiceUserMessage(result), }); } } catch (error) { uploadFailures.push({ file, message: buildActionErrorMessage(error, "上传发票失败") }); } setInvoiceUploadProgress({ completed: index + 1, total: files.length }); } setPendingOcrInvoiceIds((previous) => Array.from(new Set([...previous, ...Array.from(pendingById.keys())])), ); if (invoiceUploadLinkedItemId) { setPendingOcrLinkedItemIds((previous) => ({ ...previous, ...Object.fromEntries(Array.from(pendingById.keys()).map((invoiceId) => [invoiceId, invoiceUploadLinkedItemId])), })); } setSelectedInvoices((previous) => ({ ...Object.fromEntries(availableIds.filter((id) => !deselectedInvoiceIds.current.has(id)).map((id) => [id, invoiceUploadLinkedItemId])), ...previous, })); return { uploadedCount: files.length - uploadFailures.length, uploadFailures, bindFailures, pendingIds: Array.from(pendingById.keys()), ocrFailures, }; }, onSuccess: async (result) => { setInvoiceSearchInput(""); setInvoiceSearch(""); setSelectedInvoiceFiles(result.uploadFailures.map((item) => item.file)); setPendingOcrInvoiceIds((previous) => Array.from(new Set([...previous, ...result.pendingIds]))); await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); await queryClient.invalidateQueries({ queryKey: ["reimbursements"] }); await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }); await bindableInvoicesQuery.refetch(); const blockingMessages = [ ...result.uploadFailures.map((item) => `${item.file.name}: ${item.message}`), ...result.ocrFailures, ...result.bindFailures, ]; if (blockingMessages.length) { const message = blockingMessages.join(";"); setInvoiceActionError(message); toast.error(`发票上传完成:${result.uploadedCount} 张已上传,${blockingMessages.length} 项需处理`); return; } setInvoiceActionError(""); if (result.pendingIds.length) { toast.success(`已上传 ${result.pendingIds.length} 张发票,识别完成后默认勾选,请点击添加到报销单`); return; } toast.success(`发票上传完成:${result.uploadedCount} 张已入我的发票,请选择后添加`); }, onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }), onError: (error) => { const message = buildActionErrorMessage(error, "上传发票失败"); setInvoiceActionError(message); toast.error(message); }, }); const uploadAttachmentsMutation = useMutation({ mutationFn: async (files: File[]) => { if (!files.length) { throw new Error("请选择附件材料"); } if (files.length > MAX_REIMBURSEMENT_ATTACHMENT_FILES) { throw new Error(`单次最多上传 ${MAX_REIMBURSEMENT_ATTACHMENT_FILES} 个附件,请分批上传。`); } return uploadReimbursementAttachments(reimbursementId, files); }, onSuccess: async () => { setSelectedAttachmentFiles([]); await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }); toast.success("附件材料已上传"); }, onError: (error) => { toast.error(buildActionErrorMessage(error, "上传附件失败")); }, }); const deleteAttachmentMutation = useMutation({ mutationFn: ({ attachmentId, reason }: { attachmentId: string; reason: string }) => deleteReimbursementAttachment(reimbursementId, attachmentId, reason), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }); setDeleteAttachmentTarget(null); toast.success("附件材料已删除"); }, onError: (error) => { toast.error(buildActionErrorMessage(error, "删除附件失败")); }, }); 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(buildActionErrorMessage(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(buildActionErrorMessage(error, "打开附件失败")); }, }); async function handleOpenAttachment(attachment: { id: string; originalName: string; fileSize: number } & AttachmentGovernanceFields) { const reason = await requestDownloadReason(attachment); if (reason === null) { return; } openAttachmentMutation.mutate({ attachment, reason }); } const materialPreviewMutation = useMutation({ mutationFn: (reason?: string) => fetchReimbursementMaterialPreviewBundle(reimbursementId, reason), onSuccess: async (bundle) => { if (isWecomWebView()) { try { await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, size: bundle.fileSize }); return; } catch { toast.error("企业微信材料预览未打开,已切换到浏览器预览"); } } openFileInBrowser(bundle.signedUrl); }, onError: (error) => { const message = buildActionErrorMessage(error, "连续预览生成失败,可逐份打开原件"); toast.error(message); }, }); async function handleOpenAllMaterials() { const governedAttachment = (detailQuery.data?.attachments ?? []).find(attachmentRequiresDownloadReason); const reason = governedAttachment ? await requestDownloadReason(governedAttachment) : undefined; if (reason === null) return; materialPreviewMutation.mutate(reason); } if (!reimbursementId) { return (

缺少 reimbursementId 参数。

); } if (detailQuery.isLoading) { return ; } if (detailQuery.isError || !detailQuery.data) { const message = resolveUserErrorMessage(detailQuery.error, "加载报销详情失败"); return (

{message}

); } const detail = detailQuery.data; const displayCode = resolveReimbursementDisplayCode(detail); const budgetHint = detail.budgetHint; const canEditItems = ["draft", "rejected"].includes(detail.status); const isReimbursementParticipant = Boolean(me?.id) && [detail.ownerId, detail.submitterId].includes(me?.id ?? ""); const canMaintainReimbursement = canEditItems && isReimbursementParticipant; const pendingTaskId = detail.pendingWorkflow?.task?.id ?? null; const canRemindPendingWorkflow = Boolean( detail.status === "reviewing" && pendingTaskId && isReimbursementParticipant && detail.pendingWorkflow?.task?.assignee_id !== me?.id, ); const canOpenInvoiceBindDialog = canEditItems && isReimbursementParticipant && canUploadInvoice && canViewOwnInvoice; const canUnbindInvoices = canMaintainReimbursement; const canDeleteReimbursement = ["draft", "rejected"].includes(detail.status) && !detail.deletedAt && canDeleteExpense && isReimbursementParticipant; const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? []; const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block"); const missingInvoiceSubmitMessage = "提交前需先添加至少 1 张发票。"; const requiresAnyInvoice = detail.items.some((item) => item.expenseType?.requiresInvoice !== false); const selectedInvoiceEntries = Object.entries(selectedInvoices); function handleInvoicePdfSelection(files: FileList | null) { const { accepted, rejected } = splitPdfFiles(files); setSelectedInvoiceFiles(accepted); const message = rejectedPdfMessage(rejected); setActionError(message); if (message) { toast.warning(message); } } const expenseTypes = expenseTypesQuery.data?.items ?? []; const computeEditorDefaults = (item?: ReimbursementItem): ItemEditorInitialValues => { const defaultDate = item?.expenseDate ?? detail.reimbursementDate ?? new Date().toISOString().slice(0, 10); const defaultExpenseTypeId = item ? item.expenseTypeId ?? "" : expenseTypes[0]?.id ?? ""; return { expenseDate: defaultDate, expenseTypeId: defaultExpenseTypeId, amount: item?.amount ?? "", description: item?.description ?? "", }; }; const startCreateItem = () => { runGuarded(() => { setItemEditor({ mode: "create" }); setEditorInitialValues(computeEditorDefaults()); setItemError(""); }); }; const startEditItem = (item: ReimbursementItem) => { runGuarded(() => { setItemEditor({ mode: "edit", itemId: item.id }); setEditorInitialValues(computeEditorDefaults(item)); setItemError(""); }); }; const handleItemSave = (data: ItemEditorSaveData) => { itemSaveMutation.mutate(data); }; const handleItemCancel = () => { runGuarded(() => { setItemEditor(null); setItemError(""); }); }; const handleSubmitAction = async () => { if (!reimbursementId) { return; } try { setActionError(""); setInvoiceGuardIssues(null); if (!hasCompleteTravelSummary(detail)) { const message = "请先补全出差事由、地点和起止日期"; setActionError(message); toast.error(message); return; } if (requiresAnyInvoice && detail.invoices.length < 1) { setActionError(missingInvoiceSubmitMessage); toast.error(missingInvoiceSubmitMessage); return; } const precheck = await precheckReimbursement(reimbursementId); const message = buildPrecheckDialogMessage(precheck); if (precheck.decision === "block") { setActionError(message); toast.error("提交前校验未通过"); return; } if (precheck.decision === "warn" || precheck.decision === "manual_review") { setSubmitConfirmPrecheck(precheck); return; } mutation.mutate({ action: "submit" }); } catch (error) { const message = buildActionErrorMessage(error, "提交前校验失败,请稍后重试"); setActionError(message); toast.error(message); } }; const handleInvoiceDialogOpenChange = (open: boolean) => { setIsInvoiceDialogOpen(open); if (!open) { setInvoiceSearchInput(""); setInvoiceSearch(""); setSelectedInvoices({}); setSelectedInvoiceFiles([]); setInvoiceUploadLinkedItemId(""); setInvoiceActionError(""); setPendingOcrInvoiceIds([]); } }; const toggleInvoiceSelection = (invoiceId: string, checked: boolean) => { if (checked) deselectedInvoiceIds.current.delete(invoiceId); else deselectedInvoiceIds.current.add(invoiceId); setSelectedInvoices((previous) => { if (!checked) { const next = { ...previous }; delete next[invoiceId]; return next; } return { ...previous, [invoiceId]: previous[invoiceId] ?? invoiceUploadLinkedItemId, }; }); }; const handleBindSelectedInvoices = () => { if (!selectedInvoiceEntries.length) { return; } bindInvoicesMutation.mutate( selectedInvoiceEntries.map(([invoiceId, linkedItemId]) => ({ invoiceId, linkedItemId: linkedItemId || undefined, })), ); }; const itemCount = detail.items.length; const invoiceCount = detail.invoices.length; const hasBlockingPrecheck = blockStandardChecks.length > 0; const processingProgress = detail.processingProgress ?? { stages: [] }; const paymentHandoff = resolveReimbursementPaymentHandoff({ ...detail, progress: detail.processingProgress }); const currentProcessingStage = processingProgress.stages.find((stage) => stage.state === "current" || stage.state === "exception", ); const nextStepText = (() => { if (detail.deletedAt) return "已删除,仅供查看历史记录"; if (["draft", "rejected"].includes(detail.status)) { if (!isReimbursementParticipant) { return detail.status === "rejected" ? "等待报销人修改后重提" : "等待报销人完善"; } if (!itemCount) { return "补报销明细"; } if (requiresAnyInvoice && !invoiceCount) { return "添加发票"; } if (hasBlockingPrecheck) { return "修改超标明细"; } return "提交审批"; } if (detail.status === "reviewing") { return currentProcessingStage?.detail ?? "查看处理进度"; } if (paymentHandoff) return paymentHandoff.nextStep; return "查看记录"; })(); const primaryAction = (() => { if (canMaintainReimbursement) { if (!itemCount) { return ( ); } if (requiresAnyInvoice && !invoiceCount) { return ( ); } return ( ); } if (paymentHandoff?.targetHref) { return ; } if (paymentHandoff?.canCreateBatch && canManagePaymentBatch) { return ( ); } return null; })(); const hasMoreActions = detail.status !== "paid" || canDeleteReimbursement; const secondaryActions = [ detail.status === "reviewing" && canRemindPendingWorkflow && pendingTaskId ? ( ) : null, detail.status === "paid" ? ( ) : null, hasMoreActions ? ( ) : null, ]; const workspaceEvidence = buildReimbursementApprovalEvidence(detail, { standardViolations: precheckStandardChecks.filter((item) => item.exceeded), budgetCheck: null, budgetCheckStatus: "not_applicable", parseStatus: "ready", }); const workspaceModel = buildReimbursementWorkspaceModel(detail, workspaceEvidence); const workspaceCapabilities = { onAddInvoices: canOpenInvoiceBindDialog ? (item: { id: string }) => { handleInvoiceDialogOpenChange(true); setInvoiceUploadLinkedItemId(item.id); } : undefined, onOpenInvoice: (invoice: { id: string }) => openInvoiceMutation.mutate(invoice.id), onOpenAttachment: (attachment: { id: string }) => { const sourceAttachment = detail.attachments.find((item) => item.id === attachment.id); if (sourceAttachment) void handleOpenAttachment(sourceAttachment); }, onOpenMaterialBundle: detail.invoices.length || detail.attachments.length ? () => void handleOpenAllMaterials() : undefined, onEditItem: canMaintainReimbursement ? (item: { id: string }) => { const sourceItem = detail.items.find((candidate) => candidate.id === item.id); if (sourceItem) startEditItem(sourceItem); } : undefined, onLinkInvoice: canMaintainReimbursement ? (invoice: { id: string }, linkedItemId: string) => linkInvoiceItemMutation.mutate({ invoiceId: invoice.id, linkedItemId }) : undefined, linkInvoicePending: linkInvoiceItemMutation.isPending, linkInvoiceErrors: invoiceItemLinkErrors, onUnlinkInvoice: canUnbindInvoices ? (invoice: { id: string }) => unbindInvoiceMutation.mutate(invoice.id) : undefined, onDeleteAttachment: canMaintainReimbursement ? (attachment: { id: string; originalName: string }) => setDeleteAttachmentTarget({ id: attachment.id, originalName: attachment.originalName }) : undefined, }; const workspaceHeaderDetails = canMaintainReimbursement || isTravelReimbursement(detail) || detail.project || detail.linkedTravelRequest ? ( {canMaintainReimbursement ? : null} {canMaintainReimbursement ? ( ) : null} {detail.project ?

关联项目:{detail.project.projectNo} · {detail.project.name}

: null} {detail.linkedTravelRequest ? 差旅申请:{detail.linkedTravelRequest.requestNo ?? "未编号"} : null}
) : null; const workspaceBeforeEvidence = ( <> {invoiceGuardIssues ?

票据检查未通过

请按明细和票据中的提示处理后再次提交。

: null} {actionError ?

{actionError}

: null} {itemError ?

{itemError}

: null} {canMaintainReimbursement ? ( { const files = Array.from(event.target.files ?? []); if (files.length > MAX_REIMBURSEMENT_ATTACHMENT_FILES) { setSelectedAttachmentFiles([]); event.target.value = ""; toast.error(`单次最多上传 ${MAX_REIMBURSEMENT_ATTACHMENT_FILES} 个附件,请分批上传。`); return; } setSelectedAttachmentFiles(files); }} />}> {itemEditor ? setConfirmDeleteItemId(itemEditor.itemId) : undefined} isPending={itemSaveMutation.isPending} /> :

选择上方动作维护明细、发票或补充附件。

}
) : null} ); const workspaceAfterEvidence = ( <> {(budgetHint?.exceedsThreshold || detail.riskFlags.length) ? {budgetHint?.exceedsThreshold ?

预算 {budgetHint.budgetCode}:提交后预计使用 {budgetHint.nextUsagePercent.toFixed(2)}%,已达到预警阈值。

: null}{detail.riskFlags.length ? : null}
: null} ); const workspaceActionPanel = ( } primaryAction={primaryAction} secondaryActions={secondaryActions} moreActions={showSecondaryDetail ?
{detail.status !== "paid" ? : null}{canDeleteReimbursement ? : null}
: null} summary={<>
下一步:{nextStepText}
{workspaceModel.issueAnchors.map((issue) => {issue.label})}} testId="reimbursement-action-panel"> 0} submitHint="" submitNotice="" isPending={mutation.isPending} onSubmit={() => void handleSubmitAction()} onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })} />
); return ( <> {downloadReasonDialog} , backAction: , headerDetails: workspaceHeaderDetails, beforeEvidence: workspaceBeforeEvidence, afterEvidence: workspaceAfterEvidence, actionPanel: workspaceActionPanel, asideContext: }} /> 我的发票 选择可添加发票,或上传新发票。
setInvoiceSearchInput(event.target.value)} />
{invoiceActionError ? (

{invoiceActionError}

) : null}
{bindableInvoicesQuery.isLoading ? (

正在加载可添加发票...

) : bindableInvoicesQuery.isError ? (

{buildActionErrorMessage(bindableInvoicesQuery.error, "加载可添加发票失败")}

) : (bindableInvoicesQuery.data?.items ?? []).length ? ( bindableInvoicesQuery.data?.items.map((item) => { const isSelected = Object.prototype.hasOwnProperty.call(selectedInvoices, item.id); return ( ); }) ) : (
当前没有可添加发票,可直接在下方上传新发票。
)}
已选 {selectedInvoiceEntries.length} 张发票
或者上传新发票

仅支持 PDF 发票,可多选。

{ handleInvoicePdfSelection(event.target.files); event.target.value = ""; }} />
{detail.items.length > 0 ? ( ) : null} {selectedInvoiceFiles.length ? (
{selectedInvoiceFiles.slice(0, 5).map((file) => (
{file.name} · {Math.ceil(file.size / 1024)} KB
))} {selectedInvoiceFiles.length > 5 ?
另有 {selectedInvoiceFiles.length - 5} 个文件
: null}
) : null} {pendingOcrInvoiceIds.length ? (

{invoiceOcrTracker.statusMessage}

) : null}
{ if (!open) { setSubmitConfirmPrecheck(null); } }} > 确认提交报销单? 以下校验项不会阻止提交,但会作为预警或复核信息进入审批流程。 {submitConfirmPrecheck ? (
{buildPrecheckDialogMessage(submitConfirmPrecheck)}
{submitConfirmPrecheck.standardChecks.some((item) => item.exceeded && item.controlMode === "warn") ? (

超出费用标准的明细

{submitConfirmPrecheck.standardChecks .filter((item) => item.exceeded && item.controlMode === "warn") .map((item: ExpenseStandardCheck) => (
{item.expenseTypeName}
当前 {formatMoney(item.amount)} / 标准 {formatMoney(item.maxAmount)}/{item.perUnit} / 超出 {formatMoney(item.exceededAmount)}
{item.standardName}
))}
) : null}
) : null}
{ if (!open) setConfirmDeleteItemId(""); }} title="确认删除该明细?" variant="destructive" confirmLabel="删除" onConfirm={() => { itemDeleteMutation.mutate(confirmDeleteItemId); }} /> { deleteReimbursementMutation.mutate(); }} /> { if (!open) setDeleteAttachmentTarget(null); }} onConfirm={(deleteReason) => { if (!deleteAttachmentTarget) return; deleteAttachmentMutation.mutate({ attachmentId: deleteAttachmentTarget.id, reason: deleteReason }); }} /> ); }