feat(报销): 收敛只读内容与审批上下文 [09-02-reimbursement-readonly-content-consistency]

这个提交包含在:
2026-09-02 21:13:08 +08:00
父节点 dda625b15f
当前提交 e48514d542
共修改 20 个文件,包含 740 行新增和 47 行删除
@@ -25,7 +25,7 @@ export function ReimbursementDesktopWorkspaceShell({ model, capabilities, slots
{slots.status}
<span className="info-chip">报销人:{model.header.ownerName}</span>
<span className="info-chip">日期:{formatDisplayDate(model.header.reimbursementDate)}</span>
<span className="info-chip">合计:{formatMoney(model.header.totalAmount)}</span>
<span className="info-chip">合计:{formatMoney(model.amountSummary.authoritativeTotalAmount)}</span>
</>
)}
/>
@@ -36,13 +36,15 @@ export function ReimbursementDesktopWorkspaceShell({ model, capabilities, slots
{slots.beforeEvidence}
<SectionPanel title="报销内容" className="scroll-mt-24">
<div id="reimbursement-workspace-evidence">
<ReimbursementItemEvidenceList evidence={model.evidence} capabilities={capabilities} />
<ReimbursementItemEvidenceList amountSummary={model.amountSummary} evidence={model.evidence} capabilities={capabilities} />
</div>
</SectionPanel>
{slots.afterEvidence}
{slots.timeline}
</BusinessDetailMain>
<BusinessDetailAside>{slots.actionPanel}</BusinessDetailAside>
<BusinessDetailAside>
{slots.actionPanel}
{slots.asideContext}
</BusinessDetailAside>
</BusinessDetailWorkspace>
</PageScaffold>
);
@@ -7,6 +7,7 @@ import type {
ReimbursementInvoiceEvidence,
ReimbursementItemEvidence,
} from "../../lib/reimbursementApprovalProjection";
import { reimbursementAmountIntegrityMessage, type ReimbursementAmountSummary } from "../../lib/reimbursementWorkspace";
export type ReimbursementEvidenceCapabilities = {
onOpenInvoice?: (invoice: ReimbursementInvoiceEvidence) => void;
@@ -21,11 +22,17 @@ export type ReimbursementEvidenceCapabilities = {
};
type Props = {
amountSummary: ReimbursementAmountSummary;
evidence: ReimbursementApprovalEvidenceProjection;
capabilities?: ReimbursementEvidenceCapabilities;
mobile?: boolean;
};
function AmountIntegrityMessage({ integrity }: Pick<ReimbursementAmountSummary, "integrity">) {
const message = reimbursementAmountIntegrityMessage(integrity);
return message ? <p className="mt-1 text-sm font-medium text-red-700" role="alert">{message}</p> : null;
}
function evidenceStatusCopy(status: ReimbursementInvoiceEvidence["amountEvidenceStatus"]) {
if (status === "ocr_failed") return "该发票的票面金额未能读取,暂时无法提交;请重新上传这张发票,或联系财务处理。";
if (status === "manual_review") return "票面金额需财务查看原件核对;这是一项审批提示,不会阻止提交。";
@@ -61,7 +68,7 @@ function ItemIssues({ item }: { item: ReimbursementItemEvidence }) {
);
}
function ExpenseItemsTable({ items, capabilities }: { items: ReimbursementItemEvidence[]; capabilities: ReimbursementEvidenceCapabilities }) {
function ExpenseItemsTable({ amountSummary, items, capabilities }: { amountSummary: ReimbursementAmountSummary; 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">
@@ -93,12 +100,22 @@ function ExpenseItemsTable({ items, capabilities }: { items: ReimbursementItemEv
);
})}
</tbody>
<tfoot className="border-t border-zinc-300 bg-zinc-50" data-testid="reimbursement-amount-summary" id="reimbursement-workspace-amount-summary">
<tr>
<th className="px-3 py-3 text-right font-medium text-zinc-700" colSpan={4} scope="row">明细合计</th>
<td aria-label="服务端权威明细合计" className="px-3 py-3 text-right font-semibold tabular-nums text-zinc-950">
{formatMoney(amountSummary.authoritativeTotalAmount)}
</td>
<td className="px-3 py-3" colSpan={editable ? 2 : 1} />
</tr>
{amountSummary.integrity !== "ready" ? <tr><td className="px-3 pb-3" colSpan={editable ? 7 : 6}><AmountIntegrityMessage integrity={amountSummary.integrity} /></td></tr> : null}
</tfoot>
</table>
</div>
);
}
function ExpenseItemCards({ items, capabilities }: { items: ReimbursementItemEvidence[]; capabilities: ReimbursementEvidenceCapabilities }) {
function ExpenseItemCards({ amountSummary, items, capabilities }: { amountSummary: ReimbursementAmountSummary; items: ReimbursementItemEvidence[]; capabilities: ReimbursementEvidenceCapabilities }) {
return <div className="space-y-3">{items.map((item, index) => {
const hasBlockingIssue = itemHasBlockingIssue(item);
return (
@@ -110,7 +127,12 @@ function ExpenseItemCards({ items, capabilities }: { items: ReimbursementItemEvi
{capabilities.onEditItem ? <Button className="mt-3 min-h-11 w-full" onClick={() => capabilities.onEditItem?.(item)} type="button" variant="secondary">编辑</Button> : null}
</article>
);
})}</div>;
})}
<div className="flex items-start justify-between gap-3 border-t border-zinc-200 pt-3" data-testid="reimbursement-amount-summary" id="reimbursement-workspace-amount-summary">
<div className="min-w-0"><p className="text-sm font-medium text-zinc-700">明细合计</p><AmountIntegrityMessage integrity={amountSummary.integrity} /></div>
<p aria-label="服务端权威明细合计" className="shrink-0 font-semibold tabular-nums text-zinc-950">{formatMoney(amountSummary.authoritativeTotalAmount)}</p>
</div>
</div>;
}
function InvoiceMaterialRow({ invoice, capabilities, linkTargets }: { invoice: ReimbursementInvoiceEvidence; capabilities: ReimbursementEvidenceCapabilities; linkTargets: ReimbursementItemEvidence[] }) {
@@ -153,13 +175,21 @@ function AttachmentMaterialRow({ attachment, capabilities }: { attachment: Reimb
</div>;
}
export function ReimbursementItemEvidenceList({ evidence, capabilities = {}, mobile = false }: Props) {
export function ReimbursementItemEvidenceList({ amountSummary, evidence, capabilities = {}, mobile = false }: Props) {
const linkedInvoices = evidence.items.flatMap((item) => item.invoices);
return (
<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>}
{evidence.items.length ? (mobile ? <ExpenseItemCards amountSummary={amountSummary} capabilities={capabilities} items={evidence.items} /> : <ExpenseItemsTable amountSummary={amountSummary} capabilities={capabilities} items={evidence.items} />) : (
<div className="space-y-3">
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">当前报销单没有可核验的明细。</p>
<div className="flex items-start justify-between gap-3 border-t border-zinc-200 pt-3" data-testid="reimbursement-amount-summary" id="reimbursement-workspace-amount-summary">
<div><p className="text-sm font-medium text-zinc-700">明细合计</p><AmountIntegrityMessage integrity={amountSummary.integrity} /></div>
<p aria-label="服务端权威明细合计" className="font-semibold tabular-nums text-zinc-950">{formatMoney(amountSummary.authoritativeTotalAmount)}</p>
</div>
</div>
)}
</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>
@@ -27,7 +27,7 @@ export function ReimbursementMobileWorkspaceShell({ model, capabilities, slots,
<section className="mobile-card mobile-focus-card mobile-page-stack">
<div>
<p className="text-xs text-zinc-500">报销合计</p>
<p className="mt-1 text-3xl font-semibold text-zinc-950">{formatMoney(model.header.totalAmount)}</p>
<p className="mt-1 text-3xl font-semibold text-zinc-950">{formatMoney(model.amountSummary.authoritativeTotalAmount)}</p>
</div>
<div className="mobile-info-grid">
<div className="mobile-list-card">
@@ -48,7 +48,7 @@ export function ReimbursementMobileWorkspaceShell({ model, capabilities, slots,
{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>
<ReimbursementItemEvidenceList evidence={model.evidence} capabilities={capabilities} mobile />
<ReimbursementItemEvidenceList amountSummary={model.amountSummary} evidence={model.evidence} capabilities={capabilities} mobile />
</section>
{slots.afterEvidence}
{slots.timeline}