重构审批中心与报销详情降噪(approval-detail-simplification-refactor)
这个提交包含在:
@@ -0,0 +1,4 @@
|
||||
{"file": ".trellis/spec/frontend/index.md", "reason": "前端规约入口,用于核对 React/Vite 页面实现是否走正确规则来源。"}
|
||||
{"file": ".trellis/spec/hyb-oa/index.md", "reason": "hyb-oa 规约入口,用于核对项目级检查范围。"}
|
||||
{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "核对 Trellis 任务合同、验证和提交治理要求。"}
|
||||
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "核对 A 类 PC 页面任务优先、详情页命令面和前端验证规则。"}
|
||||
@@ -0,0 +1,71 @@
|
||||
# 审批中心与报销详情降噪实现方案
|
||||
|
||||
## Design Direction
|
||||
|
||||
本轮沿用第一轮已确认的“安静、任务优先、工作台式极简”方向。审批中心和报销详情都是 A 类 PC 页面,不能做营销式 hero,也不引入新的视觉体系;重点是压缩首屏动作、重排信息层级、把低频能力后置。
|
||||
|
||||
硬性规则:
|
||||
|
||||
- 每个页面首屏只允许 1 个主按钮。
|
||||
- 每个页面首屏最多 2 个次级入口。
|
||||
- 说明文案只服务风险、异常、空态或确认动作。
|
||||
- 低频能力后置,不删除。
|
||||
|
||||
## WorkflowTasksPage
|
||||
|
||||
目标:从“审批台账 + 统计筛选”调整为“待我处理优先”。
|
||||
|
||||
实现要点:
|
||||
|
||||
- `WorkspaceHeader` 去掉解释性 subtitle,首屏标题保持“审批中心”。
|
||||
- 默认保留待处理任务作为第一层;历史状态和业务类型筛选收纳到“高级 / 历史”折叠区。
|
||||
- 将统计卡从首屏常驻移入高级区,或压缩为待处理焦点旁的轻量摘要。
|
||||
- 删除默认 `WorkspaceNotice` 处理原则卡;错误、空态和二次确认继续保留说明。
|
||||
- 桌面表格行内主动作改为:
|
||||
- pending 且允许 approve:主按钮“处理”或“通过”保留二次确认;“详情”作为次级或更多入口。
|
||||
- 非 pending:主按钮“查看”。
|
||||
- 移动布局不是本轮重构范围;若共享代码影响移动卡片,只做必要回归,不能大改移动壳。
|
||||
|
||||
## ReimbursementDetailPage
|
||||
|
||||
目标:从“所有材料和操作平铺”调整为“状态驱动页面”。
|
||||
|
||||
实现要点:
|
||||
|
||||
- `EntityOverviewHero` 顶部压缩说明文案;主动作只表达当前状态下最应该做的一件事。
|
||||
- 新增或重组“当前状态 / 下一步”首屏任务面板,回答:
|
||||
- 当前状态是什么。
|
||||
- 下一步是什么。
|
||||
- 谁需要处理。
|
||||
- 明细/发票/附件是否满足当前状态要求。
|
||||
- 按状态决定主动作:
|
||||
- `draft` / `rejected`:优先补齐材料或提交;删除草稿进“更多”。
|
||||
- `reviewing`:优先查看审批进度;催办/撤回作为次级状态动作。
|
||||
- `approved`:财务角色优先付款;普通员工看等待付款结果。
|
||||
- `paid`:优先查看付款结果;打印/PDF作为次级留档入口。
|
||||
- 明细和发票保留完整编辑能力,但在首屏之后作为“材料”工作区;缺发票、超标准、预算阻断等异常才前置提示。
|
||||
- 附件、项目关联、差旅关联、财务导出、审批历史保留,但移到次级区、侧栏或折叠区,不与主动作抢首屏。
|
||||
- 不改变 `ReimbursementActions` 的服务端动作调用和确认语义,只调整展示位置和入口密度。
|
||||
|
||||
## Shared Implementation Notes
|
||||
|
||||
- 优先复用现有组件和类:`ListWorkspace`、`WorkspaceHeader`、`FilterBar`、`PrimaryTable`、`PageScaffold`、`EntityOverviewHero`、`SectionPanel`、`detail-workspace-grid`、`detail-command-panel`、`detail-fact-grid`、`detail-evidence-card`、`detail-timeline`。
|
||||
- 不新增 UI 框架,不引入新状态管理。
|
||||
- 不改 API 请求参数语义;高级/历史开关优先用本地状态或既有 URL 参数。
|
||||
- 不让无权限用户看到不可执行入口。
|
||||
- 不把低频入口删除;找不到合适位置时先进“更多”或次级区。
|
||||
|
||||
## Verification Plan
|
||||
|
||||
- `cd client && npm run typecheck`
|
||||
- `cd client && npm run build`
|
||||
- 审批中心 PC 视觉验证:
|
||||
- 默认显示待处理任务。
|
||||
- 高级/历史入口可展开历史状态、业务类型筛选和统计。
|
||||
- 行内动作预算符合 1 个主动作。
|
||||
- 报销详情 PC 视觉验证:
|
||||
- 草稿/被驳回状态能看到补材料/提交路径。
|
||||
- 审批中状态能看到当前处理人和审批进度入口。
|
||||
- 已通过/已支付状态不展示编辑类主动作。
|
||||
- 打印、删除、财务导出、审批记录仍可达但不抢首屏。
|
||||
- Trellis governance 和 `git diff --check` 必须通过。
|
||||
@@ -0,0 +1,4 @@
|
||||
{"file": ".trellis/spec/frontend/index.md", "reason": "前端规约入口,确认 React/Vite 页面实现规则来源。"}
|
||||
{"file": ".trellis/spec/hyb-oa/index.md", "reason": "hyb-oa 规约入口,确认项目级规则读取顺序。"}
|
||||
{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "Trellis 任务、批准、验证和提交治理规则。"}
|
||||
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "A 类 PC 页面任务优先、详情页命令面和前端验证规则。"}
|
||||
@@ -0,0 +1,115 @@
|
||||
# 审批中心与报销详情降噪重构
|
||||
|
||||
## Goal
|
||||
|
||||
延续第一轮 PC 首页和报销列表降噪规则,把同一套“任务优先、主动作收敛、低频能力后置”推广到审批中心和报销详情,让审批人进入页面后能立即处理待办,让员工进入详情后能立即知道当前状态和下一步。
|
||||
|
||||
## Scope
|
||||
|
||||
- 第二轮只重构 PC Web 的两个 A 类页面:
|
||||
- `client/src/pages/WorkflowTasksPage.tsx`
|
||||
- `client/src/pages/ReimbursementDetailPage.tsx`
|
||||
- 同步更新受影响的 Playwright/E2E 断言和视觉截图验证。
|
||||
- 继续遵守 `.trellis/spec/hyb-oa/frontend-react-vite-design.md` 的 `A-Class PC Task-First Pages` 规则。
|
||||
- 不修改后端 API、数据库、权限模型、审批状态机、报销状态机、移动端/企业微信页面、生产配置或真实数据。
|
||||
|
||||
## What I already know
|
||||
|
||||
- 用户已确认第一轮前后对比效果可作为统一设计规则。
|
||||
- 第一轮已完成首页“今日任务入口化”和报销列表“员工进度视图化”,并沉淀为前端规约。
|
||||
- 上一轮 PRD 已将审批中心和报销详情列为第二轮扩展对象。
|
||||
- 审批中心当前首屏包含统计卡、全部状态 Tab、业务类型筛选、处理原则说明、列表行内“通过/详情”等;复杂感主要来自统计和解释文案抢占首屏注意力。
|
||||
- 报销详情当前顶部同时暴露打印、返回、催办、删除草稿;正文首屏同时展示状态摘要、付款状态、差旅、项目、附件、明细、发票、预算、风险、AI、导出、审批记录和动作栏,信息层级偏平铺。
|
||||
- 目标用户是 10 人以下国内科技公司,审批链短、角色重叠;PC 端仍需要保留批量查看、财务复核、导出和留档能力,但不能抢占员工/审批人的第一屏路径。
|
||||
|
||||
## Assumptions
|
||||
|
||||
- 本轮继续采用前端层信息架构重排,不做业务能力删除。
|
||||
- 审批中心默认服务审批人“待我处理”;发起人历史、已处理、业务类型筛选和统计属于次级能力。
|
||||
- 报销详情默认服务当前状态下的下一步;明细、发票、附件、预算、审批记录、财务导出和打印留档按状态相关性排序或折叠。
|
||||
- 说明文案只在风险、异常、空态和确认动作中出现;常规流程解释不默认铺开。
|
||||
- 低频能力后置到“更多”“高级/历史”“详情页次级区”或状态相关面板,不删除。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 当前无阻塞问题;若实现发现现有 API 数据无法支持目标排序或显隐,先回到 PRD 讨论,不直接改后端。
|
||||
|
||||
## Requirements
|
||||
|
||||
- 审批中心首屏默认只回答:有多少待我处理、最需要处理哪一条、下一步是处理还是查看。
|
||||
- 审批中心首屏主动作预算:列表行主动作只保留 1 个强动作;“通过”这种高风险动作必须保留二次确认。
|
||||
- 审批中心的统计摘要、业务类型筛选、已处理/已驳回/已取消/已跳过历史、全量查询放到“高级 / 历史”区域或次级折叠区。
|
||||
- 审批中心不再默认展示“处理原则”说明卡;仅在空态、错误或特殊批量/风险场景显示说明。
|
||||
- 报销详情顶部改为状态驱动:当前状态、下一步、谁处理、关键金额/票据完整性先出现。
|
||||
- 报销详情按状态展示主动作:
|
||||
- 草稿/被驳回:补明细、补发票、提交。
|
||||
- 审批中:看进度、催办、撤回或返回列表。
|
||||
- 已通过:等待/执行付款,财务角色可付款。
|
||||
- 已支付:查看付款结果、打印/PDF、留档。
|
||||
- 报销详情首屏只保留 1 个主动作,最多 2 个次级入口;打印/PDF、删除草稿、财务导出、项目关联、附件治理、审批历史等后置。
|
||||
- 报销详情中的明细、发票、附件保留完整能力,但默认按“当前状态需要补什么”排序,不把所有面板同时作为第一层任务。
|
||||
- 普通用户界面继续避免 UUID、taskId、内部状态码或调试字段作为主标识。
|
||||
- 所有高风险动作继续使用既有确认弹窗、权限判断和服务端校验,不绕开现有安全机制。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] 审批中心默认进入“待处理”任务视图,首屏不再被统计卡和解释卡占满。
|
||||
- [x] 审批中心低频历史/筛选/统计入口后置,仍可访问。
|
||||
- [x] 审批中心行内动作满足 1 个主动作原则,并保留通过动作二次确认。
|
||||
- [x] 报销详情顶部能直接表达当前状态、下一步、谁处理、关键完整性。
|
||||
- [x] 报销详情首屏主动作不超过 1 个,次级入口不超过 2 个。
|
||||
- [x] 报销详情低频能力后置但不删除:打印/PDF、删除草稿、项目关联、财务导出、审批记录、附件治理仍可达。
|
||||
- [x] 说明文案只服务风险、异常、空态和确认动作。
|
||||
- [x] 不修改后端 API、数据库、权限、状态机、移动端/企业微信页面。
|
||||
- [x] 补充或更新聚焦 Playwright/视觉验证,覆盖审批中心和报销详情 PC 视口。
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- PRD 和设计方案获得用户批准后再进入实现。
|
||||
- 实现前按 Trellis workflow 激活任务并读取前端规约。
|
||||
- 前端改动需执行 `cd client && npm run typecheck && npm run build`。
|
||||
- 涉及 A 类核心页面时补充必要 Playwright/视觉验证。
|
||||
- 执行 `python3 ./.trellis/scripts/task.py validate .trellis/tasks/05-29-approval-detail-simplification-refactor`。
|
||||
- 执行 `node scripts/qa/check-trellis-governance.mjs`。
|
||||
- 执行 `git diff --check`。
|
||||
- 面向用户可测试的页面改动,部署和公网检查通过后再通知用户测试。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 不修改后端 API、数据库、审批/报销状态机、权限判定和审批动作语义。
|
||||
- 不重构移动端/企业微信页面。
|
||||
- 不恢复或扩展差旅能力。
|
||||
- 不做全站一次性重写。
|
||||
- 不做生产部署、真实数据写入或企业微信写入。
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- 当前任务:`.trellis/tasks/05-29-approval-detail-simplification-refactor/`
|
||||
- 上一轮任务:`.trellis/tasks/archive/2026-05/05-29-frontend-simplification-refactor/`
|
||||
- 统一设计规则:`.trellis/spec/hyb-oa/frontend-react-vite-design.md`
|
||||
- 体验事实源:`docs/产品设计规则.md`
|
||||
- 审批中心:`client/src/pages/WorkflowTasksPage.tsx`
|
||||
- 报销详情:`client/src/pages/ReimbursementDetailPage.tsx`
|
||||
- 相关视觉验证可参考:`client/tests/e2e/pc-core-workflow-visual-wave2.spec.ts`、`client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts`、`client/tests/e2e/visual-shell.spec.ts`。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- `cd client && npm run typecheck`
|
||||
- `cd client && npm run build`
|
||||
- `cd client && npx playwright test tests/e2e/pc-core-workflow-visual-wave2.spec.ts --workers=1 --reporter=list`
|
||||
- `cd client && npx playwright test tests/e2e/pc-detail-mobile-visual-wave3.spec.ts --grep "PC" --workers=1 --reporter=list`
|
||||
- 必要时新增或更新聚焦测试,验证审批中心和报销详情 PC 首屏动作预算。
|
||||
- `python3 ./.trellis/scripts/task.py validate .trellis/tasks/05-29-approval-detail-simplification-refactor`
|
||||
- `node scripts/qa/check-trellis-governance.mjs`
|
||||
- `git diff --check`
|
||||
|
||||
## Verification Results
|
||||
|
||||
- PASS:`cd client && npm run typecheck`
|
||||
- PASS:`cd client && npm run build`
|
||||
- PASS:`cd client && npx playwright test tests/e2e/pc-core-workflow-visual-wave2.spec.ts --workers=1 --reporter=list`
|
||||
- PASS:`cd client && npx playwright test tests/e2e/pc-detail-mobile-visual-wave3.spec.ts --grep "PC" --workers=1 --reporter=list`
|
||||
- PASS:`cd client && npx playwright test tests/e2e/r102-reimbursement-main-flow.spec.ts --workers=1 --reporter=list`
|
||||
- PASS:`python3 ./.trellis/scripts/task.py validate .trellis/tasks/05-29-approval-detail-simplification-refactor`
|
||||
- PASS:`node scripts/qa/check-trellis-governance.mjs`
|
||||
- PASS:`git diff --check`
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"id": "approval-detail-simplification-refactor",
|
||||
"name": "approval-detail-simplification-refactor",
|
||||
"title": "审批中心与报销详情降噪重构",
|
||||
"description": "第二轮 PC Web 前端页面降噪重构:审批中心待处理优先,报销详情状态驱动。",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": "client",
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-05-29",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [
|
||||
"client/src/pages/WorkflowTasksPage.tsx",
|
||||
"client/src/pages/ReimbursementDetailPage.tsx",
|
||||
".trellis/spec/hyb-oa/frontend-react-vite-design.md"
|
||||
],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
在新工单中引用
屏蔽一个用户