重构审批中心与报销详情降噪(approval-detail-simplification-refactor)

这个提交包含在:
2026-05-29 13:10:06 +08:00
父节点 29cfea2bbb
当前提交 5f10b83706
共修改 11 个文件,包含 739 行新增和 177 行删除
+361 -95
查看文件
@@ -22,6 +22,7 @@ import {
withdrawReimbursement,
linkReimbursementProject,
type ReimbursementItem,
type ReimbursementStatus,
} from "../api/reimbursements";
import { remindWorkflowTask } from "../api/workflow";
import {
@@ -145,6 +146,22 @@ function budgetControlModeLabel(controlMode: string | null | undefined) {
return controlMode ? labels[controlMode] ?? "按制度处理" : "按制度处理";
}
function reimbursementStatusLabel(status: ReimbursementStatus) {
const labels: Record<ReimbursementStatus, string> = {
draft: "草稿",
reviewing: "审批中",
approved: "已批准",
paid: "已支付",
rejected: "已驳回",
cancelled: "已取消",
};
return labels[status] ?? status;
}
function materialCompletenessLabel(count: number, noun: string) {
return count > 0 ? `${count} ${noun}` : `缺${noun}`;
}
export function ReimbursementDetailPage() {
const { reimbursementId = "" } = useParams();
const navigate = useNavigate();
@@ -158,6 +175,7 @@ export function ReimbursementDetailPage() {
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false);
const [isErpPreviewOpen, setIsErpPreviewOpen] = useState(false);
const [showSecondaryDetail, setShowSecondaryDetail] = useState(false);
const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState<PrecheckResponse | null>(null);
const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false);
const [invoiceSearchInput, setInvoiceSearchInput] = useState("");
@@ -971,12 +989,159 @@ export function ReimbursementDetailPage() {
return `${linkedItem.expenseType?.name ?? "未识别费用类型"} · ${linkedItem.description ?? "未填写说明"}`;
};
const itemCount = detail.items.length;
const invoiceCount = detail.invoices.length;
const attachmentCount = (detail.attachments ?? []).length;
const hasBlockingPrecheck = blockStandardChecks.length > 0;
const currentHandlerText = (() => {
if (detail.status === "reviewing") {
return detail.pendingWorkflow?.task?.step_name ?? "当前审批节点";
}
if (detail.status === "approved") {
return canPay ? "财务处理" : "等待财务";
}
if (detail.status === "paid") {
return payment.paidBy?.name ?? "财务已处理";
}
if (["draft", "rejected"].includes(detail.status)) {
return detail.owner?.name ?? "报销人";
}
return "-";
})();
const nextStepText = (() => {
if (["draft", "rejected"].includes(detail.status)) {
if (!itemCount) {
return "补报销明细";
}
if (!invoiceCount) {
return "添加发票";
}
if (hasBlockingPrecheck) {
return "修改超标明细";
}
return "提交审批";
}
if (detail.status === "reviewing") {
return detail.workflowProgress
? `查看第 ${detail.workflowProgress.currentStepIndex + 1} / ${detail.workflowProgress.totalSteps} 步`
: "查看审批进度";
}
if (detail.status === "approved") {
return canPay ? "执行付款" : "等待财务付款";
}
if (detail.status === "paid") {
return "查看付款结果";
}
return "查看记录";
})();
const primaryAction = (() => {
if (["draft", "rejected"].includes(detail.status) && canEditItems) {
if (!itemCount) {
return (
<Button
data-testid="reimbursement-primary-action"
disabled={itemSaveMutation.isPending || itemDeleteMutation.isPending}
onClick={startCreateItem}
type="button"
>
新增明细
</Button>
);
}
if (!invoiceCount) {
return (
<Button
data-testid="reimbursement-primary-action"
disabled={!canOpenInvoiceBindDialog}
onClick={() => handleInvoiceDialogOpenChange(true)}
type="button"
>
添加发票
</Button>
);
}
return (
<Button
data-testid={TEST_IDS.reimbursement.submit}
disabled={mutation.isPending || hasBlockingPrecheck}
onClick={() => void handleSubmitAction()}
type="button"
>
{mutation.isPending ? "提交中..." : "提交报销"}
</Button>
);
}
if (detail.status === "reviewing") {
return (
<Button asChild data-testid="reimbursement-primary-action">
<a href="#reimbursement-workflow-progress-panel">看审批进度</a>
</Button>
);
}
if (detail.status === "approved") {
return (
<Button asChild data-testid="reimbursement-primary-action">
<a href={canPay ? "#reimbursement-action-panel" : "#reimbursement-payment-panel"}>
{canPay ? "执行支付" : "看付款状态"}
</a>
</Button>
);
}
if (detail.status === "paid") {
return (
<Button asChild data-testid="reimbursement-primary-action">
<a href="#reimbursement-payment-panel">查看付款结果</a>
</Button>
);
}
return (
<Button asChild data-testid="reimbursement-primary-action">
<Link to="/reimbursements">返回列表</Link>
</Button>
);
})();
const secondaryActions = [
detail.status === "reviewing" && canRemindPendingWorkflow && pendingTaskId ? (
<Button
key="remind"
data-testid="reimbursement-remind-workflow"
disabled={remindMutation.isPending}
onClick={() => remindMutation.mutate(pendingTaskId)}
type="button"
variant="secondary"
>
{remindMutation.isPending ? "催办中..." : "催办审批"}
</Button>
) : detail.status === "paid" ? (
<Button key="print" asChild variant="secondary">
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
</Button>
) : (
<Button key="back" asChild variant="secondary" data-testid="reimbursement-back">
<Link to="/reimbursements">报销列表</Link>
</Button>
),
<Button
key="more"
type="button"
variant="secondary"
data-testid="reimbursement-more-toggle"
onClick={() => setShowSecondaryDetail((value) => !value)}
>
{showSecondaryDetail ? "收起更多" : "更多"}
</Button>,
];
return (
<PageScaffold>
{downloadReasonDialog}
<EntityOverviewHero
title={`报销详情 ${displayCode}`}
subtitle="先确认当前状态和下一步,再处理明细、发票、附件和审批记录。"
meta={(
<>
<StatusBadge status={detail.status} />
@@ -987,43 +1152,134 @@ export function ReimbursementDetailPage() {
</>
)}
actions={(
<div className="flex flex-wrap gap-2">
<Button asChild variant="secondary">
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
</Button>
<Button asChild variant="secondary" data-testid="reimbursement-back">
<Link to="/reimbursements">报销列表</Link>
</Button>
{canRemindPendingWorkflow && pendingTaskId ? (
<Button
data-testid="reimbursement-remind-workflow"
disabled={remindMutation.isPending}
onClick={() => remindMutation.mutate(pendingTaskId)}
type="button"
variant="secondary"
>
{remindMutation.isPending ? "催办中..." : "催办审批"}
</Button>
) : null}
{canDeleteReimbursement ? (
<Button
data-testid="reimbursement-delete-draft"
disabled={deleteReimbursementMutation.isPending}
onClick={() => setConfirmDeleteReimbursement(true)}
type="button"
variant="destructive"
>
删除草稿
</Button>
) : null}
<div className="flex flex-wrap gap-2" data-testid="reimbursement-first-screen-actions">
{primaryAction}
{secondaryActions}
</div>
)}
/>
<div className="detail-workspace-grid">
<div className="space-y-5">
<SectionPanel title="当前状态与下一步" description="提交、付款、附件和审批依据集中在本页处理。">
<div className="space-y-4" data-testid="reimbursement-overview">
<SectionPanel title="当前状态与下一步">
<div className="detail-command-panel space-y-4" data-testid="reimbursement-overview">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-xs font-semibold text-zinc-500">下一步</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{nextStepText}</div>
</div>
<div className="shrink-0">
<StatusBadge status={detail.status} />
</div>
</div>
<div className="detail-fact-grid xl:grid-cols-4" data-testid="reimbursement-summary">
<div className="detail-fact-card">
<div className="detail-fact-card__label">状态</div>
<div className="detail-fact-card__value">状态:{reimbursementStatusLabel(detail.status)}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">处理人</div>
<div className="detail-fact-card__value">{currentHandlerText}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">金额</div>
<div className="detail-fact-card__value">¥{detail.totalAmount ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">材料完整性</div>
<div className="detail-fact-card__value">
{materialCompletenessLabel(itemCount, "条明细")} / {materialCompletenessLabel(invoiceCount, "张发票")}
</div>
</div>
</div>
{actionError ? (
<p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
data-testid="reimbursement-action-error"
>
{actionError}
</p>
) : null}
{submitInvoiceNotice ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
{submitInvoiceNotice}
</p>
) : null}
{hasBlockingPrecheck ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
请修改超标明细后提交。
</p>
) : null}
</div>
{["approved", "paid"].includes(detail.status) ? (
<div id="reimbursement-payment-panel" className="detail-command-panel mt-4" data-testid="reimbursement-payment-status">
<div className="flex items-center justify-between gap-3">
<h4 className="text-base font-semibold text-zinc-900">付款状态</h4>
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
detail.status === "paid"
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-amber-200 bg-amber-50 text-amber-700"
}`}
>
{detail.status === "paid" ? "已付款" : "未付款"}
</span>
</div>
<div className="detail-fact-grid mt-3 xl:grid-cols-4">
<div className="detail-fact-card">
<div className="detail-fact-card__label">付款日期</div>
<div className="detail-fact-card__value">{payment.paymentDate ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">付款凭证号</div>
<div className="detail-fact-card__value">{payment.paymentVoucherNo ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">付款渠道</div>
<div className="detail-fact-card__value">{payment.paymentChannel ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">操作人 / 时间</div>
<div className="detail-fact-card__value">
{payment.paidBy?.name ?? "-"} · {formatDateTime(payment.paidAt)}
</div>
</div>
</div>
</div>
) : null}
{showSecondaryDetail ? (
<div className="space-y-4 pt-4" data-testid="reimbursement-secondary-detail">
<div className="detail-command-panel">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h4 className="text-base font-semibold text-zinc-900">更多入口</h4>
<div className="flex flex-wrap gap-2">
{detail.status !== "paid" ? (
<Button asChild variant="secondary">
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
</Button>
) : null}
<Button asChild variant="secondary" data-testid="reimbursement-secondary-back">
<Link to="/reimbursements">报销列表</Link>
</Button>
{canDeleteReimbursement ? (
<Button
data-testid="reimbursement-delete-draft"
disabled={deleteReimbursementMutation.isPending}
onClick={() => setConfirmDeleteReimbursement(true)}
type="button"
variant="destructive"
>
删除草稿
</Button>
) : null}
</div>
</div>
</div>
<StatStrip columns={4} className="ops-status-strip">
<MetricCard
title="关联项目"
@@ -1041,7 +1297,7 @@ export function ReimbursementDetailPage() {
/>
</StatStrip>
<div className="detail-command-panel" data-testid="reimbursement-summary">
<div className="detail-command-panel" data-testid="reimbursement-secondary-summary">
<div className="detail-fact-grid">
<div className="detail-fact-card">
<div className="detail-fact-card__label">报销编号</div>
@@ -1062,7 +1318,7 @@ export function ReimbursementDetailPage() {
</div>
</div>
<div className="detail-command-panel" data-testid="reimbursement-payment-status">
<div className="detail-command-panel" data-testid="reimbursement-secondary-payment-status">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-base font-semibold text-zinc-900">付款状态</h4>
@@ -1163,7 +1419,6 @@ export function ReimbursementDetailPage() {
))}
</select>
</div>
</div>
<div className="space-y-4 text-sm">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
@@ -1242,7 +1497,9 @@ export function ReimbursementDetailPage() {
) : (
<p className="text-sm text-zinc-500">暂无附件材料</p>
)}
</div>
</div>
</div>
) : null}
</SectionPanel>
<SectionPanel title="明细与发票" description="明细决定金额,发票决定提交完整性。">
@@ -1579,75 +1836,84 @@ export function ReimbursementDetailPage() {
</div>
<div className="space-y-5">
<DetailSideMeta title="报销摘要">
<div>报销编号:{displayCode}</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>
{detail.status === "reviewing" && detail.workflowProgress ? (
<DetailSideMeta
title="审批进度"
description={`当前位于第 ${detail.workflowProgress.currentStepIndex + 1} / ${detail.workflowProgress.totalSteps} 步。`}
>
<div className="detail-timeline" data-testid={TEST_IDS.reimbursement.workflowProgress}>
{detail.workflowProgress.steps.map((step, index) => (
<div
key={step.id}
className={
step.status === "current"
? "detail-timeline-item detail-timeline-item--current"
: step.status === "completed"
? "detail-timeline-item detail-timeline-item--done"
: "detail-timeline-item"
}
>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium text-zinc-900">
第 {index + 1} 步 · {step.name}
</span>
<span className="text-xs text-zinc-500">
{step.status === "completed" ? "已完成" : step.status === "current" ? "当前节点" : "待处理"}
</span>
</div>
</div>
))}
</div>
{showSecondaryDetail ? (
<DetailSideMeta title="报销摘要">
<div>报销编号:{displayCode}</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>附件数:{attachmentCount}</div>
<div>关联项目:{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}</div>
</DetailSideMeta>
) : null}
<DetailSideMeta title="审批记录">
{detail.approvalHistory.length ? (
<div className="detail-timeline" data-testid="reimbursement-approval-history">
{detail.approvalHistory.map((item) => (
<div key={item.id} className="detail-timeline-item">
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-zinc-900">{workflowActionLabel(item.action)}</span>
<span className="text-xs text-zinc-500">{formatDateTime(item.createdAt)}</span>
{detail.status === "reviewing" && detail.workflowProgress ? (
<div id="reimbursement-workflow-progress-panel">
<DetailSideMeta
title="审批进度"
description={`当前位于第 ${detail.workflowProgress.currentStepIndex + 1} / ${detail.workflowProgress.totalSteps} 步。`}
>
<div className="detail-timeline" data-testid={TEST_IDS.reimbursement.workflowProgress}>
{detail.workflowProgress.steps.map((step, index) => (
<div
key={step.id}
className={
step.status === "current"
? "detail-timeline-item detail-timeline-item--current"
: step.status === "completed"
? "detail-timeline-item detail-timeline-item--done"
: "detail-timeline-item"
}
>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium text-zinc-900">
第 {index + 1} 步 · {step.name}
</span>
<span className="text-xs text-zinc-500">
{step.status === "completed" ? "已完成" : step.status === "current" ? "当前节点" : "待处理"}
</span>
</div>
</div>
<div className="mt-1 text-xs text-zinc-500">
{item.stepName ?? "-"} · {item.actorName || "系统"}
))}
</div>
</DetailSideMeta>
</div>
) : null}
{showSecondaryDetail ? (
<DetailSideMeta title="审批记录">
{detail.approvalHistory.length ? (
<div className="detail-timeline" data-testid="reimbursement-approval-history">
{detail.approvalHistory.map((item) => (
<div key={item.id} className="detail-timeline-item">
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-zinc-900">{workflowActionLabel(item.action)}</span>
<span className="text-xs text-zinc-500">{formatDateTime(item.createdAt)}</span>
</div>
<div className="mt-1 text-xs text-zinc-500">
{item.stepName ?? "-"} · {item.actorName || "系统"}
</div>
{item.comment ? <div className="mt-2 text-zinc-600">{item.comment}</div> : null}
</div>
{item.comment ? <div className="mt-2 text-zinc-600">{item.comment}</div> : null}
</div>
))}
</div>
) : (
<div className="text-sm text-zinc-500">暂无审批记录。</div>
)}
</DetailSideMeta>
))}
</div>
) : (
<div className="text-sm text-zinc-500">暂无审批记录。</div>
)}
</DetailSideMeta>
) : null}
<ReimbursementActions
status={detail.status}
canPay={canPay}
actionError={actionError}
showSubmitAction={false}
showPayAction={detail.status === "approved"}
actionError={["reviewing", "approved"].includes(detail.status) ? actionError : ""}
submitDisabled={blockStandardChecks.length > 0}
submitHint={blockStandardChecks.length > 0 ? "请修改超标明细后提交" : ""}
submitNotice={submitInvoiceNotice}
submitHint=""
submitNotice=""
isPending={mutation.isPending}
onSubmit={() => void handleSubmitAction()}
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}