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, fetchInvoicePoolList, unbindInvoice, uploadInvoice, type BindableInvoiceSummary, type InvoicePoolListItem, } from "../api/invoicePool"; import { createReimbursementItem, deleteReimbursementAttachment, deleteReimbursement, deleteReimbursementItem, downloadReimbursementAttachment, fetchReimbursementDetail, precheckReimbursement, submitReimbursement, updateReimbursementItem, uploadReimbursementAttachments, type ReimbursementItem, type ReimbursementStatus, } from "../api/reimbursements"; import type { PrecheckResponse } from "../api/feeControl"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; import { ConfirmDialog } from "../components/common/ConfirmDialog"; import { PageScaffold } from "../components/patterns/PageScaffold"; 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 { buildPrecheckDialogMessage } from "../lib/precheck"; import { resolveErrorMessage } from "../lib/resolveErrorMessage"; import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay"; import { TEST_IDS } from "../lib/testIds"; import { withUploadTimeout } from "../lib/uploadTimeout"; import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker"; import { cn } from "../lib/utils"; 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", }; const INVOICE_STATUS_LABEL: Record = { pending_ocr: "识别中", ocr_failed: "识别失败", available: "可用", bound: "已添加", archived: "已归档", }; function todayDate() { return new Date().toISOString().slice(0, 10); } function formatCurrency(value: string | null | undefined) { const numberValue = Number(value ?? 0); if (!Number.isFinite(numberValue)) { return "¥0.00"; } return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY", }).format(numberValue); } function formatDate(value: string | null | undefined) { return value || "未填写"; } function formatDateTime(value: string | null | undefined) { if (!value) { return "-"; } return value.slice(0, 19).replace("T", " "); } function formatInvoiceTitle(invoice: { invoiceCode?: string | null; invoiceNumber?: string | null; sellerName?: string | null; originalName?: string | null; }) { if (invoice.invoiceCode || invoice.invoiceNumber) { return `${invoice.invoiceCode || "-"}-${invoice.invoiceNumber || "-"}`; } return invoice.sellerName || invoice.originalName || "未识别票据"; } 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"; } function itemLabel(item: ReimbursementItem) { const typeName = item.expenseType?.name ?? "费用"; const desc = item.description?.trim(); return desc ? `${typeName} · ${desc}` : typeName; } export function MobileReimbursementDetailPage() { const { reimbursementId = "" } = useParams(); const navigate = useNavigate(); const queryClient = useQueryClient(); const { me, permissions, accessToken } = useAuth(); const fileInputRef = useRef(null); const fileDocumentInputRef = useRef(null); const attachmentInputRef = useRef(null); const [itemForm, setItemForm] = useState(null); const [itemError, setItemError] = useState(""); const [actionError, setActionError] = useState(""); const [confirmDeleteItemId, setConfirmDeleteItemId] = useState(""); const [confirmDeleteInvoiceId, setConfirmDeleteInvoiceId] = useState(""); const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false); 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 [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState([]); const [invoiceActionError, setInvoiceActionError] = useState(""); const [invoiceActionMessage, setInvoiceActionMessage] = useState(""); const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = 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 canViewAllExpense = hasPermission(permissions, "expense.view_all"); 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 canOpenInvoiceDialog = Boolean(canEdit && isParticipant && canUploadInvoice && canViewOwnInvoice); const canUnbindInvoices = Boolean(canEdit && (isParticipant || canViewAllExpense)); const canDeleteDraft = Boolean(detail?.status === "draft" && canDeleteExpense && (isParticipant || canViewAllExpense)); const canManageAttachments = Boolean(canEdit && (isParticipant || canViewAllExpense)); const selectedInvoiceEntries = Object.entries(selectedInvoices); 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, totalAmount: item.totalAmount, originalName: item.originalName, createdAt: item.createdAt, status: "ocr_failed", unavailableMessage: "识别失败,可删除后重新上传。", })), [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(""); 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(); 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 () => { await invalidateDetail(); setSubmitConfirmPrecheck(null); setActionError(""); toast.success("已提交审批"); }, onError: (error) => { const message = resolveErrorMessage(error); setActionError(message); 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))), ); if (!failed.length) { setInvoiceActionError(""); setInvoiceActionMessage(`已添加 ${succeededIds.length} 张发票`); toast.success(`已添加 ${succeededIds.length} 张发票`); return; } const message = failed.map((item) => `${item.invoiceId.slice(0, 8)}:${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 }))); }, [bindInvoicesMutation], ); const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null }>) => { const message = items .map((item) => `${item.originalName ?? "未命名发票"}:${item.availabilityMessage ?? item.message}`) .join(";"); setInvoiceActionError(message); setInvoiceActionMessage(""); 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(""); toast.success("发票已移除"); }, onError: (error) => { const message = resolveErrorMessage(error); setInvoiceActionError(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: uploadResult.unavailableMessage ?? "该发票不可添加", }); } else if (uploadResult.status === "available") { try { await bindInvoice(uploadResult.invoicePoolId, { reimbursementId }); 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: uploadResult.unavailableMessage ?? "发票识别失败,请重新识别或手动处理" }); } else { failures.push({ file, message: uploadResult.unavailableMessage ?? "该发票不可添加" }); } } 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]))); setSelectedInvoiceFiles(result.failures.map((item) => item.file)); if (!result.failures.length) { if (fileInputRef.current) { fileInputRef.current.value = ""; } if (fileDocumentInputRef.current) { fileDocumentInputRef.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("请选择附件材料"); } 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: string) => deleteReimbursementAttachment(reimbursementId, attachmentId), onSuccess: async () => { await invalidateDetail(); toast.success("附件材料已删除"); }, onError: (error) => { toast.error(resolveErrorMessage(error)); }, }); const openAttachmentMutation = useMutation({ mutationFn: (attachmentId: string) => downloadReimbursementAttachment(reimbursementId, attachmentId), onSuccess: (result) => { 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)); }, }); const handleSubmit = async () => { if (!detail) { return; } if (!detail.items.length) { const message = "请先添加至少 1 条报销明细"; setActionError(message); toast.error(message); return; } if (!detail.invoices.length) { const message = "请先添加至少 1 张发票"; setActionError(message); toast.error(message); return; } try { setActionError(""); 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([]); setInvoiceActionError(""); setInvoiceActionMessage(""); setConfirmDeleteInvoiceId(""); setPendingOcrInvoiceIds([]); if (fileInputRef.current) { fileInputRef.current.value = ""; } if (fileDocumentInputRef.current) { fileDocumentInputRef.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 resolveLinkedItemLabel = (linkedItemId: string | null) => { if (!linkedItemId) { return "自动生成或匹配明细"; } const item = detail?.items.find((candidate) => candidate.id === linkedItemId); return item ? itemLabel(item) : "明细"; }; const expenseTypes = expenseTypesQuery.data?.items ?? []; if (!reimbursementId) { return (
缺少报销单 ID。
); } if (detailQuery.isLoading) { return (
); } if (detailQuery.isError || !detail) { return (
{resolveErrorMessage(detailQuery.error)}
); } const displayCode = resolveReimbursementDisplayCode(detail); const showSubmitAction = Boolean(canEdit && canSubmitExpense); const showStickyActions = Boolean(showSubmitAction || canOpenInvoiceDialog); const showSecondaryActions = Boolean(actionError || canDeleteDraft || !canEdit); return (
{displayCode}
报销详情
{STATUS_LABEL[detail.status]}
合计金额
{formatCurrency(detail.totalAmount)}
明细
{detail.items.length}
发票
{detail.invoices.length}
日期
{formatDate(detail.reimbursementDate)}
{detail.description ?

{detail.description}

: null}

报销明细

{canEdit ? ( ) : null}
{itemError ? (
{itemError}
) : null} {itemForm ? (