876 行
40 KiB
TypeScript
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>
|
|
);
|
|
}
|