重构审批中心与报销详情降噪(approval-detail-simplification-refactor)
这个提交包含在:
@@ -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 })}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
StatStrip,
|
||||
WorkspaceEmptyState,
|
||||
WorkspaceHeader,
|
||||
WorkspaceNotice,
|
||||
} from "../components/patterns/ListWorkspace";
|
||||
import { MetricCard } from "../components/patterns/MetricCard";
|
||||
import { Button } from "../components/ui/button";
|
||||
@@ -137,12 +136,12 @@ function isAlreadyProcessedTaskError(error: unknown) {
|
||||
|
||||
export function WorkflowTasksPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [confirmApproveTaskId, setConfirmApproveTaskId] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const status = normalizeStatusFilter(searchParams.get("status"));
|
||||
const entityType = normalizeEntityFilter(searchParams.get("entityType"));
|
||||
const page = Number(searchParams.get("page") ?? "1") || 1;
|
||||
const [confirmApproveTaskId, setConfirmApproveTaskId] = useState<string | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(() => status !== "pending" || entityType !== "all");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
function updateSearchParams(next: {
|
||||
status?: WorkflowStatusFilter;
|
||||
@@ -203,6 +202,15 @@ export function WorkflowTasksPage() {
|
||||
const totalPages = Math.max(1, Math.ceil((query.data?.total ?? 0) / PAGE_SIZE));
|
||||
const taskItems = query.data?.items ?? [];
|
||||
const confirmCopy = confirmApproveTaskId ? getWorkflowConfirmCopy("approve") : null;
|
||||
const isDefaultTaskView = status === "pending" && entityType === "all";
|
||||
const advancedVisible = showAdvanced || !isDefaultTaskView;
|
||||
const pendingTotal = summaryQuery.data?.pendingTotal ?? "-";
|
||||
const overdueTotal = summaryQuery.data?.overdueTotal ?? "-";
|
||||
const pendingEntitySummary =
|
||||
(summaryQuery.data?.byEntityType ?? [])
|
||||
.filter((item) => item.pendingTotal > 0)
|
||||
.map((item) => `${entityLabel(item.key)} ${item.pendingTotal}`)
|
||||
.join(" / ") || "-";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -210,73 +218,107 @@ export function WorkflowTasksPage() {
|
||||
<WorkspaceHeader
|
||||
eyebrow="审批"
|
||||
title="审批中心"
|
||||
subtitle="先看待处理,再进入详情核对依据;快速通过前仍会弹出确认。"
|
||||
actions={
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
data-testid="workflow-tasks-advanced-toggle"
|
||||
onClick={() => setShowAdvanced((value) => !value)}
|
||||
>
|
||||
{advancedVisible ? "收起高级" : "高级 / 历史"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div data-testid="workflow-task-summary">
|
||||
<StatStrip columns={4} className="ops-status-strip">
|
||||
<MetricCard title="待办总数" value={summaryQuery.data?.pendingTotal ?? "-"} />
|
||||
<MetricCard title="超时待办" value={summaryQuery.data?.overdueTotal ?? "-"} tone="warning" />
|
||||
<MetricCard title="最早待处理" value={formatSummaryTime(summaryQuery.data?.earliestPendingAt)} />
|
||||
<MetricCard
|
||||
title="业务分布"
|
||||
value={
|
||||
<span className="block truncate text-base">
|
||||
{(summaryQuery.data?.byEntityType ?? [])
|
||||
.filter((item) => item.pendingTotal > 0)
|
||||
.map((item) => `${entityLabel(item.key)} ${item.pendingTotal}`)
|
||||
.join(" / ") || "-"}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</StatStrip>
|
||||
</div>
|
||||
|
||||
<FilterBar className="ops-command-bar !p-3 md:!p-3.5">
|
||||
<div className="ops-command-bar__row">
|
||||
<Tabs
|
||||
data-testid="workflow-tasks-status-tabs"
|
||||
value={status}
|
||||
onValueChange={(value) => updateSearchParams({ status: value as WorkflowStatusFilter, page: 1 })}
|
||||
>
|
||||
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
|
||||
{WORKFLOW_STATUS_OPTIONS.map((item) => (
|
||||
<TabsTrigger
|
||||
data-testid={`workflow-tasks-tab-${item.value}`}
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
>
|
||||
{item.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3" data-testid="workflow-tasks-focus">
|
||||
<span className="badge-neutral">待处理</span>
|
||||
<span className="text-2xl font-semibold text-zinc-900">{pendingTotal}</span>
|
||||
<span className="text-sm text-zinc-500">
|
||||
最早:{formatSummaryTime(summaryQuery.data?.earliestPendingAt)}
|
||||
</span>
|
||||
{summaryQuery.data?.overdueTotal ? (
|
||||
<span className="rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700">
|
||||
超时 {summaryQuery.data.overdueTotal}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="ops-command-bar__actions">
|
||||
<label className="ops-filter-label" htmlFor="workflow-entity-type-filter">
|
||||
业务类型
|
||||
</label>
|
||||
<select
|
||||
id="workflow-entity-type-filter"
|
||||
className="select-sm min-w-32"
|
||||
data-testid="workflow-tasks-entity-filter"
|
||||
value={entityType}
|
||||
onChange={(event) => updateSearchParams({ entityType: event.target.value as WorkflowEntityFilter, page: 1 })}
|
||||
>
|
||||
{WORKFLOW_ENTITY_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{!isDefaultTaskView ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowAdvanced(false);
|
||||
updateSearchParams({ status: "pending", entityType: "all", page: 1 });
|
||||
}}
|
||||
>
|
||||
回到待处理
|
||||
</Button>
|
||||
) : null}
|
||||
<span className="text-xs text-zinc-500">业务:{pendingEntitySummary}</span>
|
||||
</div>
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
{status === "pending" ? (
|
||||
<WorkspaceNotice title="处理原则" tone="neutral">
|
||||
先看业务编号、申请人、金额/天数和当前节点;需要查看附件、审批历史或驳回原因时进入详情页。
|
||||
</WorkspaceNotice>
|
||||
{advancedVisible ? (
|
||||
<div className="space-y-3" data-testid="workflow-tasks-advanced-panel">
|
||||
<div data-testid="workflow-task-summary">
|
||||
<StatStrip columns={4} className="ops-status-strip">
|
||||
<MetricCard title="待办总数" value={pendingTotal} />
|
||||
<MetricCard title="超时待办" value={overdueTotal} tone="warning" />
|
||||
<MetricCard title="最早待处理" value={formatSummaryTime(summaryQuery.data?.earliestPendingAt)} />
|
||||
<MetricCard
|
||||
title="业务分布"
|
||||
value={<span className="block truncate text-base">{pendingEntitySummary}</span>}
|
||||
/>
|
||||
</StatStrip>
|
||||
</div>
|
||||
|
||||
<FilterBar className="ops-command-bar !p-3 md:!p-3.5">
|
||||
<div className="ops-command-bar__row">
|
||||
<Tabs
|
||||
data-testid="workflow-tasks-status-tabs"
|
||||
value={status}
|
||||
onValueChange={(value) => updateSearchParams({ status: value as WorkflowStatusFilter, page: 1 })}
|
||||
>
|
||||
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
|
||||
{WORKFLOW_STATUS_OPTIONS.map((item) => (
|
||||
<TabsTrigger
|
||||
data-testid={`workflow-tasks-tab-${item.value}`}
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
>
|
||||
{item.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="ops-command-bar__actions">
|
||||
<label className="ops-filter-label" htmlFor="workflow-entity-type-filter">
|
||||
业务类型
|
||||
</label>
|
||||
<select
|
||||
id="workflow-entity-type-filter"
|
||||
className="select-sm min-w-32"
|
||||
data-testid="workflow-tasks-entity-filter"
|
||||
value={entityType}
|
||||
onChange={(event) =>
|
||||
updateSearchParams({ entityType: event.target.value as WorkflowEntityFilter, page: 1 })
|
||||
}
|
||||
>
|
||||
{WORKFLOW_ENTITY_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</FilterBar>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{query.isLoading ? <TableSkeleton columns={7} rows={5} /> : null}
|
||||
@@ -348,13 +390,15 @@ export function WorkflowTasksPage() {
|
||||
variant="secondary"
|
||||
data-testid={TEST_IDS.workflow.detailLink}
|
||||
>
|
||||
<Link to={`/workflow/tasks/${task.id}`}>详情</Link>
|
||||
<Link to={`/workflow/tasks/${task.id}`}>查看</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<WorkspaceEmptyState>当前筛选下没有审批任务。</WorkspaceEmptyState>
|
||||
<WorkspaceEmptyState>
|
||||
{isDefaultTaskView ? "当前没有待处理任务。" : "当前筛选下没有审批任务。"}
|
||||
</WorkspaceEmptyState>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -405,10 +449,10 @@ export function WorkflowTasksPage() {
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
variant={status === "pending" && (task.allowedActions ?? []).includes("approve") ? "secondary" : "default"}
|
||||
data-testid={TEST_IDS.workflow.detailLink}
|
||||
>
|
||||
<Link to={`/workflow/tasks/${task.id}`}>详情</Link>
|
||||
<Link to={`/workflow/tasks/${task.id}`}>查看</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -417,7 +461,7 @@ export function WorkflowTasksPage() {
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell className="px-6 py-8 text-center text-zinc-500" colSpan={8}>
|
||||
当前筛选下没有审批任务
|
||||
{isDefaultTaskView ? "当前没有待处理任务" : "当前筛选下没有审批任务"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
@@ -20,6 +20,9 @@ export interface ReimbursementActionsProps {
|
||||
submitDisabled?: boolean;
|
||||
submitHint?: string;
|
||||
submitNotice?: string;
|
||||
showSubmitAction?: boolean;
|
||||
showWithdrawAction?: boolean;
|
||||
showPayAction?: boolean;
|
||||
isPending: boolean;
|
||||
onSubmit: () => void;
|
||||
onWithdraw: (comment: string) => void;
|
||||
@@ -33,6 +36,9 @@ export function ReimbursementActions({
|
||||
submitDisabled = false,
|
||||
submitHint = "",
|
||||
submitNotice = "",
|
||||
showSubmitAction = true,
|
||||
showWithdrawAction = true,
|
||||
showPayAction = true,
|
||||
isPending,
|
||||
onSubmit,
|
||||
onWithdraw,
|
||||
@@ -43,9 +49,16 @@ export function ReimbursementActions({
|
||||
const [paymentDate, setPaymentDate] = useState("2026-02-08");
|
||||
const [paymentChannel, setPaymentChannel] = useState<PaymentChannel>("bank");
|
||||
const [paymentNote, setPaymentNote] = useState("");
|
||||
const canShowSubmit = showSubmitAction && ["draft", "rejected"].includes(status);
|
||||
const canShowWithdraw = showWithdrawAction && status === "reviewing";
|
||||
const canShowPay = showPayAction && canPay && status === "approved";
|
||||
|
||||
if (!canShowSubmit && !canShowWithdraw && !canShowPay && !actionError && !submitHint && !submitNotice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<div id="reimbursement-action-panel" className="card-std space-y-4 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">可执行动作</h3>
|
||||
|
||||
{actionError ? (
|
||||
@@ -57,16 +70,18 @@ export function ReimbursementActions({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
data-testid="reimbursement-submit"
|
||||
disabled={isPending || !["draft", "rejected"].includes(status) || submitDisabled}
|
||||
onClick={onSubmit}
|
||||
type="button"
|
||||
>
|
||||
提交报销
|
||||
</Button>
|
||||
</div>
|
||||
{canShowSubmit ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
data-testid="reimbursement-submit"
|
||||
disabled={isPending || submitDisabled}
|
||||
onClick={onSubmit}
|
||||
type="button"
|
||||
>
|
||||
提交报销
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{submitHint ? <p className="text-sm text-zinc-500">{submitHint}</p> : null}
|
||||
{submitNotice ? (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
|
||||
@@ -74,7 +89,7 @@ export function ReimbursementActions({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{status === "reviewing" ? (
|
||||
{canShowWithdraw ? (
|
||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
||||
<p className="text-sm font-medium text-zinc-800">撤回报销</p>
|
||||
<Textarea
|
||||
@@ -95,7 +110,7 @@ export function ReimbursementActions({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canPay ? (
|
||||
{canShowPay ? (
|
||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
||||
<p className="text-sm font-medium text-zinc-800">财务支付</p>
|
||||
<Input
|
||||
@@ -134,7 +149,7 @@ export function ReimbursementActions({
|
||||
<Button
|
||||
data-testid="reimbursement-pay"
|
||||
variant="secondary"
|
||||
disabled={isPending || !["approved", "paid"].includes(status)}
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
onPay({
|
||||
paymentVoucherNo: paymentVoucherNo.trim(),
|
||||
|
||||
@@ -328,5 +328,11 @@ test("PC 核心工作流 Wave2 视觉截图", async ({ page }) => {
|
||||
await page.goto("/workflow/tasks");
|
||||
await expect(page.getByTestId("workflow-tasks-table")).toBeVisible();
|
||||
await expect(page.locator(".ops-command-bar").first()).toBeVisible();
|
||||
await expect(page.getByTestId("workflow-tasks-focus")).toBeVisible();
|
||||
await expect(page.getByTestId("workflow-task-summary")).toHaveCount(0);
|
||||
await expect(page.getByTestId("workflow-tasks-status-tabs")).toHaveCount(0);
|
||||
await page.getByTestId("workflow-tasks-advanced-toggle").click();
|
||||
await expect(page.getByTestId("workflow-tasks-advanced-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("workflow-task-summary")).toBeVisible();
|
||||
await capture(page, "04-workflow-tasks");
|
||||
});
|
||||
|
||||
@@ -1059,8 +1059,13 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
|
||||
|
||||
await page.goto("/reimbursements/reim-wave3");
|
||||
await expect(page.getByRole("heading", { name: /报销详情/ })).toBeVisible();
|
||||
await expect(page.getByTestId("reimbursement-overview")).toBeVisible();
|
||||
await expect(page.getByTestId("reimbursement-summary")).toBeVisible();
|
||||
await expect(page.getByTestId("reimbursement-payment-status")).toBeVisible();
|
||||
await expect(page.getByTestId("reimbursement-first-screen-actions").locator("a,button")).toHaveCount(3);
|
||||
await expect(page.getByTestId("reimbursement-first-screen-actions")).toContainText("看审批进度");
|
||||
await expect(page.getByTestId("reimbursement-payment-status")).toHaveCount(0);
|
||||
await page.getByTestId("reimbursement-more-toggle").click();
|
||||
await expect(page.getByTestId("reimbursement-secondary-detail")).toBeVisible();
|
||||
await page.screenshot({ path: screenshotPath("01-pc-reimbursement-detail.png"), fullPage: true });
|
||||
|
||||
await page.goto("/workflow/tasks/task-wave3");
|
||||
|
||||
@@ -337,6 +337,8 @@ test.describe("R102 reimbursement main flow", () => {
|
||||
|
||||
await expect(page.getByRole("heading", { name: `报销详情 ${formNumber}` })).toBeVisible();
|
||||
await expect(page.getByText("报销ID")).toHaveCount(0);
|
||||
await expect(page.getByTestId("reimbursement-delete-draft")).toHaveCount(0);
|
||||
await page.getByTestId("reimbursement-more-toggle").click();
|
||||
await expect(page.getByTestId("reimbursement-delete-draft")).toBeVisible();
|
||||
|
||||
await page.getByTestId("reimbursement-invoice-add").click();
|
||||
|
||||
在新工单中引用
屏蔽一个用户