refactor(ui): [UI7-1] 深度翻新报销、发票与用户管理等核心视窗

这个提交包含在:
2026-03-06 09:48:11 +08:00
父节点 61180e2b3b
当前提交 c632648e16
共修改 8 个文件,包含 1105 行新增和 1250 行删除
+125 -576
查看文件
@@ -23,12 +23,15 @@ import {
import { fetchReimbursements } from "../api/reimbursements";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { toast } from "sonner";
import { downloadBlob } from "../lib/download";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { InvoiceUploadCard } from "./invoice-pool/InvoiceUploadCard";
import { InvoiceListCard } from "./invoice-pool/InvoiceListCard";
import { InvoiceBindCard } from "./invoice-pool/InvoiceBindCard";
import { InvoiceDetailPanel } from "./invoice-pool/InvoiceDetailPanel";
const PAGE_SIZE = 20;
@@ -58,6 +61,9 @@ export function InvoicePoolPage() {
const [editInvoiceDate, setEditInvoiceDate] = useState("");
const [editTotalAmount, setEditTotalAmount] = useState("");
const [error, setError] = useState("");
const [confirmAction, setConfirmAction] = useState<{ type: string; invoiceId: string } | null>(null);
// --- Queries ---
const listQuery = useQuery({
queryKey: ["invoice-pool", "list", status, ownerScope, page],
@@ -105,6 +111,8 @@ export function InvoicePoolPage() {
},
});
// --- Side effects ---
useEffect(() => {
if (!detailQuery.data || editOpen) {
return;
@@ -118,6 +126,30 @@ export function InvoicePoolPage() {
setEditTotalAmount(detailQuery.data.totalAmount ?? "");
}, [detailQuery.data, editOpen]);
useEffect(() => {
if (!canManageInvoice && ownerScope === "all") {
setOwnerScope("own");
}
}, [canManageInvoice, ownerScope]);
useEffect(() => {
if (!detailInvoiceId && listQuery.data?.items?.length) {
setDetailInvoiceId(listQuery.data.items[0].id);
}
}, [detailInvoiceId, listQuery.data?.items]);
// --- Helper for error handling ---
const handleError = (err: unknown, fallback: string) => {
const msg = isApiError(err)
? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`
: err instanceof Error ? err.message : fallback;
setError(msg);
toast.error(msg);
};
// --- Mutations ---
const uploadMutation = useMutation({
mutationFn: async () => {
if (!selectedFile) {
@@ -130,16 +162,9 @@ export function InvoicePoolPage() {
setDetailInvoiceId(data.invoicePoolId);
setSelectedFile(null);
setError("");
toast.success("上传成功");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
} else if (err instanceof Error) {
setError(err.message);
} else {
setError("上传失败,请稍后重试");
}
},
onError: (err) => handleError(err, "上传失败,请稍后重试"),
});
const bindMutation = useMutation({
@@ -152,16 +177,9 @@ export function InvoicePoolPage() {
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
setError("");
toast.success("绑定成功");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
} else if (err instanceof Error) {
setError(err.message);
} else {
setError("绑定失败,请稍后重试");
}
},
onError: (err) => handleError(err, "绑定失败,请稍后重试"),
});
const signedUrlMutation = useMutation({
@@ -178,18 +196,6 @@ export function InvoicePoolPage() {
},
});
useEffect(() => {
if (!canManageInvoice && ownerScope === "all") {
setOwnerScope("own");
}
}, [canManageInvoice, ownerScope]);
useEffect(() => {
if (!detailInvoiceId && listQuery.data?.items?.length) {
setDetailInvoiceId(listQuery.data.items[0].id);
}
}, [detailInvoiceId, listQuery.data?.items]);
const patchMutation = useMutation({
mutationFn: async () => {
if (!detailInvoiceId) {
@@ -209,16 +215,9 @@ export function InvoicePoolPage() {
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
setEditOpen(false);
setError("");
toast.success("修正已保存");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
} else if (err instanceof Error) {
setError(err.message);
} else {
setError("保存失败,请稍后重试");
}
},
onError: (err) => handleError(err, "保存失败,请稍后重试"),
});
const archiveMutation = useMutation({
@@ -226,14 +225,9 @@ export function InvoicePoolPage() {
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
setError("");
toast.success("已归档");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
} else {
setError("归档失败,请稍后重试");
}
},
onError: (err) => handleError(err, "归档失败,请稍后重试"),
});
const unbindMutation = useMutation({
@@ -241,14 +235,9 @@ export function InvoicePoolPage() {
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
setError("");
toast.success("已解绑");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
} else {
setError("解绑失败,请稍后重试");
}
},
onError: (err) => handleError(err, "解绑失败,请稍后重试"),
});
const retryOcrMutation = useMutation({
@@ -256,14 +245,9 @@ export function InvoicePoolPage() {
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
setError("");
toast.success("OCR 重试已触发");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
} else {
setError("OCR 重试失败,请稍后再试");
}
},
onError: (err) => handleError(err, "OCR 重试失败,请稍后再试"),
});
const triggerVerificationMutation = useMutation({
@@ -274,14 +258,9 @@ export function InvoicePoolPage() {
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "verification"] }),
]);
setError("");
toast.success("验真已触发");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
} else {
setError("触发验真失败,请稍后重试");
}
},
onError: (err) => handleError(err, "触发验真失败,请稍后重试"),
});
const retryVerificationMutation = useMutation({
@@ -292,14 +271,9 @@ export function InvoicePoolPage() {
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "verification"] }),
]);
setError("");
toast.success("验真重试已触发");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
} else {
setError("重试验真失败,请稍后重试");
}
},
onError: (err) => handleError(err, "重试验真失败,请稍后重试"),
});
const overrideDuplicateMutation = useMutation({
@@ -311,14 +285,9 @@ export function InvoicePoolPage() {
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
setError("");
toast.success("判重例外已生效");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
} else {
setError("判重例外失败,请稍后重试");
}
},
onError: (err) => handleError(err, "判重例外失败,请稍后重试"),
});
const exportMutation = useMutation({
@@ -331,25 +300,24 @@ export function InvoicePoolPage() {
onSuccess: (response, format) => {
downloadBlob(response.blob, response.filename ?? `invoice-pool.${format}`);
setError("");
toast.success("导出完成");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
} else {
setError("导出失败,请稍后重试");
}
},
onError: (err) => handleError(err, "导出失败,请稍后重试"),
});
// --- Derived state ---
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
const detail = detailQuery.data;
const canEditDetail = Boolean(
detail &&
(canManageInvoice ||
detail.ownerEmployeeId === me?.id ||
detail.uploadedByEmployeeId === me?.id),
(canManageInvoice ||
detail.ownerEmployeeId === me?.id ||
detail.uploadedByEmployeeId === me?.id),
);
// --- Render ---
return (
<div className="space-y-6">
<PageHeader
@@ -360,7 +328,7 @@ export function InvoicePoolPage() {
<DropdownMenuTrigger asChild>
<Button
data-testid="invoice-export"
variant="secondary"
className="btn-secondary"
type="button"
disabled={exportMutation.isPending}
>
@@ -385,491 +353,72 @@ export function InvoicePoolPage() {
</p>
) : null}
<Card className="space-y-3 p-6">
<h3 className="text-base font-semibold text-slate-900">上传票据(VAT OCR)</h3>
{showProxyOwnerSelector ? (
<select
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
data-testid="invoice-upload-owner"
onChange={(event) => setUploadOwnerEmployeeId(event.target.value)}
value={uploadOwnerEmployeeId}
>
<option value="">自己</option>
{(proxyDelegationsQuery.data?.received ?? [])
.filter((item) => item.status === "active")
.map((item) => (
<option key={item.id} value={item.principalEmployeeId}>
{item.principal?.name ?? item.principalEmployeeId}
</option>
))}
</select>
) : null}
<input
accept="application/pdf,image/jpeg,image/png,image/webp"
data-testid="invoice-upload-file"
onChange={(event) => setSelectedFile(event.target.files?.[0] ?? null)}
type="file"
/>
<Button
data-testid="invoice-upload-submit"
disabled={uploadMutation.isPending || !selectedFile}
onClick={() => uploadMutation.mutate()}
type="button"
>
{uploadMutation.isPending ? "上传中..." : "上传并触发OCR"}
</Button>
</Card>
<InvoiceUploadCard
uploadMutation={uploadMutation}
selectedFile={selectedFile}
setSelectedFile={setSelectedFile}
showProxyOwnerSelector={showProxyOwnerSelector}
proxyDelegationsQuery={proxyDelegationsQuery}
uploadOwnerEmployeeId={uploadOwnerEmployeeId}
setUploadOwnerEmployeeId={setUploadOwnerEmployeeId}
/>
<Card className="space-y-3 p-6">
<h3 className="text-base font-semibold text-slate-900">筛选与列表</h3>
<div className="flex flex-wrap gap-2">
<select
className="h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
data-testid="invoice-filter-status"
onChange={(event) => {
setStatus(event.target.value as InvoicePoolStatus | "");
setPage(1);
}}
value={status}
>
<option value="">全部状态</option>
<option value="pending_ocr">pending_ocr</option>
<option value="available">available</option>
<option value="ocr_failed">ocr_failed</option>
<option value="bound">bound</option>
<option value="archived">archived</option>
</select>
<InvoiceListCard
listQuery={listQuery}
status={status}
setStatus={setStatus}
ownerScope={ownerScope}
setOwnerScope={setOwnerScope}
page={page}
setPage={setPage}
totalPages={totalPages}
detailInvoiceId={detailInvoiceId}
setDetailInvoiceId={setDetailInvoiceId}
signedUrlMutation={signedUrlMutation}
/>
<select
className="h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
data-testid="invoice-filter-owner-scope"
onChange={(event) => {
setOwnerScope(event.target.value as "own" | "all");
setPage(1);
}}
value={ownerScope}
>
<option value="own">own</option>
<option value="all">all</option>
</select>
</div>
<InvoiceBindCard
listQuery={listQuery}
reimbursementQuery={reimbursementQuery}
bindMutation={bindMutation}
selectedInvoiceId={selectedInvoiceId}
setSelectedInvoiceId={setSelectedInvoiceId}
selectedReimbursementId={selectedReimbursementId}
setSelectedReimbursementId={setSelectedReimbursementId}
/>
<div className="rounded-lg border border-slate-200">
<Table data-testid="invoice-pool-table">
<TableHeader>
<tr>
<TableHead>ID</TableHead>
<TableHead>状态</TableHead>
<TableHead>发票号</TableHead>
<TableHead>OCR</TableHead>
<TableHead className="w-40">操作</TableHead>
</tr>
</TableHeader>
<TableBody>
{(listQuery.data?.items ?? []).length ? (
listQuery.data?.items.map((item) => (
<TableRow data-testid="invoice-row" key={item.id}>
<TableCell className="font-mono text-xs">{item.id}</TableCell>
<TableCell>{item.status}</TableCell>
<TableCell>
{item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
</TableCell>
<TableCell>
{item.status === "pending_ocr" ? (
<span className="text-amber-700">
OCR 处理中(attempts={item.ocrAttempts})
</span>
) : item.status === "ocr_failed" ? (
<span className="text-red-600">
OCR 失败({item.ocrLastError || "unknown"})
</span>
) : (
<span className="text-slate-600">attempts={item.ocrAttempts}</span>
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button
data-testid="invoice-detail-button"
size="sm"
variant="secondary"
onClick={() => setDetailInvoiceId(item.id)}
type="button"
>
详情
</Button>
<Button
data-testid="invoice-preview-button"
size="sm"
variant="secondary"
onClick={() => signedUrlMutation.mutate(item.id)}
type="button"
>
预览
</Button>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="py-3 text-slate-500" colSpan={5}>
暂无数据
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between text-sm">
<span>
第 {page} / {totalPages} 页
</span>
<div className="flex gap-2">
<Button
data-testid="invoice-prev-page"
variant="secondary"
disabled={page <= 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
type="button"
>
上一页
</Button>
<Button
data-testid="invoice-next-page"
variant="secondary"
disabled={page >= totalPages}
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
type="button"
>
下一页
</Button>
</div>
</div>
</Card>
<Card className="space-y-3 p-6">
<h3 className="text-base font-semibold text-slate-900">绑定报销</h3>
<select
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
data-testid="invoice-bind-invoice"
onChange={(event) => setSelectedInvoiceId(event.target.value)}
value={selectedInvoiceId}
>
<option value="">选择票据</option>
{(listQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.id} ({item.status})
</option>
))}
</select>
<select
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
data-testid="invoice-bind-reimbursement"
onChange={(event) => setSelectedReimbursementId(event.target.value)}
value={selectedReimbursementId}
>
<option value="">选择报销单</option>
{(reimbursementQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.formNumber ?? item.id} ({item.status})
</option>
))}
</select>
<Button
data-testid="invoice-bind-submit"
variant="secondary"
disabled={bindMutation.isPending || !selectedInvoiceId || !selectedReimbursementId}
onClick={() => bindMutation.mutate()}
type="button"
>
{bindMutation.isPending ? "绑定中..." : "绑定票据"}
</Button>
</Card>
<Card className="space-y-2 p-6 text-sm" data-testid="invoice-detail-panel">
<h3 className="text-base font-semibold text-slate-900">详情轮询</h3>
{detail ? (
<>
<div>票据ID:{detail.id}</div>
<div>状态:{detail.status}</div>
<div>OCR错误:{detail.ocrLastError || "-"}</div>
<div>
发票号:{detail.invoiceCode || "-"}-{detail.invoiceNumber || "-"}
</div>
<div>开票日期:{detail.invoiceDate || "-"}</div>
<div>总金额:{detail.totalAmount || "-"}</div>
<div>备注:{detail.remarks || "-"}</div>
<div>关联报销:{detail.linkedReimbursementId || "-"}</div>
<div>
判重例外:{detail.isDuplicateOverride ? "已例外" : "否"}{" "}
{detail.overrideRefFormNumber ? `(ref=${detail.overrideRefFormNumber})` : ""}
</div>
<div>
验真状态:
{detail.verification
? `${detail.verification.status} (${detail.verification.provider})`
: "未触发"}
</div>
{detail.verification?.resultMessage ? (
<div>验真结果:{detail.verification.resultMessage}</div>
) : null}
{detail.verification?.errorMessage ? (
<div className="text-red-600">验真错误:{detail.verification.errorMessage}</div>
) : null}
<div className="flex flex-wrap gap-2 pt-2">
{canVerifyInvoice ? (
<Button
data-testid="invoice-trigger-verification"
size="sm"
variant="secondary"
type="button"
disabled={triggerVerificationMutation.isPending || detail.status === "pending_ocr"}
onClick={() => triggerVerificationMutation.mutate(detail.id)}
>
{triggerVerificationMutation.isPending ? "触发中..." : "触发验真"}
</Button>
) : null}
{canVerifyInvoice &&
detail.verification &&
["failed", "error"].includes(detail.verification.status) ? (
<Button
data-testid="invoice-retry-verification"
size="sm"
variant="secondary"
type="button"
disabled={retryVerificationMutation.isPending}
onClick={() => retryVerificationMutation.mutate(detail.id)}
>
{retryVerificationMutation.isPending ? "重试中..." : "重试验真"}
</Button>
) : null}
{canEditDetail ? (
<Button
data-testid="invoice-edit-open"
size="sm"
variant="secondary"
type="button"
disabled={patchMutation.isPending}
onClick={() => setEditOpen((prev) => !prev)}
>
{editOpen ? "关闭编辑" : "人工修正"}
</Button>
) : null}
{canEditDetail && detail.status === "ocr_failed" ? (
<Button
data-testid="invoice-retry-ocr"
size="sm"
variant="secondary"
type="button"
disabled={retryOcrMutation.isPending}
onClick={() => {
if (!window.confirm("确认重试 OCR?将重新进入 pending_ocr 并消耗一次尝试次数。")) return;
retryOcrMutation.mutate(detail.id);
}}
>
重试 OCR
</Button>
) : null}
{canEditDetail && canUploadInvoice && detail.status === "bound" ? (
<Button
data-testid="invoice-unbind"
size="sm"
variant="secondary"
type="button"
disabled={unbindMutation.isPending}
onClick={() => {
if (!window.confirm("确认解绑该票据?仅草稿/驳回报销允许解绑。")) return;
unbindMutation.mutate(detail.id);
}}
>
解绑
</Button>
) : null}
{canEditDetail && detail.status !== "bound" && detail.status !== "archived" ? (
<Button
data-testid="invoice-archive"
size="sm"
variant="secondary"
type="button"
disabled={archiveMutation.isPending}
onClick={() => {
if (!window.confirm("确认归档该票据?归档后将不再用于绑定与判重。")) return;
archiveMutation.mutate(detail.id);
}}
>
手工归档
</Button>
) : null}
{canOverrideDuplicate &&
!detail.isDuplicateOverride &&
["available", "ocr_failed"].includes(detail.status) ? (
<Button
data-testid="invoice-override-duplicate"
size="sm"
variant="secondary"
type="button"
disabled={overrideDuplicateMutation.isPending}
onClick={() => {
if (!window.confirm("确认执行判重例外?该操作将写入审计并影响判重口径。")) return;
const refFormNumber = window.prompt("请输入参考单号(必填)");
if (!refFormNumber?.trim()) return;
const reason = window.prompt("请输入例外原因(必填)");
if (!reason?.trim()) return;
overrideDuplicateMutation.mutate({
invoiceId: detail.id,
refFormNumber: refFormNumber.trim(),
reason: reason.trim(),
});
}}
>
判重例外
</Button>
) : null}
</div>
{canVerifyInvoice ? (
<div className="mt-3 rounded-lg border border-slate-200 p-3" data-testid="invoice-verification-records">
<p className="mb-2 text-sm font-medium text-slate-800">验真记录</p>
{(verificationQuery.data?.items ?? []).length ? (
<div className="space-y-2 text-xs">
{(verificationQuery.data?.items ?? []).map((record) => (
<div key={record.id} className="rounded-md border border-slate-200 px-2 py-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-mono">{record.id}</span>
<span>{record.status}</span>
</div>
<div className="text-slate-600">
provider={record.provider} attempts={record.attempts} requestedAt={record.requestedAt || "-"}
</div>
{record.resultMessage ? <div className="text-slate-700">{record.resultMessage}</div> : null}
{record.errorMessage ? <div className="text-red-600">{record.errorMessage}</div> : null}
</div>
))}
</div>
) : (
<p className="text-xs text-slate-500">暂无验真记录</p>
)}
</div>
) : null}
{editOpen ? (
<div className="mt-4 space-y-3 rounded-lg border border-slate-200 p-4">
<p className="text-sm font-medium text-slate-800">人工修正(仅必要字段)</p>
{detail.status === "ocr_failed" ? (
<label className="flex items-center gap-2 text-sm">
<input
data-testid="invoice-edit-set-available"
checked={editSetAvailable}
onChange={(event) => setEditSetAvailable(event.target.checked)}
type="checkbox"
/>
<span>将状态标记为 available(人工确认可用)</span>
</label>
) : null}
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1">
<label className="text-xs text-slate-600" htmlFor="invoiceCode">
发票代码
</label>
<input
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
data-testid="invoice-edit-code"
id="invoiceCode"
onChange={(event) => setEditInvoiceCode(event.target.value)}
value={editInvoiceCode}
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-600" htmlFor="invoiceNumber">
发票号码
</label>
<input
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
data-testid="invoice-edit-number"
id="invoiceNumber"
onChange={(event) => setEditInvoiceNumber(event.target.value)}
value={editInvoiceNumber}
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-600" htmlFor="invoiceDate">
开票日期
</label>
<input
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
data-testid="invoice-edit-date"
id="invoiceDate"
onChange={(event) => setEditInvoiceDate(event.target.value)}
type="date"
value={editInvoiceDate}
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-600" htmlFor="invoiceTotalAmount">
总金额
</label>
<input
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
data-testid="invoice-edit-total-amount"
id="invoiceTotalAmount"
onChange={(event) => setEditTotalAmount(event.target.value)}
value={editTotalAmount}
/>
</div>
<div className="space-y-1 md:col-span-2">
<label className="text-xs text-slate-600" htmlFor="invoiceRemarks">
备注
</label>
<textarea
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
data-testid="invoice-edit-remarks"
id="invoiceRemarks"
onChange={(event) => setEditRemarks(event.target.value)}
rows={2}
value={editRemarks}
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
data-testid="invoice-edit-save"
disabled={patchMutation.isPending}
onClick={() => patchMutation.mutate()}
type="button"
>
{patchMutation.isPending ? "保存中..." : "保存修正"}
</Button>
<Button
data-testid="invoice-edit-cancel"
variant="secondary"
disabled={patchMutation.isPending}
onClick={() => setEditOpen(false)}
type="button"
>
取消
</Button>
</div>
</div>
) : null}
</>
) : (
<p className="text-slate-500">请选择一条票据查看详情</p>
)}
</Card>
<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}
editTotalAmount={editTotalAmount}
setEditTotalAmount={setEditTotalAmount}
confirmAction={confirmAction}
setConfirmAction={setConfirmAction}
/>
</div>
);
}