重构财务台账工具与异常优先页面(05-30-finance-ledger-tools-redesign)
这个提交包含在:
@@ -46,6 +46,7 @@ export type PaymentBatch = {
|
||||
reviewComment: string | null;
|
||||
secondaryReviewComment: string | null;
|
||||
executionSummaryJson: Record<string, unknown> | null;
|
||||
receiptAttachmentCount?: number;
|
||||
createdAt: string | null;
|
||||
createdBy: string | null;
|
||||
reviewedAt: string | null;
|
||||
|
||||
@@ -284,6 +284,7 @@ export function ApOperationsPage() {
|
||||
const [ledgerDateTo, setLedgerDateTo] = useState("");
|
||||
const [ledgerSearch, setLedgerSearch] = useState("");
|
||||
const [ledgerPage, setLedgerPage] = useState(1);
|
||||
const [showLedgerTools, setShowLedgerTools] = useState(false);
|
||||
|
||||
const [reviewTarget, setReviewTarget] = useState<PaymentBatch | null>(null);
|
||||
const [reviewComment, setReviewComment] = useState("");
|
||||
@@ -617,8 +618,8 @@ export function ApOperationsPage() {
|
||||
return (
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
title="财务付款与对账"
|
||||
subtitle="待付款、异常付款、风险、对账和台账集中处理。"
|
||||
title="应付账款"
|
||||
subtitle="优先处理待付款、异常付款、风险和对账差异;导出和代账交接进入台账工具。"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
@@ -632,24 +633,16 @@ export function ApOperationsPage() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => exportMutation.mutate("csv")}
|
||||
disabled={exportMutation.isPending}
|
||||
onClick={() => setShowLedgerTools((current) => !current)}
|
||||
>
|
||||
{exportMutation.isPending ? "导出中..." : "导出 CSV"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => exportMutation.mutate("xlsx")}
|
||||
disabled={exportMutation.isPending}
|
||||
>
|
||||
{exportMutation.isPending ? "导出中..." : "导出 Excel"}
|
||||
台账工具
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{summaryQuery.isError ? (
|
||||
<SectionError message={resolveErrorMessage(summaryQuery.error, "AP 摘要加载失败")} />
|
||||
<SectionError message={resolveErrorMessage(summaryQuery.error, "应付摘要加载失败")} />
|
||||
) : null}
|
||||
{paymentBatchesQuery.isError ? (
|
||||
<SectionError message={resolveErrorMessage(paymentBatchesQuery.error, "付款批次数据加载失败")} />
|
||||
@@ -677,6 +670,40 @@ export function ApOperationsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showLedgerTools ? (
|
||||
<div className="finance-command-panel space-y-3" data-testid="ap-ledger-tools">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">台账工具</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-zinc-500">
|
||||
导出只用于财务复核和代账交接,范围按当前筛选条件生成;文件可能包含付款、票据和员工摘要信息。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => exportMutation.mutate("csv")}
|
||||
disabled={exportMutation.isPending}
|
||||
>
|
||||
{exportMutation.isPending ? "导出中..." : "导出 CSV"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => exportMutation.mutate("xlsx")}
|
||||
disabled={exportMutation.isPending}
|
||||
>
|
||||
{exportMutation.isPending ? "导出中..." : "导出 Excel"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-600">
|
||||
当前工具不会执行真实付款,也不会生成正式总账凭证;付款仍由线下银行或既有财务流程完成。
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="finance-command-panel">
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as OperationsTab)}>
|
||||
<TabsList className="ops-command-bar__tabs !w-full !justify-start">
|
||||
|
||||
@@ -85,6 +85,7 @@ export function InvoicePoolPage() {
|
||||
const [editTotalAmount, setEditTotalAmount] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [confirmAction, setConfirmAction] = useState<{ type: string; invoiceId: string } | null>(null);
|
||||
const [showLedgerTools, setShowLedgerTools] = useState(false);
|
||||
const previousDetailStatusRef = useRef<InvoicePoolStatus | null>(null);
|
||||
|
||||
// --- Queries ---
|
||||
@@ -459,28 +460,11 @@ export function InvoicePoolPage() {
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
title="票据中心"
|
||||
subtitle="上传、识别、绑定和异常复核集中处理。"
|
||||
subtitle="优先看识别状态、失败票据和待绑定票据;导出、全范围和财务交接进入台账工具。"
|
||||
actions={
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
data-testid="invoice-export"
|
||||
className="btn-secondary"
|
||||
type="button"
|
||||
disabled={exportMutation.isPending}
|
||||
>
|
||||
{exportMutation.isPending ? "导出中..." : "导出"}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem data-testid="invoice-export-csv" onSelect={() => exportMutation.mutate("csv")}>
|
||||
导出 CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem data-testid="invoice-export-xlsx" onSelect={() => exportMutation.mutate("xlsx")}>
|
||||
导出 Excel
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((current) => !current)}>
|
||||
台账工具
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -490,6 +474,42 @@ export function InvoicePoolPage() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{showLedgerTools ? (
|
||||
<div className="finance-command-panel space-y-3" data-testid="invoice-ledger-tools">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">台账工具</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-zinc-500">
|
||||
导出按当前状态和范围生成,只用于财务复核、OCR 排障和代账交接;普通员工日常补票据仍从报销详情或移动上传进入。
|
||||
</p>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
data-testid="invoice-export"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={exportMutation.isPending}
|
||||
>
|
||||
{exportMutation.isPending ? "导出中..." : "导出"}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem data-testid="invoice-export-csv" onSelect={() => exportMutation.mutate("csv")}>
|
||||
导出 CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem data-testid="invoice-export-xlsx" onSelect={() => exportMutation.mutate("xlsx")}>
|
||||
导出 Excel
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-600">
|
||||
票据原文、预览和下载仍按详情页权限与审计控制处理,列表导出不作为敏感材料分发入口。
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="finance-status-strip" data-testid="invoice-pool-summary">
|
||||
<div className="finance-metric-card">
|
||||
<div className="finance-metric-card__label">票据总数</div>
|
||||
|
||||
@@ -113,6 +113,7 @@ export function PaymentBatchesPage() {
|
||||
const receiptInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [selectedReceiptFiles, setSelectedReceiptFiles] = useState<File[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [showBatchTools, setShowBatchTools] = useState(false);
|
||||
const [confirmPaymentAction, setConfirmPaymentAction] = useState<{
|
||||
kind: "financeReview" | "financePayment" | "cancel";
|
||||
batchId: string;
|
||||
@@ -132,9 +133,17 @@ export function PaymentBatchesPage() {
|
||||
const batchStats = useMemo(() => {
|
||||
return {
|
||||
total: batches.length,
|
||||
actionable: batches.filter((item) => ["draft", "reviewed", "failed"].includes(item.status)).length,
|
||||
highRisk: batches.filter((item) => item.highRisk).length,
|
||||
failed: batches.filter((item) => item.status === "failed").length,
|
||||
needsReview: batches.filter((item) => item.reviewRequirement.nextStep !== "ready_for_execute" && ["draft", "reviewed"].includes(item.status)).length,
|
||||
readyToPay: batches.filter((item) => item.reviewRequirement.nextStep === "ready_for_execute" && ["reviewed", "failed"].includes(item.status)).length,
|
||||
needsReceipt: batches.filter((item) =>
|
||||
["reviewed", "failed"].includes(item.status) &&
|
||||
(item.receiptAttachmentCount ?? 0) === 0 &&
|
||||
item.items.some((entry) =>
|
||||
["pending", "failed"].includes(entry.status) &&
|
||||
(entry.paymentChannelOverride ?? item.defaultPaymentChannel) === "bank",
|
||||
),
|
||||
).length,
|
||||
exceptions: batches.filter((item) => item.highRisk || item.status === "failed").length,
|
||||
};
|
||||
}, [batches]);
|
||||
|
||||
@@ -143,7 +152,6 @@ export function PaymentBatchesPage() {
|
||||
enabled: Boolean(selectedBatch?.id),
|
||||
queryFn: () => fetchPaymentBatchReceiptAttachments(selectedBatch!.id),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedBatch) {
|
||||
return;
|
||||
@@ -353,68 +361,88 @@ export function PaymentBatchesPage() {
|
||||
return (
|
||||
<ListWorkspace>
|
||||
{downloadReasonDialog}
|
||||
<WorkspaceHeader title="付款批次" subtitle="复核、回单和已付款标记集中处理。" />
|
||||
<WorkspaceHeader
|
||||
title="付款"
|
||||
subtitle="先核对待复核、待付款和异常批次;真实付款在线下完成,OA 只记录复核、回单和付款状态。"
|
||||
actions={
|
||||
<>
|
||||
<Button type="button" variant="secondary" onClick={() => void refresh()}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="button" variant={showBatchTools ? "default" : "secondary"} onClick={() => setShowBatchTools((current) => !current)}>
|
||||
台账工具
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{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="finance-status-strip" data-testid="payment-batches-summary">
|
||||
<div className="finance-metric-card">
|
||||
<div className="finance-metric-card__label">待复核</div>
|
||||
<div className="finance-metric-card__value">{batchStats.needsReview}</div>
|
||||
</div>
|
||||
<div className="finance-metric-card">
|
||||
<div className="finance-metric-card__label">待付款标记</div>
|
||||
<div className="finance-metric-card__value">{batchStats.readyToPay}</div>
|
||||
</div>
|
||||
<div className="finance-metric-card">
|
||||
<div className="finance-metric-card__label">待补回单</div>
|
||||
<div className="finance-metric-card__value">{batchStats.needsReceipt}</div>
|
||||
</div>
|
||||
<div className="finance-metric-card">
|
||||
<div className="finance-metric-card__label">异常批次</div>
|
||||
<div className="finance-metric-card__value">{batchStats.exceptions}</div>
|
||||
</div>
|
||||
<div className="finance-metric-card">
|
||||
<div className="finance-metric-card__label">批次数</div>
|
||||
<div className="finance-metric-card__value">{batchStats.total}</div>
|
||||
</div>
|
||||
<div className="finance-metric-card">
|
||||
<div className="finance-metric-card__label">可处理</div>
|
||||
<div className="finance-metric-card__value">{batchStats.actionable}</div>
|
||||
</div>
|
||||
<div className="finance-metric-card">
|
||||
<div className="finance-metric-card__label">高风险</div>
|
||||
<div className="finance-metric-card__value">{batchStats.highRisk}</div>
|
||||
</div>
|
||||
<div className="finance-metric-card">
|
||||
<div className="finance-metric-card__label">失败批次</div>
|
||||
<div className="finance-metric-card__value">{batchStats.failed}</div>
|
||||
</div>
|
||||
<div className="finance-metric-card">
|
||||
<div className="finance-metric-card__label">当前批次</div>
|
||||
<div className="finance-metric-card__value text-base">{selectedBatch?.batchNo ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="finance-workspace-grid">
|
||||
{showBatchTools ? (
|
||||
<div className="finance-command-panel space-y-4" data-testid="payment-batch-create-panel">
|
||||
<h3 className="text-base font-semibold text-zinc-900">新建批次</h3>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">台账工具 · 新建批次</h3>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
仅把已确认待付款的单据归入批次。真实付款仍在银行或既有财务流程完成,OA 记录复核、回单和状态。
|
||||
</p>
|
||||
</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>
|
||||
<select
|
||||
className="select-std"
|
||||
data-testid="payment-batch-create-entity-type"
|
||||
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
|
||||
data-testid="payment-batch-create-name"
|
||||
value={name}
|
||||
disabled={!canManageBatch}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="例如:2026Q2-财务付款批次-01"
|
||||
/>
|
||||
<div className="grid gap-3 lg:grid-cols-[180px_minmax(0,1fr)]">
|
||||
<div className="space-y-2">
|
||||
<Label>实体类型</Label>
|
||||
<select
|
||||
className="select-std"
|
||||
data-testid="payment-batch-create-entity-type"
|
||||
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
|
||||
data-testid="payment-batch-create-name"
|
||||
value={name}
|
||||
disabled={!canManageBatch}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="例如:2026Q2-财务付款批次-01"
|
||||
/>
|
||||
</div>
|
||||
</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"
|
||||
className="min-h-24 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm disabled:bg-zinc-50"
|
||||
data-testid="payment-batch-create-entity-ids"
|
||||
value={entityIds}
|
||||
disabled={!canManageBatch}
|
||||
@@ -422,7 +450,7 @@ export function PaymentBatchesPage() {
|
||||
placeholder="输入报销或差旅待付款单据 ID,多个用逗号或换行分隔"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label>默认付款日期</Label>
|
||||
<DateTextInput
|
||||
@@ -439,29 +467,31 @@ export function PaymentBatchesPage() {
|
||||
data-testid="payment-batch-create-payment-channel"
|
||||
value={defaultPaymentChannel}
|
||||
disabled={!canManageBatch}
|
||||
onChange={(event) => setDefaultPaymentChannel((event.target.value as PaymentChannel | "") || "") }
|
||||
onChange={(event) => setDefaultPaymentChannel((event.target.value as PaymentChannel | "") || "")}
|
||||
>
|
||||
<option value="">不设置</option>
|
||||
{PAYMENT_CHANNEL_OPTIONS.map((item) => <option key={item} value={item}>{paymentChannelLabel(item)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>凭证前缀</Label>
|
||||
<Input
|
||||
data-testid="payment-batch-create-voucher-prefix"
|
||||
value={voucherPrefix}
|
||||
disabled={!canManageBatch}
|
||||
onChange={(event) => setVoucherPrefix(event.target.value)}
|
||||
placeholder="例如 PBX"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label>凭证前缀</Label>
|
||||
<Input
|
||||
data-testid="payment-batch-create-voucher-prefix"
|
||||
value={voucherPrefix}
|
||||
disabled={!canManageBatch}
|
||||
onChange={(event) => setVoucherPrefix(event.target.value)}
|
||||
placeholder="例如 PBX"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" disabled={createMutation.isPending || !canManageBatch} onClick={() => createMutation.mutate()}>
|
||||
{createMutation.isPending ? "创建中..." : "创建批次"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="finance-workspace-grid">
|
||||
<div className="grid gap-5 xl:col-span-2 xl:grid-cols-[340px,minmax(0,1fr)]">
|
||||
<div className="finance-command-panel">
|
||||
<h3 className="mb-3 text-base font-semibold text-zinc-900">批次列表</h3>
|
||||
<div className="space-y-2">
|
||||
|
||||
在新工单中引用
屏蔽一个用户