文件
hyb-oa/client/src/pages/PaymentBatchesPage.tsx
T
wangsli 7c39f8b0f9
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
收口付款批次创建与复核(08-01-reimbursement-notification-progress-clarity)
2026-08-02 00:29:47 +08:00

876 行
40 KiB
TypeScript

import { useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import {
cancelPaymentBatch,
createPaymentBatch,
downloadPaymentBatchReceiptAttachment,
executePaymentBatch,
fetchPaymentBatchCandidates,
fetchPaymentBatchReceiptAttachments,
fetchPaymentBatches,
reviewPaymentBatch,
updatePaymentBatchItem,
uploadPaymentBatchReceiptAttachments,
voidPaymentBatchReceiptAttachment,
type PaymentBatch,
type PaymentBatchReceiptAttachment,
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 { HeaderActionGroup, ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import {
BusinessListToolbar,
BusinessMobileList,
BusinessTable,
} from "../components/patterns/BusinessListTemplate";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../components/ui/dialog";
import { Textarea } from "../components/ui/textarea";
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 { paymentBatchStatusLabel } from "../lib/paymentBatchDisplay";
import { toast } from "sonner";
const PAYMENT_CHANNEL_OPTIONS: PaymentChannel[] = ["bank", "alipay", "wechat", "cash", "other"];
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 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 { me, permissions } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const canManageBatch = hasPermission(permissions, "payment.batch.manage");
const canReviewBatch =
hasPermission(permissions, "payment.batch.review") &&
(me?.roleCodes.includes("manager") || me?.roleCodes.includes("ceo"));
const [candidateSearch, setCandidateSearch] = useState("");
const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]);
const receiptInputRef = useRef<HTMLInputElement | null>(null);
const [error, setError] = useState("");
const [showCreatePanel, setShowCreatePanel] = useState(false);
const [confirmPaymentAction, setConfirmPaymentAction] = useState<{
kind: "financeReview" | "financePayment";
batchId: string;
batchNo: string;
highRisk: boolean;
totalAmount: string;
} | null>(null);
const [reasonAction, setReasonAction] = useState<
| { kind: "cancel"; batchId: string; batchNo: string }
| { kind: "voidReceipt"; batchId: string; batchNo: string; attachmentId: string; attachmentName: string }
| null
>(null);
const [actionReason, setActionReason] = useState("");
const listQuery = useQuery({
queryKey: ["payment-batches"],
queryFn: () => fetchPaymentBatches(),
});
const candidatesQuery = useQuery({
queryKey: ["payment-batch-candidates", candidateSearch],
enabled: showCreatePanel && canManageBatch,
queryFn: () => fetchPaymentBatchCandidates({ q: candidateSearch, page: 1, pageSize: 50 }),
});
const selectedBatchId = searchParams.get("id") ?? "";
const batches = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]);
const selectedBatch = useMemo<PaymentBatch | null>(() => {
return batches.find((item) => item.id === selectedBatchId) ?? null;
}, [batches, selectedBatchId]);
const receiptAttachmentsQuery = useQuery({
queryKey: ["payment-batch-receipts", selectedBatch?.id],
enabled: Boolean(selectedBatch?.id),
queryFn: () => fetchPaymentBatchReceiptAttachments(selectedBatch!.id),
});
const canEditSelectedBatch =
canManageBatch &&
!!selectedBatch &&
["draft", "reviewed", "failed"].includes(selectedBatch.status);
const canEditReceiptAttachments =
canManageBatch &&
!!selectedBatch &&
selectedBatch.status !== "cancelled";
const canUploadReceipt = canEditReceiptAttachments && receiptAttachmentsQuery.isSuccess;
const canReviewSelectedBatch =
canReviewBatch &&
!!selectedBatch &&
selectedBatch.createdBy !== me?.id &&
(
selectedBatch.status === "draft" && selectedBatch.reviewRequirement.nextStep === "first_review"
);
const canExecuteSelectedBatch =
canManageBatch &&
!!selectedBatch &&
["reviewed", "failed"].includes(selectedBatch.status) &&
selectedBatch.reviewRequirement.nextStep === "ready_for_execute";
const requiresBankReceipt = Boolean(selectedBatch?.items.some((item) =>
["pending", "failed"].includes(item.status) &&
(item.paymentChannelOverride ?? selectedBatch.defaultPaymentChannel) === "bank",
));
const activeReceiptCount = (receiptAttachmentsQuery.data?.items ?? []).filter((item) => !item.archivedAt).length;
const needsBankReceipt =
canExecuteSelectedBatch && requiresBankReceipt && receiptAttachmentsQuery.isSuccess && activeReceiptCount === 0;
const canRegisterPaymentComplete =
canExecuteSelectedBatch &&
(!requiresBankReceipt || (receiptAttachmentsQuery.isSuccess && activeReceiptCount > 0));
const canCancelSelectedBatch =
canManageBatch &&
!!selectedBatch &&
!["executed", "cancelled"].includes(selectedBatch.status);
const refresh = async () => {
await queryClient.invalidateQueries({ queryKey: ["payment-batches"] });
await queryClient.invalidateQueries({ queryKey: ["payment-batch-candidates"] });
await queryClient.invalidateQueries({ queryKey: ["payment-batch-receipts"] });
await queryClient.invalidateQueries({ queryKey: ["ap-operations"] });
};
const returnToList = () => {
const next = new URLSearchParams(searchParams);
next.delete("id");
setSearchParams(next, { replace: true });
};
const createMutation = useMutation({
mutationFn: async () =>
createPaymentBatch({
entityType: "reimbursement",
entityIds: selectedCandidateIds,
}),
onSuccess: async ({ item }) => {
setError("");
setSelectedCandidateIds([]);
setCandidateSearch("");
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: ({ batchId, reason }: { batchId: string; reason: string }) => cancelPaymentBatch(batchId, reason),
onSuccess: async () => {
setError("");
setReasonAction(null);
setActionReason("");
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 (files: File[]) => {
if (!selectedBatch) {
throw new Error("请先选择付款批次");
}
if (!files.length) {
throw new Error("请选择银行回单附件");
}
return uploadPaymentBatchReceiptAttachments(selectedBatch.id, files);
},
onSuccess: async () => {
setError("");
if (receiptInputRef.current) {
receiptInputRef.current.value = "";
}
await refresh();
toast.success("银行回单附件已上传");
},
onError: (err) => {
const message = resolveError(err);
setError(message);
toast.error(message);
},
});
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
const downloadReceiptMutation = useMutation({
mutationFn: async ({ attachment, reason }: { attachment: PaymentBatchReceiptAttachment; reason?: string }) => {
if (!selectedBatch) {
throw new Error("请先选择付款批次");
}
const response = await downloadPaymentBatchReceiptAttachment(selectedBatch.id, attachment.id, reason);
return { response, attachment };
},
onSuccess: ({ response, attachment }) => {
downloadBlob(response.blob, response.filename ?? attachment.originalName);
},
onError: (err) => {
const message = resolveError(err);
setError(message);
toast.error(message);
},
});
async function handleDownloadReceipt(attachment: PaymentBatchReceiptAttachment) {
const reason = await requestDownloadReason(attachment);
if (reason === null) {
return;
}
downloadReceiptMutation.mutate({ attachment, reason });
}
const voidReceiptMutation = useMutation({
mutationFn: async ({ batchId, attachmentId, reason }: { batchId: string; attachmentId: string; reason: string }) => {
return voidPaymentBatchReceiptAttachment(batchId, attachmentId, reason);
},
onSuccess: async () => {
setError("");
setReasonAction(null);
setActionReason("");
await refresh();
toast.success("银行回单已留痕作废");
},
onError: (err) => {
const message = resolveError(err);
setError(message);
toast.error(message);
},
});
return (
<ListWorkspace>
{downloadReasonDialog}
<WorkspaceHeader
title="付款"
actions={
<HeaderActionGroup>
{selectedBatchId ? (
<Button type="button" variant="secondary" onClick={returnToList}>
返回列表
</Button>
) : null}
{!selectedBatchId && canManageBatch ? (
<Button type="button" onClick={() => setShowCreatePanel(true)}>
新建批次
</Button>
) : null}
</HeaderActionGroup>
}
/>
{error ? <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div> : null}
{!selectedBatchId && showCreatePanel ? (
<div className="finance-command-panel space-y-4" data-testid="payment-batch-create-panel">
<div>
<h3 className="text-base font-semibold text-zinc-900">新建批次</h3>
</div>
{!canManageBatch ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
当前账号仅可查看批次并执行复核,创建与编辑入口仅对付款批次管理员开放。
</div>
) : null}
<div className="space-y-2">
<Label>选择待付款报销</Label>
<Input
data-testid="payment-batch-candidate-search"
value={candidateSearch}
disabled={!canManageBatch}
onChange={(event) => setCandidateSearch(event.target.value)}
placeholder="按报销编号、人员或摘要搜索"
/>
<div className="max-h-72 space-y-2 overflow-y-auto rounded-lg border border-zinc-200 p-2" data-testid="payment-batch-candidates">
{candidatesQuery.isLoading ? <div className="px-2 py-3 text-sm text-zinc-500">正在加载待付款报销...</div> : null}
{candidatesQuery.isError ? <div className="px-2 py-3 text-sm text-red-600">{resolveError(candidatesQuery.error)}</div> : null}
{(candidatesQuery.data?.items ?? []).map((candidate) => {
const selected = selectedCandidateIds.includes(candidate.entityId);
return (
<label
key={candidate.entityId}
className="flex cursor-pointer items-start gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-3 hover:border-zinc-300"
data-testid="payment-batch-candidate-row"
>
<input
type="checkbox"
className="mt-1 h-4 w-4"
checked={selected}
onChange={(event) => setSelectedCandidateIds((current) =>
event.target.checked
? Array.from(new Set([...current, candidate.entityId]))
: current.filter((id) => id !== candidate.entityId),
)}
/>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center justify-between gap-2 text-sm font-medium text-zinc-900">
<span>{candidate.businessNo} · {candidate.employee.name}</span>
<span>{candidate.amount} {candidate.currencyCode}</span>
</span>
<span className="mt-1 block text-xs text-zinc-500">
{formatDisplayDate(candidate.businessDate)}
{candidate.summary ? ` · ${candidate.summary}` : ""}
</span>
</span>
</label>
);
})}
{!candidatesQuery.isLoading && !candidatesQuery.isError && !(candidatesQuery.data?.items ?? []).length ? (
<div className="px-2 py-3 text-sm text-zinc-500">没有可加入批次的已审批报销。</div>
) : null}
</div>
<div className="text-xs text-zinc-500">
已选择 {selectedCandidateIds.length} 项;系统自动生成编号和标题,默认按银行转账提交负责人复核。
</div>
</div>
<Button type="button" disabled={createMutation.isPending || !canManageBatch || selectedCandidateIds.length === 0} onClick={() => createMutation.mutate()}>
{createMutation.isPending ? "提交中..." : "创建并提交复核"}
</Button>
</div>
) : null}
{selectedBatchId ? (
selectedBatch ? (
<div className="space-y-5">
<div className="finance-command-panel space-y-3" data-testid="payment-batch-detail-panel">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-xs text-zinc-500">{selectedBatch.batchNo}</div>
<div className="flex flex-wrap items-center gap-2">
<div className="text-lg font-semibold text-zinc-900">{selectedBatch.name}</div>
{selectedBatch.highRisk ? (
<span className="rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700">高风险</span>
) : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
{canReviewSelectedBatch ? (
<Button
type="button"
disabled={reviewMutation.isPending}
onClick={() => setConfirmPaymentAction({
kind: "financeReview",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
highRisk: selectedBatch.highRisk,
totalAmount: selectedBatch.totalAmount,
})}
>
复核通过
</Button>
) : null}
{needsBankReceipt ? (
<Button
type="button"
disabled={uploadReceiptMutation.isPending}
onClick={() => receiptInputRef.current?.click()}
>
{uploadReceiptMutation.isPending ? "上传中..." : "上传银行回单"}
</Button>
) : null}
{canRegisterPaymentComplete ? (
<Button
type="button"
disabled={executeMutation.isPending}
onClick={() => setConfirmPaymentAction({
kind: "financePayment",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
highRisk: selectedBatch.highRisk,
totalAmount: selectedBatch.totalAmount,
})}
>
登记付款完成
</Button>
) : null}
{canManageBatch ? (
<Button
type="button"
variant="secondary"
disabled={cancelMutation.isPending || !canCancelSelectedBatch}
onClick={() => {
setActionReason("");
setReasonAction({
kind: "cancel",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
});
}}
>
取消
</Button>
) : null}
</div>
</div>
<div className="detail-fact-grid text-sm xl:grid-cols-6">
<div className="detail-fact-card"><div className="detail-fact-card__label">状态</div><div className="detail-fact-card__value">{paymentBatchStatusLabel(selectedBatch.status)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">实体</div><div className="detail-fact-card__value">{entityTypeLabel(selectedBatch.entityType)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">总金额</div><div className="detail-fact-card__value">{selectedBatch.totalAmount}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">默认日期</div><div className="detail-fact-card__value">{formatDisplayDate(selectedBatch.defaultPaymentDate)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">默认渠道</div><div className="detail-fact-card__value">{paymentChannelLabel(selectedBatch.defaultPaymentChannel)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">复核进度</div><div className="detail-fact-card__value">{selectedBatch.reviewRequirement.completedReviewCount}/{selectedBatch.reviewRequirement.requiredReviewCount}</div></div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-600">
{selectedBatch.status === "draft"
? "已提交付款复核,等待负责人处理;制单人不能复核自己的批次。"
: selectedBatch.status === "reviewed" && requiresBankReceipt && !receiptAttachmentsQuery.isSuccess
? "付款复核已通过,正在核对银行回单状态。"
: selectedBatch.status === "reviewed" && needsBankReceipt
? "付款复核已通过。请财务先在线下银行完成付款,再上传银行回单。"
: selectedBatch.status === "reviewed"
? "付款复核已通过。请财务核对付款结果并登记付款完成。"
: selectedBatch.status === "executed"
? "付款已登记完成。"
: selectedBatch.status === "failed"
? "付款登记失败,请财务核对失败原因后重试。"
: "该付款批次已取消。"}
{selectedBatch.highRisk ? ` 批次金额达到 ${selectedBatch.reviewRequirement.thresholdAmount},处理前需再次核对付款对象、金额和回单,但不增加审批层级。` : ""}
{selectedBatch.cancelledReason ? ` 取消原因:${selectedBatch.cancelledReason}` : ""}
</div>
</div>
<div className="finance-command-panel space-y-4" data-testid="payment-batch-receipt-panel">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">银行回单附件</h3>
<p className="mt-1 text-sm text-zinc-500">先在线下银行完成付款,再上传回单;系统不会连接银行自动转账。</p>
</div>
{canUploadReceipt ? (
<div className="flex flex-wrap items-center gap-2">
<input
ref={receiptInputRef}
type="file"
data-testid="payment-batch-receipt-file-input"
className="hidden"
accept=".pdf,image/jpeg,image/png,image/webp"
multiple
disabled={uploadReceiptMutation.isPending}
onChange={(event) => {
const files = Array.from(event.target.files ?? []);
if (files.length) uploadReceiptMutation.mutate(files);
}}
/>
<Button
type="button"
variant="secondary"
disabled={uploadReceiptMutation.isPending}
onClick={() => receiptInputRef.current?.click()}
>
{uploadReceiptMutation.isPending ? "上传中..." : "上传银行回单"}
</Button>
</div>
) : null}
</div>
{receiptAttachmentsQuery.isError ? (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
<span>{resolveError(receiptAttachmentsQuery.error)}</span>
<Button
type="button"
size="sm"
variant="secondary"
disabled={receiptAttachmentsQuery.isFetching}
onClick={() => void receiptAttachmentsQuery.refetch()}
>
{receiptAttachmentsQuery.isFetching ? "重新加载中..." : "重新加载"}
</Button>
</div>
) : null}
<div className="space-y-2">
{(receiptAttachmentsQuery.data?.items ?? []).map((attachment) => (
<div key={attachment.id} className="detail-evidence-card flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-zinc-900">{attachment.originalName}</div>
<div className="mt-1 text-xs text-zinc-500">
{attachment.mimeType} · {formatFileSize(attachment.fileSize)} · {formatDisplayDateTime(attachment.createdAt)}
{attachment.uploadedBy ? ` · ${attachment.uploadedBy.name}` : ""}
</div>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="secondary"
disabled={downloadReceiptMutation.isPending || Boolean(attachment.archivedAt)}
onClick={() => void handleDownloadReceipt(attachment)}
>
下载
</Button>
{canManageBatch ? (
<Button
type="button"
size="sm"
variant="secondary"
disabled={selectedBatch.status === "cancelled" || Boolean(attachment.archivedAt) || voidReceiptMutation.isPending}
onClick={() => {
setActionReason("");
setReasonAction({
kind: "voidReceipt",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
attachmentId: attachment.id,
attachmentName: attachment.originalName,
});
}}
>
{attachment.archivedAt ? "已作废" : "作废"}
</Button>
) : null}
</div>
</div>
))}
{receiptAttachmentsQuery.isSuccess && !(receiptAttachmentsQuery.data?.items ?? []).length ? (
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-500">
暂无银行回单;银行转账必须先上传回单,才能登记付款完成。
</div>
) : null}
</div>
</div>
<div className="finance-command-panel space-y-4">
<h3 className="text-base font-semibold text-zinc-900">批次项</h3>
<div className="space-y-4">
{selectedBatch.items.map((item) => (
<div key={item.id} className="detail-evidence-card">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-medium text-zinc-900">{item.entityCode}</div>
<div className="text-sm text-zinc-600">{item.entityTitle}</div>
</div>
<div className="text-sm text-zinc-600">{item.amount} {item.currencyCode} · {paymentItemStatusLabel(item.status)}</div>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-4">
<Input
defaultValue={item.paymentVoucherNoOverride ?? ""}
placeholder="凭证号"
disabled={!canEditSelectedBatch || !["pending", "failed"].includes(item.status)}
onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentVoucherNoOverride: event.target.value || null })}
/>
<DateTextInput
defaultValue={item.paymentDateOverride ?? ""}
disabled={!canEditSelectedBatch || !["pending", "failed"].includes(item.status)}
onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentDateOverride: event.target.value || null })}
/>
<select
className="select-std"
defaultValue={item.paymentChannelOverride ?? ""}
disabled={!canEditSelectedBatch || !["pending", "failed"].includes(item.status)}
onChange={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentChannelOverride: (event.target.value as PaymentChannel | "") || null })}
>
<option value="">默认渠道</option>
{PAYMENT_CHANNEL_OPTIONS.map((option) => <option key={option} value={option}>{paymentChannelLabel(option)}</option>)}
</select>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
{item.failureMessage ? `失败原因:${item.failureMessage}` : "付款异常:无"}
</div>
</div>
<RiskFlagsCard items={item.openRiskFlags} title="关联开放风险" />
</div>
))}
</div>
</div>
</div>
) : (
<div className="finance-command-panel text-sm text-zinc-500">未找到付款批次</div>
)
) : (
<>
<BusinessListToolbar
actions={
<Button type="button" size="sm" variant="secondary" onClick={() => void refresh()}>
刷新
</Button>
}
>
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600">
全部批次 {batches.length}
</span>
</BusinessListToolbar>
<BusinessMobileList testId="payment-batches-mobile-list">
{batches.map((item) => (
<button
key={item.id}
type="button"
className="w-full rounded-lg border border-zinc-200 bg-white p-3 text-left"
onClick={() => {
const next = new URLSearchParams(searchParams);
next.set("id", item.id);
setSearchParams(next, { replace: true });
}}
>
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-zinc-900">{item.batchNo}</div>
<StatusBadge status={item.status} label={paymentBatchStatusLabel(item.status)} />
</div>
<div className="mt-1 text-sm text-zinc-600">{item.name}</div>
<div className="mt-1 text-xs text-zinc-500">
金额 {item.totalAmount}
{item.highRisk ? " · 高风险" : ""}
{` · 复核 ${item.reviewRequirement.completedReviewCount}/${item.reviewRequirement.requiredReviewCount}`}
</div>
</button>
))}
{!listQuery.data?.items?.length ? <div className="rounded-lg border border-zinc-200 bg-white p-4 text-sm text-zinc-500">暂无付款批次</div> : null}
</BusinessMobileList>
<BusinessTable testId="payment-batches-table">
<thead className="table-header">
<tr>
<th className="table-th">批次</th>
<th className="table-th">实体</th>
<th className="table-th">金额</th>
<th className="table-th">复核</th>
<th className="table-th">状态</th>
<th className="table-th w-28">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-50">
{batches.length ? (
batches.map((item) => (
<tr className="table-row" key={item.id}>
<td className="table-td">
<div className="font-semibold text-zinc-900">{item.batchNo}</div>
<div className="text-xs text-zinc-500">{item.name}</div>
</td>
<td className="table-td">{entityTypeLabel(item.entityType)}</td>
<td className="table-td-strong">{item.totalAmount}</td>
<td className="table-td">
{item.reviewRequirement.completedReviewCount}/{item.reviewRequirement.requiredReviewCount}
</td>
<td className="table-td">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge status={item.status} label={paymentBatchStatusLabel(item.status)} />
{item.highRisk ? <span className="badge-warning">高风险</span> : null}
</div>
</td>
<td className="table-td">
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => {
const next = new URLSearchParams(searchParams);
next.set("id", item.id);
setSearchParams(next, { replace: true });
}}
>
查看详情
</Button>
</td>
</tr>
))
) : (
<tr>
<td className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
暂无付款批次
</td>
</tr>
)}
</tbody>
</BusinessTable>
</>
)}
<ActionConfirmDialog
open={Boolean(confirmPaymentAction)}
onOpenChange={(open) => {
if (!open) setConfirmPaymentAction(null);
}}
kind={confirmPaymentAction?.kind ?? "financePayment"}
targetName={confirmPaymentAction?.batchNo}
description={
confirmPaymentAction?.kind === "financeReview"
? confirmPaymentAction.highRisk
? `该批次金额为 ${confirmPaymentAction.totalAmount},请核对付款对象和金额。确认后完成一次独立复核,不增加第二审批层级。`
: "确认后将完成一次独立复核并写入付款批次审计记录。"
: confirmPaymentAction?.highRisk
? `这是高额付款批次,金额 ${confirmPaymentAction.totalAmount}。请再次核对付款对象、金额、渠道和回单;确认后将登记付款完成并更新源单状态。`
: "请确认线下付款和银行回单均已核对。确认后将登记付款完成并更新源单状态。"
}
onConfirm={async () => {
if (!confirmPaymentAction) return;
if (confirmPaymentAction.kind === "financeReview") reviewMutation.mutate(confirmPaymentAction.batchId);
if (confirmPaymentAction.kind === "financePayment") executeMutation.mutate(confirmPaymentAction.batchId);
}}
/>
<Dialog
open={Boolean(reasonAction)}
onOpenChange={(open) => {
if (!open) {
setReasonAction(null);
setActionReason("");
}
}}
>
<DialogContent className="max-w-md p-5">
<DialogHeader>
<DialogTitle>{reasonAction?.kind === "voidReceipt" ? "作废银行回单" : "取消付款批次"}</DialogTitle>
<DialogDescription>
{reasonAction?.kind === "voidReceipt"
? `回单:${reasonAction.attachmentName}。文件记录会保留并标记为已作废。`
: reasonAction
? `批次:${reasonAction.batchNo}。只取消尚未执行的付款项,已执行项不会回滚。`
: "请填写原因。"}
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-2">
<Textarea
value={actionReason}
onChange={(event) => setActionReason(event.target.value)}
aria-label={reasonAction?.kind === "voidReceipt" ? "回单作废原因" : "批次取消原因"}
rows={4}
maxLength={500}
placeholder={reasonAction?.kind === "voidReceipt" ? "说明回单为何作废" : "说明批次为何不再继续"}
/>
<div className="flex items-center justify-between text-xs text-zinc-500">
<span>原因需填写 2–500 个字符,并写入审计记录。</span>
<span>{actionReason.trim().length}/500</span>
</div>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => {
setReasonAction(null);
setActionReason("");
}}>
返回
</Button>
<Button
type="button"
variant="destructive"
disabled={
actionReason.trim().length < 2 ||
cancelMutation.isPending ||
voidReceiptMutation.isPending
}
onClick={() => {
if (!reasonAction) return;
const reason = actionReason.trim();
if (reasonAction.kind === "cancel") {
cancelMutation.mutate({ batchId: reasonAction.batchId, reason });
} else {
voidReceiptMutation.mutate({
batchId: reasonAction.batchId,
attachmentId: reasonAction.attachmentId,
reason,
});
}
}}
>
{reasonAction?.kind === "voidReceipt" ? "确认作废" : "确认取消"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ListWorkspace>
);
}