文件
hyb-oa/client/src/pages/PaymentBatchesPage.tsx
T

1084 行
55 KiB
TypeScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import { useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import {
cancelPaymentBatch,
createPaymentBatch,
downloadPaymentBatchReceiptAttachment,
downloadPaymentOrder,
executePaymentBatch,
fetchPaymentBatchCandidates,
fetchPaymentBatchDetail,
fetchPaymentBatchReceiptAttachments,
fetchPaymentBatches,
reviewPaymentBatch,
revealPaymentBatchPayeeBank,
updatePaymentBatchItem,
uploadPaymentBatchReceiptAttachments,
voidPaymentBatchReceiptAttachment,
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, formatMoney } 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
: "操作失败";
}
function paymentOrderBlockerLabel(blocker: { code: string; entityCode?: string }) {
const subject = blocker.entityCode ? `${blocker.entityCode}:` : "";
const messages: Record<string, string> = {
status_not_reviewed: "仅待财务付款批次可生成制单",
reimbursement_only: "仅报销付款批次支持制单",
bank_channel_required: "付款渠道不是银行转账",
legacy_payment_account_snapshot_missing: "历史批次未冻结公司付款账户,仅支持人工付款",
payment_account_snapshot_invalid: "冻结的公司付款账户资料不完整",
payer_account_unavailable: "冻结的公司付款账号不可用",
payer_bank_institution_missing: "公司付款账户缺少规范化开户银行",
payer_bank_not_supported: "公司付款账户不是建设银行",
items_missing: "批次没有可制单的报销明细",
legacy_payee_snapshot_missing: "未冻结完整收款账号,仅支持人工付款",
payee_name_missing: "冻结的收款人姓名不完整",
payee_bank_name_missing: "冻结的收款开户行名称不完整",
payee_account_masked_missing: "冻结的收款账号摘要不完整",
payee_bank_institution_missing: "未冻结规范化开户银行,仅支持人工付款",
payee_bank_not_supported: "收款账户不是建设银行,首版请人工转账",
};
return `${subject}${messages[blocker.code] ?? "制单资料不完整,请联系管理员核对"}`;
}
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 canExportPaymentOrder =
hasPermission(permissions, "payment.order.export") &&
Boolean(me?.roleCodes.some((roleCode) => ["finance", "finance_mgr"].includes(roleCode)));
const canRevealPayeeBank =
hasPermission(permissions, "employee.bank.read") &&
Boolean(me?.roleCodes.some((roleCode) => ["finance", "finance_mgr"].includes(roleCode)));
const [candidateSearch, setCandidateSearch] = useState("");
const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]);
const [selectedPaymentChannel, setSelectedPaymentChannel] = useState<PaymentChannel>("bank");
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 }
| { kind: "paymentOrder"; batchId: string; batchNo: string; reexport: boolean }
| { kind: "payeeBank"; batchId: string; batchNo: string; itemId: string; entityCode: string }
| null
>(null);
const [actionReason, setActionReason] = useState("");
const [revealedPayeeAccount, setRevealedPayeeAccount] = 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 selectedBatchQuery = useQuery({
queryKey: ["payment-batches", "detail", selectedBatchId],
enabled: Boolean(selectedBatchId),
queryFn: () => fetchPaymentBatchDetail(selectedBatchId),
});
const selectedBatch = selectedBatchQuery.data?.item ?? null;
const receiptAttachmentsQuery = useQuery({
queryKey: ["payment-batch-receipts", selectedBatch?.id],
enabled: Boolean(selectedBatch?.id),
queryFn: () => fetchPaymentBatchReceiptAttachments(selectedBatch!.id),
});
const canEditSelectedBatch =
canManageBatch &&
!!selectedBatch &&
["reviewed", "failed"].includes(selectedBatch.status);
const canEditReceiptAttachments =
canManageBatch &&
!!selectedBatch &&
["reviewed", "failed"].includes(selectedBatch.status);
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,
defaultPaymentChannel: selectedPaymentChannel,
}),
onSuccess: async ({ item }) => {
setError("");
setSelectedCandidateIds([]);
setCandidateSearch("");
setSelectedPaymentChannel("bank");
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 = selectedBatch?.id === batchId
? selectedBatch
: 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 }) =>
updatePaymentBatchItem(payload.batchId, payload.itemId, {
paymentVoucherNoOverride: payload.paymentVoucherNoOverride,
paymentDateOverride: payload.paymentDateOverride,
}),
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);
},
});
const paymentOrderMutation = useMutation({
mutationFn: ({ batchId, reason }: { batchId: string; reason: string }) =>
downloadPaymentOrder(batchId, reason),
onSuccess: async (response) => {
downloadBlob(response.blob, response.filename ?? "payment-order.xls");
setReasonAction(null);
setActionReason("");
await refresh();
toast.success("银行制单已生成,请导入企业网银后核对结果");
},
onError: (err) => {
const message = resolveError(err);
setError(message);
toast.error(message);
},
});
const revealPayeeBankMutation = useMutation({
mutationFn: ({ batchId, itemId, reason }: { batchId: string; itemId: string; reason: string }) =>
revealPaymentBatchPayeeBank(batchId, itemId, reason),
onSuccess: ({ bankAccount }) => {
setRevealedPayeeAccount(bankAccount);
window.setTimeout(() => revealPayeeBankMutation.reset(), 0);
},
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>{formatMoney(candidate.amount)}</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="max-w-sm space-y-2">
<Label htmlFor="payment-batch-default-channel">付款渠道</Label>
<select
id="payment-batch-default-channel"
data-testid="payment-batch-default-channel"
className="select-std w-full"
value={selectedPaymentChannel}
disabled={!canManageBatch}
onChange={(event) => setSelectedPaymentChannel(event.target.value as PaymentChannel)}
>
{PAYMENT_CHANNEL_OPTIONS.map((option) => (
<option key={option} value={option}>{paymentChannelLabel(option)}</option>
))}
</select>
</div>
<div className="text-xs text-zinc-500">
已选择 {selectedCandidateIds.length} 项;系统自动生成编号和标题,按{paymentChannelLabel(selectedPaymentChannel)}提交负责人复核。
</div>
</div>
<Button type="button" disabled={createMutation.isPending || !canManageBatch || selectedCandidateIds.length === 0} onClick={() => createMutation.mutate()}>
{createMutation.isPending ? "提交中..." : "创建并提交复核"}
</Button>
</div>
) : null}
{selectedBatchId ? (
selectedBatchQuery.isLoading ? (
<div className="finance-command-panel text-sm text-zinc-500">正在加载付款批次...</div>
) : selectedBatchQuery.isError ? (
<div className="finance-command-panel flex flex-wrap items-center justify-between gap-3 text-sm text-red-600">
<span>{resolveError(selectedBatchQuery.error)}</span>
<Button
type="button"
size="sm"
variant="secondary"
disabled={selectedBatchQuery.isFetching}
onClick={() => void selectedBatchQuery.refetch()}
>
{selectedBatchQuery.isFetching ? "重新加载中..." : "重新加载"}
</Button>
</div>
) : 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}
{selectedBatch.status === "reviewed" && selectedBatch.canExportPaymentOrder && canExportPaymentOrder && selectedBatch.paymentOrderExportCount === 0 ? (
<Button
type="button"
disabled={paymentOrderMutation.isPending}
onClick={() => {
setActionReason("");
setReasonAction({ kind: "paymentOrder", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, reexport: false });
}}
>
{paymentOrderMutation.isPending ? "生成中..." : "下载银行制单"}
</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}
{selectedBatch.status === "reviewed" && selectedBatch.canExportPaymentOrder && canExportPaymentOrder && selectedBatch.paymentOrderExportCount > 0 ? (
<Button
type="button"
variant="secondary"
disabled={paymentOrderMutation.isPending}
onClick={() => {
setActionReason("");
setReasonAction({ kind: "paymentOrder", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, reexport: true });
}}
>
重新下载制单
</Button>
) : null}
</div>
</div>
<div className="detail-fact-grid text-sm xl:grid-cols-4">
<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">{formatMoney(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 className="detail-fact-card"><div className="detail-fact-card__label">付款账户</div><div className="detail-fact-card__value">{selectedBatch.paymentAccount ? `${selectedBatch.paymentAccount.accountName} · ${selectedBatch.paymentAccount.accountNumberMasked}` : "未冻结(仅支持人工付款)"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">银行制单</div><div className="detail-fact-card__value">{selectedBatch.paymentOrderExportCount > 0 ? `已生成 ${selectedBatch.paymentOrderExportCount} 次` : "尚未生成"}</div></div>
</div>
{selectedBatch.status === "reviewed" && selectedBatch.paymentOrderExportBlockers.length > 0 ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-900">
<div className="font-medium">当前批次不能生成银行制单,可继续人工付款:</div>
<ul className="mt-1 list-disc space-y-1 pl-5">
{selectedBatch.paymentOrderExportBlockers.map((blocker, index) => (
<li key={`${blocker.code}-${blocker.entityCode ?? "batch"}-${index}`}>{paymentOrderBlockerLabel(blocker)}</li>
))}
</ul>
</div>
) : null}
</div>
<div className="finance-command-panel space-y-3" data-testid="payment-batch-current-processing">
<h3 className="text-base font-semibold text-zinc-900">当前处理</h3>
<div className="detail-fact-grid text-sm md:grid-cols-2">
<div className="detail-fact-card">
<div className="detail-fact-card__label">当前责任</div>
<div className="detail-fact-card__value">
{selectedBatch.status === "draft"
? "负责人(非制单人)"
: ["reviewed", "failed"].includes(selectedBatch.status)
? "财务"
: selectedBatch.status === "executed"
? "已完成"
: "已结束"}
</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">下一步</div>
<div className="detail-fact-card__value">
{selectedBatch.status === "draft"
? "复核通过"
: selectedBatch.status === "reviewed" && requiresBankReceipt && !receiptAttachmentsQuery.isSuccess
? "核对银行回单状态"
: selectedBatch.status === "reviewed" && needsBankReceipt
? selectedBatch.paymentOrderExportCount > 0
? "制单已生成,完成付款并上传银行回单"
: "线下付款并上传银行回单"
: selectedBatch.status === "reviewed"
? "登记付款完成"
: selectedBatch.status === "failed"
? "核对失败原因并重试"
: "-"}
</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.paymentOrderExportCount > 0
? "已生成银行制单,等待付款完成并上传回单。"
: "付款复核已通过。等待财务线下付款并上传回单。"
: selectedBatch.status === "reviewed"
? "付款复核已通过。请财务核对付款结果并登记付款完成。"
: selectedBatch.status === "executed"
? "付款已登记完成。"
: selectedBatch.status === "failed"
? "付款登记失败,请财务核对失败原因后重试。"
: "该付款批次已取消。"}
{selectedBatch.highRisk ? ` 批次金额达到 ${formatMoney(selectedBatch.reviewRequirement.thresholdAmount)},处理前需再次核对付款对象、金额和回单,但不增加审批层级。` : ""}
{selectedBatch.cancelledReason ? ` 取消原因:${selectedBatch.cancelledReason}` : ""}
</div>
</div>
<div className="finance-command-panel space-y-4" data-testid="payment-batch-items-panel">
<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">{formatMoney(item.amount)} · {paymentItemStatusLabel(item.status)}</div>
</div>
<div className="mt-3 detail-fact-grid text-sm md:grid-cols-4">
<div className="detail-fact-card"><div className="detail-fact-card__label">收款人</div><div className="detail-fact-card__value">{item.payeeName || "-"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">开户银行</div><div className="detail-fact-card__value">{item.bankName || "-"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">银行账户</div><div className="detail-fact-card__value">{item.bankAccountMasked || "-"}</div>{canRevealPayeeBank && ["reviewed", "failed"].includes(selectedBatch.status) && selectedBatch.paymentAccount ? <Button type="button" size="sm" variant="secondary" className="mt-2" onClick={() => { setActionReason(""); setRevealedPayeeAccount(""); setReasonAction({ kind: "payeeBank", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, itemId: item.id, entityCode: item.entityCode }); }}>查看完整账号</Button> : null}</div>
<div className="detail-fact-card"><div className="detail-fact-card__label">付款渠道</div><div className="detail-fact-card__value">{paymentChannelLabel(item.paymentChannelOverride ?? selectedBatch.defaultPaymentChannel)}</div></div>
</div>
{["reviewed", "failed"].includes(selectedBatch.status) ? (
<div className="mt-3 grid gap-3 md:grid-cols-3" data-testid="payment-batch-item-payment-fields">
<Input
aria-label={`付款凭证号 ${item.entityCode}`}
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
aria-label={`实际付款日期 ${item.entityCode}`}
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 })}
/>
<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>
) : ["executed", "cancelled"].includes(selectedBatch.status) ? (
<div className="mt-3 detail-fact-grid text-sm md:grid-cols-3" data-testid="payment-batch-item-payment-readonly">
<div className="detail-fact-card"><div className="detail-fact-card__label">付款凭证号</div><div className="detail-fact-card__value">{item.paymentVoucherNoOverride || "-"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">实际付款日期</div><div className="detail-fact-card__value">{formatDisplayDate(item.paymentDateOverride ?? selectedBatch.defaultPaymentDate)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">付款结果</div><div className="detail-fact-card__value">{item.failureMessage || paymentItemStatusLabel(item.status)}</div></div>
</div>
) : (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
付款复核通过后,由财务登记付款凭证号和实际付款日期。
</div>
)}
<RiskFlagsCard items={item.openRiskFlags} title="关联开放风险" />
</div>
))}
</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">
{selectedBatch.status === "draft"
? "付款复核通过后,由财务在线下完成付款并上传银行回单。"
: "先在线下银行完成付款,再上传回单;系统不会连接银行自动转账。"}
</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);
}}
/>
{!needsBankReceipt ? (
<Button
type="button"
variant="secondary"
disabled={uploadReceiptMutation.isPending}
onClick={() => receiptInputRef.current?.click()}
>
{uploadReceiptMutation.isPending ? "上传中..." : "上传银行回单"}
</Button>
) : null}
</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>
{canEditReceiptAttachments ? (
<Button
type="button"
size="sm"
variant="secondary"
disabled={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>
) : (
<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">
金额 {formatMoney(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">{formatMoney(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
? `该批次金额为 ${formatMoney(confirmPaymentAction.totalAmount)},请核对付款对象和金额。确认后完成一次独立复核,不增加第二审批层级。`
: "确认后将完成一次独立复核并写入付款批次审计记录。"
: confirmPaymentAction?.highRisk
? `这是高额付款批次,金额 ${formatMoney(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("");
setRevealedPayeeAccount("");
}
}}
>
<DialogContent className="max-w-md p-5">
<DialogHeader>
<DialogTitle>{reasonAction?.kind === "voidReceipt" ? "作废银行回单" : reasonAction?.kind === "paymentOrder" ? (reasonAction.reexport ? "重新下载银行制单" : "下载银行制单") : reasonAction?.kind === "payeeBank" ? "查看收款完整账号" : "取消付款批次"}</DialogTitle>
<DialogDescription>
{reasonAction?.kind === "voidReceipt"
? `回单:${reasonAction.attachmentName}。文件记录会保留并标记为已作废。`
: reasonAction?.kind === "paymentOrder"
? reasonAction.reexport
? `批次:${reasonAction.batchNo}。重新下载不会作废之前的文件。如果上一份已在网银导入,请勿重复提交。`
: `批次:${reasonAction.batchNo}。文件包含完整银行账号,仅用于导入企业网银。`
: reasonAction?.kind === "payeeBank"
? `报销单:${reasonAction.entityCode}。本次查看会记录查看人、批次项、时间和原因。`
: reasonAction
? `批次:${reasonAction.batchNo}。只取消尚未执行的付款项,已执行项不会回滚。`
: "请填写原因。"}
</DialogDescription>
</DialogHeader>
{reasonAction?.kind === "payeeBank" && revealedPayeeAccount ? (
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 font-mono text-sm text-amber-900 break-all">{revealedPayeeAccount}</div>
) : <div className="mt-4 space-y-2">
<Textarea
value={actionReason}
onChange={(event) => setActionReason(event.target.value)}
aria-label={reasonAction?.kind === "voidReceipt" ? "回单作废原因" : reasonAction?.kind === "paymentOrder" ? "制单下载原因" : reasonAction?.kind === "payeeBank" ? "查看收款账号原因" : "批次取消原因"}
rows={4}
maxLength={500}
placeholder={reasonAction?.kind === "voidReceipt" ? "说明回单为何作废" : reasonAction?.kind === "paymentOrder" ? "说明本次制单用途" : reasonAction?.kind === "payeeBank" ? "说明查看完整账号的业务用途" : "说明批次为何不再继续"}
/>
<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>
{!(reasonAction?.kind === "payeeBank" && revealedPayeeAccount) ? <Button
type="button"
variant={reasonAction?.kind === "paymentOrder" ? "default" : "destructive"}
disabled={
actionReason.trim().length < 2 ||
cancelMutation.isPending ||
voidReceiptMutation.isPending ||
paymentOrderMutation.isPending ||
revealPayeeBankMutation.isPending
}
onClick={() => {
if (!reasonAction) return;
const reason = actionReason.trim();
if (reasonAction.kind === "cancel") {
cancelMutation.mutate({ batchId: reasonAction.batchId, reason });
} else if (reasonAction.kind === "paymentOrder") {
paymentOrderMutation.mutate({ batchId: reasonAction.batchId, reason });
} else if (reasonAction.kind === "payeeBank") {
revealPayeeBankMutation.mutate({ batchId: reasonAction.batchId, itemId: reasonAction.itemId, reason });
} else {
voidReceiptMutation.mutate({
batchId: reasonAction.batchId,
attachmentId: reasonAction.attachmentId,
reason,
});
}
}}
>
{reasonAction?.kind === "voidReceipt" ? "确认作废" : reasonAction?.kind === "paymentOrder" ? "确认下载" : reasonAction?.kind === "payeeBank" ? "确认查看" : "确认取消"}
</Button> : null}
</DialogFooter>
</DialogContent>
</Dialog>
</ListWorkspace>
);
}