优化报销审批证据布局(reimbursement-form-shared-component-migration)
这个提交包含在:
@@ -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>
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
在新工单中引用
屏蔽一个用户