import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; import { cancelPaymentBatch, createPaymentBatch, executePaymentBatch, fetchPaymentBatches, reviewPaymentBatch, updatePaymentBatchItem, type PaymentBatch, 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 { PageHeader } from "../components/common/PageHeader"; 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 { toast } from "sonner"; const PAYMENT_CHANNEL_OPTIONS: PaymentChannel[] = ["bank", "alipay", "wechat", "cash", "other"]; 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 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 [error, setError] = useState(""); 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]); 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?.status === "draft"; 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: ["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); }, }); return (
{error ?
{error}
: null}

新建批次

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