R31-1 R31-4: 收口详情页工作台化与 E2E 契约

这个提交包含在:
2026-03-24 08:58:36 +08:00
父节点 994d98e74c
当前提交 279be83931
共修改 17 个文件,包含 906 行新增和 455 行删除
+206 -143
查看文件
@@ -23,11 +23,15 @@ import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { CardSkeleton } from "../components/common/CardSkeleton";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { PageHeader } from "../components/common/PageHeader";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { StatusBadge } from "../components/common/StatusBadge";
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
import { Button } from "../components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
import {
ReimbursementItemEditor,
@@ -80,7 +84,7 @@ export function ReimbursementDetailPage() {
const erpPreviewQuery = useQuery({
enabled: Boolean(reimbursementId) && canManageIntegration && detailQuery.data?.status === "paid",
queryKey: ["reimbursements", reimbursementId, "erp-journal-preview"],
queryKey: ["reimbursements", reimbursementId, "integration-journal-preview"],
queryFn: () => previewReimbursementJournal(reimbursementId),
});
@@ -247,7 +251,7 @@ export function ReimbursementDetailPage() {
},
onSuccess: async (result) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "erp-journal-preview"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "integration-journal-preview"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
]);
setActionError("");
@@ -390,128 +394,91 @@ export function ReimbursementDetailPage() {
};
return (
<div className="space-y-6">
<PageHeader
<PageScaffold>
<EntityOverviewHero
title={`报销详情 ${detail.formNumber ?? detail.id}`}
subtitle={`状态:${detail.status}`}
actions={
subtitle="在同一工作台内查看概览、处理明细、跟进风险并进入财务桥接。"
meta={(
<>
<StatusBadge status={detail.status} />
<span className="info-chip">报销日期:{detail.reimbursementDate ?? "-"}</span>
<span className="info-chip">报销人:{detail.owner?.name ?? "-"}</span>
<span className="info-chip">提交人:{detail.submitter?.name ?? "-"}</span>
<span className="info-chip">金额:{detail.totalAmount ?? "-"}</span>
</>
)}
actions={(
<Button asChild variant="secondary" data-testid="reimbursement-back">
<Link to="/reimbursements">返回列表</Link>
</Button>
}
)}
/>
<div className="card-std space-y-2 p-6 text-sm" data-testid="reimbursement-summary">
<div>报销ID:{detail.id}</div>
<div>报销日期:{detail.reimbursementDate ?? "-"}</div>
<div>金额:{detail.totalAmount ?? "-"}</div>
<div>提交人:{detail.submitter?.name ?? "-"}</div>
<div>报销人:{detail.owner?.name ?? "-"}</div>
<div>说明:{detail.description || "-"}</div>
</div>
<div className="card-std space-y-3 p-6 text-sm">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">关联项目</h3>
<p className="text-xs text-zinc-500">报销可选挂到项目,用于项目执行面板汇总。</p>
</div>
{detail.project ? <Link to={`/projects/${detail.project.id}`} className="text-sm text-primary hover:underline">查看项目</Link> : null}
</div>
<select
className="select-std max-w-xl"
value={detail.projectId ?? ""}
onChange={(event) => projectLinkMutation.mutate(event.target.value || null)}
disabled={projectLinkMutation.isPending}
>
<option value="">不关联项目</option>
{(projectsQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.projectNo} · {item.name}
</option>
))}
</select>
</div>
{budgetHint ? (
<div className="card-std space-y-2 p-6 text-sm" data-testid="reimbursement-budget-hint">
<h3 className="text-base font-semibold text-zinc-900">预算提示</h3>
<div>预算池:{budgetHint.budgetCode} / {budgetHint.budgetName}</div>
<div>
本次提交后预计占用:{budgetHint.nextUsedAmount} / {budgetHint.allocatedAmount}
({budgetHint.nextUsagePercent.toFixed(2)}%)
</div>
<div>控制模式:{budgetHint.controlMode}</div>
{budgetHint.exceedsBudget && budgetHint.controlMode === "block" ? (
<p className="rounded-md border border-red-200 bg-red-50 px-2 py-1 text-red-600">
当前金额将超出预算上限,提交时会被阻断(block 模式)。
</p>
) : budgetHint.exceedsThreshold ? (
<p className="rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-amber-700">
当前金额已达到预算预警阈值,提交后会记录预算预警审计。
</p>
) : (
<p className="text-zinc-500">当前预算充足,可继续提交。</p>
)}
</div>
) : null}
{canManageIntegration && detail.status === "paid" ? (
<div className="card-std space-y-4 p-6" data-testid="reimbursement-erp-export">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<h3 className="text-base font-semibold text-zinc-900">财务桥接</h3>
<p className="text-sm text-zinc-500">已付款报销单会按标准凭证切片通过 Outbox 异步发送到财务桥接出口。</p>
</div>
<Button onClick={() => erpExportMutation.mutate()} disabled={erpExportMutation.isPending}>
{erpExportMutation.isPending ? "出队中..." : "发送到财务桥接"}
</Button>
</div>
{erpPreviewQuery.isLoading ? <p className="text-sm text-zinc-500">正在加载凭证切片预览...</p> : null}
{erpPreviewQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(erpPreviewQuery.error)
? `${erpPreviewQuery.error.code}: ${mapApiErrorMessage(
erpPreviewQuery.error.code,
erpPreviewQuery.error.details,
erpPreviewQuery.error.message,
)}`
: "加载财务桥接预览失败"}
</p>
) : null}
{erpPreviewQuery.data ? (
<div className="grid gap-4 lg:grid-cols-2 text-sm">
<div className="rounded-lg border border-zinc-200 p-4 space-y-2">
<h4 className="font-medium text-zinc-900">凭证切片</h4>
<div>类型:{erpPreviewQuery.data.journal.journalType}</div>
<div>单号:{erpPreviewQuery.data.journal.documentNo ?? "-"}</div>
<div>金额:{erpPreviewQuery.data.journal.totalAmount ?? "-"}</div>
<div>法体:{erpPreviewQuery.data.journal.dimensions.legalEntityCode ?? "-"}</div>
<div>成本中心:{erpPreviewQuery.data.journal.dimensions.costCenterCode ?? "-"}</div>
<div>付款凭证号:{erpPreviewQuery.data.journal.paymentVoucherNo ?? "-"}</div>
<div>付款日期:{erpPreviewQuery.data.journal.paymentDate ?? "-"}</div>
<div className="detail-shell-grid">
<div className="space-y-6">
<SectionPanel title="概览与关联" description="先看主档和关联项目,再决定是否继续补明细或提交流程。">
<div className="space-y-6" data-testid="reimbursement-overview">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
<div className="text-zinc-500">当前状态</div>
<div className="mt-2"><StatusBadge status={detail.status} /></div>
</div>
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
<div className="text-zinc-500">关联项目</div>
<div className="mt-2 font-medium text-zinc-900">{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}</div>
</div>
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
<div className="text-zinc-500">明细数</div>
<div className="mt-2 font-medium text-zinc-900">{detail.items.length}</div>
</div>
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
<div className="text-zinc-500">关联票据</div>
<div className="mt-2 font-medium text-zinc-900">{detail.invoices.length}</div>
</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4 space-y-2">
<h4 className="font-medium text-zinc-900">导出状态</h4>
<div>外部ID:{erpPreviewQuery.data.exportState.externalId ?? "-"}</div>
<div>Outbox:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}</div>
<div>队列状态:{erpPreviewQuery.data.exportState.lastOutboxStatus ?? "-"}</div>
<div>最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}</div>
<div className="break-all">最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}</div>
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="reimbursement-summary">
<div>状态:{detail.status}</div>
<div>报销ID:{detail.id}</div>
<div>报销日期:{detail.reimbursementDate ?? "-"}</div>
<div>金额:{detail.totalAmount ?? "-"}</div>
<div>提交人:{detail.submitter?.name ?? "-"}</div>
<div>报销人:{detail.owner?.name ?? "-"}</div>
<div>说明:{detail.description || "-"}</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4 text-sm">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-base font-semibold text-zinc-900">关联项目</h4>
<p className="text-xs text-zinc-500">报销可选挂到项目,用于项目执行面板汇总。</p>
</div>
{detail.project ? (
<Link to={`/projects/${detail.project.id}`} className="text-sm text-primary hover:underline">
查看项目
</Link>
) : null}
</div>
<select
className="select-std mt-4 max-w-xl"
value={detail.projectId ?? ""}
onChange={(event) => projectLinkMutation.mutate(event.target.value || null)}
disabled={projectLinkMutation.isPending}
>
<option value="">不关联项目</option>
{(projectsQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.projectNo} · {item.name}
</option>
))}
</select>
</div>
</div>
) : null}
</div>
) : null}
</SectionPanel>
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
<AiSuggestionCard targetType="reimbursement" targetId={detail.id} />
<div className="card-std space-y-4 p-6" data-testid="reimbursement-items">
<SectionPanel title="执行 · 明细与票据" description="明细、票据和编辑器在同一层处理,避免长页面往返。">
<div className="space-y-6">
<div className="space-y-4" data-testid="reimbursement-items">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h3 className="text-base font-semibold text-zinc-900">报销明细</h3>
@@ -616,34 +583,130 @@ export function ReimbursementDetailPage() {
isPending={itemSaveMutation.isPending}
/>
) : null}
</div>
<ReimbursementActions
status={detail.status}
canPay={canPay}
actionError={actionError}
isPending={mutation.isPending}
onSubmit={() => void handleSubmitAction()}
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
onPay={(payload) => mutation.mutate({ action: "pay", payload })}
/>
<div className="card-std space-y-2 p-6" data-testid="reimbursement-invoices">
<h3 className="text-base font-semibold text-zinc-900">关联票据</h3>
{(detail.invoices ?? []).length ? (
<div className="space-y-2 text-sm">
{detail.invoices.map((item) => (
<div className="rounded-lg border border-zinc-200 px-3 py-2" key={item.id}>
<div className="font-medium text-zinc-900">{item.id}</div>
<div className="text-zinc-600">
状态 {item.status} / 发票 {item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-zinc-500">暂无关联票据</p>
)}
<div className="space-y-2 text-sm" data-testid="reimbursement-invoices">
<h3 className="text-base font-semibold text-zinc-900">关联票据</h3>
{(detail.invoices ?? []).length ? (
<div className="space-y-2">
{detail.invoices.map((item) => (
<div className="rounded-lg border border-zinc-200 px-3 py-2" key={item.id}>
<div className="font-medium text-zinc-900">{item.id}</div>
<div className="text-zinc-600">
状态 {item.status} / 发票 {item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-zinc-500">暂无关联票据</p>
)}
</div>
</div>
</SectionPanel>
<SectionPanel title="风险与桥接" description="预算提示、风险信号、AI 建议与财务桥接统一查看。">
<div className="space-y-6">
{budgetHint ? (
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="reimbursement-budget-hint">
<h3 className="text-base font-semibold text-zinc-900">预算提示</h3>
<div className="mt-3">预算池:{budgetHint.budgetCode} / {budgetHint.budgetName}</div>
<div>
本次提交后预计占用:{budgetHint.nextUsedAmount} / {budgetHint.allocatedAmount}
({budgetHint.nextUsagePercent.toFixed(2)}%)
</div>
<div>控制模式:{budgetHint.controlMode}</div>
{budgetHint.exceedsBudget && budgetHint.controlMode === "block" ? (
<p className="mt-3 rounded-md border border-red-200 bg-red-50 px-2 py-1 text-red-600">
当前金额将超出预算上限,提交时会被阻断(block 模式)。
</p>
) : budgetHint.exceedsThreshold ? (
<p className="mt-3 rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-amber-700">
当前金额已达到预算预警阈值,提交后会记录预算预警审计。
</p>
) : (
<p className="mt-3 text-zinc-500">当前预算充足,可继续提交。</p>
)}
</div>
) : null}
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
<AiSuggestionCard targetType="reimbursement" targetId={detail.id} />
{canManageIntegration && detail.status === "paid" ? (
<div className="space-y-4 rounded-lg border border-zinc-200 p-4" data-testid="reimbursement-erp-export">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<h3 className="text-base font-semibold text-zinc-900">财务桥接</h3>
<p className="text-sm text-zinc-500">已付款报销单会按标准凭证切片通过 Outbox 异步发送到财务桥接出口。</p>
</div>
<Button onClick={() => erpExportMutation.mutate()} disabled={erpExportMutation.isPending}>
{erpExportMutation.isPending ? "出队中..." : "发送到财务桥接"}
</Button>
</div>
{erpPreviewQuery.isLoading ? <p className="text-sm text-zinc-500">正在加载凭证切片预览...</p> : null}
{erpPreviewQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(erpPreviewQuery.error)
? `${erpPreviewQuery.error.code}: ${mapApiErrorMessage(
erpPreviewQuery.error.code,
erpPreviewQuery.error.details,
erpPreviewQuery.error.message,
)}`
: "加载财务桥接预览失败"}
</p>
) : null}
{erpPreviewQuery.data ? (
<div className="grid gap-4 text-sm lg:grid-cols-2">
<div className="rounded-lg border border-zinc-200 p-4 space-y-2">
<h4 className="font-medium text-zinc-900">凭证切片</h4>
<div>类型:{erpPreviewQuery.data.journal.journalType}</div>
<div>单号:{erpPreviewQuery.data.journal.documentNo ?? "-"}</div>
<div>金额:{erpPreviewQuery.data.journal.totalAmount ?? "-"}</div>
<div>法体:{erpPreviewQuery.data.journal.dimensions.legalEntityCode ?? "-"}</div>
<div>成本中心:{erpPreviewQuery.data.journal.dimensions.costCenterCode ?? "-"}</div>
<div>付款凭证号:{erpPreviewQuery.data.journal.paymentVoucherNo ?? "-"}</div>
<div>付款日期:{erpPreviewQuery.data.journal.paymentDate ?? "-"}</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4 space-y-2">
<h4 className="font-medium text-zinc-900">导出状态</h4>
<div>外部ID:{erpPreviewQuery.data.exportState.externalId ?? "-"}</div>
<div>Outbox:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}</div>
<div>队列状态:{erpPreviewQuery.data.exportState.lastOutboxStatus ?? "-"}</div>
<div>最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}</div>
<div className="break-all">最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}</div>
</div>
</div>
) : null}
</div>
) : null}
</div>
</SectionPanel>
</div>
<div className="space-y-6">
<DetailSideMeta title="报销摘要" description="帮助处理人先判断单据状态、关联量和当前审批位置。">
<div>报销编号:{detail.formNumber ?? detail.id}</div>
<div>报销人:{detail.owner?.name ?? "-"}</div>
<div>提交人:{detail.submitter?.name ?? "-"}</div>
<div>当前审批:{detail.pendingWorkflow?.task?.step_name ?? "-"}</div>
<div>明细数:{detail.items.length}</div>
<div>票据数:{detail.invoices.length}</div>
<div>关联项目:{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}</div>
</DetailSideMeta>
<ReimbursementActions
status={detail.status}
canPay={canPay}
actionError={actionError}
isPending={mutation.isPending}
onSubmit={() => void handleSubmitAction()}
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
onPay={(payload) => mutation.mutate({ action: "pay", payload })}
/>
</div>
</div>
<ConfirmDialog
@@ -654,6 +717,6 @@ export function ReimbursementDetailPage() {
confirmLabel="删除"
onConfirm={() => { itemDeleteMutation.mutate(confirmDeleteItemId); }}
/>
</div>
</PageScaffold>
);
}