RI6 完善报销票据体验与状态文案
这个提交包含在:
@@ -0,0 +1,136 @@
|
||||
import type { InvoicePoolStatus } from "../api/invoicePool";
|
||||
import { formatInvoiceOcrErrorMessage } from "./invoiceOcr";
|
||||
|
||||
type InvoiceDisplayLike = {
|
||||
status?: string | null;
|
||||
invoiceCode?: string | null;
|
||||
invoiceNumber?: string | null;
|
||||
invoiceDate?: string | null;
|
||||
sellerName?: string | null;
|
||||
originalName?: string | null;
|
||||
unavailableReason?: string | null;
|
||||
unavailableMessage?: string | null;
|
||||
availabilityReason?: string | null;
|
||||
availabilityMessage?: string | null;
|
||||
archivedReason?: string | null;
|
||||
reason?: string | null;
|
||||
message?: string | null;
|
||||
ocrLastError?: string | null;
|
||||
};
|
||||
|
||||
const INVOICE_STATUS_LABELS: Record<InvoicePoolStatus, string> = {
|
||||
pending_ocr: "识别中",
|
||||
ocr_failed: "识别失败",
|
||||
available: "可添加",
|
||||
bound: "已添加",
|
||||
archived: "已归档/已报销",
|
||||
};
|
||||
|
||||
export function resolveInvoiceStatusLabel(status: string | null | undefined) {
|
||||
return status && status in INVOICE_STATUS_LABELS
|
||||
? INVOICE_STATUS_LABELS[status as InvoicePoolStatus]
|
||||
: "未知状态";
|
||||
}
|
||||
|
||||
export function formatInvoiceDisplayTitle(invoice: InvoiceDisplayLike | null | undefined) {
|
||||
if (!invoice) {
|
||||
return "未识别发票";
|
||||
}
|
||||
|
||||
if (invoice.invoiceCode || invoice.invoiceNumber) {
|
||||
return `${invoice.invoiceCode || "无代码"}-${invoice.invoiceNumber || "无号码"}`;
|
||||
}
|
||||
|
||||
return invoice.sellerName || invoice.originalName || "未识别发票";
|
||||
}
|
||||
|
||||
function looksLikeMachineMessage(message: string) {
|
||||
return /^[a-z0-9_:. -]+$/i.test(message) && /[a-z]/i.test(message);
|
||||
}
|
||||
|
||||
function resolveReasonMessage(reason: string | null | undefined) {
|
||||
if (!reason) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (/duplicate/i.test(reason)) {
|
||||
return "已识别出相同发票,不能重复添加。";
|
||||
}
|
||||
|
||||
if (/used_complete|used|archived/i.test(reason)) {
|
||||
return "该发票已报销或归档,不能再次添加。";
|
||||
}
|
||||
|
||||
if (/bound|linked/i.test(reason)) {
|
||||
return "该发票已添加到其他单据,不能重复添加。";
|
||||
}
|
||||
|
||||
if (/pending/i.test(reason)) {
|
||||
return "发票正在识别,完成后会自动刷新。";
|
||||
}
|
||||
|
||||
if (/failed|missing_required_fields/i.test(reason)) {
|
||||
return formatInvoiceOcrErrorMessage(reason);
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function resolveInvoiceUserMessage(invoice: InvoiceDisplayLike | null | undefined) {
|
||||
if (!invoice) {
|
||||
return "该发票暂不可添加,请稍后刷新。";
|
||||
}
|
||||
|
||||
const explicitMessage = invoice.unavailableMessage || invoice.availabilityMessage || invoice.message || "";
|
||||
if (explicitMessage && !looksLikeMachineMessage(explicitMessage)) {
|
||||
return explicitMessage;
|
||||
}
|
||||
|
||||
const reasonMessage =
|
||||
resolveReasonMessage(explicitMessage) ||
|
||||
resolveReasonMessage(invoice.unavailableReason) ||
|
||||
resolveReasonMessage(invoice.availabilityReason) ||
|
||||
resolveReasonMessage(invoice.reason) ||
|
||||
resolveReasonMessage(invoice.archivedReason);
|
||||
if (reasonMessage) {
|
||||
return reasonMessage;
|
||||
}
|
||||
|
||||
if (invoice.ocrLastError) {
|
||||
const errorMessage = formatInvoiceOcrErrorMessage(invoice.ocrLastError);
|
||||
return /重试|重新上传|人工处理/.test(errorMessage)
|
||||
? errorMessage
|
||||
: `${errorMessage} 可删除后重新上传。`;
|
||||
}
|
||||
|
||||
switch (invoice.status) {
|
||||
case "pending_ocr":
|
||||
return "发票正在识别,完成后会自动刷新。";
|
||||
case "available":
|
||||
return "可添加到当前报销单。";
|
||||
case "bound":
|
||||
return "该发票已添加到报销单,草稿或驳回状态可移除。";
|
||||
case "archived":
|
||||
return "该发票已归档或已报销,不能再次添加。";
|
||||
case "ocr_failed":
|
||||
return "识别失败,可删除后重新上传。";
|
||||
default:
|
||||
return "该发票暂不可添加,请稍后刷新。";
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveInvoiceNextStepText(invoice: InvoiceDisplayLike | null | undefined) {
|
||||
if (!invoice?.status) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (invoice.status === "available") {
|
||||
return "可添加到当前报销单。";
|
||||
}
|
||||
|
||||
if (invoice.status === "bound") {
|
||||
return "已添加到报销单,草稿或驳回状态可移除。";
|
||||
}
|
||||
|
||||
return resolveInvoiceUserMessage(invoice);
|
||||
}
|
||||
@@ -45,6 +45,12 @@ import {
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { buildPrecheckDialogMessage } from "../lib/precheck";
|
||||
import {
|
||||
formatInvoiceDisplayTitle,
|
||||
resolveInvoiceNextStepText,
|
||||
resolveInvoiceStatusLabel,
|
||||
resolveInvoiceUserMessage,
|
||||
} from "../lib/invoiceStatus";
|
||||
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
||||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
@@ -86,14 +92,6 @@ const STATUS_TONE: Record<ReimbursementStatus, string> = {
|
||||
cancelled: "bg-zinc-100 text-zinc-500",
|
||||
};
|
||||
|
||||
const INVOICE_STATUS_LABEL: Record<string, string> = {
|
||||
pending_ocr: "识别中",
|
||||
ocr_failed: "识别失败",
|
||||
available: "可用",
|
||||
bound: "已添加",
|
||||
archived: "已归档",
|
||||
};
|
||||
|
||||
function todayDate() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -134,18 +132,6 @@ function workflowActionLabel(action: string) {
|
||||
return labels[action] ?? action;
|
||||
}
|
||||
|
||||
function formatInvoiceTitle(invoice: {
|
||||
invoiceCode?: string | null;
|
||||
invoiceNumber?: string | null;
|
||||
sellerName?: string | null;
|
||||
originalName?: string | null;
|
||||
}) {
|
||||
if (invoice.invoiceCode || invoice.invoiceNumber) {
|
||||
return `${invoice.invoiceCode || "-"}-${invoice.invoiceNumber || "-"}`;
|
||||
}
|
||||
return invoice.sellerName || invoice.originalName || "未识别票据";
|
||||
}
|
||||
|
||||
function matchInvoiceSearch(
|
||||
invoice: {
|
||||
invoiceCode?: string | null;
|
||||
@@ -282,7 +268,7 @@ export function MobileReimbursementDetailPage() {
|
||||
originalName: item.originalName,
|
||||
createdAt: item.createdAt,
|
||||
status: "ocr_failed",
|
||||
unavailableMessage: "识别失败,可删除后重新上传。",
|
||||
unavailableMessage: resolveInvoiceUserMessage(item),
|
||||
})),
|
||||
[failedInvoicesQuery.data?.items, invoiceSearchLower],
|
||||
);
|
||||
@@ -463,7 +449,12 @@ export function MobileReimbursementDetailPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = failed.map((item) => `${item.invoiceId.slice(0, 8)}:${item.message ?? "添加失败"}`).join(";");
|
||||
const message = failed
|
||||
.map((item) => {
|
||||
const invoice = invoiceLibraryItems.find((candidate) => candidate.id === item.invoiceId);
|
||||
return `${formatInvoiceDisplayTitle(invoice)}:${item.message ?? "添加失败"}`;
|
||||
})
|
||||
.join(";");
|
||||
setInvoiceActionError(message);
|
||||
toast.error("部分发票添加失败");
|
||||
},
|
||||
@@ -486,7 +477,7 @@ export function MobileReimbursementDetailPage() {
|
||||
|
||||
const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null }>) => {
|
||||
const message = items
|
||||
.map((item) => `${item.originalName ?? "未命名发票"}:${item.availabilityMessage ?? item.message}`)
|
||||
.map((item) => `${formatInvoiceDisplayTitle(item)}:${resolveInvoiceUserMessage(item)}`)
|
||||
.join(";");
|
||||
setInvoiceActionError(message);
|
||||
setInvoiceActionMessage("");
|
||||
@@ -565,7 +556,7 @@ export function MobileReimbursementDetailPage() {
|
||||
if (uploadResult.uploadAction === "blocked" || !uploadResult.invoicePoolId) {
|
||||
failures.push({
|
||||
file,
|
||||
message: uploadResult.unavailableMessage ?? "该发票不可添加",
|
||||
message: resolveInvoiceUserMessage(uploadResult),
|
||||
});
|
||||
} else if (uploadResult.status === "available") {
|
||||
try {
|
||||
@@ -577,9 +568,9 @@ export function MobileReimbursementDetailPage() {
|
||||
} else if (uploadResult.status === "pending_ocr") {
|
||||
pendingIds.push(uploadResult.invoicePoolId);
|
||||
} else if (uploadResult.status === "ocr_failed") {
|
||||
failures.push({ file, message: uploadResult.unavailableMessage ?? "发票识别失败,请重新识别或手动处理" });
|
||||
failures.push({ file, message: resolveInvoiceUserMessage(uploadResult) });
|
||||
} else {
|
||||
failures.push({ file, message: uploadResult.unavailableMessage ?? "该发票不可添加" });
|
||||
failures.push({ file, message: resolveInvoiceUserMessage(uploadResult) });
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push({ file, message: resolveErrorMessage(error) });
|
||||
@@ -1013,37 +1004,41 @@ export function MobileReimbursementDetailPage() {
|
||||
|
||||
{detail.invoices.length ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{detail.invoices.map((invoice) => (
|
||||
<article key={invoice.id} className="mobile-list-card">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-zinc-900">{formatInvoiceTitle(invoice)}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{invoice.sellerName || "未识别销售方"} · {formatDate(invoice.invoiceDate)}
|
||||
{detail.invoices.map((invoice) => {
|
||||
const invoiceHint = resolveInvoiceNextStepText(invoice);
|
||||
return (
|
||||
<article key={invoice.id} className="mobile-list-card">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-zinc-900">{formatInvoiceDisplayTitle(invoice)}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{invoice.sellerName || "未识别销售方"} · {formatDate(invoice.invoiceDate)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">明细:{resolveLinkedItemLabel(invoice.linkedItemId)}</div>
|
||||
{invoiceHint ? <div className="mt-1 text-xs text-zinc-500">{invoiceHint}</div> : null}
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-sm font-semibold text-zinc-900">{formatCurrency(invoice.totalAmount)}</div>
|
||||
<span className="mt-1 inline-flex rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600">
|
||||
{resolveInvoiceStatusLabel(invoice.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">明细:{resolveLinkedItemLabel(invoice.linkedItemId)}</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-sm font-semibold text-zinc-900">{formatCurrency(invoice.totalAmount)}</div>
|
||||
<span className="mt-1 inline-flex rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600">
|
||||
{INVOICE_STATUS_LABEL[invoice.status] ?? "未知状态"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{canUnbindInvoices ? (
|
||||
<Button
|
||||
className="mt-3 w-full"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={unbindInvoiceMutation.isPending}
|
||||
onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
{canUnbindInvoices ? (
|
||||
<Button
|
||||
className="mt-3 w-full"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={unbindInvoiceMutation.isPending}
|
||||
onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
|
||||
@@ -1356,11 +1351,18 @@ export function MobileReimbursementDetailPage() {
|
||||
/>
|
||||
) : (
|
||||
<span className="mt-0.5 inline-flex shrink-0 rounded-full bg-red-100 px-2 py-1 text-xs font-medium text-red-700">
|
||||
识别失败
|
||||
{resolveInvoiceStatusLabel(invoice.status)}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-zinc-900">{formatInvoiceTitle(invoice)}</div>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="truncate text-sm font-semibold text-zinc-900">{formatInvoiceDisplayTitle(invoice)}</div>
|
||||
{isAvailable ? (
|
||||
<span className="shrink-0 rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">
|
||||
{resolveInvoiceStatusLabel(invoice.status)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{invoice.sellerName || "未识别销售方"} · {formatDate(invoice.invoiceDate)} · {formatCurrency(invoice.totalAmount)}
|
||||
</div>
|
||||
@@ -1368,7 +1370,7 @@ export function MobileReimbursementDetailPage() {
|
||||
</div>
|
||||
{isFailed ? (
|
||||
<div className="mt-3 rounded-md bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
{invoice.unavailableMessage ?? "识别失败,可删除后重新上传。"}
|
||||
{resolveInvoiceUserMessage(invoice)}
|
||||
</div>
|
||||
) : null}
|
||||
{isAvailable && checked ? (
|
||||
@@ -1474,7 +1476,7 @@ export function MobileReimbursementDetailPage() {
|
||||
open={confirmDeleteReimbursement}
|
||||
onOpenChange={setConfirmDeleteReimbursement}
|
||||
title="删除草稿?"
|
||||
description="删除后无法恢复,已添加发票会回到可用状态。"
|
||||
description="删除后无法恢复,已添加发票会回到可添加状态。"
|
||||
variant="destructive"
|
||||
confirmLabel="删除草稿"
|
||||
onConfirm={() => deleteReimbursementMutation.mutate()}
|
||||
|
||||
@@ -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(); }}
|
||||
|
||||
@@ -188,7 +188,8 @@ test.describe.serial("R109 企业微信移动报销反馈专项", () => {
|
||||
const meMetrics = await collectMobileMetrics(employee.page);
|
||||
expect(meMetrics.moreMenuRatio ?? 0).toBeLessThanOrEqual(0.5);
|
||||
expect(meMetrics.navHeight).toBeLessThanOrEqual(68);
|
||||
expect(meMetrics.activeNavAlpha).toBeGreaterThan(0.9);
|
||||
expect(meMetrics.activeNavAlpha).toBeGreaterThanOrEqual(0.08);
|
||||
expect(meMetrics.activeNavAlpha).toBeLessThanOrEqual(0.3);
|
||||
expect(meMetrics.activeNavAverage, meMetrics.activeNavBackground).toBeLessThan(245);
|
||||
} finally {
|
||||
await seeded.client.api.delete(`/api/reimbursements/${seeded.id}`).catch(() => undefined);
|
||||
|
||||
@@ -590,8 +590,9 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
const availableInvoiceCard = invoiceDialog.locator("article").filter({ hasText: "044002600111-87654321" });
|
||||
const failedInvoiceCard = invoiceDialog.locator("article").filter({ hasText: "识别失败发票.pdf" });
|
||||
await expect(availableInvoiceCard).toBeVisible();
|
||||
await expect(availableInvoiceCard.getByText("可添加", { exact: true })).toBeVisible();
|
||||
await expect(failedInvoiceCard.getByText("识别失败", { exact: true })).toBeVisible();
|
||||
await expect(failedInvoiceCard.getByText("识别失败,可删除后重新上传。")).toBeVisible();
|
||||
await expect(failedInvoiceCard).toContainText("可删除后重新上传");
|
||||
|
||||
await availableInvoiceCard.getByRole("button", { name: "删除" }).click();
|
||||
await expect(mobile.page.getByRole("heading", { name: "删除发票?" })).toBeVisible();
|
||||
|
||||
在新工单中引用
屏蔽一个用户