import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isApiError } from "../api/types"; import { mapApiErrorMessage } from "../api/errorMessages"; import { fetchMyDelegations } from "../api/delegations"; import { exportInvoicePool } from "../api/exports"; import { archiveInvoice, bindInvoice, fetchInvoicePoolDetail, fetchInvoicePoolList, fetchInvoiceSignedUrl, overrideInvoiceDuplicate, patchInvoice, retryInvoiceOcr, unbindInvoice, uploadInvoice, type InvoicePoolStatus, } from "../api/invoicePool"; import { fetchReimbursements } from "../api/reimbursements"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; import { downloadBlob } from "../lib/download"; import { PageHeader } from "../components/common/PageHeader"; import { Button } from "../components/ui/button"; import { Card } from "../components/ui/card"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; const PAGE_SIZE = 20; export function InvoicePoolPage() { const queryClient = useQueryClient(); const { permissions, me } = useAuth(); const canManageInvoice = hasPermission(permissions, "invoice.manage"); const canOverrideDuplicate = hasPermission(permissions, "expense.finance.override_duplicate"); const canUploadInvoice = hasPermission(permissions, "invoice.upload"); const canProxySubmit = hasPermission(permissions, "expense.proxy_submit"); const isAdministration = (me?.roleCodes ?? []).includes("administration"); const showProxyOwnerSelector = canProxySubmit && isAdministration; const [status, setStatus] = useState(""); const [ownerScope, setOwnerScope] = useState<"own" | "all">("own"); const [page, setPage] = useState(1); const [selectedFile, setSelectedFile] = useState(null); const [uploadOwnerEmployeeId, setUploadOwnerEmployeeId] = useState(""); const [selectedInvoiceId, setSelectedInvoiceId] = useState(""); const [selectedReimbursementId, setSelectedReimbursementId] = useState(""); const [detailInvoiceId, setDetailInvoiceId] = useState(""); const [editOpen, setEditOpen] = useState(false); const [editSetAvailable, setEditSetAvailable] = useState(false); const [editRemarks, setEditRemarks] = useState(""); const [editInvoiceCode, setEditInvoiceCode] = useState(""); const [editInvoiceNumber, setEditInvoiceNumber] = useState(""); const [editInvoiceDate, setEditInvoiceDate] = useState(""); const [editTotalAmount, setEditTotalAmount] = useState(""); const [error, setError] = useState(""); const listQuery = useQuery({ queryKey: ["invoice-pool", "list", status, ownerScope, page], queryFn: () => fetchInvoicePoolList({ status: status || undefined, ownerScope, page, pageSize: PAGE_SIZE, }), }); const reimbursementQuery = useQuery({ queryKey: ["reimbursements", "for-bind"], queryFn: () => fetchReimbursements({ page: 1, pageSize: 50, scope: "own" }), }); const proxyDelegationsQuery = useQuery({ enabled: showProxyOwnerSelector, queryKey: ["delegations", "mine", "active"], queryFn: () => fetchMyDelegations("active"), }); const detailQuery = useQuery({ enabled: Boolean(detailInvoiceId), queryKey: ["invoice-pool", "detail", detailInvoiceId], queryFn: () => fetchInvoicePoolDetail(detailInvoiceId), refetchInterval: (query) => { const statusValue = query.state.data?.status; if (statusValue === "pending_ocr") { return 3_000; } return false; }, }); useEffect(() => { if (!detailQuery.data || editOpen) { return; } setEditSetAvailable(false); setEditRemarks(detailQuery.data.remarks ?? ""); setEditInvoiceCode(detailQuery.data.invoiceCode ?? ""); setEditInvoiceNumber(detailQuery.data.invoiceNumber ?? ""); setEditInvoiceDate(detailQuery.data.invoiceDate ?? ""); setEditTotalAmount(detailQuery.data.totalAmount ?? ""); }, [detailQuery.data, editOpen]); const uploadMutation = useMutation({ mutationFn: async () => { if (!selectedFile) { throw new Error("请选择文件"); } return uploadInvoice(selectedFile, "vat", uploadOwnerEmployeeId || undefined); }, onSuccess: async (data) => { await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); setDetailInvoiceId(data.invoicePoolId); setSelectedFile(null); setError(""); }, onError: (err) => { if (isApiError(err)) { setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); } else if (err instanceof Error) { setError(err.message); } else { setError("上传失败,请稍后重试"); } }, }); const bindMutation = useMutation({ mutationFn: async () => { if (!selectedInvoiceId || !selectedReimbursementId) { throw new Error("请选择票据与报销单"); } return bindInvoice(selectedInvoiceId, selectedReimbursementId); }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); setError(""); }, onError: (err) => { if (isApiError(err)) { setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); } else if (err instanceof Error) { setError(err.message); } else { setError("绑定失败,请稍后重试"); } }, }); const signedUrlMutation = useMutation({ mutationFn: async (invoiceId: string) => fetchInvoiceSignedUrl(invoiceId), onSuccess: (data) => { window.open(data.signedUrl, "_blank", "noopener,noreferrer"); }, onError: (err) => { if (isApiError(err)) { setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); } else { setError("预览链接获取失败"); } }, }); useEffect(() => { if (!canManageInvoice && ownerScope === "all") { setOwnerScope("own"); } }, [canManageInvoice, ownerScope]); useEffect(() => { if (!detailInvoiceId && listQuery.data?.items?.length) { setDetailInvoiceId(listQuery.data.items[0].id); } }, [detailInvoiceId, listQuery.data?.items]); const patchMutation = useMutation({ mutationFn: async () => { if (!detailInvoiceId) { throw new Error("请选择一条票据"); } return patchInvoice(detailInvoiceId, { ...(editSetAvailable ? { status: "available" } : {}), remarks: editRemarks.trim() ? editRemarks.trim() : null, invoiceCode: editInvoiceCode.trim() ? editInvoiceCode.trim() : null, invoiceNumber: editInvoiceNumber.trim() ? editInvoiceNumber.trim() : null, invoiceDate: editInvoiceDate ? editInvoiceDate : null, totalAmount: editTotalAmount.trim() ? editTotalAmount.trim() : null, }); }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); setEditOpen(false); setError(""); }, onError: (err) => { if (isApiError(err)) { setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); } else if (err instanceof Error) { setError(err.message); } else { setError("保存失败,请稍后重试"); } }, }); const archiveMutation = useMutation({ mutationFn: async (invoiceId: string) => archiveInvoice(invoiceId), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); setError(""); }, onError: (err) => { if (isApiError(err)) { setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); } else { setError("归档失败,请稍后重试"); } }, }); const unbindMutation = useMutation({ mutationFn: async (invoiceId: string) => unbindInvoice(invoiceId), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); setError(""); }, onError: (err) => { if (isApiError(err)) { setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); } else { setError("解绑失败,请稍后重试"); } }, }); const retryOcrMutation = useMutation({ mutationFn: async (invoiceId: string) => retryInvoiceOcr(invoiceId), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); setError(""); }, onError: (err) => { if (isApiError(err)) { setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); } else { setError("OCR 重试失败,请稍后再试"); } }, }); const overrideDuplicateMutation = useMutation({ mutationFn: async (params: { invoiceId: string; refFormNumber: string; reason: string }) => overrideInvoiceDuplicate(params.invoiceId, { refFormNumber: params.refFormNumber, reason: params.reason, }), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); setError(""); }, onError: (err) => { if (isApiError(err)) { setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); } else { setError("判重例外失败,请稍后重试"); } }, }); const exportMutation = useMutation({ mutationFn: (format: "csv" | "xlsx") => exportInvoicePool({ format, status: status || undefined, ownerScope, }), onSuccess: (response, format) => { downloadBlob(response.blob, response.filename ?? `invoice-pool.${format}`); setError(""); }, onError: (err) => { if (isApiError(err)) { setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); } else { setError("导出失败,请稍后重试"); } }, }); const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE)); const detail = detailQuery.data; const canEditDetail = Boolean( detail && (canManageInvoice || detail.ownerEmployeeId === me?.id || detail.uploadedByEmployeeId === me?.id), ); return (
exportMutation.mutate("csv")}> 导出 CSV exportMutation.mutate("xlsx")}> 导出 Excel } /> {error ? (

{error}

) : null}

上传票据(VAT OCR)

{showProxyOwnerSelector ? ( ) : null} setSelectedFile(event.target.files?.[0] ?? null)} type="file" />

筛选与列表

ID状态发票号OCR操作 {(listQuery.data?.items ?? []).length ? ( listQuery.data?.items.map((item) => ( {item.id} {item.status} {item.invoiceCode || "-"}-{item.invoiceNumber || "-"} {item.status === "pending_ocr" ? ( OCR 处理中(attempts={item.ocrAttempts}) ) : item.status === "ocr_failed" ? ( OCR 失败({item.ocrLastError || "unknown"}) ) : ( attempts={item.ocrAttempts} )}
)) ) : ( 暂无数据 )}
第 {page} / {totalPages} 页

绑定报销

详情轮询

{detail ? ( <>
票据ID:{detail.id}
状态:{detail.status}
OCR错误:{detail.ocrLastError || "-"}
发票号:{detail.invoiceCode || "-"}-{detail.invoiceNumber || "-"}
开票日期:{detail.invoiceDate || "-"}
总金额:{detail.totalAmount || "-"}
备注:{detail.remarks || "-"}
关联报销:{detail.linkedReimbursementId || "-"}
判重例外:{detail.isDuplicateOverride ? "已例外" : "否"}{" "} {detail.overrideRefFormNumber ? `(ref=${detail.overrideRefFormNumber})` : ""}
{canEditDetail ? ( ) : null} {canEditDetail && detail.status === "ocr_failed" ? ( ) : null} {canEditDetail && canUploadInvoice && detail.status === "bound" ? ( ) : null} {canEditDetail && detail.status !== "bound" && detail.status !== "archived" ? ( ) : null} {canOverrideDuplicate && !detail.isDuplicateOverride && ["available", "ocr_failed"].includes(detail.status) ? ( ) : null}
{editOpen ? (

人工修正(仅必要字段)

{detail.status === "ocr_failed" ? ( ) : null}
setEditInvoiceCode(event.target.value)} value={editInvoiceCode} />
setEditInvoiceNumber(event.target.value)} value={editInvoiceNumber} />
setEditInvoiceDate(event.target.value)} type="date" value={editInvoiceDate} />
setEditTotalAmount(event.target.value)} value={editTotalAmount} />