优化报销审批证据布局(reimbursement-form-shared-component-migration)

这个提交包含在:
2026-09-02 12:55:24 +08:00
父节点 9fc3b76438
当前提交 4b974fd87c
共修改 17 个文件,包含 291 行新增和 243 行删除
@@ -34,7 +34,7 @@ export function ReimbursementDesktopWorkspaceShell({ model, capabilities, slots
<BusinessDetailMain>
{slots.headerDetails}
{slots.beforeEvidence}
<SectionPanel title="明细与票据" className="scroll-mt-24">
<SectionPanel title="报销内容" className="scroll-mt-24">
<div id="reimbursement-workspace-evidence">
<ReimbursementItemEvidenceList evidence={model.evidence} capabilities={capabilities} />
</div>
@@ -13,10 +13,7 @@ export type ReimbursementEvidenceCapabilities = {
onOpenAttachment?: (attachment: ReimbursementAttachmentEvidence) => void;
onOpenMaterialBundle?: () => void;
onDeleteAttachment?: (attachment: ReimbursementAttachmentEvidence) => void;
onReferenceItem?: (item: ReimbursementItemEvidence) => void;
onReferenceInvoice?: (invoice: ReimbursementInvoiceEvidence) => void;
onEditItem?: (item: ReimbursementItemEvidence) => void;
onDeleteItem?: (item: ReimbursementItemEvidence) => void;
onLinkInvoice?: (invoice: ReimbursementInvoiceEvidence, linkedItemId: string) => void;
linkInvoicePending?: boolean;
linkInvoiceErrors?: Record<string, string>;
@@ -35,198 +32,147 @@ function evidenceStatusCopy(status: ReimbursementInvoiceEvidence["amountEvidence
return null;
}
function InvoiceCard({
invoice,
capabilities,
linkTargets,
}: {
invoice: ReimbursementInvoiceEvidence;
capabilities: ReimbursementEvidenceCapabilities;
linkTargets: ReimbursementItemEvidence[];
}) {
function itemHasBlockingIssue(item: ReimbursementItemEvidence) {
return item.amountExceeded
|| item.invoices.some((invoice) => invoice.amountEvidenceStatus === "ocr_failed")
|| (item.expenseType?.requiresInvoice !== false && item.invoices.length === 0);
}
function itemInvoiceStatus(item: ReimbursementItemEvidence) {
if (item.expenseType?.requiresInvoice === false && item.invoices.length === 0) return "免票";
if (!item.invoices.length) return "未关联";
if (item.amountExceeded || item.invoices.some((invoice) => invoice.amountEvidenceStatus !== "verified")) return "需核对";
return `${item.invoices.length} 张`;
}
function ItemIssues({ item }: { item: ReimbursementItemEvidence }) {
return (
<>
{item.assetPriorApprovalBasis ? <p className="mt-2 text-sm text-blue-800">事前批准依据:{item.assetPriorApprovalBasis}</p> : null}
{item.standardViolations.map((violation) => (
<p className={violation.controlMode === "block" ? "mt-2 text-sm text-red-700" : "mt-2 text-sm text-amber-800"} key={`${violation.itemId}-${violation.standardId}`}>
提交时费用标准提示:申报 {formatMoney(violation.amount)},标准 {formatMoney(violation.maxAmount)},超出 {formatMoney(violation.exceededAmount)}。审批时请核对原因。
</p>
))}
{item.showOfficeElectronicsHint ? <p className="mt-2 text-sm text-amber-800">该金额通常应选择办公电子设备,请核对费用类型和事前批准依据。</p> : null}
{item.amountExceeded ? <p className="mt-2 text-sm text-red-700">当前申报金额高于已读取的票面金额合计,暂时不能据此通过;请核对明细金额和票据是否完整。</p> : null}
{item.expenseType?.requiresInvoice !== false && !item.invoices.length ? <p className="mt-2 text-sm text-red-700">这条明细尚无对应票据,暂时无法提交;请在票据区添加票据并选择这条明细。</p> : null}
</>
);
}
function ExpenseItemsTable({ items, capabilities }: { items: ReimbursementItemEvidence[]; capabilities: ReimbursementEvidenceCapabilities }) {
const editable = Boolean(capabilities.onEditItem);
return (
<div className="overflow-x-auto rounded-lg border border-zinc-200" data-testid="reimbursement-expense-items-table">
<table className="w-full min-w-[700px] table-fixed text-left text-sm">
<thead className="bg-zinc-50 text-xs text-zinc-600">
<tr>
<th className="w-12 px-3 py-2.5 font-medium" scope="col">序号</th>
<th className="w-32 px-3 py-2.5 font-medium" scope="col">费用类型</th>
<th className="w-28 px-3 py-2.5 font-medium" scope="col">发生日期</th>
<th className="px-3 py-2.5 font-medium" scope="col">费用说明</th>
<th className="w-28 px-3 py-2.5 text-right font-medium" scope="col">金额</th>
<th className="w-24 px-3 py-2.5 font-medium" scope="col">票据</th>
{editable ? <th className="w-20 px-3 py-2.5 text-right font-medium" scope="col">操作</th> : null}
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 bg-white">
{items.map((item, index) => {
const hasBlockingIssue = itemHasBlockingIssue(item);
return (
<tr data-testid="approval-item-evidence" data-reimbursement-guard-issue={hasBlockingIssue ? "true" : undefined} id={`reimbursement-item-${item.id}`} key={item.id} tabIndex={hasBlockingIssue ? -1 : undefined}>
<td className="px-3 py-3 align-top text-zinc-500">{index + 1}</td>
<td className="px-3 py-3 align-top font-medium text-zinc-900">{item.expenseType?.name ?? "未分类费用"}</td>
<td className="px-3 py-3 align-top text-zinc-600">{formatDisplayDate(item.expenseDate)}</td>
<td className="px-3 py-3 align-top text-zinc-700"><p>{item.description || "未填写明细说明"}</p><ItemIssues item={item} /></td>
<td className="px-3 py-3 text-right align-top font-medium tabular-nums text-zinc-900">{formatMoney(item.amount)}</td>
<td className={hasBlockingIssue ? "px-3 py-3 align-top font-medium text-red-700" : "px-3 py-3 align-top text-zinc-600"}>{itemInvoiceStatus(item)}</td>
{editable ? <td className="px-3 py-2 text-right align-top"><Button className="min-h-11" onClick={() => capabilities.onEditItem?.(item)} size="sm" type="button" variant="secondary">编辑</Button></td> : null}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function ExpenseItemCards({ items, capabilities }: { items: ReimbursementItemEvidence[]; capabilities: ReimbursementEvidenceCapabilities }) {
return <div className="space-y-3">{items.map((item, index) => {
const hasBlockingIssue = itemHasBlockingIssue(item);
return (
<article className="mobile-list-card" data-testid="approval-item-evidence" data-reimbursement-guard-issue={hasBlockingIssue ? "true" : undefined} id={`reimbursement-item-${item.id}`} key={item.id} tabIndex={hasBlockingIssue ? -1 : undefined}>
<div className="flex items-start justify-between gap-3"><div className="min-w-0"><p className="text-xs text-zinc-500">明细 {index + 1} · {formatDisplayDate(item.expenseDate)}</p><h3 className="mt-1 font-medium text-zinc-950">{item.expenseType?.name ?? "未分类费用"}</h3></div><p className="shrink-0 font-semibold tabular-nums text-zinc-950">{formatMoney(item.amount)}</p></div>
<p className="mt-2 text-sm leading-6 text-zinc-700">{item.description || "未填写明细说明"}</p>
<p className={hasBlockingIssue ? "mt-2 text-sm font-medium text-red-700" : "mt-2 text-sm text-zinc-600"}>票据:{itemInvoiceStatus(item)}</p>
<ItemIssues item={item} />
{capabilities.onEditItem ? <Button className="mt-3 min-h-11 w-full" onClick={() => capabilities.onEditItem?.(item)} type="button" variant="secondary">编辑</Button> : null}
</article>
);
})}</div>;
}
function InvoiceMaterialRow({ invoice, capabilities, linkTargets }: { invoice: ReimbursementInvoiceEvidence; capabilities: ReimbursementEvidenceCapabilities; linkTargets: ReimbursementItemEvidence[] }) {
const [selectedItemId, setSelectedItemId] = useState("");
const title = invoice.invoiceNumber || invoice.originalName || "未命名发票";
const hasBlockingIssue = !invoice.linkedItemId || invoice.amountEvidenceStatus === "ocr_failed";
return (
<article
className="rounded-lg border border-zinc-200 bg-white p-3"
data-testid="approval-invoice-evidence"
data-reimbursement-guard-issue={hasBlockingIssue ? "true" : undefined}
tabIndex={hasBlockingIssue ? -1 : undefined}
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<h4 className="truncate text-sm font-medium text-zinc-900">{title}</h4>
<p className="mt-1 text-xs leading-5 text-zinc-600">
{invoice.sellerName || invoice.documentSubtype || invoice.documentCategory || "票据"}
{" · "}{formatDisplayDate(invoice.invoiceDate)}
{" · "}{formatMoney(invoice.totalAmount)}
</p>
{evidenceStatusCopy(invoice.amountEvidenceStatus) ? (
<p className={invoice.amountEvidenceStatus === "ocr_failed" ? "mt-1 text-xs text-red-700" : "mt-1 text-xs text-zinc-600"}>
{evidenceStatusCopy(invoice.amountEvidenceStatus)}
</p>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{capabilities.onOpenInvoice ? (
<Button aria-label={`查看发票原件:${title}`} className="min-h-11" onClick={() => capabilities.onOpenInvoice?.(invoice)} size="sm" type="button" variant="secondary">
查看原件
</Button>
) : null}
{capabilities.onReferenceInvoice ? (
<Button className="min-h-11" data-testid="approval-reference-invoice" onClick={() => capabilities.onReferenceInvoice?.(invoice)} size="sm" type="button" variant="secondary">
引用到退回意见
</Button>
) : null}
{capabilities.onLinkInvoice && !invoice.linkedItemId ? (
<div className="grid min-w-[220px] gap-2">
<label className="text-xs font-medium text-zinc-700" htmlFor={`reimbursement-link-item-${invoice.id}`}>选择报销明细</label>
<select
className="select-std min-h-11"
disabled={capabilities.linkInvoicePending}
id={`reimbursement-link-item-${invoice.id}`}
onChange={(event) => setSelectedItemId(event.target.value)}
value={selectedItemId}
>
<option value="">请选择报销明细</option>
{linkTargets.map((item, index) => (
<option key={item.id} value={item.id}>明细 {index + 1} · {item.expenseType?.name ?? "未分类费用"} · {formatMoney(item.amount)}</option>
))}
</select>
<Button
className="min-h-11"
disabled={!selectedItemId || capabilities.linkInvoicePending}
onClick={() => capabilities.onLinkInvoice?.(invoice, selectedItemId)}
size="sm"
type="button"
variant="secondary"
>
{capabilities.linkInvoicePending ? "关联中..." : "确认关联"}
</Button>
{capabilities.linkInvoiceErrors?.[invoice.id] ? (
<p className="text-xs text-red-700" role="alert">{capabilities.linkInvoiceErrors[invoice.id]}</p>
) : null}
</div>
) : null}
{capabilities.onUnlinkInvoice && invoice.linkedItemId ? (
<Button className="min-h-11" onClick={() => capabilities.onUnlinkInvoice?.(invoice)} size="sm" type="button" variant="secondary">移除对应关系</Button>
) : null}
<article className="rounded-lg border border-zinc-200 bg-white p-3" data-testid="approval-invoice-evidence" data-reimbursement-guard-issue={hasBlockingIssue ? "true" : undefined} id={`reimbursement-invoice-${invoice.id}`} tabIndex={hasBlockingIssue ? -1 : undefined}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
{capabilities.onOpenInvoice ? <Button aria-label={`查看发票:${title}`} className="h-auto min-h-11 max-w-full justify-start whitespace-normal px-0 text-left" onClick={() => capabilities.onOpenInvoice?.(invoice)} type="button" variant="link">{title}</Button> : <h4 className="text-sm font-medium text-zinc-900">{title}</h4>}
<p className="text-xs leading-5 text-zinc-600">{invoice.sellerName || invoice.documentSubtype || invoice.documentCategory || "票据"} · {formatDisplayDate(invoice.invoiceDate)} · {formatMoney(invoice.totalAmount)}</p>
{evidenceStatusCopy(invoice.amountEvidenceStatus) ? <p className={invoice.amountEvidenceStatus === "ocr_failed" ? "mt-1 text-xs text-red-700" : "mt-1 text-xs text-zinc-600"}>{evidenceStatusCopy(invoice.amountEvidenceStatus)}</p> : null}
</div>
{capabilities.onUnlinkInvoice && invoice.linkedItemId ? <Button className="min-h-11" onClick={() => capabilities.onUnlinkInvoice?.(invoice)} size="sm" type="button" variant="secondary">移除对应关系</Button> : null}
</div>
{capabilities.onLinkInvoice && !invoice.linkedItemId ? (
<div className="mt-3 grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-end">
<label className="grid gap-1 text-xs font-medium text-zinc-700" htmlFor={`reimbursement-link-item-${invoice.id}`}>选择报销明细
<select className="select-std min-h-11" disabled={capabilities.linkInvoicePending} id={`reimbursement-link-item-${invoice.id}`} onChange={(event) => setSelectedItemId(event.target.value)} value={selectedItemId}>
<option value="">请选择报销明细</option>
{linkTargets.map((item, index) => <option key={item.id} value={item.id}>明细 {index + 1} · {item.expenseType?.name ?? "未分类费用"} · {formatMoney(item.amount)}</option>)}
</select>
</label>
<Button className="min-h-11" disabled={!selectedItemId || capabilities.linkInvoicePending} onClick={() => capabilities.onLinkInvoice?.(invoice, selectedItemId)} size="sm" type="button" variant="secondary">{capabilities.linkInvoicePending ? "关联中..." : "确认关联"}</Button>
{capabilities.linkInvoiceErrors?.[invoice.id] ? <p className="text-xs text-red-700 sm:col-span-2" role="alert">{capabilities.linkInvoiceErrors[invoice.id]}</p> : null}
</div>
) : null}
</article>
);
}
function AttachmentMaterialRow({ attachment, capabilities }: { attachment: ReimbursementAttachmentEvidence; capabilities: ReimbursementEvidenceCapabilities }) {
return <div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-zinc-200 px-3 py-2">
<div className="min-w-0 flex-1">
{capabilities.onOpenAttachment ? <Button aria-label={`查看补充附件:${attachment.originalName}`} className="h-auto min-h-11 max-w-full justify-start whitespace-normal px-0 text-left" onClick={() => capabilities.onOpenAttachment?.(attachment)} type="button" variant="link">{attachment.originalName}</Button> : <p className="truncate text-sm font-medium text-zinc-900">{attachment.originalName}</p>}
<p className="text-xs text-zinc-500">{attachment.mimeType} · {(attachment.fileSize / 1024).toFixed(1)} KB</p>
</div>
{capabilities.onDeleteAttachment ? <Button className="min-h-11" onClick={() => capabilities.onDeleteAttachment?.(attachment)} size="sm" type="button" variant="secondary">删除附件</Button> : null}
</div>;
}
export function ReimbursementItemEvidenceList({ evidence, capabilities = {}, mobile = false }: Props) {
const linkedInvoices = evidence.items.flatMap((item) => item.invoices);
return (
<div className="space-y-5" data-testid="reimbursement-approval-evidence">
<div className="space-y-3">
{evidence.items.length ? evidence.items.map((item, index) => {
const hasBlockingIssue = item.amountExceeded || item.invoices.some((invoice) => invoice.amountEvidenceStatus === "ocr_failed") || (item.expenseType?.requiresInvoice !== false && item.invoices.length === 0);
return (
<article className={mobile ? "mobile-list-card" : "rounded-xl border border-zinc-200 bg-zinc-50/60 p-4"} data-testid="approval-item-evidence" data-reimbursement-guard-issue={hasBlockingIssue ? "true" : undefined} tabIndex={hasBlockingIssue ? -1 : undefined} key={item.id}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-medium text-zinc-500">明细 {index + 1}</p>
<h3 className="mt-1 font-medium text-zinc-950">{item.expenseType?.name ?? "未分类费用"}</h3>
<p className="mt-1 text-sm text-zinc-600">
{formatDisplayDate(item.expenseDate)} · {formatMoney(item.amount)}
</p>
</div>
{capabilities.onReferenceItem ? (
<Button className="min-h-11" data-testid="approval-reference-item" onClick={() => capabilities.onReferenceItem?.(item)} size="sm" type="button" variant="secondary">
引用到退回意见
</Button>
) : null}
{capabilities.onEditItem ? (
<Button className="min-h-11" onClick={() => capabilities.onEditItem?.(item)} size="sm" type="button" variant="secondary">编辑明细</Button>
) : null}
{capabilities.onDeleteItem ? (
<Button className="min-h-11" onClick={() => capabilities.onDeleteItem?.(item)} size="sm" type="button" variant="secondary">删除明细</Button>
) : null}
</div>
<p className="mt-3 text-sm leading-6 text-zinc-700">{item.description || "未填写明细说明"}</p>
{item.assetPriorApprovalBasis ? (
<p className="mt-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-800">
事前批准依据:{item.assetPriorApprovalBasis}
</p>
) : null}
{item.standardViolations.map((violation) => (
<p
className={violation.controlMode === "block"
? "mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
: "mt-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800"}
key={`${violation.itemId}-${violation.standardId}`}
>
提交时费用标准提示:申报 {formatMoney(violation.amount)},标准 {formatMoney(violation.maxAmount)},超出 {formatMoney(violation.exceededAmount)}。审批时请核对原因。
</p>
))}
{item.showOfficeElectronicsHint ? (
<p className="mt-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
该金额通常应选择办公电子设备,请核对费用类型和事前批准依据。
</p>
) : null}
{item.amountExceeded ? (
<p className="mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
当前申报金额高于已读取的票面金额合计,暂时不能据此通过;请核对明细金额和票据是否完整。
</p>
) : null}
{item.invoices.length || item.expenseType?.requiresInvoice !== false ? <div className="mt-4 space-y-2">
<p className="text-sm font-medium text-zinc-800">所属票据({item.invoices.length})</p>
{item.invoices.length ? item.invoices.map((invoice) => (
<InvoiceCard capabilities={capabilities} invoice={invoice} key={invoice.id} linkTargets={evidence.items} />
)) : (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
这条明细尚无对应票据,暂时无法提交;请添加票据并选择这条明细。
</p>
)}
</div> : null}
</article>
); }) : (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">当前报销单没有可核验的明细。</p>
)}
</div>
{evidence.unlinkedInvoices.length ? (
<section className="space-y-2 rounded-xl border border-red-200 bg-red-50 p-4" data-testid="approval-unlinked-invoices" id="reimbursement-workspace-unlinked">
<div>
<h3 className="font-medium text-red-900">尚未对应明细的票据({evidence.unlinkedInvoices.length})</h3>
<p className="mt-1 text-sm text-red-800">这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。</p>
</div>
{evidence.unlinkedInvoices.map((invoice) => (
<InvoiceCard capabilities={capabilities} invoice={invoice} key={invoice.id} linkTargets={evidence.items} />
))}
</section>
) : null}
<section className="space-y-2" data-testid="approval-attachments">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-medium text-zinc-900">补充附件({evidence.attachments.length})</h3>
{capabilities.onOpenMaterialBundle ? (
<Button className="min-h-11" onClick={capabilities.onOpenMaterialBundle} size="sm" type="button" variant="secondary">
查看完整材料
</Button>
) : null}
<div className="space-y-6" data-testid="reimbursement-approval-evidence">
<section className="space-y-3" data-testid="reimbursement-expense-items">
<h3 className="font-medium text-zinc-900">费用明细</h3>
{evidence.items.length ? (mobile ? <ExpenseItemCards capabilities={capabilities} items={evidence.items} /> : <ExpenseItemsTable capabilities={capabilities} items={evidence.items} />) : <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">当前报销单没有可核验的明细。</p>}
</section>
<section className="space-y-4" data-testid="approval-materials">
<div className="flex flex-wrap items-center justify-between gap-2"><h3 className="font-medium text-zinc-900">票据与附件</h3>{capabilities.onOpenMaterialBundle ? <Button className="min-h-11" onClick={capabilities.onOpenMaterialBundle} size="sm" type="button" variant="secondary">查看完整材料</Button> : null}</div>
<div className="space-y-2" data-testid="approval-invoices">
<h4 className="text-sm font-medium text-zinc-800">发票({linkedInvoices.length + evidence.unlinkedInvoices.length})</h4>
{linkedInvoices.map((invoice) => <InvoiceMaterialRow capabilities={capabilities} invoice={invoice} key={invoice.id} linkTargets={evidence.items} />)}
{evidence.unlinkedInvoices.length ? <div className="space-y-2 rounded-lg border border-red-200 bg-red-50 p-3" data-testid="approval-unlinked-invoices" id="reimbursement-workspace-unlinked"><p className="text-sm font-medium text-red-900">尚未对应明细({evidence.unlinkedInvoices.length})</p><p className="text-sm text-red-800">这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。</p>{evidence.unlinkedInvoices.map((invoice) => <InvoiceMaterialRow capabilities={capabilities} invoice={invoice} key={invoice.id} linkTargets={evidence.items} />)}</div> : null}
{!linkedInvoices.length && !evidence.unlinkedInvoices.length ? <p className="text-sm text-zinc-500">暂无发票</p> : null}
</div>
<div className="space-y-2" data-testid="approval-attachments">
<h4 className="text-sm font-medium text-zinc-800">补充附件({evidence.attachments.length})</h4>
{evidence.attachments.length ? evidence.attachments.map((attachment) => <AttachmentMaterialRow attachment={attachment} capabilities={capabilities} key={attachment.id} />) : <p className="text-sm text-zinc-500">未上传补充附件</p>}
</div>
{evidence.attachments.length ? evidence.attachments.map((attachment) => (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-zinc-200 px-3 py-2" key={attachment.id}>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-zinc-900">{attachment.originalName}</p>
<p className="text-xs text-zinc-500">{attachment.mimeType} · {(attachment.fileSize / 1024).toFixed(1)} KB</p>
</div>
{capabilities.onOpenAttachment ? (
<Button aria-label={`查看补充附件:${attachment.originalName}`} className="min-h-11" onClick={() => capabilities.onOpenAttachment?.(attachment)} size="sm" type="button" variant="secondary">
查看附件
</Button>
) : null}
{capabilities.onDeleteAttachment ? (
<Button className="min-h-11" onClick={() => capabilities.onDeleteAttachment?.(attachment)} size="sm" type="button" variant="secondary">
删除附件
</Button>
) : null}
</div>
)) : <p className="text-sm text-zinc-500">未上传补充附件</p>}
</section>
</div>
);
@@ -47,7 +47,7 @@ export function ReimbursementMobileWorkspaceShell({ model, capabilities, slots,
</section>
{slots.beforeEvidence}
<section className="mobile-card scroll-mt-20" data-testid="reimbursement-workspace-evidence" id="reimbursement-workspace-evidence">
<h2 className="mb-3 text-base font-semibold text-zinc-900">明细与票据</h2>
<h2 className="mb-3 text-base font-semibold text-zinc-900">报销内容</h2>
<ReimbursementItemEvidenceList evidence={model.evidence} capabilities={capabilities} mobile />
</section>
{slots.afterEvidence}
@@ -0,0 +1,34 @@
import { useMemo, useState } from "react";
import type { ReimbursementApprovalEvidenceProjection } from "../../lib/reimbursementApprovalProjection";
import { buildReimbursementReturnReference } from "../../lib/reimbursementApprovalProjection";
import { formatDisplayDate } from "../../lib/displayFormat";
import { Button } from "../ui/button";
type Props = { evidence: ReimbursementApprovalEvidenceProjection; onReference: (reference: string) => void };
export function ReimbursementReturnReferencePicker({ evidence, onReference }: Props) {
const [showAll, setShowAll] = useState(false);
const [selected, setSelected] = useState<Set<string>>(() => new Set());
const candidates = useMemo(() => {
const items = evidence.items.map((item) => ({
id: `item:${item.id}`,
kind: "item" as const,
label: `明细:${item.expenseType?.name ?? "未分类费用"} · ${formatDisplayDate(item.expenseDate)}`,
abnormal: item.amountExceeded || item.standardViolations.length > 0 || item.showOfficeElectronicsHint || item.invoices.some((invoice) => invoice.amountEvidenceStatus !== "verified") || (item.expenseType?.requiresInvoice !== false && item.invoices.length === 0),
reference: buildReimbursementReturnReference({ item }),
}));
const invoices = [...evidence.items.flatMap((item) => item.invoices), ...evidence.unlinkedInvoices].map((invoice) => ({
id: `invoice:${invoice.id}`,
kind: "invoice" as const,
label: `发票:${invoice.invoiceNumber || invoice.originalName || "未命名发票"}`,
abnormal: !invoice.linkedItemId || invoice.amountEvidenceStatus !== "verified",
reference: buildReimbursementReturnReference({ invoice }),
}));
return [...items, ...invoices].sort((left, right) => Number(right.abnormal) - Number(left.abnormal));
}, [evidence]);
const visible = showAll ? candidates : candidates.filter((candidate) => candidate.abnormal);
return <div className="space-y-2 rounded-lg border border-zinc-200 bg-zinc-50 p-3" data-testid="reimbursement-return-reference-picker">
<div className="flex items-center justify-between gap-2"><p className="text-xs font-medium text-zinc-700">引用报销内容</p>{candidates.some((candidate) => !candidate.abnormal) ? <Button className="min-h-11" onClick={() => setShowAll((value) => !value)} size="sm" type="button" variant="ghost">{showAll ? "只看需关注" : "展开全部"}</Button> : null}</div>
{visible.length ? visible.map((candidate) => <label className="flex min-h-11 cursor-pointer items-center gap-2 rounded-md px-2 text-sm text-zinc-700 hover:bg-white" key={candidate.id}><input checked={selected.has(candidate.id)} data-testid={candidate.kind === "item" ? "approval-reference-item" : "approval-reference-invoice"} onChange={(event) => { if (!event.target.checked) return; setSelected((current) => new Set(current).add(candidate.id)); onReference(candidate.reference); }} type="checkbox" /><span>{candidate.label}{candidate.abnormal ? " · 需关注" : ""}</span></label>) : <p className="text-xs text-zinc-500">当前没有需优先引用的问题;可展开全部选择。</p>}
</div>;
}