1084 行
55 KiB
TypeScript
1084 行
55 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,
|
||
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>
|
||
);
|
||
}
|