实现 B-2a 报销审批内容工作台(reimbursement-approval-content-workbench)

这个提交包含在:
2026-09-01 20:32:34 +08:00
父节点 4c8ee07abb
当前提交 c840574d36
共修改 36 个文件,包含 2271 行新增和 91 行删除
@@ -0,0 +1,183 @@
import { Button } from "../ui/button";
import { formatDisplayDate, formatMoney } from "../../lib/displayFormat";
import type {
ReimbursementApprovalEvidenceProjection,
ReimbursementAttachmentEvidence,
ReimbursementInvoiceEvidence,
ReimbursementItemEvidence,
} from "../../lib/reimbursementApprovalProjection";
export type ReimbursementEvidenceCapabilities = {
onOpenInvoice?: (invoice: ReimbursementInvoiceEvidence) => void;
onOpenAttachment?: (attachment: ReimbursementAttachmentEvidence) => void;
onOpenMaterialBundle?: () => void;
onReferenceItem?: (item: ReimbursementItemEvidence) => void;
onReferenceInvoice?: (invoice: ReimbursementInvoiceEvidence) => void;
onEditItem?: (item: ReimbursementItemEvidence) => void;
onDeleteItem?: (item: ReimbursementItemEvidence) => void;
onLinkInvoice?: (invoice: ReimbursementInvoiceEvidence) => void;
onUnlinkInvoice?: (invoice: ReimbursementInvoiceEvidence) => void;
};
type Props = {
evidence: ReimbursementApprovalEvidenceProjection;
capabilities?: ReimbursementEvidenceCapabilities;
};
function evidenceStatusCopy(status: ReimbursementInvoiceEvidence["amountEvidenceStatus"]) {
if (status === "verified") return "票面金额已读取";
if (status === "ocr_failed") return "票面金额未能读取,暂时不能完成核验";
return "票面金额需查看原件人工核对";
}
function InvoiceCard({
invoice,
capabilities,
}: {
invoice: ReimbursementInvoiceEvidence;
capabilities: ReimbursementEvidenceCapabilities;
}) {
const title = invoice.invoiceNumber || invoice.originalName || "未命名发票";
return (
<article className="rounded-lg border border-zinc-200 bg-white p-3" data-testid="approval-invoice-evidence">
<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>
<p className={invoice.amountEvidenceStatus === "ocr_failed" ? "mt-1 text-xs text-red-700" : "mt-1 text-xs text-zinc-600"}>
{evidenceStatusCopy(invoice.amountEvidenceStatus)}
</p>
</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>
) : 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 ? (
<Button className="min-h-11" onClick={() => capabilities.onLinkInvoice?.(invoice)} size="sm" type="button" variant="secondary">选择明细</Button>
) : null}
{capabilities.onUnlinkInvoice && invoice.linkedItemId ? (
<Button className="min-h-11" onClick={() => capabilities.onUnlinkInvoice?.(invoice)} size="sm" type="button" variant="secondary">移除对应关系</Button>
) : null}
</div>
</div>
</article>
);
}
export function ReimbursementItemEvidenceList({ evidence, capabilities = {} }: 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}>
<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}
<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} />
)) : (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
这条明细尚无对应票据,请在作出审批决定前核对是否需要补充。
</p>
)}
</div>
</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">
<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} />
))}
</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>
{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 className="min-h-11" onClick={() => capabilities.onOpenAttachment?.(attachment)} size="sm" type="button" variant="secondary">
查看附件
</Button>
) : null}
</div>
)) : <p className="text-sm text-zinc-500">未上传补充附件</p>}
</section>
</div>
);
}