统一业务台账首屏布局(05-30-fix-payment-ledger-layout-consistency)

这个提交包含在:
2026-05-30 21:44:16 +08:00
父节点 eea2af7718
当前提交 16fd9bdf52
共修改 25 个文件,包含 834 行新增和 543 行删除
+117 -100
查看文件
@@ -259,6 +259,7 @@ export function ApOperationsPage() {
const [ledgerSearch, setLedgerSearch] = useState("");
const [ledgerPage, setLedgerPage] = useState(1);
const [showLedgerTools, setShowLedgerTools] = useState(false);
const [showLedgerAdvancedFilters, setShowLedgerAdvancedFilters] = useState(false);
const [reviewTarget, setReviewTarget] = useState<PaymentBatch | null>(null);
const [reviewComment, setReviewComment] = useState("");
@@ -384,6 +385,7 @@ export function ApOperationsPage() {
}),
[openRiskFlags, riskBucket],
);
const hasLedgerAdvancedFilters = Boolean(ledgerInvoiceStatus !== "all" || ledgerDateFrom || ledgerDateTo);
const failedDetailItem = useMemo(() => {
if (!failedDetailTarget) {
@@ -666,108 +668,123 @@ export function ApOperationsPage() {
</TabsList>
<TabsContent value="ledger" className="mt-6 space-y-4">
<div className="finance-command-panel grid gap-3 !p-3 md:grid-cols-6">
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">审批状态</div>
<select
className="select-std"
value={ledgerStatus}
onChange={(event) => {
setLedgerStatus(event.target.value as FinanceLedgerStatusFilter);
setLedgerPage(1);
}}
>
<option value="all">全部</option>
<option value="approved">已通过</option>
<option value="paid">已支付</option>
<option value="reviewing">审批中</option>
<option value="rejected">已驳回</option>
<option value="cancelled">已取消</option>
</select>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">付款状态</div>
<select
className="select-std"
value={ledgerPaymentStatus}
onChange={(event) => {
setLedgerPaymentStatus(event.target.value as FinanceLedgerPaymentFilter);
setLedgerPage(1);
}}
>
<option value="all">全部</option>
<option value="unpaid">未付款</option>
<option value="paid">已付款</option>
</select>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">发票状态</div>
<select
className="select-std"
value={ledgerInvoiceStatus}
onChange={(event) => {
setLedgerInvoiceStatus(event.target.value as FinanceLedgerInvoiceFilter);
setLedgerPage(1);
}}
>
<option value="all">全部</option>
<option value="none">无发票</option>
<option value="pending_ocr">识别中</option>
<option value="ocr_failed">识别失败</option>
<option value="available">可添加</option>
<option value="bound">已绑定</option>
<option value="archived">已归档</option>
</select>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">报销日期从</div>
<DateTextInput
value={ledgerDateFrom}
onChange={(event) => {
setLedgerDateFrom(event.target.value);
setLedgerPage(1);
}}
/>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">报销日期至</div>
<DateTextInput
value={ledgerDateTo}
onChange={(event) => {
setLedgerDateTo(event.target.value);
setLedgerPage(1);
}}
/>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">搜索</div>
<Input
placeholder="单号、姓名、部门、项目"
value={ledgerSearch}
onChange={(event) => {
setLedgerSearch(event.target.value);
setLedgerPage(1);
}}
/>
<div className="finance-command-panel space-y-3 !p-3">
<div className="grid gap-3 xl:grid-cols-[minmax(240px,1fr)_160px_160px_auto_auto]">
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">搜索</div>
<Input
placeholder="单号、姓名、部门、项目"
value={ledgerSearch}
onChange={(event) => {
setLedgerSearch(event.target.value);
setLedgerPage(1);
}}
/>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">审批状态</div>
<select
className="select-std"
value={ledgerStatus}
onChange={(event) => {
setLedgerStatus(event.target.value as FinanceLedgerStatusFilter);
setLedgerPage(1);
}}
>
<option value="all">全部</option>
<option value="approved">已通过</option>
<option value="paid">已支付</option>
<option value="reviewing">审批中</option>
<option value="rejected">已驳回</option>
<option value="cancelled">已取消</option>
</select>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">付款状态</div>
<select
className="select-std"
value={ledgerPaymentStatus}
onChange={(event) => {
setLedgerPaymentStatus(event.target.value as FinanceLedgerPaymentFilter);
setLedgerPage(1);
}}
>
<option value="all">全部</option>
<option value="unpaid">未付款</option>
<option value="paid">已付款</option>
</select>
</div>
<div className="flex items-end">
<Button
type="button"
variant={showLedgerAdvancedFilters || hasLedgerAdvancedFilters ? "default" : "secondary"}
onClick={() => setShowLedgerAdvancedFilters((current) => !current)}
>
更多筛选
</Button>
</div>
<div className="flex items-end">
<Button
type="button"
variant="ghost"
onClick={() => {
setLedgerStatus("all");
setLedgerPaymentStatus("all");
setLedgerInvoiceStatus("all");
setLedgerDateFrom("");
setLedgerDateTo("");
setLedgerSearch("");
setLedgerPage(1);
}}
>
清空
</Button>
</div>
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
onClick={() => {
setLedgerStatus("all");
setLedgerPaymentStatus("all");
setLedgerInvoiceStatus("all");
setLedgerDateFrom("");
setLedgerDateTo("");
setLedgerSearch("");
setLedgerPage(1);
}}
>
清空台账筛选
</Button>
</div>
{showLedgerAdvancedFilters || hasLedgerAdvancedFilters ? (
<div className="finance-command-panel grid gap-3 !p-3 md:grid-cols-3">
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">发票状态</div>
<select
className="select-std"
value={ledgerInvoiceStatus}
onChange={(event) => {
setLedgerInvoiceStatus(event.target.value as FinanceLedgerInvoiceFilter);
setLedgerPage(1);
}}
>
<option value="all">全部</option>
<option value="none">无发票</option>
<option value="pending_ocr">识别中</option>
<option value="ocr_failed">识别失败</option>
<option value="available">可添加</option>
<option value="bound">已绑定</option>
<option value="archived">已归档</option>
</select>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">报销日期从</div>
<DateTextInput
value={ledgerDateFrom}
onChange={(event) => {
setLedgerDateFrom(event.target.value);
setLedgerPage(1);
}}
/>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">报销日期至</div>
<DateTextInput
value={ledgerDateTo}
onChange={(event) => {
setLedgerDateTo(event.target.value);
setLedgerPage(1);
}}
/>
</div>
</div>
) : null}
<div className="ops-table-focus overflow-x-auto" data-testid="finance-ledger-table">
<Table>
+58 -39
查看文件
@@ -798,6 +798,14 @@ export function AssetRegistryPage() {
setIsCreatingAsset(false);
setSelectedAssetId(assetId);
};
const returnToAssetList = () => {
setIsCreatingAsset(false);
setSelectedAssetId("");
const next = new URLSearchParams(searchParams);
next.delete("id");
setSearchParams(next, { replace: true });
};
const isAssetDetailMode = isCreatingAsset || Boolean(selectedAssetId);
return (
<ListWorkspace>
@@ -805,17 +813,26 @@ export function AssetRegistryPage() {
title="资产台账"
actions={
<div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" onClick={() => navigate("/mobile/assets/scan") }>
移动扫码查找
</Button>
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((value) => !value)}>
台账工具
</Button>
{isAssetDetailMode ? (
<Button type="button" variant="secondary" onClick={returnToAssetList}>
返回列表
</Button>
) : (
<>
<Button type="button" variant="secondary" onClick={() => navigate("/mobile/assets/scan") }>
移动扫码查找
</Button>
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((value) => !value)}>
台账工具
</Button>
</>
)}
</div>
}
/>
<div className={showLedgerTools ? "grid gap-6 xl:grid-cols-[380px_minmax(0,1fr)]" : "grid gap-6"}>
{!isAssetDetailMode ? (
<div className="grid gap-6">
{showLedgerTools ? (
<div className="card-std space-y-4 p-4 md:p-5" data-testid="asset-ledger-tools">
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
@@ -1025,17 +1042,13 @@ export function AssetRegistryPage() {
<div className="space-y-3 md:hidden">
{orderedAssetItems.length ? (
orderedAssetItems.map((item) => (
<button
data-testid="asset-mobile-row"
type="button"
key={item.id}
onClick={() => selectAsset(item.id)}
className={`w-full rounded-lg border p-3 text-left ${
item.id === selectedAssetId && !isCreatingAsset
? "border-zinc-400 bg-zinc-50"
: "border-zinc-200 bg-white"
}`}
>
<button
data-testid="asset-mobile-row"
type="button"
key={item.id}
onClick={() => selectAsset(item.id)}
className="w-full rounded-lg border border-zinc-200 bg-white p-3 text-left"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">
@@ -1065,9 +1078,10 @@ export function AssetRegistryPage() {
<div>
<div className="text-zinc-400">位置</div>
<div className="mt-0.5 truncate text-zinc-700">{item.location ?? "-"}</div>
</div>
</div>
</button>
</div>
</div>
<div className="mt-3 text-sm font-medium text-primary">查看详情</div>
</button>
))
) : (
<div className="rounded-lg border border-zinc-200 p-4 text-sm text-zinc-500">暂无资产卡片</div>
@@ -1079,17 +1093,18 @@ export function AssetRegistryPage() {
<tr>
<TableHead>资产</TableHead>
<TableHead>类型</TableHead>
<TableHead>责任链</TableHead>
<TableHead>位置</TableHead>
<TableHead>状态</TableHead>
</tr>
<TableHead>责任链</TableHead>
<TableHead>位置</TableHead>
<TableHead>状态</TableHead>
<TableHead className="w-24 text-right">操作</TableHead>
</tr>
</TableHeader>
<TableBody>
{orderedAssetItems.length ? (
orderedAssetItems.map((item) => (
<TableRow data-testid="asset-row" key={item.id} onClick={() => {
selectAsset(item.id);
}} className={item.id === selectedAssetId && !isCreatingAsset ? "bg-zinc-50" : "cursor-pointer"}>
<TableRow data-testid="asset-row" key={item.id} onClick={() => {
selectAsset(item.id);
}} className="cursor-pointer">
<TableCell>
<div className="font-medium text-zinc-900">
<CompactAssetNo value={item.assetNo} />
@@ -1107,12 +1122,17 @@ export function AssetRegistryPage() {
<div>{formatTextWithCompactLongNumbers(item.location ?? "-")}</div>
<div className="text-xs text-zinc-500">盘点 {item.lastVerifiedAt ? formatDisplayDateTime(item.lastVerifiedAt) : "未登记"}</div>
</TableCell>
<TableCell><StatusBadge status={item.status} /></TableCell>
</TableRow>
<TableCell><StatusBadge status={item.status} /></TableCell>
<TableCell className="text-right">
<Button type="button" size="sm" variant="secondary" onClick={() => selectAsset(item.id)}>
查看详情
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="py-4 text-zinc-500">
<TableCell colSpan={6} className="py-4 text-zinc-500">
暂无资产卡片
</TableCell>
</TableRow>
@@ -1121,11 +1141,12 @@ export function AssetRegistryPage() {
</Table>
</PrimaryTable>
</>
) : null}
</FilterBar>
{selectedAssetId || isCreatingAsset ? (
<div className="card-std space-y-4 p-4 md:p-5">
) : null}
</FilterBar>
</div>
</div>
) : (
<div className="card-std space-y-4 p-4 md:p-5">
{selectedAssetId && !isCreatingAsset && assetDetailQuery.isLoading ? <TableSkeleton columns={2} rows={8} /> : null}
{selectedAssetId && !isCreatingAsset && assetDetailQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(assetDetailQuery.error)}</p>
@@ -1559,9 +1580,7 @@ export function AssetRegistryPage() {
</>
) : null}
</div>
) : null}
</div>
</div>
)}
</ListWorkspace>
);
}
+18 -37
查看文件
@@ -109,15 +109,11 @@ export function InventoryChecksPage() {
});
useEffect(() => {
const nextId = inventoryChecksQuery.data?.items?.[0]?.id ?? "";
if (!selectedCheckId && nextId) {
setSelectedCheckId(nextId);
}
if (selectedCheckId && inventoryChecksQuery.data?.items?.some((item) => item.id === selectedCheckId)) {
if (!selectedCheckId) {
return;
}
if (nextId !== selectedCheckId) {
setSelectedCheckId(nextId);
if (!inventoryChecksQuery.data?.items?.some((item) => item.id === selectedCheckId)) {
setSelectedCheckId("");
}
}, [inventoryChecksQuery.data?.items, selectedCheckId]);
@@ -247,7 +243,6 @@ export function InventoryChecksPage() {
<PageScaffold className="space-y-6">
<PageHeader
title="资产盘点"
subtitle="按任务执行盘点:先确定范围,再启动、扫码/录入、核对结果并确认完成。"
actions={
<Button
type="button"
@@ -260,30 +255,20 @@ export function InventoryChecksPage() {
}
/>
<section className="ops-status-strip grid gap-4 md:grid-cols-4" data-testid="inventory-check-summary">
<div className="finance-metric-card">
<div className="text-sm text-zinc-500">盘点任务</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{inventoryStats.total}</div>
<div className="mt-1 text-xs text-zinc-500">按季度或事件触发</div>
</div>
<div className="finance-metric-card">
<div className="text-sm text-zinc-500">草稿</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{inventoryStats.draft}</div>
<div className="mt-1 text-xs text-zinc-500">待启动后录入</div>
</div>
<div className="finance-metric-card">
<div className="text-sm text-zinc-500">盘点中</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{inventoryStats.counting}</div>
<div className="mt-1 text-xs text-zinc-500">可扫码或手工录入</div>
</div>
<div className="finance-metric-card">
<div className="text-sm text-zinc-500">已完成</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{inventoryStats.completed}</div>
<div className="mt-1 text-xs text-zinc-500">保留差异和备注</div>
</div>
<section className="flex flex-wrap gap-2" data-testid="inventory-check-summary">
{[
["全部", inventoryStats.total],
["草稿", inventoryStats.draft],
["盘点中", inventoryStats.counting],
["已完成", inventoryStats.completed],
].map(([label, value]) => (
<span key={label} className="rounded-full border border-zinc-200 bg-white px-3 py-1 text-sm text-zinc-700">
{label} <span className="font-semibold text-zinc-900">{value}</span>
</span>
))}
</section>
<div className="grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
<div className={selectedCheck ? "grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]" : "grid gap-6"}>
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">新建盘点任务</h3>
<div className="space-y-2">
@@ -338,7 +323,7 @@ export function InventoryChecksPage() {
<TableRow
data-testid="inventory-row"
key={item.id}
className={item.id === selectedCheckId ? "bg-zinc-50 cursor-pointer" : "cursor-pointer"}
className="cursor-pointer"
onClick={() => setSelectedCheckId(item.id)}
>
<TableCell className="font-medium text-zinc-900">{item.checkNo}</TableCell>
@@ -358,17 +343,12 @@ export function InventoryChecksPage() {
</div>
</div>
{selectedCheck ? (
<div className="card-std space-y-4 p-6">
{inventoryChecksQuery.isLoading ? <TableSkeleton columns={3} rows={6} /> : null}
{inventoryChecksQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(inventoryChecksQuery.error)}</p>
) : null}
{!inventoryChecksQuery.isLoading && !selectedCheck ? (
<div className="rounded-lg border border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500">
请先创建或选择盘点任务。
</div>
) : null}
{selectedCheck ? (
<>
<div className="flex items-center justify-between gap-3">
@@ -464,6 +444,7 @@ export function InventoryChecksPage() {
</>
) : null}
</div>
) : null}
</div>
<ActionConfirmDialog
open={confirmCompleteCheck}
+143 -125
查看文件
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchMyDelegations } from "../api/delegations";
@@ -51,6 +52,7 @@ type UploadBatchResult = {
export function InvoicePoolPage() {
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const { permissions, me } = useAuth();
const canManageInvoice = hasPermission(permissions, "invoice.manage");
const canOverrideDuplicate = hasPermission(permissions, "expense.finance.override_duplicate");
@@ -71,7 +73,7 @@ export function InvoicePoolPage() {
const [selectedReimbursementId, setSelectedReimbursementId] = useState("");
const [selectedTravelRequestId, setSelectedTravelRequestId] = useState("");
const [selectedTravelOrderId, setSelectedTravelOrderId] = useState("");
const [detailInvoiceId, setDetailInvoiceId] = useState("");
const [detailInvoiceId, setDetailInvoiceId] = useState(searchParams.get("id") ?? "");
const [editOpen, setEditOpen] = useState(false);
const [editSetAvailable, setEditSetAvailable] = useState(false);
const [editRemarks, setEditRemarks] = useState("");
@@ -203,10 +205,25 @@ export function InvoicePoolPage() {
}, [canManageTravelPayments]);
useEffect(() => {
if (!detailInvoiceId && listQuery.data?.items?.length) {
setDetailInvoiceId(listQuery.data.items[0].id);
const searchInvoiceId = searchParams.get("id") ?? "";
if (searchInvoiceId !== detailInvoiceId) {
setDetailInvoiceId(searchInvoiceId);
}
}, [detailInvoiceId, listQuery.data?.items]);
}, [detailInvoiceId, searchParams]);
useEffect(() => {
const currentId = searchParams.get("id") ?? "";
if (detailInvoiceId === currentId) {
return;
}
const next = new URLSearchParams(searchParams);
if (detailInvoiceId) {
next.set("id", detailInvoiceId);
} else {
next.delete("id");
}
setSearchParams(next, { replace: true });
}, [detailInvoiceId, searchParams, setSearchParams]);
// --- Helper for error handling ---
@@ -453,6 +470,13 @@ export function InvoicePoolPage() {
detail.ownerEmployeeId === me?.id ||
detail.uploadedByEmployeeId === me?.id),
);
const isInvoiceDetailMode = Boolean(detailInvoiceId);
const returnToInvoiceList = () => {
setDetailInvoiceId("");
const next = new URLSearchParams(searchParams);
next.delete("id");
setSearchParams(next, { replace: true });
};
// --- Render ---
@@ -460,11 +484,18 @@ export function InvoicePoolPage() {
<ListWorkspace>
<WorkspaceHeader
title="票据中心"
subtitle="优先看识别状态、失败票据和待绑定票据;导出、全范围和财务交接进入台账工具。"
actions={
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((current) => !current)}>
台账工具
</Button>
<div className="flex flex-wrap gap-2">
{isInvoiceDetailMode ? (
<Button type="button" variant="secondary" onClick={returnToInvoiceList}>
返回列表
</Button>
) : (
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((current) => !current)}>
台账工具
</Button>
)}
</div>
}
/>
@@ -474,14 +505,11 @@ export function InvoicePoolPage() {
</p>
) : null}
{showLedgerTools ? (
{!isInvoiceDetailMode && 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>
@@ -504,124 +532,114 @@ export function InvoicePoolPage() {
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-600">
票据原文、预览和下载仍按详情页权限与审计控制处理,列表导出不作为敏感材料分发入口。
</div>
<InvoiceUploadCard
selectedFiles={selectedFiles}
setSelectedFiles={setSelectedFiles}
isUploading={uploadMutation.isPending}
uploadProgressLabel={
uploadProgress
? `上传中 ${uploadProgress.completed}/${uploadProgress.total}`
: "上传中..."
}
onUpload={() => uploadMutation.mutate()}
showProxyOwnerSelector={showProxyOwnerSelector}
proxyDelegationsQuery={proxyDelegationsQuery}
uploadOwnerEmployeeId={uploadOwnerEmployeeId}
setUploadOwnerEmployeeId={setUploadOwnerEmployeeId}
/>
<InvoiceBindCard
listQuery={listQuery}
reimbursementQuery={reimbursementQuery}
travelRequestQuery={travelRequestQuery}
travelRequestDetailQuery={travelRequestDetailQuery}
bindMutation={bindMutation}
bindMode={bindMode}
setBindMode={setBindMode}
canManageTravelPayments={canManageTravelPayments}
selectedInvoiceId={selectedInvoiceId}
setSelectedInvoiceId={setSelectedInvoiceId}
selectedReimbursementId={selectedReimbursementId}
setSelectedReimbursementId={setSelectedReimbursementId}
selectedTravelRequestId={selectedTravelRequestId}
setSelectedTravelRequestId={setSelectedTravelRequestId}
selectedTravelOrderId={selectedTravelOrderId}
setSelectedTravelOrderId={setSelectedTravelOrderId}
/>
</div>
) : null}
<div className="finance-status-strip" data-testid="invoice-pool-summary">
<div className="finance-metric-card">
<div className="finance-metric-card__label">票据总数</div>
<div className="finance-metric-card__value">{invoiceStats.total}</div>
{!isInvoiceDetailMode ? (
<div className="flex flex-wrap gap-2" data-testid="invoice-pool-summary">
{[
["全部", invoiceStats.total],
["识别中", invoiceStats.pending],
["识别失败", invoiceStats.failed],
["可绑定", invoiceStats.available],
["已绑定", invoiceStats.bound],
].map(([label, value]) => (
<span key={label} className="rounded-full border border-zinc-200 bg-white px-3 py-1 text-sm text-zinc-700">
{label} <span className="font-semibold text-zinc-900">{value}</span>
</span>
))}
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">识别中</div>
<div className="finance-metric-card__value">{invoiceStats.pending}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">识别失败</div>
<div className="finance-metric-card__value">{invoiceStats.failed}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">可绑定</div>
<div className="finance-metric-card__value">{invoiceStats.available}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">已绑定</div>
<div className="finance-metric-card__value">{invoiceStats.bound}</div>
</div>
</div>
) : null}
<InvoiceUploadCard
selectedFiles={selectedFiles}
setSelectedFiles={setSelectedFiles}
isUploading={uploadMutation.isPending}
uploadProgressLabel={
uploadProgress
? `上传中 ${uploadProgress.completed}/${uploadProgress.total}`
: "上传中..."
}
onUpload={() => uploadMutation.mutate()}
showProxyOwnerSelector={showProxyOwnerSelector}
proxyDelegationsQuery={proxyDelegationsQuery}
uploadOwnerEmployeeId={uploadOwnerEmployeeId}
setUploadOwnerEmployeeId={setUploadOwnerEmployeeId}
/>
<InvoiceListCard
listQuery={listQuery}
status={status}
setStatus={setStatus}
ownerScope={ownerScope}
setOwnerScope={setOwnerScope}
page={page}
setPage={setPage}
totalPages={totalPages}
detailInvoiceId={detailInvoiceId}
setDetailInvoiceId={setDetailInvoiceId}
signedUrlMutation={signedUrlMutation}
/>
<InvoiceBindCard
listQuery={listQuery}
reimbursementQuery={reimbursementQuery}
travelRequestQuery={travelRequestQuery}
travelRequestDetailQuery={travelRequestDetailQuery}
bindMutation={bindMutation}
bindMode={bindMode}
setBindMode={setBindMode}
canManageTravelPayments={canManageTravelPayments}
selectedInvoiceId={selectedInvoiceId}
setSelectedInvoiceId={setSelectedInvoiceId}
selectedReimbursementId={selectedReimbursementId}
setSelectedReimbursementId={setSelectedReimbursementId}
selectedTravelRequestId={selectedTravelRequestId}
setSelectedTravelRequestId={setSelectedTravelRequestId}
selectedTravelOrderId={selectedTravelOrderId}
setSelectedTravelOrderId={setSelectedTravelOrderId}
/>
<InvoiceDetailPanel
detail={detail}
detailQuery={detailQuery}
canEditDetail={canEditDetail}
canVerifyInvoice={canVerifyInvoice}
canUploadInvoice={canUploadInvoice}
canOverrideDuplicate={canOverrideDuplicate}
patchMutation={patchMutation}
archiveMutation={archiveMutation}
unbindMutation={unbindMutation}
retryOcrMutation={retryOcrMutation}
triggerVerificationMutation={triggerVerificationMutation}
retryVerificationMutation={retryVerificationMutation}
overrideDuplicateMutation={overrideDuplicateMutation}
verificationQuery={verificationQuery}
editOpen={editOpen}
setEditOpen={setEditOpen}
editSetAvailable={editSetAvailable}
setEditSetAvailable={setEditSetAvailable}
editRemarks={editRemarks}
setEditRemarks={setEditRemarks}
editInvoiceCode={editInvoiceCode}
setEditInvoiceCode={setEditInvoiceCode}
editInvoiceNumber={editInvoiceNumber}
setEditInvoiceNumber={setEditInvoiceNumber}
editInvoiceDate={editInvoiceDate}
setEditInvoiceDate={setEditInvoiceDate}
editSellerName={editSellerName}
setEditSellerName={setEditSellerName}
editBuyerName={editBuyerName}
setEditBuyerName={setEditBuyerName}
editAmount={editAmount}
setEditAmount={setEditAmount}
editTaxAmount={editTaxAmount}
setEditTaxAmount={setEditTaxAmount}
editTotalAmount={editTotalAmount}
setEditTotalAmount={setEditTotalAmount}
confirmAction={confirmAction}
setConfirmAction={setConfirmAction}
/>
{!isInvoiceDetailMode ? (
<InvoiceListCard
listQuery={listQuery}
status={status}
setStatus={setStatus}
ownerScope={ownerScope}
setOwnerScope={setOwnerScope}
page={page}
setPage={setPage}
totalPages={totalPages}
detailInvoiceId={detailInvoiceId}
setDetailInvoiceId={setDetailInvoiceId}
signedUrlMutation={signedUrlMutation}
/>
) : (
<InvoiceDetailPanel
detail={detail}
detailQuery={detailQuery}
canEditDetail={canEditDetail}
canVerifyInvoice={canVerifyInvoice}
canUploadInvoice={canUploadInvoice}
canOverrideDuplicate={canOverrideDuplicate}
patchMutation={patchMutation}
archiveMutation={archiveMutation}
unbindMutation={unbindMutation}
retryOcrMutation={retryOcrMutation}
triggerVerificationMutation={triggerVerificationMutation}
retryVerificationMutation={retryVerificationMutation}
overrideDuplicateMutation={overrideDuplicateMutation}
verificationQuery={verificationQuery}
editOpen={editOpen}
setEditOpen={setEditOpen}
editSetAvailable={editSetAvailable}
setEditSetAvailable={setEditSetAvailable}
editRemarks={editRemarks}
setEditRemarks={setEditRemarks}
editInvoiceCode={editInvoiceCode}
setEditInvoiceCode={setEditInvoiceCode}
editInvoiceNumber={editInvoiceNumber}
setEditInvoiceNumber={setEditInvoiceNumber}
editInvoiceDate={editInvoiceDate}
setEditInvoiceDate={setEditInvoiceDate}
editSellerName={editSellerName}
setEditSellerName={setEditSellerName}
editBuyerName={editBuyerName}
setEditBuyerName={setEditBuyerName}
editAmount={editAmount}
setEditAmount={setEditAmount}
editTaxAmount={editTaxAmount}
setEditTaxAmount={setEditTaxAmount}
editTotalAmount={editTotalAmount}
setEditTotalAmount={setEditTotalAmount}
confirmAction={confirmAction}
setConfirmAction={setConfirmAction}
/>
)}
</ListWorkspace>
);
}
+75 -80
查看文件
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import {
@@ -128,7 +128,7 @@ export function PaymentBatchesPage() {
const selectedBatchId = searchParams.get("id") ?? "";
const batches = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]);
const selectedBatch = useMemo<PaymentBatch | null>(() => {
return batches.find((item) => item.id === selectedBatchId) ?? batches[0] ?? null;
return batches.find((item) => item.id === selectedBatchId) ?? null;
}, [batches, selectedBatchId]);
const batchStats = useMemo(() => {
return {
@@ -152,17 +152,6 @@ export function PaymentBatchesPage() {
enabled: Boolean(selectedBatch?.id),
queryFn: () => fetchPaymentBatchReceiptAttachments(selectedBatch!.id),
});
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 &&
@@ -195,6 +184,12 @@ export function PaymentBatchesPage() {
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({
@@ -363,51 +358,47 @@ export function PaymentBatchesPage() {
{downloadReasonDialog}
<WorkspaceHeader
title="付款"
subtitle="先核对待复核、待付款和异常批次;真实付款在线下完成,OA 只记录复核、回单和付款状态。"
actions={
<>
<div className="flex flex-wrap gap-2">
{selectedBatchId ? (
<Button type="button" variant="secondary" onClick={returnToList}>
返回列表
</Button>
) : null}
<Button type="button" variant="secondary" onClick={() => void refresh()}>
刷新
</Button>
<Button type="button" variant={showBatchTools ? "default" : "secondary"} onClick={() => setShowBatchTools((current) => !current)}>
台账工具
</Button>
</>
{!selectedBatchId ? (
<Button type="button" variant={showBatchTools ? "default" : "secondary"} onClick={() => setShowBatchTools((current) => !current)}>
台账工具
</Button>
) : null}
</div>
}
/>
{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>
{!selectedBatchId ? (
<div className="flex flex-wrap gap-2" data-testid="payment-batches-summary">
{[
["全部", batchStats.total],
["待复核", batchStats.needsReview],
["待付款", batchStats.readyToPay],
["待补回单", batchStats.needsReceipt],
["异常", batchStats.exceptions],
].map(([label, value]) => (
<span key={label} className="rounded-full border border-zinc-200 bg-white px-3 py-1 text-sm text-zinc-700">
{label} <span className="font-semibold text-zinc-900">{value}</span>
</span>
))}
</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>
) : null}
{showBatchTools ? (
{!selectedBatchId && showBatchTools ? (
<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>
<p className="mt-1 text-sm text-zinc-500">
仅把已确认待付款的单据归入批次。真实付款仍在银行或既有财务流程完成,OA 记录复核、回单和状态。
</p>
<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">
@@ -490,39 +481,8 @@ export function PaymentBatchesPage() {
</div>
) : null}
<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">
{batches.map((item) => (
<button
key={item.id}
type="button"
className={selectedBatch?.id === item.id ? "finance-list-card finance-list-card--active" : "finance-list-card"}
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={batchStatusLabel(item.status)} />
</div>
<div className="mt-1 text-sm text-zinc-600">{item.name}</div>
<div className="mt-1 text-xs text-zinc-500">
金额 {item.totalAmount}
{item.highRisk ? " · 高风险" : ""}
{` · 复核 ${item.reviewRequirement.completedReviewCount}/${item.reviewRequirement.requiredReviewCount}`}
</div>
</button>
))}
{!listQuery.data?.items?.length ? <div className="text-sm text-zinc-500">暂无付款批次</div> : null}
</div>
</div>
{selectedBatch ? (
{selectedBatchId ? (
selectedBatch ? (
<div className="space-y-5">
<div className="finance-command-panel space-y-3" data-testid="payment-batch-detail-panel">
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -727,9 +687,44 @@ export function PaymentBatchesPage() {
</div>
</div>
</div>
) : null}
) : (
<div className="finance-command-panel text-sm text-zinc-500">未找到付款批次</div>
)
) : (
<div className="finance-command-panel" data-testid="payment-batches-list-panel">
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">批次列表</h3>
<span className="text-sm text-zinc-500">共 {batches.length} 条</span>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{batches.map((item) => (
<button
key={item.id}
type="button"
className="finance-list-card"
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={batchStatusLabel(item.status)} />
</div>
<div className="mt-1 text-sm text-zinc-600">{item.name}</div>
<div className="mt-1 text-xs text-zinc-500">
金额 {item.totalAmount}
{item.highRisk ? " · 高风险" : ""}
{` · 复核 ${item.reviewRequirement.completedReviewCount}/${item.reviewRequirement.requiredReviewCount}`}
</div>
<div className="mt-3 text-sm font-medium text-primary">查看详情</div>
</button>
))}
{!listQuery.data?.items?.length ? <div className="text-sm text-zinc-500">暂无付款批次</div> : null}
</div>
</div>
</div>
)}
<ActionConfirmDialog
open={Boolean(confirmPaymentAction)}
onOpenChange={(open) => {
+56 -57
查看文件
@@ -187,67 +187,66 @@ export function ProjectsPage() {
{showLedgerTools ? (
<SectionPanel title="台账工具">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr),260px]">
<div>
{!canManageProjects ? (
<div className="text-sm text-zinc-500">你当前只有查看权限,项目创建入口仅对老板 / 管理员 / 项目管理角色开放。</div>
) : (
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label>项目名称</Label>
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:深圳某客户现场实施" />
</div>
<div className="space-y-2">
<Label>客户名称</Label>
<Input value={clientName} onChange={(event) => setClientName(event.target.value)} placeholder="客户全称" />
</div>
<div className="space-y-2">
<Label>客户简称</Label>
<Input value={clientAlias} onChange={(event) => setClientAlias(event.target.value)} placeholder="可选" />
</div>
<div className="space-y-2">
<Label>项目经理</Label>
<select className="select-std" value={managerEmployeeId} onChange={(event) => setManagerEmployeeId(event.target.value)}>
<option value="">未指定</option>
{managerCandidates.map((item) => (
<option key={item.id} value={item.id}>
{item.name} · {item.employeeNo}
</option>
))}
</select>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label>开始日期</Label>
<DateTextInput value={startsAt} onChange={(event) => setStartsAt(event.target.value)} />
</div>
<div className="space-y-2">
<Label>目标完成</Label>
<DateTextInput value={targetEndAt} onChange={(event) => setTargetEndAt(event.target.value)} />
</div>
<div className="space-y-4">
{!canManageProjects ? (
<div className="text-sm text-zinc-500">你当前只有查看权限,项目创建入口仅对老板 / 管理员 / 项目管理角色开放。</div>
) : (
<>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label>项目名称</Label>
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:深圳某客户现场实施" />
</div>
<div className="space-y-2">
<Label>项目摘要</Label>
<Textarea value={summary} onChange={(event) => setSummary(event.target.value)} placeholder="填项目目标、交付范围、关键约束" />
<Label>客户名称</Label>
<Input value={clientName} onChange={(event) => setClientName(event.target.value)} placeholder="客户全称" />
</div>
<div className="space-y-2">
<Label>客户简称</Label>
<Input value={clientAlias} onChange={(event) => setClientAlias(event.target.value)} placeholder="可选" />
</div>
<div className="space-y-2">
<Label>项目经理</Label>
<select className="select-std" value={managerEmployeeId} onChange={(event) => setManagerEmployeeId(event.target.value)}>
<option value="">未指定</option>
{managerCandidates.map((item) => (
<option key={item.id} value={item.id}>
{item.name} · {item.employeeNo}
</option>
))}
</select>
</div>
<label className="flex items-center gap-2 text-sm text-zinc-700">
<input type="checkbox" checked={isConfidential} onChange={(event) => setIsConfidential(event.target.checked)} />
项目含涉密内容(正文与附件继续离线保存)
</label>
<Button
type="button"
disabled={createMutation.isPending || !name.trim() || !clientName.trim()}
onClick={() => createMutation.mutate()}
>
{createMutation.isPending ? "创建中..." : "创建项目"}
</Button>
</div>
)}
</div>
<div className="space-y-3 rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
<div className="font-medium text-slate-900">低频查看</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label>开始日期</Label>
<DateTextInput value={startsAt} onChange={(event) => setStartsAt(event.target.value)} />
</div>
<div className="space-y-2">
<Label>目标完成</Label>
<DateTextInput value={targetEndAt} onChange={(event) => setTargetEndAt(event.target.value)} />
</div>
</div>
<div className="space-y-2">
<Label>项目摘要</Label>
<Textarea value={summary} onChange={(event) => setSummary(event.target.value)} placeholder="填项目目标、交付范围、关键约束" />
</div>
<label className="flex items-center gap-2 text-sm text-zinc-700">
<input type="checkbox" checked={isConfidential} onChange={(event) => setIsConfidential(event.target.checked)} />
项目含涉密内容(正文与附件继续离线保存)
</label>
</>
)}
<div className="flex flex-wrap gap-2">
{canManageProjects ? (
<Button
type="button"
disabled={createMutation.isPending || !name.trim() || !clientName.trim()}
onClick={() => createMutation.mutate()}
>
{createMutation.isPending ? "创建中..." : "创建项目"}
</Button>
) : null}
<Button type="button" variant={showTrialData ? "default" : "secondary"} onClick={() => setShowTrialData((value) => !value)}>
{showTrialData ? "隐藏测试数据" : "显示测试数据"}
</Button>
+78 -59
查看文件
@@ -935,6 +935,18 @@ export function PurchaseRequestsPage() {
);
const detailEvidenceHint = detail ? resolvePurchaseEvidenceHint(detail) : null;
const detailNextStepText = detail ? resolvePurchaseNextStep(detail) : "";
const isPurchaseDetailMode = Boolean(selectedId);
const openPurchaseDetail = (id: string) => {
setSelectedId(id);
setHydratedDetailId("");
};
const returnToPurchaseList = () => {
setSelectedId("");
setHydratedDetailId("");
const next = new URLSearchParams(searchParams);
next.delete("id");
setSearchParams(next, { replace: true });
};
return (
<ListWorkspace>
@@ -942,16 +954,37 @@ export function PurchaseRequestsPage() {
<WorkspaceHeader
title="采购申请"
actions={
canCreate ? (
<Button
data-testid="purchase-requests-create"
disabled={createMutation.isPending}
onClick={() => createMutation.mutate()}
type="button"
>
{createMutation.isPending ? "创建中..." : "新建采购"}
</Button>
) : undefined
<div className="flex flex-wrap gap-2">
{isPurchaseDetailMode ? (
<Button type="button" variant="secondary" onClick={returnToPurchaseList}>
返回列表
</Button>
) : (
<>
{canCreate ? (
<Button
data-testid="purchase-requests-create"
disabled={createMutation.isPending}
onClick={() => createMutation.mutate()}
type="button"
>
{createMutation.isPending ? "创建中..." : "新建采购"}
</Button>
) : null}
{canShowLedgerTools ? (
<Button
data-testid="purchase-ledger-tools"
type="button"
variant={showAdvancedTools ? "default" : "secondary"}
onClick={() => setShowLedgerTools((current) => !current)}
>
<Wrench aria-hidden className="mr-1 size-4" />
台账工具
</Button>
) : null}
</>
)}
</div>
}
/>
@@ -961,6 +994,8 @@ export function PurchaseRequestsPage() {
</p>
) : null}
{!isPurchaseDetailMode ? (
<>
<FilterBar className="ops-command-bar space-y-3 !p-3 md:!p-3.5">
<div className="ops-command-bar__row">
<div className="min-w-0">
@@ -990,18 +1025,6 @@ export function PurchaseRequestsPage() {
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600">
{mine ? "我相关" : "全范围"}
</span>
{canShowLedgerTools ? (
<Button
data-testid="purchase-ledger-tools"
size="sm"
type="button"
variant={showAdvancedTools ? "default" : "secondary"}
onClick={() => setShowLedgerTools((current) => !current)}
>
<Wrench aria-hidden className="mr-1 size-4" />
台账工具
</Button>
) : null}
</div>
</div>
@@ -1119,18 +1142,13 @@ export function PurchaseRequestsPage() {
purchaseItems.map((item) => {
const evidenceHint = resolvePurchaseEvidenceHint(item);
return (
<button
data-testid="purchase-request-mobile-row"
key={item.id}
type="button"
className={`w-full rounded-lg border p-3 text-left ${
item.id === selectedId ? "border-zinc-400 bg-zinc-50" : "border-zinc-200 bg-white"
}`}
onClick={() => {
setSelectedId(item.id);
setHydratedDetailId("");
}}
>
<button
data-testid="purchase-request-mobile-row"
key={item.id}
type="button"
className="w-full rounded-lg border border-zinc-200 bg-white p-3 text-left"
onClick={() => openPurchaseDetail(item.id)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">
@@ -1161,10 +1179,11 @@ export function PurchaseRequestsPage() {
? `${formatTextWithCompactLongNumbers(item.project.projectNo)} · ${item.project.name}`
: "未关联项目"}
</div>
<div className={`mt-0.5 ${evidenceHint.className}`}>{evidenceHint.label}</div>
</div>
</div>
</button>
<div className={`mt-0.5 ${evidenceHint.className}`}>{evidenceHint.label}</div>
</div>
</div>
<div className="mt-3 text-sm font-medium text-primary">{resolvePurchaseRowActionLabel(item.status)}</div>
</button>
);
})
) : (
@@ -1190,10 +1209,11 @@ export function PurchaseRequestsPage() {
purchaseItems.map((item) => {
const evidenceHint = resolvePurchaseEvidenceHint(item);
return (
<TableRow
key={item.id}
className={item.id === selectedId ? "bg-zinc-50" : undefined}
>
<TableRow
key={item.id}
className="cursor-pointer"
onClick={() => openPurchaseDetail(item.id)}
>
<TableCell>
<div className="font-medium text-zinc-900">
<CompactBusinessCode value={resolvePurchaseDisplayCode(item)} />
@@ -1233,11 +1253,10 @@ export function PurchaseRequestsPage() {
size="sm"
type="button"
variant={["draft", "rejected"].includes(item.status) ? "default" : "secondary"}
onClick={(event) => {
event.stopPropagation();
setSelectedId(item.id);
setHydratedDetailId("");
}}
onClick={(event) => {
event.stopPropagation();
openPurchaseDetail(item.id);
}}
>
{resolvePurchaseRowActionLabel(item.status)}
</Button>
@@ -1261,16 +1280,17 @@ export function PurchaseRequestsPage() {
</>
) : null}
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
testIdPrefix="purchase-requests"
/>
</div>
{selectedId ? (
<div className="card-std space-y-5 p-4 md:p-5">
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
testIdPrefix="purchase-requests"
/>
</div>
</div>
</>
) : (
<div className="card-std space-y-5 p-4 md:p-5">
{selectedId && detailQuery.isLoading ? <TableSkeleton columns={3} rows={8} /> : null}
{selectedId && detailQuery.isError ? (
@@ -2050,8 +2070,7 @@ export function PurchaseRequestsPage() {
</>
) : null}
</div>
) : null}
</div>
)}
<ActionConfirmDialog
open={Boolean(submitPrecheckConfirm)}
onOpenChange={(open) => {
+9 -27
查看文件
@@ -1,7 +1,7 @@
import { useMemo, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowRight, Clock3, FilePenLine, RotateCcw, Wrench } from "lucide-react";
import { Clock3, FilePenLine, RotateCcw, Wrench } from "lucide-react";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchMyDelegations } from "../api/delegations";
@@ -53,32 +53,24 @@ const REIMBURSEMENT_TASK_STATUS_OPTIONS = [
{
value: "rejected",
title: "被驳回待补正",
idleNote: "暂无需要补正的报销",
activeNote: "优先补齐说明或附件后重新提交",
icon: RotateCcw,
accentClass: "border-red-200 bg-red-50 text-red-700",
},
{
value: "draft",
title: "草稿待完善",
idleNote: "还没有报销草稿",
activeNote: "继续完善明细、发票和附件",
icon: FilePenLine,
accentClass: "border-amber-200 bg-amber-50 text-amber-700",
},
{
value: "reviewing",
title: "审批中进度",
idleNote: "暂无审批中的报销",
activeNote: "查看当前节点和处理进度",
icon: Clock3,
accentClass: "border-sky-200 bg-sky-50 text-sky-700",
},
] satisfies Array<{
value: ReimbursementStatus;
title: string;
idleNote: string;
activeNote: string;
icon: typeof RotateCcw;
accentClass: string;
}>;
@@ -360,7 +352,7 @@ export function ReimbursementsPage() {
</p>
) : null}
<section className="grid gap-3 md:grid-cols-3" aria-label="我的报销任务">
<section className="flex flex-wrap gap-2" aria-label="我的报销任务">
{REIMBURSEMENT_TASK_STATUS_OPTIONS.map((item) => {
const Icon = item.icon;
const count = taskSummaryTotalByStatus[item.value];
@@ -369,29 +361,19 @@ export function ReimbursementsPage() {
return (
<button
className={`card-std min-h-[124px] p-4 text-left transition hover:border-primary/40 hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-200 ${
active ? "border-primary/40 bg-primary/5" : ""
className={`inline-flex min-h-10 items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition hover:border-primary/40 hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-200 ${
active ? "border-primary/40 bg-primary/5 text-primary" : "border-zinc-200 bg-white text-zinc-700"
}`}
data-testid={`reimbursements-task-card-${item.value}`}
key={item.value}
onClick={() => setSearchParams({ status: item.value, page: "1", scope: "own" })}
type="button"
>
<div className="flex items-start justify-between gap-3">
<span className={`flex size-9 items-center justify-center rounded-lg border ${item.accentClass}`}>
<Icon aria-hidden className="size-4" />
</span>
<ArrowRight aria-hidden className="mt-1 size-4 text-zinc-300" />
</div>
<div className="mt-4 flex items-end justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-zinc-900">{item.title}</div>
<div className="mt-1 text-xs leading-5 text-zinc-500">
{typeof count === "number" && count > 0 ? item.activeNote : item.idleNote}
</div>
</div>
<div className="text-3xl font-semibold tabular-nums text-zinc-950">{displayCount}</div>
</div>
<span className={`flex size-7 items-center justify-center rounded-full border ${item.accentClass}`}>
<Icon aria-hidden className="size-3.5" />
</span>
<span className="font-medium">{item.title}</span>
<span className="font-semibold tabular-nums text-zinc-950">{displayCount}</span>
</button>
);
})}
@@ -114,6 +114,7 @@ function verificationMessage(message: string | null | undefined) {
export function InvoiceDetailPanel({
detail,
detailQuery,
canEditDetail,
canVerifyInvoice,
canUploadInvoice,
@@ -531,8 +532,12 @@ export function InvoiceDetailPanel({
</div>
) : null}
</>
) : detailQuery.isLoading ? (
<p className="text-zinc-500 py-6 text-center bg-zinc-50/50 rounded-lg border border-dashed border-zinc-200 mt-2">加载票据详情...</p>
) : detailQuery.isError ? (
<p className="text-red-600 py-6 text-center bg-red-50 rounded-lg border border-red-100 mt-2">票据详情加载失败</p>
) : (
<p className="text-zinc-500 py-6 text-center bg-zinc-50/50 rounded-lg border border-dashed border-zinc-200 mt-2">请从左侧列表选择一条票据查看详情与修正选项</p>
<p className="text-zinc-500 py-6 text-center bg-zinc-50/50 rounded-lg border border-dashed border-zinc-200 mt-2">未找到票据详情</p>
)}
</div>
@@ -94,7 +94,6 @@ export function InvoiceUploadCard({
{selectedFiles.length > 4 ? <div>另有 {selectedFiles.length - 4} 个文件</div> : null}
</div>
) : null}
<p className="text-xs text-zinc-500">发票上传仅支持 PDF;附件材料可在对应业务单据中上传图片或其他证明文件。</p>
</div>
);
}
@@ -304,6 +304,7 @@ export async function uploadLiveInvoiceAndWait(page: Page, invoiceFilePath: stri
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
});
await page.getByRole("button", { name: "台账工具" }).click();
await page.getByTestId("invoice-upload-file").setInputFiles(invoiceFilePath);
await page.getByTestId("invoice-upload-submit").click();
const uploadPayload = (await (await uploadResponsePromise).json()) as UploadInvoiceResponse;
@@ -61,6 +61,7 @@ test("E2E 轻交互 OCR:票据中心缺关键字段后可在当前页修正并
await (await openNavLink(page, "票据")).click();
await expect(page.getByTestId("invoice-pool-table")).toBeVisible();
await page.getByRole("button", { name: "台账工具" }).click();
await page.getByTestId("invoice-upload-file").setInputFiles({
name: `r73-missing-${Date.now()}.pdf`,
mimeType: "application/pdf",
+4 -3
查看文件
@@ -40,6 +40,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
});
await page.getByRole("button", { name: "台账工具" }).click();
await page.getByTestId("invoice-upload-file").setInputFiles({
name: invoiceFileName,
mimeType: "application/pdf",
@@ -57,15 +58,15 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
// 预览(popup 存在即可)
const popupPromise = page.waitForEvent("popup");
const invoiceRow = page.getByTestId("invoice-row").filter({ hasText: invoiceFileName }).first();
await expect(invoiceRow).toBeVisible();
await invoiceRow.getByRole("button").click();
await page.getByTestId("invoice-preview-button").click();
const popup = await popupPromise;
await expect(popup).toHaveURL(new RegExp(`/api/invoice-pool/${invoiceId}`));
await popup.close();
// 3) bind invoice to reimbursement
await page.goto("/invoice-pool");
await expect(page.getByTestId("invoice-pool-table")).toBeVisible();
await page.getByRole("button", { name: "台账工具" }).click();
await page.getByTestId("invoice-bind-invoice").selectOption(invoiceId);
await page.getByTestId("invoice-bind-reimbursement").selectOption(reimbursementId);
await page.getByTestId("invoice-bind-submit").click();
@@ -1000,9 +1000,9 @@ async function capture(page: Page, name: string) {
}
const pages = [
{ path: "/payment-batches", summary: "payment-batches-summary", proof: "text=批次列表", screenshot: "01-payment-batches" },
{ path: "/payment-batches", summary: "payment-batches-summary", proof: "text=批次列表", screenshot: "01-payment-batches", expectStatusStrip: false },
{ path: "/ap-operations", proof: "text=异常付款", screenshot: "02-ap-operations", expectStatusStrip: false },
{ path: "/invoice-pool", summary: "invoice-pool-summary", proof: "data-testid=invoice-pool-table", screenshot: "03-invoice-pool" },
{ path: "/invoice-pool", summary: "invoice-pool-summary", proof: "data-testid=invoice-pool-table", screenshot: "03-invoice-pool", expectStatusStrip: false },
{ path: "/system/approval-rules", summary: "approval-rules-summary", proof: "data-testid=approval-rules-table", screenshot: "04-approval-rules" },
{ path: "/people", summary: "people-summary", proof: "data-testid=people-table", screenshot: "05-people" },
{ path: "/organization", summary: "organization-summary", proof: "text=研发部", screenshot: "06-organization" },
@@ -1043,7 +1043,7 @@ test("PC 财务台账工具默认收起并可按需打开", async ({ page }) =>
await expect(page.getByTestId("payment-batch-create-panel")).toHaveCount(0);
await page.getByRole("button", { name: "台账工具" }).click();
await expect(page.getByTestId("payment-batch-create-panel")).toBeVisible();
await expect(page.getByTestId("payment-batch-create-panel")).toContainText("台账工具 · 新建批次");
await expect(page.getByTestId("payment-batch-create-panel")).toContainText("新建批次");
await page.goto("/ap-operations");
await expect(page.getByRole("button", { name: "导出 CSV" })).toHaveCount(0);
@@ -1082,7 +1082,7 @@ test("PC 票据中心辅助核验动作可提交并重试", async ({ page }) =>
await page.setViewportSize({ width: 1440, height: 960 });
await login(page);
await page.goto("/invoice-pool");
await page.goto(`/invoice-pool?id=${invoiceListItem.id}`);
await expect(page.getByTestId("invoice-detail-panel")).toBeVisible();
await expect(page.getByTestId("invoice-trigger-verification")).toBeVisible();
await expect(page.getByTestId("invoice-retry-verification")).toBeVisible();
@@ -1118,7 +1118,7 @@ test("PC 票据中心辅助核验错误提示可理解", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 });
await login(page);
await page.goto("/invoice-pool");
await page.goto(`/invoice-pool?id=${invoiceListItem.id}`);
await expect(page.getByTestId("invoice-detail-panel")).toBeVisible();
await page.getByTestId("invoice-trigger-verification").click();
@@ -344,15 +344,20 @@ test.describe("R103 cross page UI consistency", () => {
test("采购页显示中文标题并使用中文附件上传控件", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto(`/purchase-requests?id=${purchaseId}`);
await page.goto("/purchase-requests");
await expect(page.locator("header").filter({ hasText: "业务" }).getByText("采购申请")).toBeVisible();
await expect(page.getByRole("heading", { name: "采购申请" })).toBeVisible();
await expect(page.getByTestId("purchase-requests-table")).toBeVisible();
await expect(page.getByTestId("purchase-ledger-tools")).toBeVisible();
await expect(page.getByTestId("purchase-attachment-upload")).toHaveCount(0);
await expect(page.getByTestId("purchase-row-next-step")).toHaveCount(0);
await expect(page.getByText("付款处理")).toHaveCount(0);
await expect(page.getByText("/purchase-requests")).toHaveCount(0);
await page.goto(`/purchase-requests?id=${purchaseId}`);
await expect(page.getByTestId("purchase-requests-table")).toHaveCount(0);
await expect(page.getByTestId("purchase-ledger-tools")).toHaveCount(0);
await expect(page.getByTestId("purchase-attachment-upload")).toHaveText("上传附件");
await expect(page.getByText("Choose File")).toHaveCount(0);
@@ -370,6 +375,8 @@ test.describe("R103 cross page UI consistency", () => {
await page.goto("/invoice-pool");
await expect(page.getByRole("heading", { name: "票据中心" })).toBeVisible();
await expect(page.getByTestId("invoice-detail-panel")).toHaveCount(0);
await page.getByRole("button", { name: "台账工具" }).click();
await expect(page.getByTestId("invoice-bind-reimbursement")).toContainText("草稿单 · 2026-05-06");
await expect(page.getByTestId("invoice-bind-reimbursement")).not.toContainText(reimbursementId);
await expect(page.getByText("/invoice-pool")).toHaveCount(0);
@@ -326,6 +326,9 @@ test.describe("R92 手机端票据与报销可用性", () => {
await expect(page.getByText("票据详情")).toBeVisible();
await attachScreenshot(page, testInfo, "r97-invoice-pool-mobile.png");
await page.goto("/invoice-pool");
await expect(page.getByTestId("invoice-mobile-row")).toBeVisible();
await page.getByRole("button", { name: "台账工具" }).click();
await page.getByTestId("invoice-upload-file").setInputFiles([
{ name: "invoice-a.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-a") },
{ name: "invoice-b.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-b") },
@@ -307,7 +307,7 @@ test.describe("R7E 全站高风险操作二次确认", () => {
});
await login(page, user);
await page.goto("/payment-batches");
await page.goto(`/payment-batches?id=${paymentBatchId}`);
await expect(page.getByText("PAY-ACTION-001").first()).toBeVisible();
await page.getByRole("button", { name: "执行" }).click();
+3 -1
查看文件
@@ -59,6 +59,7 @@ test("E2E 差旅支付主链路:申请→审批→支付→绑票", async ({ p
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
});
await page.getByRole("button", { name: "台账工具" }).click();
await page.getByTestId("invoice-upload-file").setInputFiles({
name: invoiceFileName,
mimeType: "application/pdf",
@@ -76,8 +77,9 @@ test("E2E 差旅支付主链路:申请→审批→支付→绑票", async ({ p
const invoiceDetailText = await page.getByTestId("invoice-detail-panel").innerText();
const invoiceCode = extractInvoiceCode(invoiceDetailText);
await page.reload();
await page.goto("/invoice-pool");
await expect(page.getByTestId("invoice-pool-table")).toBeVisible();
await page.getByRole("button", { name: "台账工具" }).click();
const bindCard = page.locator(".card-std").filter({ hasText: "绑定业务单据" }).first();
await bindCard.getByRole("combobox").first().selectOption("travelOrder");
await page.getByTestId("invoice-bind-invoice").selectOption(invoiceId);