import { useEffect, 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 { bindInvoice, fetchBindableInvoices, unbindInvoice, uploadInvoice, type BindableInvoiceSummary, } from "../api/invoicePool"; import { createReimbursementItem, deleteReimbursement, deleteReimbursementItem, fetchReimbursementDetail, precheckReimbursement, submitReimbursement, updateReimbursementItem, 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 { 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"; 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 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 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 } = useAuth(); const fileInputRef = useRef(null); const [itemForm, setItemForm] = useState(null); const [itemError, setItemError] = useState(""); const [actionError, setActionError] = useState(""); const [confirmDeleteItemId, setConfirmDeleteItemId] = 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 [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 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, }); 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 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 resolveDefaultLinkedItemId = (invoice?: BindableInvoiceSummary | null) => { if (!detail?.items.length) { return ""; } if (detail.items.length === 1) { return detail.items[0].id; } const invoiceAmount = Number(invoice?.totalAmount ?? NaN); if (!Number.isFinite(invoiceAmount)) { return ""; } const matchedItems = detail.items.filter((item) => Number(item.amount) === invoiceAmount); return matchedItems.length === 1 ? matchedItems[0].id : ""; }; 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 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 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.status === "available") { const linkedItemId = resolveDefaultLinkedItemId(null); try { await bindInvoice(uploadResult.invoicePoolId, { reimbursementId, ...(linkedItemId ? { linkedItemId } : {}), }); 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: "OCR 识别失败,请到发票池补录后再绑定" }); } } 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(result.pendingIds); setSelectedInvoiceFiles(result.failures.map((item) => item.file)); if (!result.failures.length && 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; } toast.success(messages.length ? messages.join(",") : "上传完成"); }, onError: (error) => { const message = resolveErrorMessage(error); setInvoiceActionError(message); toast.error(message); }, onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }), }); 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(""); 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] ?? resolveDefaultLinkedItemId(invoice), }; }); }; const updateSelectedInvoiceItem = (invoiceId: string, linkedItemId: string) => { setSelectedInvoices((previous) => ({ ...previous, [invoiceId]: linkedItemId, })); }; 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); 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 ? (