refactor(ui): 提取表单原生组件公共样式并修复面板边距bug
这个提交包含在:
@@ -0,0 +1,69 @@
|
||||
import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
|
||||
import type { InvoicePoolListResponse } from "../../api/invoicePool";
|
||||
import type { ReimbursementListResponse } from "../../api/reimbursements";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
export type InvoiceBindCardProps = {
|
||||
listQuery: UseQueryResult<InvoicePoolListResponse, Error>;
|
||||
reimbursementQuery: UseQueryResult<ReimbursementListResponse, Error>;
|
||||
bindMutation: UseMutationResult<Record<string, unknown>, Error, void>;
|
||||
selectedInvoiceId: string;
|
||||
setSelectedInvoiceId: (id: string) => void;
|
||||
selectedReimbursementId: string;
|
||||
setSelectedReimbursementId: (id: string) => void;
|
||||
};
|
||||
|
||||
export function InvoiceBindCard({
|
||||
listQuery,
|
||||
reimbursementQuery,
|
||||
bindMutation,
|
||||
selectedInvoiceId,
|
||||
setSelectedInvoiceId,
|
||||
selectedReimbursementId,
|
||||
setSelectedReimbursementId,
|
||||
}: InvoiceBindCardProps) {
|
||||
return (
|
||||
<div className="card-std space-y-4">
|
||||
<h3 className="card-title">绑定报销</h3>
|
||||
<select
|
||||
className="select-std"
|
||||
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.invoiceCode ? `${item.invoiceCode}-${item.invoiceNumber}` : item.id.substring(0, 8)} ({item.status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="select-std"
|
||||
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>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
data-testid="invoice-bind-submit"
|
||||
className="btn-primary"
|
||||
disabled={bindMutation.isPending || !selectedInvoiceId || !selectedReimbursementId}
|
||||
onClick={() => bindMutation.mutate()}
|
||||
type="button"
|
||||
>
|
||||
{bindMutation.isPending ? "绑定中..." : "绑定票据"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
|
||||
import type { InvoicePoolDetail, InvoiceVerificationRecord } from "../../api/invoicePool";
|
||||
import { ConfirmDialog } from "../../components/common/ConfirmDialog";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
export type InvoiceDetailPanelProps = {
|
||||
detail: InvoicePoolDetail | undefined;
|
||||
detailQuery: UseQueryResult<InvoicePoolDetail, Error>;
|
||||
canEditDetail: boolean;
|
||||
canVerifyInvoice: boolean;
|
||||
canUploadInvoice: boolean;
|
||||
canOverrideDuplicate: boolean;
|
||||
patchMutation: UseMutationResult<InvoicePoolDetail, Error, void>;
|
||||
archiveMutation: UseMutationResult<InvoicePoolDetail, Error, string>;
|
||||
unbindMutation: UseMutationResult<InvoicePoolDetail, Error, string>;
|
||||
retryOcrMutation: UseMutationResult<InvoicePoolDetail, Error, string>;
|
||||
triggerVerificationMutation: UseMutationResult<{ invoiceId: string; verificationId: string; provider: string; triggerMode: string }, Error, string>;
|
||||
retryVerificationMutation: UseMutationResult<{ invoiceId: string; verificationId: string; status: string }, Error, string>;
|
||||
overrideDuplicateMutation: UseMutationResult<InvoicePoolDetail, Error, { invoiceId: string; refFormNumber: string; reason: string }>;
|
||||
verificationQuery: UseQueryResult<{ invoiceId: string; items: InvoiceVerificationRecord[] }, Error>;
|
||||
editOpen: boolean;
|
||||
setEditOpen: (open: boolean) => void;
|
||||
editSetAvailable: boolean;
|
||||
setEditSetAvailable: (value: boolean) => void;
|
||||
editRemarks: string;
|
||||
setEditRemarks: (value: string) => void;
|
||||
editInvoiceCode: string;
|
||||
setEditInvoiceCode: (value: string) => void;
|
||||
editInvoiceNumber: string;
|
||||
setEditInvoiceNumber: (value: string) => void;
|
||||
editInvoiceDate: string;
|
||||
setEditInvoiceDate: (value: string) => void;
|
||||
editTotalAmount: string;
|
||||
setEditTotalAmount: (value: string) => void;
|
||||
confirmAction: { type: string; invoiceId: string } | null;
|
||||
setConfirmAction: (action: { type: string; invoiceId: string } | null) => void;
|
||||
};
|
||||
|
||||
export function InvoiceDetailPanel({
|
||||
detail,
|
||||
canEditDetail,
|
||||
canVerifyInvoice,
|
||||
canUploadInvoice,
|
||||
canOverrideDuplicate,
|
||||
patchMutation,
|
||||
archiveMutation,
|
||||
unbindMutation,
|
||||
retryOcrMutation,
|
||||
triggerVerificationMutation,
|
||||
retryVerificationMutation,
|
||||
overrideDuplicateMutation,
|
||||
verificationQuery,
|
||||
editOpen,
|
||||
setEditOpen,
|
||||
editSetAvailable,
|
||||
setEditSetAvailable,
|
||||
editRemarks,
|
||||
setEditRemarks,
|
||||
editInvoiceCode,
|
||||
setEditInvoiceCode,
|
||||
editInvoiceNumber,
|
||||
setEditInvoiceNumber,
|
||||
editInvoiceDate,
|
||||
setEditInvoiceDate,
|
||||
editTotalAmount,
|
||||
setEditTotalAmount,
|
||||
confirmAction,
|
||||
setConfirmAction,
|
||||
}: InvoiceDetailPanelProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="card-std space-y-4" data-testid="invoice-detail-panel">
|
||||
<h3 className="card-title">详情轮询</h3>
|
||||
{detail ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-y-3 pt-2 text-sm text-zinc-700">
|
||||
<div><span className="text-zinc-500">票据ID:</span>{detail.id}</div>
|
||||
<div><span className="text-zinc-500">状态:</span>{detail.status}</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-zinc-500">发票号:</span>
|
||||
{detail.invoiceCode || "-"}-{detail.invoiceNumber || "-"}
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-zinc-500">总金额:</span>
|
||||
{detail.totalAmount ? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(Number(detail.totalAmount)) : "-"}
|
||||
</div>
|
||||
<div><span className="text-zinc-500">开票日期:</span>{detail.invoiceDate || "-"}</div>
|
||||
<div><span className="text-zinc-500">关联报销:</span>{detail.linkedReimbursementId || "-"}</div>
|
||||
<div className="col-span-2 flex flex-col gap-1">
|
||||
<div>
|
||||
<span className="text-zinc-500">验真状态:</span>
|
||||
{detail.verification ? `${detail.verification.status} (${detail.verification.provider})` : "未触发"}
|
||||
</div>
|
||||
{detail.verification?.resultMessage ? (
|
||||
<div className="text-zinc-900 bg-zinc-50 px-2 py-1.5 rounded-md mt-1 border border-zinc-100">{detail.verification.resultMessage}</div>
|
||||
) : null}
|
||||
{detail.verification?.errorMessage ? (
|
||||
<div className="text-red-700 bg-red-50 px-2 py-1.5 rounded-md mt-1 border border-red-100 flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined text-[16px]">error</span>
|
||||
{detail.verification.errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{detail.ocrLastError ? <div className="col-span-2 text-red-600"><span className="text-zinc-500">OCR错误:</span>{detail.ocrLastError}</div> : null}
|
||||
{detail.remarks ? <div className="col-span-2"><span className="text-zinc-500">备注:</span>{detail.remarks}</div> : null}
|
||||
<div className="col-span-2">
|
||||
<span className="text-zinc-500">判重例外:</span>
|
||||
{detail.isDuplicateOverride ? "已例外" : "否"} {detail.overrideRefFormNumber ? `(ref=${detail.overrideRefFormNumber})` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-4 border-t border-zinc-100 mt-4">
|
||||
{canVerifyInvoice ? (
|
||||
<Button
|
||||
data-testid="invoice-trigger-verification"
|
||||
className="btn-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"
|
||||
className="btn-secondary"
|
||||
type="button"
|
||||
disabled={retryVerificationMutation.isPending}
|
||||
onClick={() => retryVerificationMutation.mutate(detail.id)}
|
||||
>
|
||||
{retryVerificationMutation.isPending ? "重试中..." : "重试验真"}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{canEditDetail ? (
|
||||
<Button
|
||||
data-testid="invoice-edit-open"
|
||||
className="btn-secondary"
|
||||
type="button"
|
||||
disabled={patchMutation.isPending}
|
||||
onClick={() => setEditOpen(!editOpen)}
|
||||
>
|
||||
{editOpen ? "关闭编辑" : "人工修正"}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{canEditDetail && detail.status === "ocr_failed" ? (
|
||||
<Button
|
||||
data-testid="invoice-retry-ocr"
|
||||
className="btn-secondary"
|
||||
type="button"
|
||||
disabled={retryOcrMutation.isPending}
|
||||
onClick={() => setConfirmAction({ type: "retryOcr", invoiceId: detail.id })}
|
||||
>
|
||||
重试 OCR
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{canEditDetail && canUploadInvoice && detail.status === "bound" ? (
|
||||
<Button
|
||||
data-testid="invoice-unbind"
|
||||
className="btn-secondary"
|
||||
type="button"
|
||||
disabled={unbindMutation.isPending}
|
||||
onClick={() => setConfirmAction({ type: "unbind", invoiceId: detail.id })}
|
||||
>
|
||||
解绑
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{canEditDetail && detail.status !== "bound" && detail.status !== "archived" ? (
|
||||
<Button
|
||||
data-testid="invoice-archive"
|
||||
className="btn-secondary text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
type="button"
|
||||
disabled={archiveMutation.isPending}
|
||||
onClick={() => setConfirmAction({ type: "archive", invoiceId: detail.id })}
|
||||
>
|
||||
手工归档
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{canOverrideDuplicate &&
|
||||
!detail.isDuplicateOverride &&
|
||||
["available", "ocr_failed"].includes(detail.status) ? (
|
||||
<Button
|
||||
data-testid="invoice-override-duplicate"
|
||||
className="btn-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-4 rounded-lg bg-zinc-50 border border-zinc-200 p-4" data-testid="invoice-verification-records">
|
||||
<p className="mb-3 text-sm font-semibold text-zinc-900 border-b border-zinc-200/60 pb-2">验真记录</p>
|
||||
{(verificationQuery.data?.items ?? []).length ? (
|
||||
<div className="space-y-3 text-xs">
|
||||
{(verificationQuery.data?.items ?? []).map((record) => (
|
||||
<div key={record.id} className="rounded-md border border-zinc-200 bg-white px-3 py-2 shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-1">
|
||||
<span className="font-mono text-zinc-500">{record.id}</span>
|
||||
<span className={`font-semibold ${record.status === "failed" || record.status === "error" ? "text-red-600" : "text-emerald-600"}`}>
|
||||
{record.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-zinc-600 break-words">
|
||||
服务商: <strong className="font-medium text-zinc-800">{record.provider}</strong> |
|
||||
重试次数: <strong className="font-medium text-zinc-800">{record.attempts}</strong> |
|
||||
验真时间: <strong className="font-medium text-zinc-800">{record.requestedAt || "-"}</strong>
|
||||
</div>
|
||||
{record.resultMessage ? <div className="mt-1 text-zinc-800 py-1 px-2 bg-zinc-50 rounded italic">{record.resultMessage}</div> : null}
|
||||
{record.errorMessage ? <div className="mt-1 text-red-600 font-medium py-1 px-2 border border-red-100 bg-red-50 rounded">{record.errorMessage}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-zinc-500">暂无验真记录</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{editOpen ? (
|
||||
<div className="mt-4 space-y-4 rounded-lg border border-zinc-200 bg-zinc-50/50 p-4">
|
||||
<p className="text-sm font-semibold text-zinc-900 border-b border-zinc-200 pb-2">人工修正(仅必要字段)</p>
|
||||
|
||||
{detail.status === "ocr_failed" ? (
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
|
||||
<input
|
||||
className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-900 focus:ring-offset-1"
|
||||
data-testid="invoice-edit-set-available"
|
||||
checked={editSetAvailable}
|
||||
onChange={(event) => setEditSetAvailable(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span className="text-zinc-700">将状态标记为 available(人工确认可用)</span>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-zinc-600" htmlFor="invoiceCode">
|
||||
发票代码
|
||||
</label>
|
||||
<input
|
||||
className="input-std"
|
||||
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 font-medium text-zinc-600" htmlFor="invoiceNumber">
|
||||
发票号码
|
||||
</label>
|
||||
<input
|
||||
className="input-std"
|
||||
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 font-medium text-zinc-600" htmlFor="invoiceDate">
|
||||
开票日期
|
||||
</label>
|
||||
<input
|
||||
className="input-std"
|
||||
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 font-medium text-zinc-600" htmlFor="invoiceTotalAmount">
|
||||
总金额
|
||||
</label>
|
||||
<input
|
||||
className="input-std"
|
||||
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 font-medium text-zinc-600" htmlFor="invoiceRemarks">
|
||||
备注
|
||||
</label>
|
||||
<textarea
|
||||
className="input-std resize-none"
|
||||
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"
|
||||
className="btn-primary"
|
||||
disabled={patchMutation.isPending}
|
||||
onClick={() => patchMutation.mutate()}
|
||||
type="button"
|
||||
>
|
||||
{patchMutation.isPending ? "保存中..." : "保存修正"}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="invoice-edit-cancel"
|
||||
className="btn-secondary"
|
||||
disabled={patchMutation.isPending}
|
||||
onClick={() => setEditOpen(false)}
|
||||
type="button"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmAction)}
|
||||
title={
|
||||
confirmAction?.type === "retryOcr" ? "确认重试 OCR" :
|
||||
confirmAction?.type === "unbind" ? "确认解绑" :
|
||||
confirmAction?.type === "archive" ? "确认归档" : "确认操作"
|
||||
}
|
||||
description={
|
||||
confirmAction?.type === "retryOcr" ? "将重新进入 pending_ocr 并消耗一次尝试次数。" :
|
||||
confirmAction?.type === "unbind" ? "仅草稿/驳回报销允许解绑。" :
|
||||
confirmAction?.type === "archive" ? "归档后将不再用于绑定与判重。" : ""
|
||||
}
|
||||
variant={confirmAction?.type === "archive" ? "destructive" : "default"}
|
||||
onConfirm={async () => {
|
||||
if (!confirmAction) return;
|
||||
if (confirmAction.type === "retryOcr") retryOcrMutation.mutate(confirmAction.invoiceId);
|
||||
if (confirmAction.type === "unbind") unbindMutation.mutate(confirmAction.invoiceId);
|
||||
if (confirmAction.type === "archive") archiveMutation.mutate(confirmAction.invoiceId);
|
||||
}}
|
||||
onOpenChange={(open) => { if (!open) setConfirmAction(null); }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
|
||||
import type { InvoicePoolListResponse, InvoicePoolStatus } from "../../api/invoicePool";
|
||||
import { StatusBadge } from "../../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../../components/common/TableSkeleton";
|
||||
import { Pagination } from "../../components/common/Pagination";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu";
|
||||
|
||||
export type InvoiceListCardProps = {
|
||||
listQuery: UseQueryResult<InvoicePoolListResponse, Error>;
|
||||
status: InvoicePoolStatus | "";
|
||||
setStatus: (status: InvoicePoolStatus | "") => void;
|
||||
ownerScope: "own" | "all";
|
||||
setOwnerScope: (scope: "own" | "all") => void;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
totalPages: number;
|
||||
detailInvoiceId: string;
|
||||
setDetailInvoiceId: (id: string) => void;
|
||||
signedUrlMutation: UseMutationResult<{ signedUrl: string; expiresAt: string }, Error, string>;
|
||||
};
|
||||
|
||||
export function InvoiceListCard({
|
||||
listQuery,
|
||||
status,
|
||||
setStatus,
|
||||
ownerScope,
|
||||
setOwnerScope,
|
||||
page,
|
||||
setPage,
|
||||
totalPages,
|
||||
setDetailInvoiceId,
|
||||
signedUrlMutation,
|
||||
}: InvoiceListCardProps) {
|
||||
return (
|
||||
<div className="card-std space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="card-title">筛选与列表</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className="select-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">识别中</option>
|
||||
<option value="available">可用</option>
|
||||
<option value="ocr_failed">识别失败</option>
|
||||
<option value="bound">已绑定</option>
|
||||
<option value="archived">已归档</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="select-sm"
|
||||
data-testid="invoice-filter-owner-scope"
|
||||
onChange={(event) => {
|
||||
setOwnerScope(event.target.value as "own" | "all");
|
||||
setPage(1);
|
||||
}}
|
||||
value={ownerScope}
|
||||
>
|
||||
<option value="own">仅看自己</option>
|
||||
<option value="all">查看全部</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{listQuery.isLoading ? <TableSkeleton columns={5} rows={5} /> : null}
|
||||
{!listQuery.isLoading ? (
|
||||
<>
|
||||
<div className="table-container">
|
||||
<table className="w-full text-left text-sm text-zinc-600" data-testid="invoice-pool-table">
|
||||
<thead className="bg-zinc-50/50">
|
||||
<tr>
|
||||
<th className="table-th w-[280px]">票据流水号</th>
|
||||
<th className="table-th w-[120px]">状态</th>
|
||||
<th className="table-th w-[200px]">发票号码</th>
|
||||
<th className="table-th">识别情况 (OCR)</th>
|
||||
<th className="table-th w-[80px] text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100">
|
||||
{(listQuery.data?.items ?? []).length ? (
|
||||
listQuery.data?.items.map((item) => (
|
||||
<tr className="table-row" data-testid="invoice-row" key={item.id}>
|
||||
<td className="table-td">
|
||||
<div className="font-mono text-xs text-zinc-500">{item.id}</div>
|
||||
</td>
|
||||
<td className="table-td"><StatusBadge status={item.status} /></td>
|
||||
<td className="table-td font-medium text-zinc-900">
|
||||
{item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
|
||||
</td>
|
||||
<td className="table-td">
|
||||
{item.status === "pending_ocr" ? (
|
||||
<span className="flex items-center gap-1.5 text-amber-600 font-medium">
|
||||
<span className="size-1.5 rounded-full bg-amber-500 animate-pulse" />
|
||||
识别中 <span className="text-zinc-400 font-normal text-xs">(重试: {item.ocrAttempts})</span>
|
||||
</span>
|
||||
) : item.status === "ocr_failed" ? (
|
||||
<span className="flex items-center gap-1.5 text-red-600">
|
||||
<span className="size-1.5 rounded-full bg-red-500" />
|
||||
识别失败 <span className="text-zinc-400 text-xs">({item.ocrLastError || "未知错误"})</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-zinc-400 text-xs">解析成功</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="table-td text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="rounded-md p-1 hover:bg-zinc-100 focus:outline-none transition-colors">
|
||||
<span className="material-symbols-outlined text-[20px] text-zinc-400">more_horiz</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-32">
|
||||
<DropdownMenuItem data-testid="invoice-detail-button" onSelect={() => setDetailInvoiceId(item.id)}>
|
||||
查看详情
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem data-testid="invoice-preview-button" onSelect={() => signedUrlMutation.mutate(item.id)}>
|
||||
下载 / 预览
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td className="table-td text-center text-zinc-500 py-8" colSpan={5}>
|
||||
暂无票据数据
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} testIdPrefix="invoice" />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
|
||||
import type { FetchMyDelegationsResponse } from "../../api/delegations";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
export type InvoiceUploadCardProps = {
|
||||
uploadMutation: UseMutationResult<{ invoicePoolId: string; status: string; fileSha256: string }, Error, void>;
|
||||
selectedFile: File | null;
|
||||
setSelectedFile: (file: File | null) => void;
|
||||
showProxyOwnerSelector: boolean;
|
||||
proxyDelegationsQuery: UseQueryResult<FetchMyDelegationsResponse, Error>;
|
||||
uploadOwnerEmployeeId: string;
|
||||
setUploadOwnerEmployeeId: (id: string) => void;
|
||||
};
|
||||
|
||||
export function InvoiceUploadCard({
|
||||
uploadMutation,
|
||||
selectedFile,
|
||||
setSelectedFile,
|
||||
showProxyOwnerSelector,
|
||||
proxyDelegationsQuery,
|
||||
uploadOwnerEmployeeId,
|
||||
setUploadOwnerEmployeeId,
|
||||
}: InvoiceUploadCardProps) {
|
||||
return (
|
||||
<div className="card-std space-y-4">
|
||||
<h3 className="card-title">上传票据(VAT OCR)</h3>
|
||||
{showProxyOwnerSelector ? (
|
||||
<select
|
||||
className="select-std"
|
||||
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}
|
||||
<div className="flex items-center gap-3">
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="cursor-pointer inline-flex h-9 items-center justify-center rounded-md border border-dashed border-zinc-300 bg-zinc-50 px-4 py-2 text-sm font-medium text-zinc-600 shadow-sm transition-colors hover:bg-zinc-100 hover:text-zinc-900 focus-within:ring-1 focus-within:ring-zinc-400"
|
||||
>
|
||||
<span className="material-symbols-outlined mr-2 text-[18px]">cloud_upload</span>
|
||||
{selectedFile ? selectedFile.name : "选择文件"}
|
||||
</label>
|
||||
<input
|
||||
id="file-upload"
|
||||
className="hidden"
|
||||
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"
|
||||
className={selectedFile ? "" : "opacity-50"}
|
||||
disabled={uploadMutation.isPending || !selectedFile}
|
||||
onClick={() => uploadMutation.mutate()}
|
||||
type="button"
|
||||
>
|
||||
{uploadMutation.isPending ? "上传中..." : "上传并触发 OCR"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户