import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { fetchExpenseTypes } from "../api/expenseTypes"; import { archiveInvoice, bindInvoice, fetchBindableInvoices, fetchInvoiceSignedUrl, fetchInvoicePoolList, unbindInvoice, uploadInvoice, type BindableInvoiceSummary, type InvoicePoolListItem, } from "../api/invoicePool"; import { createReimbursementItem, deleteReimbursementAttachment, deleteReimbursement, deleteReimbursementItem, downloadReimbursementAttachment, fetchReimbursementAttachmentPreviewUrl, fetchReimbursementDetail, fetchReimbursementMaterialPreviewBundle, MAX_REIMBURSEMENT_ATTACHMENT_FILES, precheckReimbursement, submitReimbursement, updateReimbursementItem, uploadReimbursementAttachments, linkReimbursementInvoiceItem, type ReimbursementItem, type ReimbursementStatus, } from "../api/reimbursements"; import type { PrecheckResponse } from "../api/feeControl"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog"; import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog"; import { ConfirmDialog } from "../components/common/ConfirmDialog"; import { RiskFlagsCard } from "../components/common/RiskFlagsCard"; import { MobileIcon } from "../components/mobile/MobileIcon"; import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary"; import { ReimbursementHeaderEditor } from "../components/reimbursements/ReimbursementHeaderEditor"; import { isTravelReimbursement } from "../lib/reimbursementTravel"; import { ReimbursementMobileWorkspaceShell } from "../components/reimbursements/ReimbursementMobileWorkspaceShell"; import { ProcessingProgress } from "../components/patterns/ProcessingProgress"; import { PageScaffold } from "../components/patterns/PageScaffold"; import { MobileStickyActionBar } from "../components/patterns/MobileStickyActionBar"; import { Button } from "../components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "../components/ui/dialog"; import { Input } from "../components/ui/input"; import { Textarea } from "../components/ui/textarea"; import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles"; import { buildPrecheckDialogMessage } from "../lib/precheck"; import { buildReimbursementApprovalEvidence } from "../lib/reimbursementApprovalProjection"; import { buildReimbursementWorkspaceModel } from "../lib/reimbursementWorkspace"; import { formatInvoiceDisplayTitle, resolveInvoicePartyText, resolveInvoiceStatusLabel, resolveInvoiceUserMessage, } from "../lib/invoiceStatus"; import { resolveErrorMessage } from "../lib/resolveErrorMessage"; import { hasCompleteTravelSummary } from "../lib/reimbursementTravel"; import { formatMoney } from "../lib/displayFormat"; import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance"; import { TEST_IDS } from "../lib/testIds"; import { withUploadTimeout } from "../lib/uploadTimeout"; import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker"; import { cn } from "../lib/utils"; import { isWecomWebView } from "../lib/runtimeContext"; import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview"; import { shouldShowOfficeElectronicsHint } from "../lib/reimbursementPolicyHints"; import { focusFirstInvoiceGuardIssue, parseReimbursementInvoiceGuardIssues, resolveInvoiceGuardIssuesAfterLink, type ReimbursementInvoiceGuardIssues, } from "../lib/reimbursementInvoiceGuard"; type ItemFormState = { mode: "create" | "edit"; itemId?: string; expenseTypeId: string; expenseDate: string; amount: string; description: string; }; type SelectedInvoiceMap = Record; type InvoicePanel = "upload" | "library"; type MobileInvoiceLibraryItem = BindableInvoiceSummary & { status: "available" | "ocr_failed"; unavailableMessage?: string | null; }; const STATUS_LABEL: Record = { draft: "草稿", reviewing: "审批中", approved: "已通过", paid: "已支付", rejected: "已驳回", cancelled: "已取消", }; const STATUS_TONE: Record = { draft: "bg-zinc-100 text-zinc-700", reviewing: "bg-amber-50 text-amber-700", approved: "bg-emerald-50 text-emerald-700", paid: "bg-blue-50 text-blue-700", rejected: "bg-red-50 text-red-700", cancelled: "bg-zinc-100 text-zinc-500", }; function reimbursementFallback(status: ReimbursementStatus) { return `/mobile/reimbursements?status=${encodeURIComponent(status)}`; } function todayDate() { return new Date().toISOString().slice(0, 10); } function formatDate(value: string | null | undefined) { return value || "未填写"; } function matchInvoiceSearch( invoice: { invoiceCode?: string | null; invoiceNumber?: string | null; sellerName?: string | null; originalName?: string | null; }, searchLower: string, ) { if (!searchLower) { return true; } return [invoice.invoiceCode, invoice.invoiceNumber, invoice.sellerName, invoice.originalName] .filter(Boolean) .some((value) => String(value).toLowerCase().includes(searchLower)); } function isEditableStatus(status: ReimbursementStatus | undefined) { return status === "draft" || status === "rejected"; } export function MobileReimbursementDetailPage() { const { reimbursementId = "" } = useParams(); const navigate = useNavigate(); const queryClient = useQueryClient(); const { me, permissions, accessToken } = useAuth(); const fileInputRef = useRef(null); const attachmentInputRef = useRef(null); const [itemForm, setItemForm] = useState(null); const [itemError, setItemError] = useState(""); const [actionError, setActionError] = useState(""); const [invoiceGuardIssues, setInvoiceGuardIssues] = useState(null); const [invoiceItemLinkErrors, setInvoiceItemLinkErrors] = useState>({}); const [confirmDeleteItemId, setConfirmDeleteItemId] = useState(""); const [confirmDeleteInvoiceId, setConfirmDeleteInvoiceId] = useState(""); const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false); const [deleteAttachmentTarget, setDeleteAttachmentTarget] = useState<{ id: string; name: string } | null>(null); const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState(null); const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false); const [invoicePanel, setInvoicePanel] = useState("upload"); const [invoiceSearchInput, setInvoiceSearchInput] = useState(""); const [invoiceSearch, setInvoiceSearch] = useState(""); const [selectedInvoices, setSelectedInvoices] = useState({}); const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState([]); const [invoiceUploadLinkedItemId, setInvoiceUploadLinkedItemId] = useState(""); const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState([]); const [invoiceActionError, setInvoiceActionError] = useState(""); const [invoiceActionMessage, setInvoiceActionMessage] = useState(""); const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState([]); const [pendingOcrLinkedItemIds, setPendingOcrLinkedItemIds] = useState>({}); const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 }); const detailQuery = useQuery({ enabled: Boolean(reimbursementId), queryKey: ["reimbursements", reimbursementId], queryFn: () => fetchReimbursementDetail(reimbursementId), }); const expenseTypesQuery = useQuery({ queryKey: ["expenseTypes", "enabled"], queryFn: () => fetchExpenseTypes({ enabled: true }), }); const detail = detailQuery.data; const canUploadInvoice = hasPermission(permissions, "invoice.upload"); const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own"); const canDeleteExpense = hasPermission(permissions, "expense.delete_own"); const canSubmitExpense = hasPermission(permissions, "expense.submit"); const isParticipant = Boolean(detail && me?.id && [detail.ownerId, detail.submitterId].includes(me.id)); const canEdit = isEditableStatus(detail?.status); const canMaintainReimbursement = Boolean(canEdit && isParticipant); const canOpenInvoiceDialog = Boolean(canMaintainReimbursement && canUploadInvoice && canViewOwnInvoice); const canUnbindInvoices = canMaintainReimbursement; const canDeleteDraft = Boolean(detail?.status === "draft" && canDeleteExpense && isParticipant); const selectedInvoiceEntries = Object.entries(selectedInvoices); const reimbursementPrecheckQuery = useQuery({ enabled: Boolean(reimbursementId && canSubmitExpense && canMaintainReimbursement), queryKey: ["reimbursements", reimbursementId, "precheck"], queryFn: () => precheckReimbursement(reimbursementId), }); function handleInvoicePdfSelection(files: FileList | null) { const { accepted, rejected } = splitPdfFiles(files); setSelectedInvoiceFiles(accepted); const message = rejectedPdfMessage(rejected); setInvoiceActionError(message); if (message) { toast.warning(message); } } const bindableInvoicesQuery = useQuery({ enabled: Boolean(reimbursementId && isInvoiceDialogOpen && canOpenInvoiceDialog), queryKey: ["invoice-pool", "bindable", reimbursementId, invoiceSearch], queryFn: () => fetchBindableInvoices({ reimbursementId, search: invoiceSearch || undefined, limit: 20, }), refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false, }); const failedInvoicesQuery = useQuery({ enabled: Boolean(reimbursementId && isInvoiceDialogOpen && canOpenInvoiceDialog), queryKey: ["invoice-pool", "failed-own", reimbursementId], queryFn: () => fetchInvoicePoolList({ status: "ocr_failed", ownerScope: "own", pageSize: 20, }), refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false, }); const invoiceStatusTone = pendingOcrInvoiceIds.length ? "pending" : "success"; const invoiceSearchLower = invoiceSearch.toLowerCase(); const bindableLibraryInvoices = useMemo( () => (bindableInvoicesQuery.data?.items ?? []).map((item) => ({ ...item, status: "available", })), [bindableInvoicesQuery.data?.items], ); const failedLibraryInvoices = useMemo( () => (failedInvoicesQuery.data?.items ?? []) .filter((item: InvoicePoolListItem) => !item.linkedReimbursementId && !item.linkedItemId && !item.linkedTravelOrderId) .filter((item: InvoicePoolListItem) => matchInvoiceSearch(item, invoiceSearchLower)) .map((item: InvoicePoolListItem) => ({ id: item.id, invoiceCode: item.invoiceCode, invoiceNumber: item.invoiceNumber, invoiceDate: item.invoiceDate, sellerName: null, buyerName: null, documentCategory: item.documentCategory, documentSubtype: item.documentSubtype, totalAmount: item.totalAmount, originalName: item.originalName, createdAt: item.createdAt, status: "ocr_failed", unavailableMessage: resolveInvoiceUserMessage(item), })), [failedInvoicesQuery.data?.items, invoiceSearchLower], ); const invoiceLibraryItems = useMemo( () => [...bindableLibraryInvoices, ...failedLibraryInvoices], [bindableLibraryInvoices, failedLibraryInvoices], ); const invoiceLibraryLoading = bindableInvoicesQuery.isLoading || failedInvoicesQuery.isLoading; const invoiceLibraryFetching = bindableInvoicesQuery.isFetching || failedInvoicesQuery.isFetching; const invoiceLibraryError = bindableInvoicesQuery.error ?? failedInvoicesQuery.error ?? null; useEffect(() => { const timerId = window.setTimeout(() => { setInvoiceSearch(invoiceSearchInput.trim()); }, 300); return () => window.clearTimeout(timerId); }, [invoiceSearchInput]); useEffect(() => { const candidateIds = new Set(bindableLibraryInvoices.map((item) => item.id)); setSelectedInvoices((previous) => Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => candidateIds.has(invoiceId))), ); }, [bindableLibraryInvoices]); const invalidateDetail = async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["reimbursements"] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), queryClient.invalidateQueries({ queryKey: ["mobile", "reimbursements"] }), queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }), ]); }; const startCreateItem = () => { setItemForm({ mode: "create", expenseTypeId: expenseTypesQuery.data?.items?.[0]?.id ?? "", expenseDate: detail?.reimbursementDate ?? todayDate(), amount: "", description: "", }); setItemError(""); }; const startEditItem = (item: ReimbursementItem) => { setItemForm({ mode: "edit", itemId: item.id, expenseTypeId: item.expenseTypeId, expenseDate: item.expenseDate ?? detail?.reimbursementDate ?? todayDate(), amount: item.amount, description: item.description ?? "", }); setItemError(""); }; const itemSaveMutation = useMutation({ mutationFn: (form: ItemFormState) => { if (!reimbursementId) { throw new Error("缺少报销单 ID"); } const payload = { expenseTypeId: form.expenseTypeId, expenseDate: form.expenseDate, amount: form.amount, description: form.description.trim() || undefined, }; if (form.mode === "edit" && form.itemId) { return updateReimbursementItem(reimbursementId, form.itemId, payload); } return createReimbursementItem(reimbursementId, payload); }, onSuccess: async () => { await invalidateDetail(); setItemForm(null); setItemError(""); setInvoiceGuardIssues(null); toast.success("明细已保存"); }, onError: (error) => { const message = resolveErrorMessage(error); setItemError(message); toast.error(message); }, }); const itemDeleteMutation = useMutation({ mutationFn: (itemId: string) => deleteReimbursementItem(reimbursementId, itemId), onSuccess: async () => { await invalidateDetail(); setItemForm(null); setConfirmDeleteItemId(""); toast.success("明细已删除"); }, onError: (error) => { const message = resolveErrorMessage(error); setItemError(message); toast.error(message); }, }); const deleteReimbursementMutation = useMutation({ mutationFn: () => deleteReimbursement(reimbursementId), onSuccess: async () => { await invalidateDetail(); toast.success("草稿已删除"); navigate("/mobile/reimbursements"); }, onError: (error) => { const message = resolveErrorMessage(error); setActionError(message); toast.error(message); }, }); const submitMutation = useMutation({ mutationFn: () => submitReimbursement(reimbursementId), onSuccess: async (data) => { await invalidateDetail(); setSubmitConfirmPrecheck(null); setActionError(""); setInvoiceGuardIssues(null); toast.success( data.invoiceAmountManualReviewRequired ? "已提交,票面金额将由财务核对" : "已提交审批", ); }, onError: (error) => { const message = resolveErrorMessage(error); const issues = parseReimbursementInvoiceGuardIssues(error); setSubmitConfirmPrecheck(null); setActionError(message); setInvoiceGuardIssues(issues); if (issues) window.requestAnimationFrame(focusFirstInvoiceGuardIssue); toast.error(message); }, }); const bindInvoicesMutation = useMutation({ mutationFn: async (invoiceSelections: Array<{ invoiceId: string; linkedItemId?: string }>) => { 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 } : {}), }); results.push({ invoiceId: selection.invoiceId, success: true }); } catch (error) { results.push({ invoiceId: selection.invoiceId, success: false, message: resolveErrorMessage(error) }); } } return results; }, onSuccess: async (results) => { await invalidateDetail(); await bindableInvoicesQuery.refetch(); const failed = 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 (!failed.length) { setInvoiceActionError(""); setInvoiceActionMessage(`已添加 ${succeededIds.length} 张发票`); toast.success(`已添加 ${succeededIds.length} 张发票`); return; } const message = failed .map((item) => { const invoice = invoiceLibraryItems.find((candidate) => candidate.id === item.invoiceId); return `${formatInvoiceDisplayTitle(invoice)}:${item.message ?? "添加失败"}`; }) .join(";"); setInvoiceActionError(message); toast.error("部分发票添加失败"); }, onError: (error) => { const message = resolveErrorMessage(error); setInvoiceActionError(message); toast.error(message); }, }); const handleTrackedInvoicesAvailable = useCallback( (invoiceIds: string[]) => { if (!invoiceIds.length) { return; } bindInvoicesMutation.mutate( invoiceIds.map((invoiceId) => ({ invoiceId, linkedItemId: pendingOcrLinkedItemIds[invoiceId] || undefined, })), ); }, [bindInvoicesMutation, pendingOcrLinkedItemIds], ); const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null }>) => { const message = items .map((item) => `${formatInvoiceDisplayTitle(item)}:${resolveInvoiceUserMessage(item)}`) .join(";"); setInvoiceActionError(message); setInvoiceActionMessage(""); 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 invoiceStatusMessage = invoiceOcrTracker.statusMessage || invoiceActionMessage; const unbindInvoiceMutation = useMutation({ mutationFn: (invoiceId: string) => unbindInvoice(invoiceId), onSuccess: async () => { await invalidateDetail(); setInvoiceActionError(""); setInvoiceGuardIssues(null); toast.success("发票已移除"); }, onError: (error) => { const message = resolveErrorMessage(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 invalidateDetail(); 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 = resolveErrorMessage(error, "关联明细失败,请刷新后重试"); setInvoiceItemLinkErrors((previous) => ({ ...previous, [variables.invoiceId]: message })); toast.error(message); }, }); const deleteAvailableInvoiceMutation = useMutation({ mutationFn: (invoiceId: string) => archiveInvoice(invoiceId), onSuccess: async (_result, invoiceId) => { await invalidateDetail(); await Promise.all([bindableInvoicesQuery.refetch(), failedInvoicesQuery.refetch()]); setSelectedInvoices((previous) => { const next = { ...previous }; delete next[invoiceId]; return next; }); setConfirmDeleteInvoiceId(""); setInvoiceActionError(""); setInvoiceActionMessage("发票已删除"); toast.success("发票已删除"); }, onError: (error) => { const message = resolveErrorMessage(error); setInvoiceActionError(message); toast.error(message); }, }); const uploadInvoiceMutation = useMutation({ mutationFn: async (files: File[]) => { if (!detail?.ownerId) { throw new Error("缺少报销人信息"); } if (!files.length) { throw new Error("请选择发票文件"); } const failures: Array<{ file: File; message: string }> = []; const pendingIds: string[] = []; let uploadedCount = 0; let boundCount = 0; setInvoiceUploadProgress({ completed: 0, total: files.length }); for (const [index, file] of files.entries()) { try { const uploadResult = await withUploadTimeout((signal) => uploadInvoice(file, "vat", detail.ownerId, { signal }), ); uploadedCount += 1; if (uploadResult.uploadAction === "blocked" || !uploadResult.invoicePoolId) { failures.push({ file, message: resolveInvoiceUserMessage(uploadResult), }); } else if (uploadResult.status === "available") { try { await bindInvoice(uploadResult.invoicePoolId, { reimbursementId, ...(detail.invoiceGuardEnabled ? { linkedItemId: invoiceUploadLinkedItemId } : {}), }); boundCount += 1; } catch (error) { failures.push({ file, message: `上传成功,添加失败:${resolveErrorMessage(error)}` }); } } else if (uploadResult.status === "pending_ocr") { pendingIds.push(uploadResult.invoicePoolId); } else if (uploadResult.status === "ocr_failed") { failures.push({ file, message: resolveInvoiceUserMessage(uploadResult) }); } else { failures.push({ file, message: resolveInvoiceUserMessage(uploadResult) }); } } catch (error) { failures.push({ file, message: resolveErrorMessage(error) }); } setInvoiceUploadProgress({ completed: index + 1, total: files.length }); } return { uploadedCount, boundCount, pendingIds, failures }; }, onSuccess: async (result) => { await invalidateDetail(); await bindableInvoicesQuery.refetch(); setPendingOcrInvoiceIds((previous) => Array.from(new Set([...previous, ...result.pendingIds]))); if (detail?.invoiceGuardEnabled && invoiceUploadLinkedItemId) { setPendingOcrLinkedItemIds((previous) => ({ ...previous, ...Object.fromEntries(result.pendingIds.map((invoiceId) => [invoiceId, invoiceUploadLinkedItemId])), })); } setSelectedInvoiceFiles(result.failures.map((item) => item.file)); if (!result.failures.length) { if (fileInputRef.current) { fileInputRef.current.value = ""; } } const messages: string[] = []; if (result.boundCount) { messages.push(`${result.boundCount} 张已添加`); } if (result.pendingIds.length) { messages.push(`已上传 ${result.pendingIds.length} 张,正在识别`); } if (result.failures.length) { setInvoiceActionError(result.failures.map((item) => `${item.file.name}:${item.message}`).join(";")); } else { setInvoiceActionError(""); } setInvoiceActionMessage(messages.length ? messages.join(",") : `已上传 ${result.uploadedCount} 张`); if (result.failures.length) { toast.error(`上传完成,${result.failures.length} 项需处理`); return; } if (result.pendingIds.length) { return; } toast.success(messages.length ? messages.join(",") : "上传完成"); }, onError: (error) => { const message = resolveErrorMessage(error); setInvoiceActionError(message); toast.error(message); }, onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }), }); 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 () => { await invalidateDetail(); setSelectedAttachmentFiles([]); if (attachmentInputRef.current) { attachmentInputRef.current.value = ""; } toast.success("附件材料已上传"); }, onError: (error) => { toast.error(resolveErrorMessage(error)); }, }); const deleteAttachmentMutation = useMutation({ mutationFn: ({ attachmentId, reason }: { attachmentId: string; reason: string }) => deleteReimbursementAttachment(reimbursementId, attachmentId, reason), onSuccess: async () => { await invalidateDetail(); setDeleteAttachmentTarget(null); toast.success("附件材料已删除"); }, onError: (error) => { toast.error(resolveErrorMessage(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(resolveErrorMessage(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(resolveErrorMessage(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 = resolveErrorMessage(error) || "连续预览生成失败,可逐份打开原件"; toast.error(message); }, }); async function handleOpenAllMaterials() { const governedAttachment = (detail?.attachments ?? []).find(attachmentRequiresDownloadReason); const reason = governedAttachment ? await requestDownloadReason(governedAttachment) : undefined; if (reason === null) return; materialPreviewMutation.mutate(reason); } const handleSubmit = async () => { if (!detail) { return; } if (!detail.items.length) { const message = "请先添加至少 1 条报销明细"; setActionError(message); toast.error(message); return; } if (!hasCompleteTravelSummary(detail)) { const message = "请先补全出差事由、地点和起止日期"; setActionError(message); toast.error(message); return; } const requiresAnyInvoice = detail.items.some((item) => item.expenseType?.requiresInvoice !== false); if (requiresAnyInvoice && !detail.invoices.length) { const message = "请先添加至少 1 张发票"; setActionError(message); toast.error(message); return; } try { setActionError(""); setInvoiceGuardIssues(null); 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; } submitMutation.mutate(); } catch (error) { const message = resolveErrorMessage(error); setActionError(message); toast.error(message); } }; const closeInvoiceDialog = (open: boolean) => { setIsInvoiceDialogOpen(open); if (open) { setInvoicePanel("upload"); return; } setInvoiceSearchInput(""); setInvoiceSearch(""); setSelectedInvoices({}); setSelectedInvoiceFiles([]); setInvoiceUploadLinkedItemId(""); setInvoiceActionError(""); setInvoiceActionMessage(""); setConfirmDeleteInvoiceId(""); setPendingOcrInvoiceIds([]); if (fileInputRef.current) { fileInputRef.current.value = ""; } }; const toggleInvoiceSelection = (invoice: BindableInvoiceSummary, checked: boolean) => { setSelectedInvoices((previous) => { if (!checked) { const next = { ...previous }; delete next[invoice.id]; return next; } return { ...previous, [invoice.id]: previous[invoice.id] ?? "", }; }); }; const expenseTypes = expenseTypesQuery.data?.items ?? []; if (!reimbursementId) { return (
缺少报销单 ID。
); } if (detailQuery.isLoading) { return (
); } if (detailQuery.isError || !detail) { return (
{resolveErrorMessage(detailQuery.error)}
); } const showSubmitAction = Boolean(canMaintainReimbursement && canSubmitExpense); const workspaceEvidence = buildReimbursementApprovalEvidence(detail, { standardViolations: (reimbursementPrecheckQuery.data?.standardChecks ?? []).filter((item) => item.exceeded), budgetCheck: null, budgetCheckStatus: "not_applicable", parseStatus: "ready", }); const workspaceModel = buildReimbursementWorkspaceModel(detail, workspaceEvidence); const workspaceCapabilities = { 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, name: attachment.originalName }) : undefined, }; const workspaceHeaderDetails = ( <> {!isParticipant ?

只读查看{detail.status === "draft" ? " · 等待报销人完善" : detail.status === "rejected" ? " · 等待报销人修改后重提" : ""}

: null} {canMaintainReimbursement ? : null} {detail.project ?

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

: 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); }} /> {itemForm ? (