实现 B-2b 统一报销工作台迁移(reimbursement-form-shared-component-migration)

这个提交包含在:
2026-09-02 07:35:01 +08:00
父节点 6bca91a360
当前提交 abdd250325
共修改 38 个文件,包含 1850 行新增和 2723 行删除
@@ -0,0 +1,49 @@
import type { ReimbursementEvidenceCapabilities } from "./ReimbursementItemEvidenceList";
import { ReimbursementItemEvidenceList } from "./ReimbursementItemEvidenceList";
import type { ReimbursementWorkspaceModel, ReimbursementWorkspaceSlots } from "../../lib/reimbursementWorkspace";
import { formatDisplayDate, formatMoney } from "../../lib/displayFormat";
import { BusinessDetailAside, BusinessDetailMain, BusinessDetailWorkspace } from "../patterns/BusinessDetailTemplate";
import { EntityOverviewHero } from "../patterns/EntityOverviewHero";
import { PageScaffold } from "../patterns/PageScaffold";
import { SectionPanel } from "../patterns/SectionPanel";
type Props = {
model: ReimbursementWorkspaceModel;
capabilities?: ReimbursementEvidenceCapabilities;
slots: ReimbursementWorkspaceSlots;
};
export function ReimbursementDesktopWorkspaceShell({ model, capabilities, slots }: Props) {
return (
<PageScaffold data-testid="reimbursement-desktop-workspace">
{slots.backAction}
<EntityOverviewHero
title={`报销单 ${model.header.displayCode}`}
subtitle={model.header.description || "未填写事由"}
meta={(
<>
{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>
</>
)}
/>
{slots.transientFeedback}
<BusinessDetailWorkspace className="mx-auto w-full max-w-7xl">
<BusinessDetailMain>
{slots.headerDetails}
{slots.beforeEvidence}
<SectionPanel title="明细与票据" className="scroll-mt-24">
<div id="reimbursement-workspace-evidence">
<ReimbursementItemEvidenceList evidence={model.evidence} capabilities={capabilities} />
</div>
</SectionPanel>
{slots.afterEvidence}
{slots.timeline}
</BusinessDetailMain>
<BusinessDetailAside>{slots.actionPanel}</BusinessDetailAside>
</BusinessDetailWorkspace>
</PageScaffold>
);
}
@@ -1,3 +1,4 @@
import { useState } from "react";
import { Button } from "../ui/button";
import { formatDisplayDate, formatMoney } from "../../lib/displayFormat";
import type {
@@ -11,35 +12,48 @@ export type ReimbursementEvidenceCapabilities = {
onOpenInvoice?: (invoice: ReimbursementInvoiceEvidence) => void;
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) => void;
onLinkInvoice?: (invoice: ReimbursementInvoiceEvidence, linkedItemId: string) => void;
linkInvoicePending?: boolean;
linkInvoiceErrors?: Record<string, string>;
onUnlinkInvoice?: (invoice: ReimbursementInvoiceEvidence) => void;
};
type Props = {
evidence: ReimbursementApprovalEvidenceProjection;
capabilities?: ReimbursementEvidenceCapabilities;
mobile?: boolean;
};
function evidenceStatusCopy(status: ReimbursementInvoiceEvidence["amountEvidenceStatus"]) {
if (status === "verified") return "票面金额已读取";
if (status === "ocr_failed") return "票面金额未能读取,暂时不能完成核验";
return "票面金额需查看原件人工核对";
if (status === "ocr_failed") return "该发票的票面金额未能读取,暂时无法提交;请重新上传这张发票,或联系财务处理。";
if (status === "manual_review") return "票面金额需财务查看原件核对;这是一项审批提示,不会阻止提交。";
return null;
}
function InvoiceCard({
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">
<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>
@@ -48,13 +62,15 @@ function InvoiceCard({
{" · "}{formatDisplayDate(invoice.invoiceDate)}
{" · "}{formatMoney(invoice.totalAmount)}
</p>
<p className={invoice.amountEvidenceStatus === "ocr_failed" ? "mt-1 text-xs text-red-700" : "mt-1 text-xs text-zinc-600"}>
{evidenceStatusCopy(invoice.amountEvidenceStatus)}
</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 className="min-h-11" onClick={() => capabilities.onOpenInvoice?.(invoice)} size="sm" type="button" variant="secondary">
<Button aria-label={`查看发票原件:${title}`} className="min-h-11" onClick={() => capabilities.onOpenInvoice?.(invoice)} size="sm" type="button" variant="secondary">
查看原件
</Button>
) : null}
@@ -64,7 +80,34 @@ function InvoiceCard({
</Button>
) : null}
{capabilities.onLinkInvoice && !invoice.linkedItemId ? (
<Button className="min-h-11" onClick={() => capabilities.onLinkInvoice?.(invoice)} size="sm" type="button" variant="secondary">选择明细</Button>
<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>
@@ -75,12 +118,14 @@ function InvoiceCard({
);
}
export function ReimbursementItemEvidenceList({ evidence, capabilities = {} }: Props) {
export function ReimbursementItemEvidenceList({ evidence, capabilities = {}, mobile = false }: Props) {
return (
<div className="space-y-5" data-testid="reimbursement-approval-evidence">
<div className="space-y-3">
{evidence.items.length ? evidence.items.map((item, index) => (
<article className="rounded-xl border border-zinc-200 bg-zinc-50/60 p-4" data-testid="approval-item-evidence" key={item.id}>
{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>
@@ -127,30 +172,30 @@ export function ReimbursementItemEvidenceList({ evidence, capabilities = {} }: P
当前申报金额高于已读取的票面金额合计,暂时不能据此通过;请核对明细金额和票据是否完整。
</p>
) : null}
<div className="mt-4 space-y-2">
{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} />
<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>
</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">
<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>
<p className="mt-1 text-sm text-red-800">这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。</p>
</div>
{evidence.unlinkedInvoices.map((invoice) => (
<InvoiceCard capabilities={capabilities} invoice={invoice} key={invoice.id} />
<InvoiceCard capabilities={capabilities} invoice={invoice} key={invoice.id} linkTargets={evidence.items} />
))}
</section>
) : null}
@@ -171,10 +216,15 @@ export function ReimbursementItemEvidenceList({ evidence, capabilities = {} }: P
<p className="text-xs text-zinc-500">{attachment.mimeType} · {(attachment.fileSize / 1024).toFixed(1)} KB</p>
</div>
{capabilities.onOpenAttachment ? (
<Button className="min-h-11" onClick={() => capabilities.onOpenAttachment?.(attachment)} size="sm" type="button" variant="secondary">
<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>
@@ -0,0 +1,58 @@
import type { ReimbursementEvidenceCapabilities } from "./ReimbursementItemEvidenceList";
import { ReimbursementItemEvidenceList } from "./ReimbursementItemEvidenceList";
import type { ReimbursementWorkspaceModel, ReimbursementWorkspaceSlots } from "../../lib/reimbursementWorkspace";
import { formatDisplayDate, formatMoney } from "../../lib/displayFormat";
import { MobileDetailHeader } from "../mobile/MobileDetailHeader";
import { MOBILE_STICKY_ACTION_PAGE_CLASS } from "../patterns/MobileStickyActionBar";
import { PageScaffold } from "../patterns/PageScaffold";
type Props = {
model: ReimbursementWorkspaceModel;
capabilities?: ReimbursementEvidenceCapabilities;
slots: ReimbursementWorkspaceSlots;
fallbackTo: string;
};
export function ReimbursementMobileWorkspaceShell({ model, capabilities, slots, fallbackTo }: Props) {
return (
<PageScaffold className={slots.mobileActions ? MOBILE_STICKY_ACTION_PAGE_CLASS : undefined} data-testid="reimbursement-mobile-workspace">
<MobileDetailHeader
fallbackTo={fallbackTo}
status={slots.status}
subtitle="报销详情"
testId="mobile-reimbursement-detail-header"
title={model.header.displayCode}
/>
{slots.transientFeedback}
<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>
</div>
<div className="mobile-info-grid">
<div className="mobile-list-card">
<p className="text-xs text-zinc-500">报销人</p>
<p className="mt-1 font-medium text-zinc-900">{model.header.ownerName}</p>
</div>
<div className="mobile-list-card">
<p className="text-xs text-zinc-500">报销日期</p>
<p className="mt-1 font-medium text-zinc-900">{formatDisplayDate(model.header.reimbursementDate)}</p>
</div>
</div>
<div>
<p className="text-xs text-zinc-500">事由</p>
<p className="mt-1 text-sm leading-6 text-zinc-700">{model.header.description || "未填写事由"}</p>
</div>
{slots.headerDetails}
</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>
<ReimbursementItemEvidenceList evidence={model.evidence} capabilities={capabilities} mobile />
</section>
{slots.afterEvidence}
{slots.timeline}
{slots.mobileActions}
</PageScaffold>
);
}