423 行
19 KiB
TypeScript
423 行
19 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { useSearchParams } from "react-router-dom";
|
|
import {
|
|
cancelPaymentBatch,
|
|
createPaymentBatch,
|
|
executePaymentBatch,
|
|
fetchPaymentBatches,
|
|
reviewPaymentBatch,
|
|
updatePaymentBatchItem,
|
|
type PaymentBatch,
|
|
type PaymentBatchEntityType,
|
|
type PaymentChannel,
|
|
} from "../api/paymentBatches";
|
|
import { isApiError } from "../api/types";
|
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
|
import { useAuth } from "../auth/AuthContext";
|
|
import { hasPermission } from "../auth/permissions";
|
|
import { PageHeader } from "../components/common/PageHeader";
|
|
import { Button } from "../components/ui/button";
|
|
import { Input } from "../components/ui/input";
|
|
import { Label } from "../components/ui/label";
|
|
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
|
|
import { toast } from "sonner";
|
|
|
|
const PAYMENT_CHANNEL_OPTIONS: PaymentChannel[] = ["bank", "alipay", "wechat", "cash", "other"];
|
|
|
|
function batchStatusLabel(value: string) {
|
|
if (value === "draft") return "草稿";
|
|
if (value === "reviewed") return "已复核";
|
|
if (value === "executed") return "已执行";
|
|
if (value === "failed") return "执行失败";
|
|
if (value === "cancelled") return "已取消";
|
|
return value || "-";
|
|
}
|
|
|
|
function entityTypeLabel(value: string) {
|
|
if (value === "reimbursement") return "报销";
|
|
if (value === "travel_order") return "差旅订单";
|
|
return value || "-";
|
|
}
|
|
|
|
function resolveError(error: unknown) {
|
|
return isApiError(error)
|
|
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
|
: error instanceof Error
|
|
? error.message
|
|
: "操作失败";
|
|
}
|
|
|
|
export function PaymentBatchesPage() {
|
|
const queryClient = useQueryClient();
|
|
const { permissions } = useAuth();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const canManageBatch = hasPermission(permissions, "payment.batch.manage");
|
|
const canReviewBatch = hasPermission(permissions, "risk.review.manage");
|
|
const [entityType, setEntityType] = useState<PaymentBatchEntityType>("reimbursement");
|
|
const [name, setName] = useState("");
|
|
const [entityIds, setEntityIds] = useState("");
|
|
const [defaultPaymentDate, setDefaultPaymentDate] = useState("");
|
|
const [defaultPaymentChannel, setDefaultPaymentChannel] = useState<PaymentChannel | "">("");
|
|
const [voucherPrefix, setVoucherPrefix] = useState("");
|
|
const [error, setError] = useState("");
|
|
|
|
const listQuery = useQuery({
|
|
queryKey: ["payment-batches"],
|
|
queryFn: () => fetchPaymentBatches(),
|
|
});
|
|
|
|
const selectedBatchId = searchParams.get("id") ?? "";
|
|
const selectedBatch = useMemo<PaymentBatch | null>(() => {
|
|
const items = listQuery.data?.items ?? [];
|
|
return items.find((item) => item.id === selectedBatchId) ?? items[0] ?? null;
|
|
}, [listQuery.data?.items, selectedBatchId]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedBatch) {
|
|
return;
|
|
}
|
|
if (selectedBatch.id === selectedBatchId) {
|
|
return;
|
|
}
|
|
const next = new URLSearchParams(searchParams);
|
|
next.set("id", selectedBatch.id);
|
|
setSearchParams(next, { replace: true });
|
|
}, [searchParams, selectedBatch, selectedBatchId, setSearchParams]);
|
|
|
|
const canEditSelectedBatch = canManageBatch && selectedBatch?.status === "draft";
|
|
const canReviewSelectedBatch =
|
|
canReviewBatch &&
|
|
!!selectedBatch &&
|
|
(
|
|
(selectedBatch.status === "draft" && selectedBatch.reviewRequirement.nextStep === "first_review") ||
|
|
(selectedBatch.status === "reviewed" && selectedBatch.reviewRequirement.nextStep === "second_review")
|
|
);
|
|
const canExecuteSelectedBatch =
|
|
canManageBatch &&
|
|
!!selectedBatch &&
|
|
["reviewed", "failed"].includes(selectedBatch.status) &&
|
|
selectedBatch.reviewRequirement.nextStep === "ready_for_execute";
|
|
const canCancelSelectedBatch =
|
|
canManageBatch &&
|
|
!!selectedBatch &&
|
|
!["executed", "cancelled"].includes(selectedBatch.status);
|
|
|
|
const refresh = async () => {
|
|
await queryClient.invalidateQueries({ queryKey: ["payment-batches"] });
|
|
await queryClient.invalidateQueries({ queryKey: ["ap-operations"] });
|
|
};
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: async () =>
|
|
createPaymentBatch({
|
|
entityType,
|
|
name: name.trim() || `${entityType}-batch`,
|
|
entityIds: entityIds.split(/[\s,]+/).map((item) => item.trim()).filter(Boolean),
|
|
defaultPaymentDate: defaultPaymentDate || undefined,
|
|
defaultPaymentChannel: defaultPaymentChannel || undefined,
|
|
voucherPrefix: voucherPrefix || undefined,
|
|
}),
|
|
onSuccess: async ({ item }) => {
|
|
setError("");
|
|
const next = new URLSearchParams(searchParams);
|
|
next.set("id", item.id);
|
|
setSearchParams(next, { replace: true });
|
|
await refresh();
|
|
toast.success("付款批次已创建");
|
|
},
|
|
onError: (err) => {
|
|
const message = resolveError(err);
|
|
setError(message);
|
|
toast.error(message);
|
|
},
|
|
});
|
|
|
|
const reviewMutation = useMutation({
|
|
mutationFn: async (batchId: string) => {
|
|
const batch = listQuery.data?.items.find((item) => item.id === batchId);
|
|
const resolveRiskFlagIds = (batch?.items ?? []).flatMap((item) => item.openRiskFlags.map((flag) => flag.id));
|
|
return reviewPaymentBatch(batchId, { resolveRiskFlagIds });
|
|
},
|
|
onSuccess: async ({ item }) => {
|
|
setError("");
|
|
await refresh();
|
|
toast.success(item.reviewRequirement.nextStep === "ready_for_execute" ? "付款批次复核完成" : "付款批次已完成首轮复核");
|
|
},
|
|
onError: (err) => {
|
|
const message = resolveError(err);
|
|
setError(message);
|
|
toast.error(message);
|
|
},
|
|
});
|
|
|
|
const executeMutation = useMutation({
|
|
mutationFn: executePaymentBatch,
|
|
onSuccess: async () => {
|
|
setError("");
|
|
await refresh();
|
|
toast.success("付款批次已执行");
|
|
},
|
|
onError: (err) => {
|
|
const message = resolveError(err);
|
|
setError(message);
|
|
toast.error(message);
|
|
},
|
|
});
|
|
|
|
const cancelMutation = useMutation({
|
|
mutationFn: cancelPaymentBatch,
|
|
onSuccess: async () => {
|
|
setError("");
|
|
await refresh();
|
|
toast.success("付款批次已取消");
|
|
},
|
|
onError: (err) => {
|
|
const message = resolveError(err);
|
|
setError(message);
|
|
toast.error(message);
|
|
},
|
|
});
|
|
|
|
const updateItemMutation = useMutation({
|
|
mutationFn: async (payload: { batchId: string; itemId: string; paymentVoucherNoOverride?: string | null; paymentDateOverride?: string | null; paymentChannelOverride?: PaymentChannel | null }) =>
|
|
updatePaymentBatchItem(payload.batchId, payload.itemId, {
|
|
paymentVoucherNoOverride: payload.paymentVoucherNoOverride,
|
|
paymentDateOverride: payload.paymentDateOverride,
|
|
paymentChannelOverride: payload.paymentChannelOverride,
|
|
}),
|
|
onSuccess: async () => {
|
|
setError("");
|
|
await refresh();
|
|
toast.success("批次项已更新");
|
|
},
|
|
onError: (err) => {
|
|
const message = resolveError(err);
|
|
setError(message);
|
|
toast.error(message);
|
|
},
|
|
});
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHeader title="支付批次" subtitle="财务批量付款、复核与执行;高风险批次达到 5 万元后需完成二次复核。" />
|
|
|
|
{error ? <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div> : null}
|
|
|
|
<div className="grid gap-6 xl:grid-cols-[360px,1fr]">
|
|
<div className="card-std space-y-4 p-6">
|
|
<h3 className="text-base font-semibold text-zinc-900">新建批次</h3>
|
|
{!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>
|
|
<select
|
|
className="select-std"
|
|
value={entityType}
|
|
disabled={!canManageBatch}
|
|
onChange={(event) => setEntityType(event.target.value as PaymentBatchEntityType)}
|
|
>
|
|
<option value="reimbursement">报销付款</option>
|
|
<option value="travel_order">差旅订单付款</option>
|
|
</select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>批次名称</Label>
|
|
<Input
|
|
value={name}
|
|
disabled={!canManageBatch}
|
|
onChange={(event) => setName(event.target.value)}
|
|
placeholder="例如:2026Q2-财务付款批次-01"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>实体 ID</Label>
|
|
<textarea
|
|
className="min-h-28 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm disabled:bg-zinc-50"
|
|
value={entityIds}
|
|
disabled={!canManageBatch}
|
|
onChange={(event) => setEntityIds(event.target.value)}
|
|
placeholder="用逗号或换行分隔 reimbursement / travel_order ID"
|
|
/>
|
|
</div>
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label>默认付款日期</Label>
|
|
<Input
|
|
type="date"
|
|
value={defaultPaymentDate}
|
|
disabled={!canManageBatch}
|
|
onChange={(event) => setDefaultPaymentDate(event.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>默认渠道</Label>
|
|
<select
|
|
className="select-std"
|
|
value={defaultPaymentChannel}
|
|
disabled={!canManageBatch}
|
|
onChange={(event) => setDefaultPaymentChannel((event.target.value as PaymentChannel | "") || "") }
|
|
>
|
|
<option value="">不设置</option>
|
|
{PAYMENT_CHANNEL_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>凭证前缀</Label>
|
|
<Input
|
|
value={voucherPrefix}
|
|
disabled={!canManageBatch}
|
|
onChange={(event) => setVoucherPrefix(event.target.value)}
|
|
placeholder="例如 PBX"
|
|
/>
|
|
</div>
|
|
<Button type="button" disabled={createMutation.isPending || !canManageBatch} onClick={() => createMutation.mutate()}>
|
|
{createMutation.isPending ? "创建中..." : "创建批次"}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<div className="card-std p-6">
|
|
<h3 className="mb-3 text-base font-semibold text-zinc-900">批次列表</h3>
|
|
<div className="space-y-2">
|
|
{(listQuery.data?.items ?? []).map((item) => (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
className={selectedBatch?.id === item.id ? "w-full rounded-lg border border-primary bg-primary/5 px-3 py-3 text-left" : "w-full rounded-lg border border-zinc-200 px-3 py-3 text-left hover:bg-zinc-50"}
|
|
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>
|
|
<div className="text-xs uppercase text-zinc-500">{item.status}</div>
|
|
</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="text-sm text-zinc-500">暂无支付批次</div> : null}
|
|
</div>
|
|
</div>
|
|
|
|
{selectedBatch ? (
|
|
<div className="space-y-6">
|
|
<div className="card-std space-y-3 p-6">
|
|
<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">
|
|
{canReviewBatch ? (
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
disabled={reviewMutation.isPending || !canReviewSelectedBatch}
|
|
onClick={() => reviewMutation.mutate(selectedBatch.id)}
|
|
>
|
|
{selectedBatch.reviewRequirement.nextStep === "second_review" ? "二次复核" : "复核"}
|
|
</Button>
|
|
) : null}
|
|
{canManageBatch ? (
|
|
<Button
|
|
type="button"
|
|
disabled={executeMutation.isPending || !canExecuteSelectedBatch}
|
|
onClick={() => executeMutation.mutate(selectedBatch.id)}
|
|
>
|
|
执行
|
|
</Button>
|
|
) : null}
|
|
{canManageBatch ? (
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
disabled={cancelMutation.isPending || !canCancelSelectedBatch}
|
|
onClick={() => cancelMutation.mutate(selectedBatch.id)}
|
|
>
|
|
取消
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-3 text-sm md:grid-cols-3 xl:grid-cols-6">
|
|
<div>状态:{batchStatusLabel(selectedBatch.status)}</div>
|
|
<div>实体:{entityTypeLabel(selectedBatch.entityType)}</div>
|
|
<div>总金额:{selectedBatch.totalAmount}</div>
|
|
<div>默认日期:{selectedBatch.defaultPaymentDate ?? "-"}</div>
|
|
<div>默认渠道:{selectedBatch.defaultPaymentChannel ?? "-"}</div>
|
|
<div>复核进度:{selectedBatch.reviewRequirement.completedReviewCount}/{selectedBatch.reviewRequirement.requiredReviewCount}</div>
|
|
</div>
|
|
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-600">
|
|
当前要求:{selectedBatch.reviewRequirement.requiredReviewCount === 2 ? `批次金额达到 ${selectedBatch.reviewRequirement.thresholdAmount},需两轮复核` : "单轮复核后可执行"}。
|
|
下一步:{selectedBatch.reviewRequirement.nextStep === "first_review" ? "等待首轮复核" : selectedBatch.reviewRequirement.nextStep === "second_review" ? "等待二次复核" : "可执行"}。
|
|
{selectedBatch.reviewedBy ? ` 首轮复核人:${selectedBatch.reviewedBy}` : ""}
|
|
{selectedBatch.secondaryReviewedBy ? ` 二次复核人:${selectedBatch.secondaryReviewedBy}` : ""}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card-std space-y-4 p-6">
|
|
<h3 className="text-base font-semibold text-zinc-900">批次项</h3>
|
|
<div className="space-y-4">
|
|
{selectedBatch.items.map((item) => (
|
|
<div key={item.id} className="rounded-lg border border-zinc-200 p-4">
|
|
<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} · {item.status}</div>
|
|
</div>
|
|
<div className="mt-3 grid gap-3 md:grid-cols-4">
|
|
<Input
|
|
defaultValue={item.paymentVoucherNoOverride ?? ""}
|
|
placeholder="凭证号"
|
|
disabled={!canEditSelectedBatch || item.status !== "pending"}
|
|
onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentVoucherNoOverride: event.target.value || null })}
|
|
/>
|
|
<Input
|
|
type="date"
|
|
defaultValue={item.paymentDateOverride ?? ""}
|
|
disabled={!canEditSelectedBatch || item.status !== "pending"}
|
|
onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentDateOverride: event.target.value || null })}
|
|
/>
|
|
<select
|
|
className="select-std"
|
|
defaultValue={item.paymentChannelOverride ?? ""}
|
|
disabled={!canEditSelectedBatch || item.status !== "pending"}
|
|
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}>{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 ?? "-"}</div>
|
|
</div>
|
|
<RiskFlagsCard items={item.openRiskFlags} title="关联开放风险" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|