P25L-1~P25L-5 实现预算控制与AP最小可用运营
合同 CSV 已回填 reviewer/QA 结果;运行时回归受当前环境缺少 node/npm 阻塞。
这个提交包含在:
@@ -3,15 +3,16 @@ import type { RiskFlag } from "./feeControl";
|
||||
|
||||
export type ApOperationsSummary = {
|
||||
summary: {
|
||||
openPaymentBatchCount: number;
|
||||
failedPaymentBatchItemCount: number;
|
||||
openRiskFlagCount: number;
|
||||
reconciliationAnomalyCount: number;
|
||||
pendingPaymentCount: number;
|
||||
partialMatchCount: number;
|
||||
exceptionCount: number;
|
||||
budgetOverspendCount: number;
|
||||
};
|
||||
openPaymentBatches: Array<{ id: string; batchNo: string; entityType: string; name: string; status: string; createdAt: string | null }>;
|
||||
pendingPaymentBatches: Array<{ id: string; batchNo: string; entityType: string; name: string; status: string; totalAmount: string; highRisk: boolean; createdAt: string | null }>;
|
||||
failedPaymentBatchItems: Array<{ id: string; paymentBatchId: string; entityId: string; entityCode: string; entityTitle: string; amount: string; currencyCode: string; failureCode: string | null; failureMessage: string | null; createdAt: string | null }>;
|
||||
openRiskFlags: RiskFlag[];
|
||||
reconciliationAnomalies: Array<{ id: string; travelReconciliationBatchId: string; travelOrderId: string | null; travelRequestId: string | null; matchStatus: string; resolutionStatus: string; anomalyCode: string | null; orderAmount: string | null; transactionAmount: string | null; currency: string; createdAt: string | null }>;
|
||||
exceptionRiskFlags: RiskFlag[];
|
||||
budgetOverspendRisks: RiskFlag[];
|
||||
partialMatchItems: Array<{ id: string; travelReconciliationBatchId: string; travelOrderId: string | null; travelRequestId: string | null; matchStatus: string; resolutionStatus: string; anomalyCode: string | null; orderAmount: string | null; transactionAmount: string | null; currency: string; createdAt: string | null }>;
|
||||
};
|
||||
|
||||
export async function fetchApOperations() {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type BudgetControlRuleEntityType = "all" | "reimbursement" | "purchase_request" | "travel_request";
|
||||
export type BudgetControlMode = "warn" | "block" | "manual_review";
|
||||
|
||||
export type BudgetControlRule = {
|
||||
id: string;
|
||||
name: string;
|
||||
entityType: BudgetControlRuleEntityType;
|
||||
departmentId: string | null;
|
||||
legalEntityId: string | null;
|
||||
costCenterId: string | null;
|
||||
expenseTypeId: string | null;
|
||||
controlMode: BudgetControlMode;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
notes: string | null;
|
||||
archivedAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type BudgetControlRuleListResponse = {
|
||||
items: BudgetControlRule[];
|
||||
};
|
||||
|
||||
function toQueryString(query: Record<string, string | boolean | undefined>) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value == null || value === "") {
|
||||
continue;
|
||||
}
|
||||
params.set(key, typeof value === "boolean" ? String(value) : value);
|
||||
}
|
||||
const encoded = params.toString();
|
||||
return encoded ? `?${encoded}` : "";
|
||||
}
|
||||
|
||||
export async function fetchBudgetControlRules(query: {
|
||||
entityType?: BudgetControlRuleEntityType;
|
||||
enabled?: boolean;
|
||||
} = {}) {
|
||||
return apiRequestData<BudgetControlRuleListResponse>(
|
||||
`/api/budget-control-rules${toQueryString(query as Record<string, string | boolean | undefined>)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createBudgetControlRule(payload: {
|
||||
name: string;
|
||||
entityType: BudgetControlRuleEntityType;
|
||||
departmentId?: string | null;
|
||||
legalEntityId?: string | null;
|
||||
expenseTypeId?: string | null;
|
||||
controlMode: BudgetControlMode;
|
||||
priority?: number;
|
||||
enabled?: boolean;
|
||||
notes?: string | null;
|
||||
}) {
|
||||
return apiRequestData<{ item: BudgetControlRule }>("/api/budget-control-rules", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchBudgetControlRule(
|
||||
ruleId: string,
|
||||
payload: Partial<{
|
||||
name: string;
|
||||
entityType: BudgetControlRuleEntityType;
|
||||
departmentId: string | null;
|
||||
legalEntityId: string | null;
|
||||
expenseTypeId: string | null;
|
||||
controlMode: BudgetControlMode;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
notes: string | null;
|
||||
}>,
|
||||
) {
|
||||
return apiRequestData<{ item: BudgetControlRule }>(`/api/budget-control-rules/${ruleId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveBudgetControlRule(ruleId: string) {
|
||||
return apiRequestData<{ item: BudgetControlRule }>(`/api/budget-control-rules/${ruleId}/archive`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
@@ -132,6 +132,26 @@ export function mapApiErrorMessage(
|
||||
return "当前状态不允许执行付款。";
|
||||
}
|
||||
|
||||
if (details?.review === "maker_checker_conflict") {
|
||||
return "复核人与批次创建人不能是同一人。";
|
||||
}
|
||||
|
||||
if (details?.review === "second_reviewer_conflict") {
|
||||
return "二次复核人不能与首个复核人相同。";
|
||||
}
|
||||
|
||||
if (details?.review === "high_risk_second_review_required") {
|
||||
return "当前为高风险付款批次,完成二次复核后才能执行。";
|
||||
}
|
||||
|
||||
if (details?.review === "batch_review_required") {
|
||||
return "当前批次尚未完成复核,不能执行。";
|
||||
}
|
||||
|
||||
if (details?.review === "batch_not_reviewable") {
|
||||
return "当前批次状态不允许继续复核。";
|
||||
}
|
||||
|
||||
if (details?.transfer === "chain_limit_exceeded") {
|
||||
return "当前任务转审次数已达上限。";
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type RiskFlag = {
|
||||
id: string;
|
||||
entityType: string;
|
||||
@@ -32,3 +34,13 @@ export type PrecheckResponse = {
|
||||
riskFlags: Array<{ code: string; level: "low" | "medium" | "high"; title: string; detail: string; willOpenOnSubmit: boolean }>;
|
||||
nextActions: string[];
|
||||
};
|
||||
|
||||
export async function precheckEntity(payload: {
|
||||
entityType: "reimbursement" | "purchase_request" | "travel_request";
|
||||
entityId: string;
|
||||
}) {
|
||||
return apiRequestData<PrecheckResponse>("/api/fee-control/precheck", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,12 +32,25 @@ export type PaymentBatch = {
|
||||
defaultPaymentDate: string | null;
|
||||
defaultPaymentChannel: PaymentChannel | null;
|
||||
voucherPrefix: string | null;
|
||||
totalAmount: string;
|
||||
highRisk: boolean;
|
||||
reviewRequirement: {
|
||||
thresholdAmount: string;
|
||||
requiredReviewCount: number;
|
||||
completedReviewCount: number;
|
||||
nextStep: "first_review" | "second_review" | "ready_for_execute";
|
||||
makerCheckerRequired: boolean;
|
||||
reviewerPermission: string;
|
||||
};
|
||||
reviewComment: string | null;
|
||||
secondaryReviewComment: string | null;
|
||||
executionSummaryJson: Record<string, unknown> | null;
|
||||
createdAt: string | null;
|
||||
createdBy: string | null;
|
||||
reviewedAt: string | null;
|
||||
reviewedBy: string | null;
|
||||
secondaryReviewedAt: string | null;
|
||||
secondaryReviewedBy: string | null;
|
||||
executedAt: string | null;
|
||||
executedBy: string | null;
|
||||
cancelledAt: string | null;
|
||||
|
||||
@@ -20,48 +20,52 @@ export function ApOperationsPage() {
|
||||
const data = summaryQuery.data;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="AP 运营" subtitle="待支付、失败批次、风险项与差旅对账异常。" />
|
||||
<PageHeader title="AP 运营" subtitle="财务视角查看待付款、部分匹配、异常和预算超支四类最小运营摘要。" />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<div className="card-std p-5"><div className="text-sm text-zinc-500">开放批次</div><div className="mt-2 text-2xl font-bold text-zinc-900">{data.summary.openPaymentBatchCount}</div></div>
|
||||
<div className="card-std p-5"><div className="text-sm text-zinc-500">失败批次项</div><div className="mt-2 text-2xl font-bold text-zinc-900">{data.summary.failedPaymentBatchItemCount}</div></div>
|
||||
<div className="card-std p-5"><div className="text-sm text-zinc-500">开放风险</div><div className="mt-2 text-2xl font-bold text-zinc-900">{data.summary.openRiskFlagCount}</div></div>
|
||||
<div className="card-std p-5"><div className="text-sm text-zinc-500">对账异常</div><div className="mt-2 text-2xl font-bold text-zinc-900">{data.summary.reconciliationAnomalyCount}</div></div>
|
||||
<div className="card-std p-5"><div className="text-sm text-zinc-500">待付款</div><div className="mt-2 text-2xl font-bold text-zinc-900">{data.summary.pendingPaymentCount}</div></div>
|
||||
<div className="card-std p-5"><div className="text-sm text-zinc-500">部分匹配</div><div className="mt-2 text-2xl font-bold text-zinc-900">{data.summary.partialMatchCount}</div></div>
|
||||
<div className="card-std p-5"><div className="text-sm text-zinc-500">异常</div><div className="mt-2 text-2xl font-bold text-zinc-900">{data.summary.exceptionCount}</div></div>
|
||||
<div className="card-std p-5"><div className="text-sm text-zinc-500">预算超支</div><div className="mt-2 text-2xl font-bold text-zinc-900">{data.summary.budgetOverspendCount}</div></div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<div className="card-std space-y-3 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">开放批次</h3>
|
||||
<h3 className="text-base font-semibold text-zinc-900">待付款批次</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
{data.openPaymentBatches.length ? data.openPaymentBatches.map((item) => (
|
||||
{data.pendingPaymentBatches.length ? data.pendingPaymentBatches.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-zinc-200 px-3 py-3">
|
||||
<div className="font-medium text-zinc-900">{item.batchNo}</div>
|
||||
<div className="text-zinc-600">{item.name}</div>
|
||||
<div className="text-xs text-zinc-500">{item.entityType} · {item.status}</div>
|
||||
<div className="text-xs text-zinc-500">{item.entityType} · {item.status} · {item.totalAmount}{item.highRisk ? " · 高风险" : ""}</div>
|
||||
</div>
|
||||
)) : <div className="text-zinc-500">暂无开放批次</div>}
|
||||
)) : <div className="text-zinc-500">暂无待付款批次</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-3 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">失败批次项</h3>
|
||||
<h3 className="text-base font-semibold text-zinc-900">异常付款项</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
{data.failedPaymentBatchItems.length ? data.failedPaymentBatchItems.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-red-200 bg-red-50 px-3 py-3">
|
||||
<div className="font-medium text-zinc-900">{item.entityCode}</div>
|
||||
<div className="text-zinc-600">{item.failureMessage ?? item.failureCode ?? "执行失败"}</div>
|
||||
<div className="text-xs text-zinc-500">{item.amount} {item.currencyCode}</div>
|
||||
</div>
|
||||
)) : <div className="text-zinc-500">暂无失败项</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RiskFlagsCard items={data.openRiskFlags} title="开放风险项" />
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<RiskFlagsCard items={data.exceptionRiskFlags} title="异常风险项" />
|
||||
<RiskFlagsCard items={data.budgetOverspendRisks} title="预算超支风险" />
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-3 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">差旅对账异常</h3>
|
||||
<h3 className="text-base font-semibold text-zinc-900">部分匹配与对账异常</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
{data.reconciliationAnomalies.length ? data.reconciliationAnomalies.map((item) => (
|
||||
{data.partialMatchItems.length ? data.partialMatchItems.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-zinc-200 px-3 py-3">
|
||||
<div className="font-medium text-zinc-900">{item.anomalyCode ?? item.matchStatus}</div>
|
||||
<div className="text-zinc-600">订单 {item.orderAmount ?? "-"} / 交易 {item.transactionAmount ?? "-"} {item.currency}</div>
|
||||
|
||||
+680
-250
文件差异内容过多而无法显示
加载差异
@@ -13,6 +13,8 @@ import {
|
||||
} 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";
|
||||
@@ -32,6 +34,9 @@ function resolveError(error: unknown) {
|
||||
|
||||
export function PaymentBatchesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions } = useAuth();
|
||||
const canManageBatch = hasPermission(permissions, "payment.batch.manage");
|
||||
const canReviewBatch = hasPermission(permissions, "risk.review.manage");
|
||||
const [selectedBatchId, setSelectedBatchId] = useState("");
|
||||
const [entityType, setEntityType] = useState<PaymentBatchEntityType>("reimbursement");
|
||||
const [name, setName] = useState("");
|
||||
@@ -50,6 +55,23 @@ export function PaymentBatchesPage() {
|
||||
const items = listQuery.data?.items ?? [];
|
||||
return items.find((item) => item.id === selectedBatchId) ?? items[0] ?? null;
|
||||
}, [listQuery.data?.items, selectedBatchId]);
|
||||
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"] });
|
||||
@@ -85,10 +107,10 @@ export function PaymentBatchesPage() {
|
||||
const resolveRiskFlagIds = (batch?.items ?? []).flatMap((item) => item.openRiskFlags.map((flag) => flag.id));
|
||||
return reviewPaymentBatch(batchId, { resolveRiskFlagIds });
|
||||
},
|
||||
onSuccess: async () => {
|
||||
onSuccess: async ({ item }) => {
|
||||
setError("");
|
||||
await refresh();
|
||||
toast.success("付款批次已复核");
|
||||
toast.success(item.reviewRequirement.nextStep === "ready_for_execute" ? "付款批次复核完成" : "付款批次已完成首轮复核");
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = resolveError(err);
|
||||
@@ -146,36 +168,67 @@ export function PaymentBatchesPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="支付批次" subtitle="财务批量付款、复核与执行。" />
|
||||
<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} onChange={(event) => setEntityType(event.target.value as PaymentBatchEntityType)}>
|
||||
<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} onChange={(event) => setName(event.target.value)} placeholder="例如:2026Q2-财务付款批次-01" />
|
||||
<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" value={entityIds} onChange={(event) => setEntityIds(event.target.value)} placeholder="用逗号或换行分隔 reimbursement / travel_order ID" />
|
||||
<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} onChange={(event) => setDefaultPaymentDate(event.target.value)} />
|
||||
<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} onChange={(event) => setDefaultPaymentChannel((event.target.value as PaymentChannel | "") || "") }>
|
||||
<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>
|
||||
@@ -183,9 +236,14 @@ export function PaymentBatchesPage() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>凭证前缀</Label>
|
||||
<Input value={voucherPrefix} onChange={(event) => setVoucherPrefix(event.target.value)} placeholder="例如 PBX" />
|
||||
<Input
|
||||
value={voucherPrefix}
|
||||
disabled={!canManageBatch}
|
||||
onChange={(event) => setVoucherPrefix(event.target.value)}
|
||||
placeholder="例如 PBX"
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" disabled={createMutation.isPending} onClick={() => createMutation.mutate()}>
|
||||
<Button type="button" disabled={createMutation.isPending || !canManageBatch} onClick={() => createMutation.mutate()}>
|
||||
{createMutation.isPending ? "创建中..." : "创建批次"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -206,6 +264,11 @@ export function PaymentBatchesPage() {
|
||||
<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}
|
||||
@@ -218,25 +281,58 @@ export function PaymentBatchesPage() {
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">{selectedBatch.batchNo}</div>
|
||||
<div className="text-lg font-semibold text-zinc-900">{selectedBatch.name}</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">
|
||||
<Button type="button" variant="secondary" disabled={reviewMutation.isPending} onClick={() => reviewMutation.mutate(selectedBatch.id)}>
|
||||
复核
|
||||
</Button>
|
||||
<Button type="button" disabled={executeMutation.isPending} onClick={() => executeMutation.mutate(selectedBatch.id)}>
|
||||
执行
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" disabled={cancelMutation.isPending} onClick={() => cancelMutation.mutate(selectedBatch.id)}>
|
||||
取消
|
||||
</Button>
|
||||
{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 md:grid-cols-4 text-sm">
|
||||
<div className="grid gap-3 text-sm md:grid-cols-3 xl:grid-cols-6">
|
||||
<div>状态:{selectedBatch.status}</div>
|
||||
<div>实体:{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>
|
||||
|
||||
@@ -253,9 +349,24 @@ export function PaymentBatchesPage() {
|
||||
<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="凭证号" onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentVoucherNoOverride: event.target.value || null })} />
|
||||
<Input type="date" defaultValue={item.paymentDateOverride ?? ""} onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentDateOverride: event.target.value || null })} />
|
||||
<select className="select-std" defaultValue={item.paymentChannelOverride ?? ""} onChange={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentChannelOverride: (event.target.value as PaymentChannel | "") || null })}>
|
||||
<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>
|
||||
|
||||
在新工单中引用
屏蔽一个用户