import { useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; import { cancelPaymentBatch, createPaymentBatch, deletePaymentBatchReceiptAttachment, downloadPaymentBatchReceiptAttachment, executePaymentBatch, fetchPaymentBatchReceiptAttachments, fetchPaymentBatches, reviewPaymentBatch, updatePaymentBatchItem, uploadPaymentBatchReceiptAttachments, type PaymentBatch, type PaymentBatchReceiptAttachment, type PaymentBatchEntityType, type PaymentChannel, } from "../api/paymentBatches"; import { isApiError } from "../api/types"; import { mapApiErrorMessage } from "../api/errorMessages"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace"; import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Label } from "../components/ui/label"; import { RiskFlagsCard } from "../components/common/RiskFlagsCard"; import { StatusBadge } from "../components/common/StatusBadge"; import { DateTextInput } from "../components/common/DateTextInput"; import { downloadBlob } from "../lib/download"; import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat"; import { toast } from "sonner"; const PAYMENT_CHANNEL_OPTIONS: PaymentChannel[] = ["bank", "alipay", "wechat", "cash", "other"]; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; function batchStatusLabel(value: string) { if (value === "draft") return "草稿"; if (value === "reviewed") return "已复核"; if (value === "executed") return "已执行"; if (value === "failed") return "执行失败"; if (value === "cancelled") return "已取消"; return value || "-"; } function entityTypeLabel(value: string) { if (value === "reimbursement") return "报销"; if (value === "travel_order") return "差旅订单"; return value || "-"; } function paymentChannelLabel(value: PaymentChannel | string | null | undefined) { if (value === "bank") return "银行转账"; if (value === "alipay") return "支付宝"; if (value === "wechat") return "微信"; if (value === "cash") return "现金"; if (value === "other") return "其他"; return value || "-"; } function paymentItemStatusLabel(value: string) { if (value === "pending") return "待付款"; if (value === "paid") return "已支付"; if (value === "executed") return "已执行"; if (value === "failed") return "付款失败"; if (value === "cancelled") return "已取消"; if (value === "skipped") return "已跳过"; return value || "-"; } function reviewNextStepLabel(value: PaymentBatch["reviewRequirement"]["nextStep"]) { if (value === "first_review") return "等待首轮复核"; if (value === "second_review") return "等待二次复核"; return "可执行付款标记"; } function reviewerDisplayName(value: string | null | undefined) { if (!value) return ""; return UUID_PATTERN.test(value) ? "已记录" : value; } function formatFileSize(value: number) { if (!Number.isFinite(value) || value <= 0) return "-"; if (value < 1024) return `${value} B`; if (value < 1024 * 1024) return `${Math.ceil(value / 1024)} KB`; return `${(value / 1024 / 1024).toFixed(1)} MB`; } function resolveError(error: unknown) { return isApiError(error) ? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}` : error instanceof Error ? error.message : "操作失败"; } export function PaymentBatchesPage() { const queryClient = useQueryClient(); const { permissions } = useAuth(); const [searchParams, setSearchParams] = useSearchParams(); const canManageBatch = hasPermission(permissions, "payment.batch.manage"); const canReviewBatch = hasPermission(permissions, "risk.review.manage"); const [entityType, setEntityType] = useState("reimbursement"); const [name, setName] = useState(""); const [entityIds, setEntityIds] = useState(""); const [defaultPaymentDate, setDefaultPaymentDate] = useState(""); const [defaultPaymentChannel, setDefaultPaymentChannel] = useState(""); const [voucherPrefix, setVoucherPrefix] = useState(""); const receiptInputRef = useRef(null); const [selectedReceiptFiles, setSelectedReceiptFiles] = useState([]); const [error, setError] = useState(""); const [confirmPaymentAction, setConfirmPaymentAction] = useState<{ kind: "financeReview" | "financePayment" | "cancel"; batchId: string; batchNo: string; } | null>(null); const listQuery = useQuery({ queryKey: ["payment-batches"], queryFn: () => fetchPaymentBatches(), }); const selectedBatchId = searchParams.get("id") ?? ""; const selectedBatch = useMemo(() => { const items = listQuery.data?.items ?? []; return items.find((item) => item.id === selectedBatchId) ?? items[0] ?? null; }, [listQuery.data?.items, selectedBatchId]); const receiptAttachmentsQuery = useQuery({ queryKey: ["payment-batch-receipts", selectedBatch?.id], enabled: Boolean(selectedBatch?.id), queryFn: () => fetchPaymentBatchReceiptAttachments(selectedBatch!.id), }); useEffect(() => { if (!selectedBatch) { return; } if (selectedBatch.id === selectedBatchId) { return; } const next = new URLSearchParams(searchParams); next.set("id", selectedBatch.id); setSearchParams(next, { replace: true }); }, [searchParams, selectedBatch, selectedBatchId, setSearchParams]); const canEditSelectedBatch = canManageBatch && !!selectedBatch && ["draft", "reviewed", "failed"].includes(selectedBatch.status); const canEditReceiptAttachments = canManageBatch && !!selectedBatch && !["executed", "cancelled"].includes(selectedBatch.status); const canReviewSelectedBatch = canReviewBatch && !!selectedBatch && ( (selectedBatch.status === "draft" && selectedBatch.reviewRequirement.nextStep === "first_review") || (selectedBatch.status === "reviewed" && selectedBatch.reviewRequirement.nextStep === "second_review") ); const canExecuteSelectedBatch = canManageBatch && !!selectedBatch && ["reviewed", "failed"].includes(selectedBatch.status) && selectedBatch.reviewRequirement.nextStep === "ready_for_execute"; const canCancelSelectedBatch = canManageBatch && !!selectedBatch && !["executed", "cancelled"].includes(selectedBatch.status); const refresh = async () => { await queryClient.invalidateQueries({ queryKey: ["payment-batches"] }); await queryClient.invalidateQueries({ queryKey: ["payment-batch-receipts"] }); await queryClient.invalidateQueries({ queryKey: ["ap-operations"] }); }; const createMutation = useMutation({ mutationFn: async () => createPaymentBatch({ entityType, name: name.trim() || `${entityType}-batch`, entityIds: entityIds.split(/[\s,]+/).map((item) => item.trim()).filter(Boolean), defaultPaymentDate: defaultPaymentDate || undefined, defaultPaymentChannel: defaultPaymentChannel || undefined, voucherPrefix: voucherPrefix || undefined, }), onSuccess: async ({ item }) => { setError(""); const next = new URLSearchParams(searchParams); next.set("id", item.id); setSearchParams(next, { replace: true }); await refresh(); toast.success("付款批次已创建"); }, onError: (err) => { const message = resolveError(err); setError(message); toast.error(message); }, }); const reviewMutation = useMutation({ mutationFn: async (batchId: string) => { const batch = listQuery.data?.items.find((item) => item.id === batchId); const resolveRiskFlagIds = (batch?.items ?? []).flatMap((item) => item.openRiskFlags.map((flag) => flag.id)); return reviewPaymentBatch(batchId, { resolveRiskFlagIds }); }, onSuccess: async ({ item }) => { setError(""); await refresh(); toast.success(item.reviewRequirement.nextStep === "ready_for_execute" ? "付款批次复核完成" : "付款批次已完成首轮复核"); }, onError: (err) => { const message = resolveError(err); setError(message); toast.error(message); }, }); const executeMutation = useMutation({ mutationFn: executePaymentBatch, onSuccess: async () => { setError(""); await refresh(); toast.success("付款批次已执行"); }, onError: (err) => { const message = resolveError(err); setError(message); toast.error(message); }, }); const cancelMutation = useMutation({ mutationFn: cancelPaymentBatch, onSuccess: async () => { setError(""); await refresh(); toast.success("付款批次已取消"); }, onError: (err) => { const message = resolveError(err); setError(message); toast.error(message); }, }); const updateItemMutation = useMutation({ mutationFn: async (payload: { batchId: string; itemId: string; paymentVoucherNoOverride?: string | null; paymentDateOverride?: string | null; paymentChannelOverride?: PaymentChannel | null }) => updatePaymentBatchItem(payload.batchId, payload.itemId, { paymentVoucherNoOverride: payload.paymentVoucherNoOverride, paymentDateOverride: payload.paymentDateOverride, paymentChannelOverride: payload.paymentChannelOverride, }), onSuccess: async () => { setError(""); await refresh(); toast.success("批次项已更新"); }, onError: (err) => { const message = resolveError(err); setError(message); toast.error(message); }, }); const uploadReceiptMutation = useMutation({ mutationFn: async () => { if (!selectedBatch) { throw new Error("请先选择付款批次"); } if (!selectedReceiptFiles.length) { throw new Error("请选择银行回单附件"); } return uploadPaymentBatchReceiptAttachments(selectedBatch.id, selectedReceiptFiles); }, onSuccess: async () => { setError(""); setSelectedReceiptFiles([]); if (receiptInputRef.current) { receiptInputRef.current.value = ""; } await refresh(); toast.success("银行回单附件已上传"); }, onError: (err) => { const message = resolveError(err); setError(message); toast.error(message); }, }); const downloadReceiptMutation = useMutation({ mutationFn: async (attachment: PaymentBatchReceiptAttachment) => { if (!selectedBatch) { throw new Error("请先选择付款批次"); } const response = await downloadPaymentBatchReceiptAttachment(selectedBatch.id, attachment.id); return { response, attachment }; }, onSuccess: ({ response, attachment }) => { downloadBlob(response.blob, response.filename ?? attachment.originalName); }, onError: (err) => { const message = resolveError(err); setError(message); toast.error(message); }, }); const deleteReceiptMutation = useMutation({ mutationFn: async (attachmentId: string) => { if (!selectedBatch) { throw new Error("请先选择付款批次"); } return deletePaymentBatchReceiptAttachment(selectedBatch.id, attachmentId); }, onSuccess: async () => { setError(""); await refresh(); toast.success("银行回单附件已删除"); }, onError: (err) => { const message = resolveError(err); setError(message); toast.error(message); }, }); return ( {error ?
{error}
: null}

新建批次

{!canManageBatch ? (
当前账号仅可查看批次并执行复核,创建与编辑入口仅对付款批次管理员开放。
) : null}
setName(event.target.value)} placeholder="例如:2026Q2-财务付款批次-01" />