RI6 完善报销票据体验与状态文案

这个提交包含在:
2026-05-12 12:02:50 +08:00
父节点 9e37528293
当前提交 21887ce28a
共修改 8 个文件,包含 278 行新增和 129 行删除
+64 -61
查看文件
@@ -53,7 +53,12 @@ import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { formatInvoiceOcrErrorMessage } from "../lib/invoiceOcr";
import {
formatInvoiceDisplayTitle,
resolveInvoiceNextStepText,
resolveInvoiceStatusLabel,
resolveInvoiceUserMessage,
} from "../lib/invoiceStatus";
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { TEST_IDS } from "../lib/testIds";
@@ -77,14 +82,6 @@ const REIMBURSEMENT_STATUS_LABEL: Record<ReimbursementStatus, string> = {
cancelled: "已取消",
};
const INVOICE_STATUS_LABEL: Record<string, string> = {
pending_ocr: "识别中",
ocr_failed: "识别失败",
available: "可添加",
bound: "已添加",
archived: "已归档",
};
function formatDateTime(value: string | null) {
if (!value) return "-";
return value.slice(0, 19).replace("T", " ");
@@ -106,10 +103,6 @@ function reimbursementStatusLabel(status: ReimbursementStatus) {
return REIMBURSEMENT_STATUS_LABEL[status] ?? "未知状态";
}
function invoiceStatusLabel(status: string) {
return INVOICE_STATUS_LABEL[status] ?? "未知状态";
}
function workflowActionLabel(action: string) {
const labels: Record<string, string> = {
start: "提交",
@@ -547,7 +540,10 @@ export function ReimbursementDetailPage() {
}
const summary = failedResults
.map((item) => `${item.invoiceId.slice(0, 8)}: ${item.message ?? "添加失败"}`)
.map((item) => {
const invoice = (bindableInvoicesQuery.data?.items ?? []).find((candidate) => candidate.id === item.invoiceId);
return `${formatInvoiceDisplayTitle(invoice)}:${item.message ?? "添加失败"}`;
})
.join(";");
setInvoiceActionError(summary);
if (succeededIds.length) {
@@ -575,7 +571,7 @@ export function ReimbursementDetailPage() {
const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null; ocrLastError?: string | null }>) => {
const message = items
.map((item) => `${item.originalName ?? "未命名发票"}: ${item.availabilityMessage ?? item.message ?? formatInvoiceOcrErrorMessage(item.ocrLastError)}`)
.map((item) => `${formatInvoiceDisplayTitle(item)}:${resolveInvoiceUserMessage(item)}`)
.join(";");
setInvoiceActionError(message);
toast.error("部分发票需要处理");
@@ -633,7 +629,7 @@ export function ReimbursementDetailPage() {
if (result.uploadAction === "blocked" || !result.invoicePoolId) {
uploadFailures.push({
file,
message: result.unavailableMessage ?? "该发票不可添加",
message: resolveInvoiceUserMessage(result),
});
} else if (result.status === "available") {
availableIds.push(result.invoicePoolId);
@@ -642,7 +638,7 @@ export function ReimbursementDetailPage() {
} else {
uploadFailures.push({
file,
message: result.unavailableMessage ?? "该发票不可添加",
message: resolveInvoiceUserMessage(result),
});
}
} catch (error) {
@@ -660,7 +656,7 @@ export function ReimbursementDetailPage() {
await bindInvoice(invoiceId, { reimbursementId });
boundCount += 1;
} catch (error) {
bindFailures.push(`${invoiceId.slice(0, 8)}: ${buildActionErrorMessage(error, "添加发票失败")}`);
bindFailures.push(`新上传发票:${buildActionErrorMessage(error, "添加发票失败")}`);
}
}
@@ -1404,45 +1400,53 @@ export function ReimbursementDetailPage() {
{(detail.invoices ?? []).length ? (
<div className="space-y-3">
{detail.invoices.map((item) => (
<div
className="rounded-lg border border-zinc-200 px-4 py-3"
key={item.id}
>
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<div className="font-medium text-zinc-900">
发票 {item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
{detail.invoices.map((item) => {
const invoiceHint = resolveInvoiceNextStepText(item);
return (
<div
className="rounded-lg border border-zinc-200 px-4 py-3"
key={item.id}
>
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<div className="font-medium text-zinc-900">
{formatInvoiceDisplayTitle(item)}
</div>
<div className="text-zinc-600">
{item.sellerName || "未识别销售方"} · {item.invoiceDate || "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"}
</div>
<div className="text-xs text-zinc-500">
原始文件:{item.originalName || "未记录原始文件"}
</div>
<div className="text-xs text-zinc-500">
明细:{resolveLinkedItemLabel(item.linkedItemId)}
</div>
</div>
<div className="text-zinc-600">
{item.sellerName || "未识别卖方"} · {item.invoiceDate || "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"}
<div className="flex flex-col items-start gap-2 md:items-end">
<div className="flex items-center gap-2">
<span className="rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600">
{resolveInvoiceStatusLabel(item.status)}
</span>
{canUnbindInvoices ? (
<Button
type="button"
variant="secondary"
size="sm"
disabled={unbindInvoiceMutation.isPending}
onClick={() => unbindInvoiceMutation.mutate(item.id)}
>
移除
</Button>
) : null}
</div>
{invoiceHint ? (
<div className="max-w-[260px] text-xs text-zinc-500 md:text-right">{invoiceHint}</div>
) : null}
</div>
<div className="text-xs text-zinc-500">
原始文件:{item.originalName || item.id}
</div>
<div className="text-xs text-zinc-500">
明细:{resolveLinkedItemLabel(item.linkedItemId)}
</div>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600">
{invoiceStatusLabel(item.status)}
</span>
{canUnbindInvoices ? (
<Button
type="button"
variant="secondary"
size="sm"
disabled={unbindInvoiceMutation.isPending}
onClick={() => unbindInvoiceMutation.mutate(item.id)}
>
移除
</Button>
) : null}
</div>
</div>
</div>
))}
);
})}
</div>
) : (
<p className="text-sm text-zinc-500">暂无发票</p>
@@ -1633,7 +1637,7 @@ export function ReimbursementDetailPage() {
>
<DialogHeader>
<DialogTitle>添加发票</DialogTitle>
<DialogDescription>选择可用发票,或上传新发票。</DialogDescription>
<DialogDescription>选择可添加发票,或上传新发票。</DialogDescription>
</DialogHeader>
<div className="space-y-4">
@@ -1669,6 +1673,7 @@ export function ReimbursementDetailPage() {
) : (bindableInvoicesQuery.data?.items ?? []).length ? (
bindableInvoicesQuery.data?.items.map((item) => {
const isSelected = Object.prototype.hasOwnProperty.call(selectedInvoices, item.id);
const invoiceHint = resolveInvoiceNextStepText({ ...item, status: "available" });
return (
<label
key={item.id}
@@ -1683,20 +1688,18 @@ export function ReimbursementDetailPage() {
onChange={(event) => toggleInvoiceSelection(item.id, event.target.checked)}
/>
<div className="space-y-1">
<div className="font-medium text-zinc-900">
发票 {item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
</div>
<div className="font-medium text-zinc-900">{formatInvoiceDisplayTitle(item)}</div>
<div className="text-zinc-600">
{item.sellerName || "未识别卖方"} · {item.invoiceDate || "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"}
{item.sellerName || "未识别销售方"} · {item.invoiceDate || "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"}
</div>
<div className="text-xs text-zinc-500">
原始文件:{item.originalName || item.id} · 上传时间 {formatDateTime(item.createdAt)}
原始文件:{item.originalName || "未记录原始文件"} · 上传时间 {formatDateTime(item.createdAt)}
</div>
</div>
</div>
<div className="min-w-[220px] rounded-md bg-primary-50 px-3 py-2 text-xs text-primary-700 md:max-w-[240px]">
添加后自动生成或匹配报销明细
{invoiceHint} 添加后自动生成或匹配报销明细。
</div>
</div>
</label>
@@ -1881,7 +1884,7 @@ export function ReimbursementDetailPage() {
open={confirmDeleteReimbursement}
onOpenChange={setConfirmDeleteReimbursement}
title={`确认删除 ${displayCode}?`}
description="删除后无法恢复,已添加发票会回到可用状态。"
description="删除后无法恢复,已添加发票会回到可添加状态。"
variant="destructive"
confirmLabel="删除草稿"
onConfirm={() => { deleteReimbursementMutation.mutate(); }}