760 行
29 KiB
TypeScript
760 行
29 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { isApiError } from "../api/types";
|
||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||
import { fetchMyDelegations } from "../api/delegations";
|
||
import { exportInvoicePool } from "../api/exports";
|
||
import {
|
||
archiveInvoice,
|
||
bindInvoice,
|
||
fetchInvoicePoolDetail,
|
||
fetchInvoicePoolList,
|
||
fetchInvoiceSignedUrl,
|
||
overrideInvoiceDuplicate,
|
||
patchInvoice,
|
||
retryInvoiceOcr,
|
||
unbindInvoice,
|
||
uploadInvoice,
|
||
type InvoicePoolStatus,
|
||
} from "../api/invoicePool";
|
||
import { fetchReimbursements } from "../api/reimbursements";
|
||
import { useAuth } from "../auth/AuthContext";
|
||
import { hasPermission } from "../auth/permissions";
|
||
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";
|
||
|
||
const PAGE_SIZE = 20;
|
||
|
||
export function InvoicePoolPage() {
|
||
const queryClient = useQueryClient();
|
||
const { permissions, me } = useAuth();
|
||
const canManageInvoice = hasPermission(permissions, "invoice.manage");
|
||
const canOverrideDuplicate = hasPermission(permissions, "expense.finance.override_duplicate");
|
||
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
|
||
const canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
|
||
const isAdministration = (me?.roleCodes ?? []).includes("administration");
|
||
const showProxyOwnerSelector = canProxySubmit && isAdministration;
|
||
const [status, setStatus] = useState<InvoicePoolStatus | "">("");
|
||
const [ownerScope, setOwnerScope] = useState<"own" | "all">("own");
|
||
const [page, setPage] = useState(1);
|
||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||
const [uploadOwnerEmployeeId, setUploadOwnerEmployeeId] = useState("");
|
||
const [selectedInvoiceId, setSelectedInvoiceId] = useState("");
|
||
const [selectedReimbursementId, setSelectedReimbursementId] = useState("");
|
||
const [detailInvoiceId, setDetailInvoiceId] = useState("");
|
||
const [editOpen, setEditOpen] = useState(false);
|
||
const [editSetAvailable, setEditSetAvailable] = useState(false);
|
||
const [editRemarks, setEditRemarks] = useState("");
|
||
const [editInvoiceCode, setEditInvoiceCode] = useState("");
|
||
const [editInvoiceNumber, setEditInvoiceNumber] = useState("");
|
||
const [editInvoiceDate, setEditInvoiceDate] = useState("");
|
||
const [editTotalAmount, setEditTotalAmount] = useState("");
|
||
const [error, setError] = useState("");
|
||
|
||
const listQuery = useQuery({
|
||
queryKey: ["invoice-pool", "list", status, ownerScope, page],
|
||
queryFn: () =>
|
||
fetchInvoicePoolList({
|
||
status: status || undefined,
|
||
ownerScope,
|
||
page,
|
||
pageSize: PAGE_SIZE,
|
||
}),
|
||
});
|
||
|
||
const reimbursementQuery = useQuery({
|
||
queryKey: ["reimbursements", "for-bind"],
|
||
queryFn: () => fetchReimbursements({ page: 1, pageSize: 50, scope: "own" }),
|
||
});
|
||
|
||
const proxyDelegationsQuery = useQuery({
|
||
enabled: showProxyOwnerSelector,
|
||
queryKey: ["delegations", "mine", "active"],
|
||
queryFn: () => fetchMyDelegations("active"),
|
||
});
|
||
|
||
const detailQuery = useQuery({
|
||
enabled: Boolean(detailInvoiceId),
|
||
queryKey: ["invoice-pool", "detail", detailInvoiceId],
|
||
queryFn: () => fetchInvoicePoolDetail(detailInvoiceId),
|
||
refetchInterval: (query) => {
|
||
const statusValue = query.state.data?.status;
|
||
if (statusValue === "pending_ocr") {
|
||
return 3_000;
|
||
}
|
||
return false;
|
||
},
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (!detailQuery.data || editOpen) {
|
||
return;
|
||
}
|
||
|
||
setEditSetAvailable(false);
|
||
setEditRemarks(detailQuery.data.remarks ?? "");
|
||
setEditInvoiceCode(detailQuery.data.invoiceCode ?? "");
|
||
setEditInvoiceNumber(detailQuery.data.invoiceNumber ?? "");
|
||
setEditInvoiceDate(detailQuery.data.invoiceDate ?? "");
|
||
setEditTotalAmount(detailQuery.data.totalAmount ?? "");
|
||
}, [detailQuery.data, editOpen]);
|
||
|
||
const uploadMutation = useMutation({
|
||
mutationFn: async () => {
|
||
if (!selectedFile) {
|
||
throw new Error("请选择文件");
|
||
}
|
||
return uploadInvoice(selectedFile, "vat", uploadOwnerEmployeeId || undefined);
|
||
},
|
||
onSuccess: async (data) => {
|
||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||
setDetailInvoiceId(data.invoicePoolId);
|
||
setSelectedFile(null);
|
||
setError("");
|
||
},
|
||
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("上传失败,请稍后重试");
|
||
}
|
||
},
|
||
});
|
||
|
||
const bindMutation = useMutation({
|
||
mutationFn: async () => {
|
||
if (!selectedInvoiceId || !selectedReimbursementId) {
|
||
throw new Error("请选择票据与报销单");
|
||
}
|
||
return bindInvoice(selectedInvoiceId, selectedReimbursementId);
|
||
},
|
||
onSuccess: async () => {
|
||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||
setError("");
|
||
},
|
||
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("绑定失败,请稍后重试");
|
||
}
|
||
},
|
||
});
|
||
|
||
const signedUrlMutation = useMutation({
|
||
mutationFn: async (invoiceId: string) => fetchInvoiceSignedUrl(invoiceId),
|
||
onSuccess: (data) => {
|
||
window.open(data.signedUrl, "_blank", "noopener,noreferrer");
|
||
},
|
||
onError: (err) => {
|
||
if (isApiError(err)) {
|
||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||
} else {
|
||
setError("预览链接获取失败");
|
||
}
|
||
},
|
||
});
|
||
|
||
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) {
|
||
throw new Error("请选择一条票据");
|
||
}
|
||
|
||
return patchInvoice(detailInvoiceId, {
|
||
...(editSetAvailable ? { status: "available" } : {}),
|
||
remarks: editRemarks.trim() ? editRemarks.trim() : null,
|
||
invoiceCode: editInvoiceCode.trim() ? editInvoiceCode.trim() : null,
|
||
invoiceNumber: editInvoiceNumber.trim() ? editInvoiceNumber.trim() : null,
|
||
invoiceDate: editInvoiceDate ? editInvoiceDate : null,
|
||
totalAmount: editTotalAmount.trim() ? editTotalAmount.trim() : null,
|
||
});
|
||
},
|
||
onSuccess: async () => {
|
||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||
setEditOpen(false);
|
||
setError("");
|
||
},
|
||
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("保存失败,请稍后重试");
|
||
}
|
||
},
|
||
});
|
||
|
||
const archiveMutation = useMutation({
|
||
mutationFn: async (invoiceId: string) => archiveInvoice(invoiceId),
|
||
onSuccess: async () => {
|
||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||
setError("");
|
||
},
|
||
onError: (err) => {
|
||
if (isApiError(err)) {
|
||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||
} else {
|
||
setError("归档失败,请稍后重试");
|
||
}
|
||
},
|
||
});
|
||
|
||
const unbindMutation = useMutation({
|
||
mutationFn: async (invoiceId: string) => unbindInvoice(invoiceId),
|
||
onSuccess: async () => {
|
||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||
setError("");
|
||
},
|
||
onError: (err) => {
|
||
if (isApiError(err)) {
|
||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||
} else {
|
||
setError("解绑失败,请稍后重试");
|
||
}
|
||
},
|
||
});
|
||
|
||
const retryOcrMutation = useMutation({
|
||
mutationFn: async (invoiceId: string) => retryInvoiceOcr(invoiceId),
|
||
onSuccess: async () => {
|
||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||
setError("");
|
||
},
|
||
onError: (err) => {
|
||
if (isApiError(err)) {
|
||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||
} else {
|
||
setError("OCR 重试失败,请稍后再试");
|
||
}
|
||
},
|
||
});
|
||
|
||
const overrideDuplicateMutation = useMutation({
|
||
mutationFn: async (params: { invoiceId: string; refFormNumber: string; reason: string }) =>
|
||
overrideInvoiceDuplicate(params.invoiceId, {
|
||
refFormNumber: params.refFormNumber,
|
||
reason: params.reason,
|
||
}),
|
||
onSuccess: async () => {
|
||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||
setError("");
|
||
},
|
||
onError: (err) => {
|
||
if (isApiError(err)) {
|
||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||
} else {
|
||
setError("判重例外失败,请稍后重试");
|
||
}
|
||
},
|
||
});
|
||
|
||
const exportMutation = useMutation({
|
||
mutationFn: (format: "csv" | "xlsx") =>
|
||
exportInvoicePool({
|
||
format,
|
||
status: status || undefined,
|
||
ownerScope,
|
||
}),
|
||
onSuccess: (response, format) => {
|
||
downloadBlob(response.blob, response.filename ?? `invoice-pool.${format}`);
|
||
setError("");
|
||
},
|
||
onError: (err) => {
|
||
if (isApiError(err)) {
|
||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||
} else {
|
||
setError("导出失败,请稍后重试");
|
||
}
|
||
},
|
||
});
|
||
|
||
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),
|
||
);
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<PageHeader
|
||
title="票据中心"
|
||
subtitle="支持上传、OCR 状态轮询、绑定报销与签名预览。"
|
||
actions={
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
data-testid="invoice-export"
|
||
variant="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>
|
||
}
|
||
/>
|
||
|
||
{error ? (
|
||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||
{error}
|
||
</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>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<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 className="flex flex-wrap gap-2 pt-2">
|
||
{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>
|
||
|
||
{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>
|
||
</div>
|
||
);
|
||
}
|