优化报销审批证据布局(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>;
}
@@ -372,6 +372,7 @@ export function MobileReimbursementDetailPage() {
mutationFn: (itemId: string) => deleteReimbursementItem(reimbursementId, itemId),
onSuccess: async () => {
await invalidateDetail();
setItemForm(null);
setConfirmDeleteItemId("");
toast.success("明细已删除");
},
@@ -924,7 +925,6 @@ export function MobileReimbursementDetailPage() {
const sourceItem = detail.items.find((candidate) => candidate.id === item.id);
if (sourceItem) startEditItem(sourceItem);
} : undefined,
onDeleteItem: canMaintainReimbursement ? (item: { id: string }) => setConfirmDeleteItemId(item.id) : undefined,
onLinkInvoice: canMaintainReimbursement ? (invoice: { id: string }, linkedItemId: string) => linkInvoiceItemMutation.mutate({ invoiceId: invoice.id, linkedItemId }) : undefined,
linkInvoicePending: linkInvoiceItemMutation.isPending,
linkInvoiceErrors: invoiceItemLinkErrors,
@@ -961,7 +961,7 @@ export function MobileReimbursementDetailPage() {
<label className="block space-y-1 text-sm text-zinc-700"><span>金额</span><Input disabled={itemSaveMutation.isPending} inputMode="decimal" value={itemForm.amount} onChange={(event) => setItemForm((current) => current ? { ...current, amount: event.target.value } : current)} /></label>
<label className="block space-y-1 text-sm text-zinc-700"><span>说明</span><Textarea disabled={itemSaveMutation.isPending} rows={2} value={itemForm.description} onChange={(event) => setItemForm((current) => current ? { ...current, description: event.target.value } : current)} /></label>
{shouldShowOfficeElectronicsHint(expenseTypes.find((type) => type.id === itemForm.expenseTypeId), itemForm.amount) ? <p className="text-xs text-amber-700">该金额通常应选择办公电子设备。</p> : null}
<div className="grid grid-cols-2 gap-2"><Button disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || itemSaveMutation.isPending} onClick={() => itemSaveMutation.mutate(itemForm)} type="button">保存</Button><Button disabled={itemSaveMutation.isPending} onClick={() => setItemForm(null)} type="button" variant="secondary">取消</Button></div>
<div className="grid grid-cols-2 gap-2"><Button disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || itemSaveMutation.isPending} onClick={() => itemSaveMutation.mutate(itemForm)} type="button">保存</Button><Button disabled={itemSaveMutation.isPending} onClick={() => setItemForm(null)} type="button" variant="secondary">取消</Button>{itemForm.mode === "edit" && itemForm.itemId ? <Button className="col-span-2" data-testid="reimbursement-item-delete" disabled={itemSaveMutation.isPending} onClick={() => setConfirmDeleteItemId(itemForm.itemId ?? "")} type="button" variant="destructive">删除明细</Button> : null}</div>
</div>
) : <p className="text-xs text-zinc-500">选择上方动作维护明细、发票或补充附件。</p>}
</section>
+3 -2
查看文件
@@ -327,6 +327,8 @@ export function ReimbursementDetailPage() {
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }),
]);
setItemEditor(null);
setConfirmDeleteItemId("");
setItemError("");
toast.success("明细已删除");
},
@@ -1056,7 +1058,6 @@ export function ReimbursementDetailPage() {
const sourceItem = detail.items.find((candidate) => candidate.id === item.id);
if (sourceItem) startEditItem(sourceItem);
} : undefined,
onDeleteItem: canMaintainReimbursement ? (item: { id: string }) => setConfirmDeleteItemId(item.id) : undefined,
onLinkInvoice: canMaintainReimbursement ? (invoice: { id: string }, linkedItemId: string) => linkInvoiceItemMutation.mutate({ invoiceId: invoice.id, linkedItemId }) : undefined,
linkInvoicePending: linkInvoiceItemMutation.isPending,
linkInvoiceErrors: invoiceItemLinkErrors,
@@ -1081,7 +1082,7 @@ export function ReimbursementDetailPage() {
{itemError ? <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">{itemError}</p> : null}
{canMaintainReimbursement ? (
<SectionPanel title="维护报销内容" actions={<div className="flex flex-wrap gap-2"><Button data-testid="reimbursement-item-add" disabled={itemSaveMutation.isPending} onClick={startCreateItem} type="button" variant="secondary">新增明细</Button><Button data-testid={TEST_IDS.reimbursement.invoiceAdd} disabled={!canOpenInvoiceBindDialog} onClick={() => handleInvoiceDialogOpenChange(true)} type="button" variant="secondary">添加发票</Button><label className="inline-flex min-h-11 cursor-pointer items-center rounded-md border border-zinc-300 bg-white px-4 text-sm font-medium text-zinc-700" htmlFor="reimbursement-attachment-upload">选择附件</label><input className="hidden" id="reimbursement-attachment-upload" type="file" accept="application/pdf,image/jpeg,image/png,image/webp" multiple onChange={(event) => { const files = Array.from(event.target.files ?? []); if (files.length > MAX_REIMBURSEMENT_ATTACHMENT_FILES) { setSelectedAttachmentFiles([]); event.target.value = ""; toast.error(`单次最多上传 ${MAX_REIMBURSEMENT_ATTACHMENT_FILES} 个附件,请分批上传。`); return; } setSelectedAttachmentFiles(files); }} /><Button disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending} onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)} type="button" variant="secondary">{uploadAttachmentsMutation.isPending ? "上传中..." : selectedAttachmentFiles.length ? `上传 ${selectedAttachmentFiles.length} 个附件` : "上传附件"}</Button></div>}>
{itemEditor ? <ReimbursementItemEditor mode={itemEditor.mode} expenseTypes={expenseTypes} expenseTypesLoading={expenseTypesQuery.isLoading} expenseTypesError={expenseTypesQuery.isError} departmentId={detail.departmentId} initialValues={editorInitialValues} onSave={handleItemSave} onCancel={handleItemCancel} isPending={itemSaveMutation.isPending} /> : <p className="text-sm text-zinc-500">选择上方动作维护明细、发票或补充附件。</p>}
{itemEditor ? <ReimbursementItemEditor mode={itemEditor.mode} expenseTypes={expenseTypes} expenseTypesLoading={expenseTypesQuery.isLoading} expenseTypesError={expenseTypesQuery.isError} departmentId={detail.departmentId} initialValues={editorInitialValues} onSave={handleItemSave} onCancel={handleItemCancel} onDelete={itemEditor.mode === "edit" ? () => setConfirmDeleteItemId(itemEditor.itemId) : undefined} isPending={itemSaveMutation.isPending} /> : <p className="text-sm text-zinc-500">选择上方动作维护明细、发票或补充附件。</p>}
</SectionPanel>
) : null}
</>
@@ -32,6 +32,7 @@ export interface ReimbursementItemEditorProps {
initialValues: ItemEditorInitialValues;
onSave: (data: ItemEditorSaveData) => void;
onCancel: () => void;
onDelete?: () => void;
isPending: boolean;
}
@@ -44,6 +45,7 @@ export function ReimbursementItemEditor({
initialValues,
onSave,
onCancel,
onDelete,
isPending,
}: ReimbursementItemEditorProps) {
const [expenseTypeId, setExpenseTypeId] = useState(initialValues.expenseTypeId);
@@ -216,6 +218,11 @@ export function ReimbursementItemEditor({
>
取消
</Button>
{mode === "edit" && onDelete ? (
<Button data-testid="reimbursement-item-delete" disabled={isPending} onClick={onDelete} type="button" variant="destructive">
删除明细
</Button>
) : null}
</div>
</div>
);
@@ -25,6 +25,7 @@ import { StatusBadge } from "../../components/common/StatusBadge";
import { useAttachmentDownloadReasonDialog } from "../../components/common/useAttachmentDownloadReasonDialog";
import { ReimbursementDesktopWorkspaceShell } from "../../components/reimbursements/ReimbursementDesktopWorkspaceShell";
import { ReimbursementMobileWorkspaceShell } from "../../components/reimbursements/ReimbursementMobileWorkspaceShell";
import { ReimbursementReturnReferencePicker } from "../../components/reimbursements/ReimbursementReturnReferencePicker";
import { BusinessDetailActionPanel } from "../../components/patterns/BusinessDetailTemplate";
import { MobileStickyActionBar } from "../../components/patterns/MobileStickyActionBar";
import { SectionPanel } from "../../components/patterns/SectionPanel";
@@ -34,10 +35,7 @@ import { Textarea } from "../../components/ui/textarea";
import { approvalRoleLabel, workflowActionLabel } from "../../lib/approvalDisplay";
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../../lib/attachmentGovernance";
import { formatDisplayDateTime } from "../../lib/displayFormat";
import {
buildReimbursementApprovalEvidence,
buildReimbursementReturnReference,
} from "../../lib/reimbursementApprovalProjection";
import { buildReimbursementApprovalEvidence } from "../../lib/reimbursementApprovalProjection";
import {
buildReimbursementWorkspaceHeader,
buildReimbursementWorkspaceIssueAnchors,
@@ -295,6 +293,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
<Label htmlFor={`${layout}-reimbursement-return-comment`}>退回意见</Label>
<p className="text-xs text-zinc-500">退回后原单可修改并重新提交,单号和审批历史会保留。</p>
<Textarea data-testid={layout === "desktop" ? "workflow-task-return-comment" : "mobile-approvals-comment"} id={`${layout}-reimbursement-return-comment`} value={returnComment} onChange={(event) => setReturnComment(event.target.value)} />
<ReimbursementReturnReferencePicker evidence={model.evidence} onReference={appendReturnReference} />
<Button className="min-h-11 w-full" data-testid={layout === "desktop" ? "workflow-task-return" : "mobile-approvals-return-submit"} disabled={actionDisabled || !returnComment.trim()} onClick={() => requestAction("return")} type="button" variant="secondary">确认退回</Button>
</div>
) : null}
@@ -341,8 +340,6 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
onOpenInvoice: (invoice: { id: string }) => openInvoiceMutation.mutate(invoice.id),
onOpenAttachment: (attachment: { id: string }) => void openAttachment(attachment.id),
onOpenMaterialBundle: hasMaterials ? () => void openAllMaterials() : undefined,
onReferenceItem: (item: Parameters<typeof buildReimbursementReturnReference>[0]["item"]) => appendReturnReference(buildReimbursementReturnReference({ item })),
onReferenceInvoice: (invoice: Parameters<typeof buildReimbursementReturnReference>[0]["invoice"]) => appendReturnReference(buildReimbursementReturnReference({ invoice })),
};
const dialogs = (