优化报销审批证据布局(reimbursement-form-shared-component-migration)
这个提交包含在:
@@ -503,6 +503,7 @@ reference, not a copy of Feishu or Ant Design branding.
|
||||
- Dispatchers: `WorkflowTaskRoutePage` and `MobileWorkflowTaskRoutePage` fetch one authorized `WorkflowTaskDetail`, then select the reimbursement adapter or the generic non-reimbursement detail.
|
||||
- Pure layout shells: `ReimbursementDesktopWorkspaceShell` and `ReimbursementMobileWorkspaceShell`.
|
||||
- Shared model/capability types: `ReimbursementWorkspaceModel`, `ReimbursementWorkspaceSlots` and `ReimbursementEvidenceCapabilities`.
|
||||
- Evidence renderers: desktop expense table, mobile expense cards, independent invoice/attachment groups, and `ReimbursementReturnReferencePicker` inside the expanded return action.
|
||||
- Structural preflight: `node scripts/qa/check-ui-design-rules.mjs --strict`.
|
||||
|
||||
### 3. Contracts
|
||||
@@ -511,11 +512,14 @@ reference, not a copy of Feishu or Ant Design branding.
|
||||
- A task dispatcher owns the single task-detail query and passes the same result plus controlled refetch to the selected renderer. The reimbursement adapter may fetch current reimbursement detail but must not fetch the same task detail again.
|
||||
- Desktop and mobile shells share the same model, section order, `ReimbursementItemEvidenceList` and capability contract. They may use different DOM/layout skeletons: desktop main evidence plus one sticky command panel; mobile focus cards plus a bottom safe-area action surface.
|
||||
- Shells compose ready data and slots only. They contain no `useQuery`, `useMutation`, API/fetch call, role/permission inference, workflow/reimbursement state-machine decision, `mode === ...` comparison or `isMobile` business branch. `check-ui-design-rules.mjs --strict` enforces this boundary and rejects a dormant reimbursement branch in the generic approval pages.
|
||||
- The shared evidence component receives a minimal projection. Open/reference/edit/delete/link/unlink behavior exists only through callbacks; the component never infers capabilities from roles. Approval injects view/reference/decision only, read-only injects view only, and edit injects maintenance/submission capabilities.
|
||||
- The shared evidence component receives a minimal projection. Desktop renders one expense table; mobile renders expense cards from the same item projection. Invoices and supplementary attachments render after expenses as separate material groups rather than nested item cards. The table/card shows only invoice count or short status; current item-to-invoice ownership remains in the projection and server contract.
|
||||
- Open/edit/link/unlink behavior exists only through callbacks; the component never infers capabilities from roles. Approval and read-only expense rows have zero business-action controls. Edit injects one item-edit entry, with delete inside that edit flow, plus material maintenance/submission capabilities.
|
||||
- A material file name or file row is the single per-file open control. Do not add adjacent `查看原件` or `查看附件` buttons. Unlinked or blocking invoice recovery remains available in the material group, and an optional `查看完整材料` stays a section-level secondary action.
|
||||
- Item/invoice return references are not persistent evidence capabilities. Only the expanded return editor mounts `ReimbursementReturnReferencePicker`; abnormal candidates appear first, normal candidates require `展开全部`, and selecting a candidate appends an editable reference to the return comment.
|
||||
- Submission-time fee-standard and budget facts come from the workflow context snapshot. Current item/invoice grouping, amount evidence and attachments come from the reimbursement detail. Approval pages do not call reimbursement precheck or present current budget data as a submission snapshot.
|
||||
- Reimbursement approval actions fail closed until workflow snapshot parsing and current reimbursement evidence are ready. Historical missing budget snapshots are explained; malformed snapshots disable all decision paths.
|
||||
- Mobile workflow caching uses an explicit persisted projection. Reimbursement items, invoices, attachments, amount evidence, material access data, workflow snapshot checks and shared component projections do not enter IndexedDB. Offline reimbursement approve/reject/return/draft is forbidden, while other workflow entity offline behavior remains unchanged.
|
||||
- The information order is compact header and reason, item with owned invoices, unlinked invoices when present, supporting attachments, real issues/differences when present, then history/progress. Normal state creates no `0 条` check card. Issue copy identifies the object, reason, impact and next step rather than exposing internal counters alone.
|
||||
- The information order is compact header and reason, expense items, invoices, supplementary attachments, real issues/differences when present, then history/progress. Normal state creates no `0 条` check card. Issue copy identifies the object, reason, impact and next step rather than exposing internal counters alone.
|
||||
- `MobileStickyActionBar` and `MOBILE_STICKY_ACTION_PAGE_CLASS` are one contract. Add the page class only when `mobileActions` exists; omitting it lets `page-scaffold` transform capture the fixed bar, while adding it without a bar leaves false bottom padding.
|
||||
- `/reimbursements/:id/print` remains a purpose-built print/PDF view. It is not a fourth interactive workbench and receives no workbench mutation.
|
||||
|
||||
@@ -530,16 +534,16 @@ reference, not a copy of Feishu or Ant Design branding.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: edit, read-only and approval display the same item/invoice grouping; only the adapter-provided actions differ. The approver reads evidence and acts from one task URL.
|
||||
- Good: edit, read-only and approval share one item/material projection; desktop uses an expense table, mobile uses cards, and only adapter-provided capabilities differ. The approver reads evidence and acts from one task URL.
|
||||
- Base: a completed task shows the latest authorized read-only evidence with a clear time-semantics note while history and submission snapshots remain historical.
|
||||
- Bad: keep a dormant reimbursement branch in `WorkflowTaskDetailPage`/`MobileApprovalsPage`, query task detail in both dispatcher and adapter, or put `if (mode === "approval")` around API calls in a shell.
|
||||
- Bad: cache evidence/context data offline, show normal `0 条` summaries, render two copies of the reimbursement header, or leave a mobile fixed action captured by an animated ancestor.
|
||||
- Bad: nest full invoice cards and persistent file/reference buttons under every expense item, cache evidence/context data offline, show normal `0 条` summaries, render two copies of the reimbursement header, or leave a mobile fixed action captured by an animated ancestor.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- UI strict/self-test proves shell and legacy-branch rules are active and reports zero findings.
|
||||
- Source/structure tests prove both task dispatchers contain one `fetchWorkflowTaskDetail` call, the approval adapter contains none, and both shells share the model/slots/capability contract and section order.
|
||||
- PC/mobile E2E covers edit/read-only/approval, no-issue suppression, fail-closed, item-local invoice recovery, submit-confirm failure recovery, task deep links/back behavior and mobile fixed-action geometry.
|
||||
- PC/mobile E2E covers edit/read-only/approval, desktop table/mobile cards, zero expense-row actions in approval/read-only, single file open controls, return-only progressive references, no-issue suppression, fail-closed, item-local invoice recovery, submit-confirm failure recovery, task deep links/back behavior and mobile fixed-action geometry.
|
||||
- IndexedDB tests inspect the stored record and reject reimbursement item/invoice/attachment/context/material fields plus synthetic sentinel values. Non-reimbursement mobile offline draft create/sync/conflict remains green.
|
||||
- Run `cd client && npm run typecheck && npm run build`, focused workflow/reimbursement Playwright, route/flow gates, Trellis governance and `git diff --check`.
|
||||
|
||||
|
||||
@@ -71,8 +71,8 @@ type ReimbursementWorkspaceShellProps = {
|
||||
### Approval Adapter
|
||||
|
||||
- 消费路由边界移交的 workflow task detail/query result,不为同一 task 再发一份分派查询;另行获取实时报销详情。
|
||||
- 拥有 task detail 的业务解释和刷新策略、allowedActions、提交快照、处理历史、审批 mutation、退回引用、材料查看、联网状态和 fail-closed。
|
||||
- 只注入证据查看、引用和审批决策能力;编辑/关联/解绑 capability 必须不存在。
|
||||
- 拥有 task detail 的业务解释和刷新策略、allowedActions、提交快照、处理历史、审批 mutation、退回动作内引用、材料查看、联网状态和 fail-closed。
|
||||
- 常驻内容只注入证据查看和审批决策能力;引用只注入已展开的退回编辑区,编辑/关联/解绑 capability 必须不存在。
|
||||
- 任务、快照、实时报销内容任一关键部分未就绪时,输出不可决策模型和原因。
|
||||
|
||||
### Readonly Adapter
|
||||
@@ -92,9 +92,9 @@ type ReimbursementWorkspaceShellProps = {
|
||||
报销人 · 日期 · 合计
|
||||
事由
|
||||
┌────────────────────────────────────┬────────────────────┐
|
||||
│ 明细及所属票据 │ 当前节点/状态 │
|
||||
│ 未归属票据(仅有时) │ 可决策状态 │
|
||||
│ 补充附件 │ 异常锚点(仅有时) │
|
||||
│ 费用明细表 │ 当前节点/状态 │
|
||||
│ 票据与附件(发票/补充附件分组) │ 可决策状态 │
|
||||
│ 未归属恢复(仅有时) │ 异常锚点(仅有时) │
|
||||
│ 异常/快照差异(仅有时) │ 审批/提交动作 │
|
||||
│ 处理历史/进度 │ sticky │
|
||||
└────────────────────────────────────┴────────────────────┘
|
||||
@@ -102,7 +102,7 @@ type ReimbursementWorkspaceShellProps = {
|
||||
|
||||
### Mobile
|
||||
|
||||
标题/状态 → 报销人/日期/合计 → 事由 → 明细/票据 → 未归属(仅有时)→ 附件 → 异常/差异(仅有时)→ 历史/进度 → 底部动作。
|
||||
标题/状态 → 报销人/日期/合计 → 事由 → 费用明细卡 → 票据与附件(分组)→ 未归属恢复(仅有时)→ 异常/差异(仅有时)→ 历史/进度 → 底部动作。
|
||||
|
||||
正常检查不生成卡片。异常模型必须带对象引用和锚点:
|
||||
|
||||
@@ -162,3 +162,27 @@ type ReimbursementWorkspaceIssue = {
|
||||
## 10. UI/UX Basis
|
||||
|
||||
本任务不引入新视觉系统。采用 `ui-ux-pro-max` 对本场景适用的通用原则:一致标题层级、组合优于模式继承、异常不只靠颜色、反馈明确、触控目标至少 44×44、渐进披露避免把所有解释预先铺开。其数据库没有命中特定“审批工作台”React 模板,因此不采用不存在的模板结论,继续以项目现有组件和 `docs/产品设计规则.md` 的信息架构为基线。
|
||||
|
||||
## 11. Post-deployment KISS Evidence Layout
|
||||
|
||||
上线真人目视反馈证明,统一工作台架构正确,但 `ReimbursementItemEvidenceList` 仍把“费用明细、所属票据、文件动作、退回引用”压进同一嵌套卡片。收尾迭代保持适配器和 capability 边界不变,把展示层拆成三个语义组件:
|
||||
|
||||
```text
|
||||
ReimbursementEvidenceSection
|
||||
├─ ReimbursementExpenseItems
|
||||
│ ├─ desktop: ExpenseItemsTable
|
||||
│ └─ mobile: ExpenseItemCards
|
||||
├─ ReimbursementMaterials
|
||||
│ ├─ invoices
|
||||
│ └─ supplementary attachments
|
||||
└─ ReimbursementReturnReferencePicker(仅退回动作展开时挂载)
|
||||
```
|
||||
|
||||
- `ExpenseItemsTable` 与 `ExpenseItemCards` 消费同一行模型:类型、日期、说明、金额、票据数量/状态、异常摘要、稳定锚点。PC/移动可以不同 DOM,不得各自重新计算业务状态。
|
||||
- 发票不再嵌套在明细 DOM 内;projection 继续保留 `linkedItemId`,由纯函数派生每行票据摘要和独立材料列表。点击问题锚点仍能定位到对应明细行或材料行。
|
||||
- 文件行的文件名/整行采用语义按钮或链接作为唯一查看入口;视觉上不重复“查看原件/查看附件”。移动命中区域不小于 44px,键盘焦点和 `aria-label` 必须包含材料类型与文件名/票号。
|
||||
- edit capability 映射为明细单一编辑入口和材料区维护入口;readonly/approval 不注入维护能力。未归属或阻断票据是例外,可在材料行就地显示关联恢复动作。
|
||||
- `onReferenceItem` / `onReferenceInvoice` 不再传给常驻明细/材料组件;审批适配器仅在 `activeSecondaryAction === "return"` 时把异常优先的候选模型传给引用选择器。选择器只写入本地退回意见文本,不调用 API。
|
||||
- “查看完整材料”是区块级批量预览能力,与单文件入口并存时必须保持次要层级;单文件打开仍沿用既有短时 URL、用途审计和失败隔离。
|
||||
|
||||
参考 `/home/shunli/shenzhou-business-suite` 的费用明细表、移动卡片和附件分区只用于确定信息架构。实现不依赖该项目代码,也不复制其权限、状态或样式实现。
|
||||
|
||||
@@ -61,6 +61,15 @@
|
||||
- [ ] 公网用合成三模式任务验证,不碰 `0016/0017/0018` 或其他真人单据。
|
||||
- [ ] 部署与真人反馈完成前,B-2b 和父任务都不得归档。
|
||||
|
||||
## Post-deployment KISS Closeout
|
||||
|
||||
1. [x] 把当前嵌套 `ReimbursementItemEvidenceList` 拆为共享行 projection、PC 明细表、移动明细卡和独立票据/附件区;不改壳、适配器查询或后端合同。
|
||||
2. [x] PC 审批/只读明细行清零常驻动作;编辑态收敛为单一编辑入口,删除进入编辑流程或二次确认。
|
||||
3. [x] 文件名/文件行成为唯一查看入口,删除重复“查看原件/查看附件”;保留区块级“查看完整材料”及异常票据就地恢复。
|
||||
4. [x] 将明细/票据引用能力移入退回编辑区,异常对象优先,全部对象按需展开;关闭或切换动作时保持现有意见状态合同。
|
||||
5. [x] 更新 PC/移动三模式与 B2 测试:正常行零动作、材料分组、单一查看入口、退回渐进披露、问题锚点、未归属恢复、键盘/触控/横屏。
|
||||
6. [ ] 复跑 typecheck/build、UI strict/self-test、相关 Playwright、B2/workflow 后端回归、治理与部署门;重新提交、push、部署均须分别批准。
|
||||
|
||||
## Rollback Points
|
||||
|
||||
- 适配器接线或非报销回归失败:停止在本地,不删除旧分支,修正后重新验证。
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
- 建立 `edit`、`readonly`、`approval` 三类模式适配器;查询、业务状态、权限判断、mutation、错误恢复和 fail-closed 均归适配器。
|
||||
- `/reimbursements/:reimbursementId` 的参与人填写态和非参与人只读态迁入工作台。
|
||||
- `/workflow/tasks/:taskId` 与 `/mobile/approvals/:taskId` 的报销任务进入同一工作台的审批适配器;其他实体继续走通用审批详情。
|
||||
- 复用并扩展 B-2a 的明细—票据证据组件、票据抽屉、提交前检查和未归属恢复。
|
||||
- 复用 B-2a 的证据 projection、capability 契约、票据抽屉、提交前检查和未归属恢复;把嵌套的明细—票据卡片替换为费用明细与材料分区。
|
||||
- 删除 PC/移动通用审批页中的报销业务分支,以及被工作台替代的旧报销明细/材料布局。
|
||||
- 收敛顶部摘要、检查提示、右侧动作摘要和退回/驳回解释。
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
### R1 一个业务对象只保留一个交互式工作台
|
||||
|
||||
- 报销详情的主内容顺序在 `edit`、`readonly`、`approval` 三种模式中一致:紧凑报销头 → 明细及所属票据 → 未归属票据 → 补充附件 → 必要检查/差异 → 处理历史或处理进度。
|
||||
- 报销详情的主内容顺序在 `edit`、`readonly`、`approval` 三种模式中一致:紧凑报销头 → 费用明细 → 票据与附件 → 必要检查/差异 → 处理历史或处理进度。发票归属仍是业务合同,但不再以嵌套卡片占据每条明细的主视觉。
|
||||
- PC 宽屏使用主列证据 + 右侧单一 sticky 动作区;窄屏和移动采用同一内容顺序的卡片/分区详情布局,动作区转到底部且不遮挡内容。PC 与移动不强求同一份 DOM,不得用密集的 `isMobile` 业务分支把两种布局重新耦合成巨型壳。
|
||||
- `/workflow/tasks/:taskId` 保留为审批入口和浏览器地址。任务解析为 `reimbursement` 后由报销审批适配器渲染工作台;不得先渲染通用报销摘要再嵌入工作台,也不得要求用户点击“查看报销单”才能取得核心内容。
|
||||
- `/reimbursements/:reimbursementId` 使用同一工作台。参与人处于 `draft/rejected` 时获得编辑能力;其他获准查看者只得到只读能力。
|
||||
@@ -65,7 +65,7 @@
|
||||
### R4 信息必须按审批人决策需要收敛
|
||||
|
||||
1. 顶部只显示报销单号、状态、报销人、日期、合计;事由作为紧接标题的正文展示。不得再用六张字段卡重复下面已经出现的信息,也不显示“业务类型=报销”等自明字段。
|
||||
2. 主列直接进入明细和所属票据;附件随后展示。相同报销头、事由、票据或检查事实不得在不同区块重复完整表达。
|
||||
2. 主列直接进入费用明细,随后进入独立的“票据与附件”区。PC 明细使用紧凑表格,移动明细使用卡片;表内只呈现票据数量/状态,不嵌套票据卡片或文件操作。发票与补充附件在证据区内分组,避免把两类业务语义合并成同一种数据。
|
||||
3. 正常项不解释、不计数。只有存在异常或人工决策需要时才显示提示,并就近说明“对象、原因、影响、下一步”;不得默认平铺“0 条”状态或仅显示无法定位对象的总数。
|
||||
4. 提交时快照与当前票据事实一致时,不并排展示两个“检查摘要”;只有出现异常、快照缺失或时间语义差异时才显示必要内容。
|
||||
5. 历史提交缺少预算快照时,以低强调度说明“该次提交未留存预算快照”;它不得使用告警视觉冒充当前业务异常,也不得用实时预算结果补写历史。
|
||||
@@ -75,8 +75,8 @@
|
||||
|
||||
### R5 编辑态围绕明细完成票据维护与提交恢复
|
||||
|
||||
- `edit` 适配器向同一明细—票据组件注入新增、编辑、删除、上传/选择 PDF 发票、关联、解绑和未归属恢复能力。
|
||||
- 员工可在当前明细上下文内查看和维护所属票据;未归属票据直接提供选择明细的恢复入口,不依赖口头操作顺序或跳到独立材料区。
|
||||
- `edit` 适配器向同一费用明细与材料组件组注入新增、编辑、删除、上传/选择 PDF 发票、关联、解绑和未归属恢复能力。
|
||||
- 员工从费用明细进入单一“编辑”入口维护该条明细;删除收进编辑弹窗或明确的二次确认,不与编辑并排常驻。票据上传、关联、解绑和未归属恢复统一位于“票据与附件”区;未归属票据直接提供选择明细的恢复入口。
|
||||
- 整单补充附件继续作为独立证据,不伪造与某条明细的结构化关系。
|
||||
- 填写过程中持续计算可解释的提交前检查;汇总区只提示仍有多少可操作问题并提供锚点,具体错误就近落到明细或票据。
|
||||
- 服务端 B2 门禁继续为权威。前端不得因工作台迁移放宽 `ocr_failed`、manual review、金额超票面、缺票或未归属合同。
|
||||
@@ -86,7 +86,7 @@
|
||||
|
||||
- 费用标准/预算使用 workflow 提交快照;票据归属、金额证据和附件使用当前实时报销详情。两者不得互相替代。
|
||||
- 完整报销内容、关键当前检查或提交快照解析未就绪时,通过、退回、驳回、转审和报销离线草稿均不可执行;动作入口除禁用外仍有中文原因。
|
||||
- 审批适配器只注入查看原件、查看附件、引用到退回意见及 workflow 决策能力,不注入报销维护能力。
|
||||
- 审批适配器只注入材料查看及 workflow 决策能力,不注入报销维护能力。明细和票据不常驻“引用到退回意见”;只有展开“退回”动作后,才在意见编辑区提供可选的明细/票据引用清单。
|
||||
- 报销证据继续排除在移动 IndexedDB workflow cache 之外;其他审批类型的离线草稿能力保持不变。
|
||||
- 已处理任务查看当前报销版本时保留时间语义提示;历史意见和提交快照不被当前内容覆盖。
|
||||
|
||||
@@ -97,9 +97,19 @@
|
||||
- PC/移动从待办列表进入、刷新、浏览器返回、深链打开和已处理任务查看均保持可预测;工作台返回目标由入口上下文决定,不形成“返回审批详情 → 又回到同一工作台”的循环。
|
||||
- 页面级加载失败显示重试和安全返回;单个材料加载失败只影响该材料,不清空已加载证据。
|
||||
|
||||
### R8 上线后按国内报销阅读习惯完成 KISS 收敛
|
||||
|
||||
- PC “费用明细”使用一张表,固定核心列为序号、费用类型、发生日期、费用说明、金额、票据;票据列只显示“免票 / N 张 / 未关联 / 需核对”等短状态。正常行不显示解释文案或业务按钮,异常在对应行内用文字与图标共同标识。
|
||||
- PC 表格的审批态和只读态行内操作为 0;编辑态只允许一个“编辑”入口,删除进入编辑弹窗或二次确认。不得恢复“编辑 + 删除 + 引用 + 查看原件”并排动作。
|
||||
- 移动端保持卡片而不复制 PC 表格;每张卡只显示费用类型、日期、金额、说明和票据状态。正常卡无操作说明,编辑态能力从单一入口进入。
|
||||
- “票据与附件”是明细之后的独立区块,内部按“发票”和“补充附件”分组。文件名/文件行本身是唯一查看入口并具有完整可访问名称;不得为每个文件重复渲染“查看原件”“查看附件”按钮。只有异常票据显示就地恢复动作。
|
||||
- 退回引用采用渐进披露:默认页面不显示引用按钮;用户展开“退回”后,意见框附近显示可勾选的异常明细/票据,必要时可展开全部,选择后生成可编辑的引用文本。关闭退回区不改变业务数据。
|
||||
- “查看完整材料”只在确有批量核对价值时保留为区块级次要入口,不与每个文件的查看入口重复;无材料时不显示空动作。
|
||||
- 本轮只重排前端展示与能力入口,不改变发票—明细关系、B2 门禁、快照/实时语义、材料授权审计、workflow 状态机或后端数据合同。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] AC1:PC 与移动的 `edit`、`readonly`、`approval` 使用同一展示模型、区块顺序、明细—票据语义组件和 capability 契约;PC 可使用右栏 sticky 布局壳,移动可使用卡片 + 底部动作布局壳,不要求同一份 DOM,模式差异只来自适配器注入的数据与能力。
|
||||
- [ ] AC1:PC 与移动的 `edit`、`readonly`、`approval` 使用同一展示模型、区块顺序、费用明细/材料语义组件和 capability 契约;PC 可使用右栏 sticky 布局壳,移动可使用卡片 + 底部动作布局壳,不要求同一份 DOM,模式差异只来自适配器注入的数据与能力。
|
||||
- [ ] AC2:PC/移动布局壳中 `useQuery`、`useMutation`、API 调用、角色/权限判断、workflow/报销状态机判断和 `if (mode === ...)` 业务分支均为 0;`scripts/qa/check-ui-design-rules.mjs --strict` 静态扫描并报 error,且该命令作为部署 preflight 执行。
|
||||
- [ ] AC3:`WorkflowTaskDetailPage` 与 `MobileApprovalsPage` 的报销业务渲染分支、六字段摘要、重复检查区和休眠兜底全部删除;通用审批页仍通过非报销回归。
|
||||
- [ ] AC4:报销审批首屏只有一套紧凑报销头;事由、明细、所属票据、附件、异常和历史各有唯一主展示位置,不再出现两套摘要或四处检查计数。
|
||||
@@ -110,6 +120,10 @@
|
||||
- [ ] AC9:报销证据不进入 IndexedDB;非报销审批离线草稿基线、PC/移动非报销详情和 workflow 状态机均无回归。
|
||||
- [ ] AC10:参与人/非参与人、`draft/rejected/reviewing`、pending/已处理任务和材料权限矩阵通过;没有新增 schema、migration、flag、seed 或生产数据改写。
|
||||
- [ ] AC11:生产部署后使用公网真实 bundle + 合成 API 完成三模式、失败关闭和离线验收;真实员工重提反馈记录后,父任务才允许归档。
|
||||
- [ ] AC12:PC 审批/只读费用明细为单表且行内业务按钮为 0;编辑态每行至多一个“编辑”入口。表格在 1280px 无横向溢出,键盘与读屏按列顺序读取。
|
||||
- [ ] AC13:移动费用明细保持卡片;PC/移动共享同一明细字段、顺序、异常语义与 capability 合同,不共享同一 DOM。375px 与横屏无内容遮挡或横向溢出。
|
||||
- [ ] AC14:票据和补充附件从明细中拆出并在独立证据区分组;正常文件仅有一个可访问的查看入口,正常明细/票据不出现“引用到退回意见”“查看原件”“查看附件”按钮文案。
|
||||
- [ ] AC15:只有展开退回动作后才出现引用选择;异常对象优先、全部对象按需展开,选择结果进入可编辑意见。B2 未归属恢复、金额异常锚点、材料查看审计和审批 fail-closed 回归通过。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
@@ -123,8 +137,8 @@
|
||||
## Test Plan
|
||||
|
||||
- 结构边界:`check-ui-design-rules.mjs --strict` 扫描 PC/移动布局壳,禁止 query/mutation/API/角色/状态机/mode 请求分支,并扫描通用审批页报销分支和旧文案为 0;三适配器能力集合另做组件/类型验证。
|
||||
- 纯函数/组件:统一报销头、明细—票据投影、异常过滤、快照/实时差异、异常锚点和 capability 注入覆盖正常/异常/缺失边界。
|
||||
- PC/移动 Playwright:三模式内容顺序;无异常降噪;异常就近文案;右栏/底部动作;票据抽屉;未归属恢复;B2 阻断后修复并再次提交;退回引用;深链/返回。
|
||||
- 纯函数/组件:统一报销头、明细行/材料投影、异常过滤、快照/实时差异、异常锚点和 capability 注入覆盖正常/异常/缺失边界。
|
||||
- PC/移动 Playwright:三模式内容顺序;PC 明细表/移动明细卡;正常行零动作与零解释;独立票据/附件区;文件单一查看入口;异常就近文案;右栏/底部动作;票据抽屉;未归属恢复;B2 阻断后修复并再次提交;退回动作展开后引用;深链/返回。
|
||||
- 权限/状态:participant/nonparticipant × draft/rejected/reviewing;pending/已处理审批;无权 task、跨单材料、无维护能力和并发关联回归。
|
||||
- 离线:直接读取 IndexedDB 禁存字段/哨兵值;报销动作和草稿 fail-closed;非报销审批离线草稿仍可形成和同步。
|
||||
- 后端:原则上不改合同;复跑 B2 门禁、报销详情只读投影、workflow task detail、材料查看审计、票据 bind/unbind/link 和审批动作聚焦测试。
|
||||
@@ -136,4 +150,5 @@
|
||||
- 大型旧页面拆分容易在中间阶段形成双实现;允许在开发分支短暂并存,但最终候选必须通过旧分支为 0 的结构门禁。
|
||||
- task 路由先取元数据再分派可能产生加载闪烁;设计必须用稳定骨架而不是先渲染通用页再切换。
|
||||
- 打印页继续独立,未来如字段语义漂移可另建“打印投影复用”任务;本轮不扩大交付面。
|
||||
- 神州 ERP 仅作为国内报销信息层级参考;不复制其旧页面状态管理、过小图标按钮、打印专用边框或数据模型。当前系统继续以自身 B2、权限、审计和可访问性合同为准。
|
||||
- 全局错误色 token、部署脚本 `--no-deps`、`.claude` gitignore 和其他治理债不并入本任务。
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
"children": [],
|
||||
"parent": "09-01-reimbursement-one-page-approval-workbench",
|
||||
"relatedFiles": [],
|
||||
"notes": "父任务第二阶段;B-2a 已部署并取得真人目视反馈。规划采用方案 A:仅报销进入业务对象工作台审批模式,其他 workflow 实体保持通用详情。实现与本地全量门禁已完成,用户最终 diff 复核通过,实现提交为 abdd250;尚未 push、部署或触碰真人单据。",
|
||||
"notes": "父任务第二阶段;方案 A 的统一报销工作台已以候选 9fc3b76 部署,生产 build marker 为 9fc3b764382d。上线真人目视反馈触发 KISS 收尾:PC 明细表格、移动明细卡片、票据/附件独立分组、单一文件查看入口和退回动作内渐进引用已完成本地实施;typecheck/build、UI strict/self-test、R102/R111/workflow/视觉聚焦回归及治理通过,等待用户 diff 复核,尚未提交、push、部署或触碰真人单据。",
|
||||
"meta": {}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function ReimbursementDesktopWorkspaceShell({ model, capabilities, slots
|
||||
<BusinessDetailMain>
|
||||
{slots.headerDetails}
|
||||
{slots.beforeEvidence}
|
||||
<SectionPanel title="明细与票据" className="scroll-mt-24">
|
||||
<SectionPanel title="报销内容" className="scroll-mt-24">
|
||||
<div id="reimbursement-workspace-evidence">
|
||||
<ReimbursementItemEvidenceList evidence={model.evidence} capabilities={capabilities} />
|
||||
</div>
|
||||
|
||||
@@ -13,10 +13,7 @@ export type ReimbursementEvidenceCapabilities = {
|
||||
onOpenAttachment?: (attachment: ReimbursementAttachmentEvidence) => void;
|
||||
onOpenMaterialBundle?: () => void;
|
||||
onDeleteAttachment?: (attachment: ReimbursementAttachmentEvidence) => void;
|
||||
onReferenceItem?: (item: ReimbursementItemEvidence) => void;
|
||||
onReferenceInvoice?: (invoice: ReimbursementInvoiceEvidence) => void;
|
||||
onEditItem?: (item: ReimbursementItemEvidence) => void;
|
||||
onDeleteItem?: (item: ReimbursementItemEvidence) => void;
|
||||
onLinkInvoice?: (invoice: ReimbursementInvoiceEvidence, linkedItemId: string) => void;
|
||||
linkInvoicePending?: boolean;
|
||||
linkInvoiceErrors?: Record<string, string>;
|
||||
@@ -35,198 +32,147 @@ function evidenceStatusCopy(status: ReimbursementInvoiceEvidence["amountEvidence
|
||||
return null;
|
||||
}
|
||||
|
||||
function InvoiceCard({
|
||||
invoice,
|
||||
capabilities,
|
||||
linkTargets,
|
||||
}: {
|
||||
invoice: ReimbursementInvoiceEvidence;
|
||||
capabilities: ReimbursementEvidenceCapabilities;
|
||||
linkTargets: ReimbursementItemEvidence[];
|
||||
}) {
|
||||
function itemHasBlockingIssue(item: ReimbursementItemEvidence) {
|
||||
return item.amountExceeded
|
||||
|| item.invoices.some((invoice) => invoice.amountEvidenceStatus === "ocr_failed")
|
||||
|| (item.expenseType?.requiresInvoice !== false && item.invoices.length === 0);
|
||||
}
|
||||
|
||||
function itemInvoiceStatus(item: ReimbursementItemEvidence) {
|
||||
if (item.expenseType?.requiresInvoice === false && item.invoices.length === 0) return "免票";
|
||||
if (!item.invoices.length) return "未关联";
|
||||
if (item.amountExceeded || item.invoices.some((invoice) => invoice.amountEvidenceStatus !== "verified")) return "需核对";
|
||||
return `${item.invoices.length} 张`;
|
||||
}
|
||||
|
||||
function ItemIssues({ item }: { item: ReimbursementItemEvidence }) {
|
||||
return (
|
||||
<>
|
||||
{item.assetPriorApprovalBasis ? <p className="mt-2 text-sm text-blue-800">事前批准依据:{item.assetPriorApprovalBasis}</p> : null}
|
||||
{item.standardViolations.map((violation) => (
|
||||
<p className={violation.controlMode === "block" ? "mt-2 text-sm text-red-700" : "mt-2 text-sm text-amber-800"} key={`${violation.itemId}-${violation.standardId}`}>
|
||||
提交时费用标准提示:申报 {formatMoney(violation.amount)},标准 {formatMoney(violation.maxAmount)},超出 {formatMoney(violation.exceededAmount)}。审批时请核对原因。
|
||||
</p>
|
||||
))}
|
||||
{item.showOfficeElectronicsHint ? <p className="mt-2 text-sm text-amber-800">该金额通常应选择办公电子设备,请核对费用类型和事前批准依据。</p> : null}
|
||||
{item.amountExceeded ? <p className="mt-2 text-sm text-red-700">当前申报金额高于已读取的票面金额合计,暂时不能据此通过;请核对明细金额和票据是否完整。</p> : null}
|
||||
{item.expenseType?.requiresInvoice !== false && !item.invoices.length ? <p className="mt-2 text-sm text-red-700">这条明细尚无对应票据,暂时无法提交;请在票据区添加票据并选择这条明细。</p> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpenseItemsTable({ items, capabilities }: { items: ReimbursementItemEvidence[]; capabilities: ReimbursementEvidenceCapabilities }) {
|
||||
const editable = Boolean(capabilities.onEditItem);
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-zinc-200" data-testid="reimbursement-expense-items-table">
|
||||
<table className="w-full min-w-[700px] table-fixed text-left text-sm">
|
||||
<thead className="bg-zinc-50 text-xs text-zinc-600">
|
||||
<tr>
|
||||
<th className="w-12 px-3 py-2.5 font-medium" scope="col">序号</th>
|
||||
<th className="w-32 px-3 py-2.5 font-medium" scope="col">费用类型</th>
|
||||
<th className="w-28 px-3 py-2.5 font-medium" scope="col">发生日期</th>
|
||||
<th className="px-3 py-2.5 font-medium" scope="col">费用说明</th>
|
||||
<th className="w-28 px-3 py-2.5 text-right font-medium" scope="col">金额</th>
|
||||
<th className="w-24 px-3 py-2.5 font-medium" scope="col">票据</th>
|
||||
{editable ? <th className="w-20 px-3 py-2.5 text-right font-medium" scope="col">操作</th> : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-200 bg-white">
|
||||
{items.map((item, index) => {
|
||||
const hasBlockingIssue = itemHasBlockingIssue(item);
|
||||
return (
|
||||
<tr data-testid="approval-item-evidence" data-reimbursement-guard-issue={hasBlockingIssue ? "true" : undefined} id={`reimbursement-item-${item.id}`} key={item.id} tabIndex={hasBlockingIssue ? -1 : undefined}>
|
||||
<td className="px-3 py-3 align-top text-zinc-500">{index + 1}</td>
|
||||
<td className="px-3 py-3 align-top font-medium text-zinc-900">{item.expenseType?.name ?? "未分类费用"}</td>
|
||||
<td className="px-3 py-3 align-top text-zinc-600">{formatDisplayDate(item.expenseDate)}</td>
|
||||
<td className="px-3 py-3 align-top text-zinc-700"><p>{item.description || "未填写明细说明"}</p><ItemIssues item={item} /></td>
|
||||
<td className="px-3 py-3 text-right align-top font-medium tabular-nums text-zinc-900">{formatMoney(item.amount)}</td>
|
||||
<td className={hasBlockingIssue ? "px-3 py-3 align-top font-medium text-red-700" : "px-3 py-3 align-top text-zinc-600"}>{itemInvoiceStatus(item)}</td>
|
||||
{editable ? <td className="px-3 py-2 text-right align-top"><Button className="min-h-11" onClick={() => capabilities.onEditItem?.(item)} size="sm" type="button" variant="secondary">编辑</Button></td> : null}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpenseItemCards({ items, capabilities }: { items: ReimbursementItemEvidence[]; capabilities: ReimbursementEvidenceCapabilities }) {
|
||||
return <div className="space-y-3">{items.map((item, index) => {
|
||||
const hasBlockingIssue = itemHasBlockingIssue(item);
|
||||
return (
|
||||
<article className="mobile-list-card" data-testid="approval-item-evidence" data-reimbursement-guard-issue={hasBlockingIssue ? "true" : undefined} id={`reimbursement-item-${item.id}`} key={item.id} tabIndex={hasBlockingIssue ? -1 : undefined}>
|
||||
<div className="flex items-start justify-between gap-3"><div className="min-w-0"><p className="text-xs text-zinc-500">明细 {index + 1} · {formatDisplayDate(item.expenseDate)}</p><h3 className="mt-1 font-medium text-zinc-950">{item.expenseType?.name ?? "未分类费用"}</h3></div><p className="shrink-0 font-semibold tabular-nums text-zinc-950">{formatMoney(item.amount)}</p></div>
|
||||
<p className="mt-2 text-sm leading-6 text-zinc-700">{item.description || "未填写明细说明"}</p>
|
||||
<p className={hasBlockingIssue ? "mt-2 text-sm font-medium text-red-700" : "mt-2 text-sm text-zinc-600"}>票据:{itemInvoiceStatus(item)}</p>
|
||||
<ItemIssues item={item} />
|
||||
{capabilities.onEditItem ? <Button className="mt-3 min-h-11 w-full" onClick={() => capabilities.onEditItem?.(item)} type="button" variant="secondary">编辑</Button> : null}
|
||||
</article>
|
||||
);
|
||||
})}</div>;
|
||||
}
|
||||
|
||||
function InvoiceMaterialRow({ invoice, capabilities, linkTargets }: { invoice: ReimbursementInvoiceEvidence; capabilities: ReimbursementEvidenceCapabilities; linkTargets: ReimbursementItemEvidence[] }) {
|
||||
const [selectedItemId, setSelectedItemId] = useState("");
|
||||
const title = invoice.invoiceNumber || invoice.originalName || "未命名发票";
|
||||
const hasBlockingIssue = !invoice.linkedItemId || invoice.amountEvidenceStatus === "ocr_failed";
|
||||
return (
|
||||
<article
|
||||
className="rounded-lg border border-zinc-200 bg-white p-3"
|
||||
data-testid="approval-invoice-evidence"
|
||||
data-reimbursement-guard-issue={hasBlockingIssue ? "true" : undefined}
|
||||
tabIndex={hasBlockingIssue ? -1 : undefined}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h4 className="truncate text-sm font-medium text-zinc-900">{title}</h4>
|
||||
<p className="mt-1 text-xs leading-5 text-zinc-600">
|
||||
{invoice.sellerName || invoice.documentSubtype || invoice.documentCategory || "票据"}
|
||||
{" · "}{formatDisplayDate(invoice.invoiceDate)}
|
||||
{" · "}{formatMoney(invoice.totalAmount)}
|
||||
</p>
|
||||
{evidenceStatusCopy(invoice.amountEvidenceStatus) ? (
|
||||
<p className={invoice.amountEvidenceStatus === "ocr_failed" ? "mt-1 text-xs text-red-700" : "mt-1 text-xs text-zinc-600"}>
|
||||
{evidenceStatusCopy(invoice.amountEvidenceStatus)}
|
||||
</p>
|
||||
) : null}
|
||||
<article className="rounded-lg border border-zinc-200 bg-white p-3" data-testid="approval-invoice-evidence" data-reimbursement-guard-issue={hasBlockingIssue ? "true" : undefined} id={`reimbursement-invoice-${invoice.id}`} tabIndex={hasBlockingIssue ? -1 : undefined}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
{capabilities.onOpenInvoice ? <Button aria-label={`查看发票:${title}`} className="h-auto min-h-11 max-w-full justify-start whitespace-normal px-0 text-left" onClick={() => capabilities.onOpenInvoice?.(invoice)} type="button" variant="link">{title}</Button> : <h4 className="text-sm font-medium text-zinc-900">{title}</h4>}
|
||||
<p className="text-xs leading-5 text-zinc-600">{invoice.sellerName || invoice.documentSubtype || invoice.documentCategory || "票据"} · {formatDisplayDate(invoice.invoiceDate)} · {formatMoney(invoice.totalAmount)}</p>
|
||||
{evidenceStatusCopy(invoice.amountEvidenceStatus) ? <p className={invoice.amountEvidenceStatus === "ocr_failed" ? "mt-1 text-xs text-red-700" : "mt-1 text-xs text-zinc-600"}>{evidenceStatusCopy(invoice.amountEvidenceStatus)}</p> : null}
|
||||
</div>
|
||||
{capabilities.onUnlinkInvoice && invoice.linkedItemId ? <Button className="min-h-11" onClick={() => capabilities.onUnlinkInvoice?.(invoice)} size="sm" type="button" variant="secondary">移除对应关系</Button> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{capabilities.onOpenInvoice ? (
|
||||
<Button aria-label={`查看发票原件:${title}`} className="min-h-11" onClick={() => capabilities.onOpenInvoice?.(invoice)} size="sm" type="button" variant="secondary">
|
||||
查看原件
|
||||
</Button>
|
||||
) : null}
|
||||
{capabilities.onReferenceInvoice ? (
|
||||
<Button className="min-h-11" data-testid="approval-reference-invoice" onClick={() => capabilities.onReferenceInvoice?.(invoice)} size="sm" type="button" variant="secondary">
|
||||
引用到退回意见
|
||||
</Button>
|
||||
) : null}
|
||||
{capabilities.onLinkInvoice && !invoice.linkedItemId ? (
|
||||
<div className="grid min-w-[220px] gap-2">
|
||||
<label className="text-xs font-medium text-zinc-700" htmlFor={`reimbursement-link-item-${invoice.id}`}>选择报销明细</label>
|
||||
<select
|
||||
className="select-std min-h-11"
|
||||
disabled={capabilities.linkInvoicePending}
|
||||
id={`reimbursement-link-item-${invoice.id}`}
|
||||
onChange={(event) => setSelectedItemId(event.target.value)}
|
||||
value={selectedItemId}
|
||||
>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-end">
|
||||
<label className="grid gap-1 text-xs font-medium text-zinc-700" htmlFor={`reimbursement-link-item-${invoice.id}`}>选择报销明细
|
||||
<select className="select-std min-h-11" disabled={capabilities.linkInvoicePending} id={`reimbursement-link-item-${invoice.id}`} onChange={(event) => setSelectedItemId(event.target.value)} value={selectedItemId}>
|
||||
<option value="">请选择报销明细</option>
|
||||
{linkTargets.map((item, index) => (
|
||||
<option key={item.id} value={item.id}>明细 {index + 1} · {item.expenseType?.name ?? "未分类费用"} · {formatMoney(item.amount)}</option>
|
||||
))}
|
||||
{linkTargets.map((item, index) => <option key={item.id} value={item.id}>明细 {index + 1} · {item.expenseType?.name ?? "未分类费用"} · {formatMoney(item.amount)}</option>)}
|
||||
</select>
|
||||
<Button
|
||||
className="min-h-11"
|
||||
disabled={!selectedItemId || capabilities.linkInvoicePending}
|
||||
onClick={() => capabilities.onLinkInvoice?.(invoice, selectedItemId)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
{capabilities.linkInvoicePending ? "关联中..." : "确认关联"}
|
||||
</Button>
|
||||
{capabilities.linkInvoiceErrors?.[invoice.id] ? (
|
||||
<p className="text-xs text-red-700" role="alert">{capabilities.linkInvoiceErrors[invoice.id]}</p>
|
||||
) : null}
|
||||
</label>
|
||||
<Button className="min-h-11" disabled={!selectedItemId || capabilities.linkInvoicePending} onClick={() => capabilities.onLinkInvoice?.(invoice, selectedItemId)} size="sm" type="button" variant="secondary">{capabilities.linkInvoicePending ? "关联中..." : "确认关联"}</Button>
|
||||
{capabilities.linkInvoiceErrors?.[invoice.id] ? <p className="text-xs text-red-700 sm:col-span-2" role="alert">{capabilities.linkInvoiceErrors[invoice.id]}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{capabilities.onUnlinkInvoice && invoice.linkedItemId ? (
|
||||
<Button className="min-h-11" onClick={() => capabilities.onUnlinkInvoice?.(invoice)} size="sm" type="button" variant="secondary">移除对应关系</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentMaterialRow({ attachment, capabilities }: { attachment: ReimbursementAttachmentEvidence; capabilities: ReimbursementEvidenceCapabilities }) {
|
||||
return <div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-zinc-200 px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
{capabilities.onOpenAttachment ? <Button aria-label={`查看补充附件:${attachment.originalName}`} className="h-auto min-h-11 max-w-full justify-start whitespace-normal px-0 text-left" onClick={() => capabilities.onOpenAttachment?.(attachment)} type="button" variant="link">{attachment.originalName}</Button> : <p className="truncate text-sm font-medium text-zinc-900">{attachment.originalName}</p>}
|
||||
<p className="text-xs text-zinc-500">{attachment.mimeType} · {(attachment.fileSize / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
{capabilities.onDeleteAttachment ? <Button className="min-h-11" onClick={() => capabilities.onDeleteAttachment?.(attachment)} size="sm" type="button" variant="secondary">删除附件</Button> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function ReimbursementItemEvidenceList({ evidence, capabilities = {}, mobile = false }: Props) {
|
||||
const linkedInvoices = evidence.items.flatMap((item) => item.invoices);
|
||||
return (
|
||||
<div className="space-y-5" data-testid="reimbursement-approval-evidence">
|
||||
<div className="space-y-3">
|
||||
{evidence.items.length ? evidence.items.map((item, index) => {
|
||||
const hasBlockingIssue = item.amountExceeded || item.invoices.some((invoice) => invoice.amountEvidenceStatus === "ocr_failed") || (item.expenseType?.requiresInvoice !== false && item.invoices.length === 0);
|
||||
return (
|
||||
<article className={mobile ? "mobile-list-card" : "rounded-xl border border-zinc-200 bg-zinc-50/60 p-4"} data-testid="approval-item-evidence" data-reimbursement-guard-issue={hasBlockingIssue ? "true" : undefined} tabIndex={hasBlockingIssue ? -1 : undefined} key={item.id}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-zinc-500">明细 {index + 1}</p>
|
||||
<h3 className="mt-1 font-medium text-zinc-950">{item.expenseType?.name ?? "未分类费用"}</h3>
|
||||
<p className="mt-1 text-sm text-zinc-600">
|
||||
{formatDisplayDate(item.expenseDate)} · {formatMoney(item.amount)}
|
||||
</p>
|
||||
</div>
|
||||
{capabilities.onReferenceItem ? (
|
||||
<Button className="min-h-11" data-testid="approval-reference-item" onClick={() => capabilities.onReferenceItem?.(item)} size="sm" type="button" variant="secondary">
|
||||
引用到退回意见
|
||||
</Button>
|
||||
) : null}
|
||||
{capabilities.onEditItem ? (
|
||||
<Button className="min-h-11" onClick={() => capabilities.onEditItem?.(item)} size="sm" type="button" variant="secondary">编辑明细</Button>
|
||||
) : null}
|
||||
{capabilities.onDeleteItem ? (
|
||||
<Button className="min-h-11" onClick={() => capabilities.onDeleteItem?.(item)} size="sm" type="button" variant="secondary">删除明细</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-6 text-zinc-700">{item.description || "未填写明细说明"}</p>
|
||||
{item.assetPriorApprovalBasis ? (
|
||||
<p className="mt-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-800">
|
||||
事前批准依据:{item.assetPriorApprovalBasis}
|
||||
</p>
|
||||
) : null}
|
||||
{item.standardViolations.map((violation) => (
|
||||
<p
|
||||
className={violation.controlMode === "block"
|
||||
? "mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
|
||||
: "mt-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800"}
|
||||
key={`${violation.itemId}-${violation.standardId}`}
|
||||
>
|
||||
提交时费用标准提示:申报 {formatMoney(violation.amount)},标准 {formatMoney(violation.maxAmount)},超出 {formatMoney(violation.exceededAmount)}。审批时请核对原因。
|
||||
</p>
|
||||
))}
|
||||
{item.showOfficeElectronicsHint ? (
|
||||
<p className="mt-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
该金额通常应选择办公电子设备,请核对费用类型和事前批准依据。
|
||||
</p>
|
||||
) : null}
|
||||
{item.amountExceeded ? (
|
||||
<p className="mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
当前申报金额高于已读取的票面金额合计,暂时不能据此通过;请核对明细金额和票据是否完整。
|
||||
</p>
|
||||
) : null}
|
||||
{item.invoices.length || item.expenseType?.requiresInvoice !== false ? <div className="mt-4 space-y-2">
|
||||
<p className="text-sm font-medium text-zinc-800">所属票据({item.invoices.length})</p>
|
||||
{item.invoices.length ? item.invoices.map((invoice) => (
|
||||
<InvoiceCard capabilities={capabilities} invoice={invoice} key={invoice.id} linkTargets={evidence.items} />
|
||||
)) : (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
这条明细尚无对应票据,暂时无法提交;请添加票据并选择这条明细。
|
||||
</p>
|
||||
)}
|
||||
</div> : null}
|
||||
</article>
|
||||
); }) : (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">当前报销单没有可核验的明细。</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{evidence.unlinkedInvoices.length ? (
|
||||
<section className="space-y-2 rounded-xl border border-red-200 bg-red-50 p-4" data-testid="approval-unlinked-invoices" id="reimbursement-workspace-unlinked">
|
||||
<div>
|
||||
<h3 className="font-medium text-red-900">尚未对应明细的票据({evidence.unlinkedInvoices.length})</h3>
|
||||
<p className="mt-1 text-sm text-red-800">这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。</p>
|
||||
</div>
|
||||
{evidence.unlinkedInvoices.map((invoice) => (
|
||||
<InvoiceCard capabilities={capabilities} invoice={invoice} key={invoice.id} linkTargets={evidence.items} />
|
||||
))}
|
||||
<div className="space-y-6" data-testid="reimbursement-approval-evidence">
|
||||
<section className="space-y-3" data-testid="reimbursement-expense-items">
|
||||
<h3 className="font-medium text-zinc-900">费用明细</h3>
|
||||
{evidence.items.length ? (mobile ? <ExpenseItemCards capabilities={capabilities} items={evidence.items} /> : <ExpenseItemsTable capabilities={capabilities} items={evidence.items} />) : <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">当前报销单没有可核验的明细。</p>}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-2" data-testid="approval-attachments">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="font-medium text-zinc-900">补充附件({evidence.attachments.length})</h3>
|
||||
{capabilities.onOpenMaterialBundle ? (
|
||||
<Button className="min-h-11" onClick={capabilities.onOpenMaterialBundle} size="sm" type="button" variant="secondary">
|
||||
查看完整材料
|
||||
</Button>
|
||||
) : null}
|
||||
<section className="space-y-4" data-testid="approval-materials">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2"><h3 className="font-medium text-zinc-900">票据与附件</h3>{capabilities.onOpenMaterialBundle ? <Button className="min-h-11" onClick={capabilities.onOpenMaterialBundle} size="sm" type="button" variant="secondary">查看完整材料</Button> : null}</div>
|
||||
<div className="space-y-2" data-testid="approval-invoices">
|
||||
<h4 className="text-sm font-medium text-zinc-800">发票({linkedInvoices.length + evidence.unlinkedInvoices.length})</h4>
|
||||
{linkedInvoices.map((invoice) => <InvoiceMaterialRow capabilities={capabilities} invoice={invoice} key={invoice.id} linkTargets={evidence.items} />)}
|
||||
{evidence.unlinkedInvoices.length ? <div className="space-y-2 rounded-lg border border-red-200 bg-red-50 p-3" data-testid="approval-unlinked-invoices" id="reimbursement-workspace-unlinked"><p className="text-sm font-medium text-red-900">尚未对应明细({evidence.unlinkedInvoices.length})</p><p className="text-sm text-red-800">这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。</p>{evidence.unlinkedInvoices.map((invoice) => <InvoiceMaterialRow capabilities={capabilities} invoice={invoice} key={invoice.id} linkTargets={evidence.items} />)}</div> : null}
|
||||
{!linkedInvoices.length && !evidence.unlinkedInvoices.length ? <p className="text-sm text-zinc-500">暂无发票</p> : null}
|
||||
</div>
|
||||
{evidence.attachments.length ? evidence.attachments.map((attachment) => (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-zinc-200 px-3 py-2" key={attachment.id}>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-zinc-900">{attachment.originalName}</p>
|
||||
<p className="text-xs text-zinc-500">{attachment.mimeType} · {(attachment.fileSize / 1024).toFixed(1)} KB</p>
|
||||
<div className="space-y-2" data-testid="approval-attachments">
|
||||
<h4 className="text-sm font-medium text-zinc-800">补充附件({evidence.attachments.length})</h4>
|
||||
{evidence.attachments.length ? evidence.attachments.map((attachment) => <AttachmentMaterialRow attachment={attachment} capabilities={capabilities} key={attachment.id} />) : <p className="text-sm text-zinc-500">未上传补充附件</p>}
|
||||
</div>
|
||||
{capabilities.onOpenAttachment ? (
|
||||
<Button aria-label={`查看补充附件:${attachment.originalName}`} className="min-h-11" onClick={() => capabilities.onOpenAttachment?.(attachment)} size="sm" type="button" variant="secondary">
|
||||
查看附件
|
||||
</Button>
|
||||
) : null}
|
||||
{capabilities.onDeleteAttachment ? (
|
||||
<Button className="min-h-11" onClick={() => capabilities.onDeleteAttachment?.(attachment)} size="sm" type="button" variant="secondary">
|
||||
删除附件
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)) : <p className="text-sm text-zinc-500">未上传补充附件</p>}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -47,7 +47,7 @@ export function ReimbursementMobileWorkspaceShell({ model, capabilities, slots,
|
||||
</section>
|
||||
{slots.beforeEvidence}
|
||||
<section className="mobile-card scroll-mt-20" data-testid="reimbursement-workspace-evidence" id="reimbursement-workspace-evidence">
|
||||
<h2 className="mb-3 text-base font-semibold text-zinc-900">明细与票据</h2>
|
||||
<h2 className="mb-3 text-base font-semibold text-zinc-900">报销内容</h2>
|
||||
<ReimbursementItemEvidenceList evidence={model.evidence} capabilities={capabilities} mobile />
|
||||
</section>
|
||||
{slots.afterEvidence}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { ReimbursementApprovalEvidenceProjection } from "../../lib/reimbursementApprovalProjection";
|
||||
import { buildReimbursementReturnReference } from "../../lib/reimbursementApprovalProjection";
|
||||
import { formatDisplayDate } from "../../lib/displayFormat";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
type Props = { evidence: ReimbursementApprovalEvidenceProjection; onReference: (reference: string) => void };
|
||||
|
||||
export function ReimbursementReturnReferencePicker({ evidence, onReference }: Props) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set());
|
||||
const candidates = useMemo(() => {
|
||||
const items = evidence.items.map((item) => ({
|
||||
id: `item:${item.id}`,
|
||||
kind: "item" as const,
|
||||
label: `明细:${item.expenseType?.name ?? "未分类费用"} · ${formatDisplayDate(item.expenseDate)}`,
|
||||
abnormal: item.amountExceeded || item.standardViolations.length > 0 || item.showOfficeElectronicsHint || item.invoices.some((invoice) => invoice.amountEvidenceStatus !== "verified") || (item.expenseType?.requiresInvoice !== false && item.invoices.length === 0),
|
||||
reference: buildReimbursementReturnReference({ item }),
|
||||
}));
|
||||
const invoices = [...evidence.items.flatMap((item) => item.invoices), ...evidence.unlinkedInvoices].map((invoice) => ({
|
||||
id: `invoice:${invoice.id}`,
|
||||
kind: "invoice" as const,
|
||||
label: `发票:${invoice.invoiceNumber || invoice.originalName || "未命名发票"}`,
|
||||
abnormal: !invoice.linkedItemId || invoice.amountEvidenceStatus !== "verified",
|
||||
reference: buildReimbursementReturnReference({ invoice }),
|
||||
}));
|
||||
return [...items, ...invoices].sort((left, right) => Number(right.abnormal) - Number(left.abnormal));
|
||||
}, [evidence]);
|
||||
const visible = showAll ? candidates : candidates.filter((candidate) => candidate.abnormal);
|
||||
return <div className="space-y-2 rounded-lg border border-zinc-200 bg-zinc-50 p-3" data-testid="reimbursement-return-reference-picker">
|
||||
<div className="flex items-center justify-between gap-2"><p className="text-xs font-medium text-zinc-700">引用报销内容</p>{candidates.some((candidate) => !candidate.abnormal) ? <Button className="min-h-11" onClick={() => setShowAll((value) => !value)} size="sm" type="button" variant="ghost">{showAll ? "只看需关注" : "展开全部"}</Button> : null}</div>
|
||||
{visible.length ? visible.map((candidate) => <label className="flex min-h-11 cursor-pointer items-center gap-2 rounded-md px-2 text-sm text-zinc-700 hover:bg-white" key={candidate.id}><input checked={selected.has(candidate.id)} data-testid={candidate.kind === "item" ? "approval-reference-item" : "approval-reference-invoice"} onChange={(event) => { if (!event.target.checked) return; setSelected((current) => new Set(current).add(candidate.id)); onReference(candidate.reference); }} type="checkbox" /><span>{candidate.label}{candidate.abnormal ? " · 需关注" : ""}</span></label>) : <p className="text-xs text-zinc-500">当前没有需优先引用的问题;可展开全部选择。</p>}
|
||||
</div>;
|
||||
}
|
||||
@@ -372,6 +372,7 @@ export function MobileReimbursementDetailPage() {
|
||||
mutationFn: (itemId: string) => deleteReimbursementItem(reimbursementId, itemId),
|
||||
onSuccess: async () => {
|
||||
await invalidateDetail();
|
||||
setItemForm(null);
|
||||
setConfirmDeleteItemId("");
|
||||
toast.success("明细已删除");
|
||||
},
|
||||
@@ -924,7 +925,6 @@ export function MobileReimbursementDetailPage() {
|
||||
const sourceItem = detail.items.find((candidate) => candidate.id === item.id);
|
||||
if (sourceItem) startEditItem(sourceItem);
|
||||
} : undefined,
|
||||
onDeleteItem: canMaintainReimbursement ? (item: { id: string }) => setConfirmDeleteItemId(item.id) : undefined,
|
||||
onLinkInvoice: canMaintainReimbursement ? (invoice: { id: string }, linkedItemId: string) => linkInvoiceItemMutation.mutate({ invoiceId: invoice.id, linkedItemId }) : undefined,
|
||||
linkInvoicePending: linkInvoiceItemMutation.isPending,
|
||||
linkInvoiceErrors: invoiceItemLinkErrors,
|
||||
@@ -961,7 +961,7 @@ export function MobileReimbursementDetailPage() {
|
||||
<label className="block space-y-1 text-sm text-zinc-700"><span>金额</span><Input disabled={itemSaveMutation.isPending} inputMode="decimal" value={itemForm.amount} onChange={(event) => setItemForm((current) => current ? { ...current, amount: event.target.value } : current)} /></label>
|
||||
<label className="block space-y-1 text-sm text-zinc-700"><span>说明</span><Textarea disabled={itemSaveMutation.isPending} rows={2} value={itemForm.description} onChange={(event) => setItemForm((current) => current ? { ...current, description: event.target.value } : current)} /></label>
|
||||
{shouldShowOfficeElectronicsHint(expenseTypes.find((type) => type.id === itemForm.expenseTypeId), itemForm.amount) ? <p className="text-xs text-amber-700">该金额通常应选择办公电子设备。</p> : null}
|
||||
<div className="grid grid-cols-2 gap-2"><Button disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || itemSaveMutation.isPending} onClick={() => itemSaveMutation.mutate(itemForm)} type="button">保存</Button><Button disabled={itemSaveMutation.isPending} onClick={() => setItemForm(null)} type="button" variant="secondary">取消</Button></div>
|
||||
<div className="grid grid-cols-2 gap-2"><Button disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || itemSaveMutation.isPending} onClick={() => itemSaveMutation.mutate(itemForm)} type="button">保存</Button><Button disabled={itemSaveMutation.isPending} onClick={() => setItemForm(null)} type="button" variant="secondary">取消</Button>{itemForm.mode === "edit" && itemForm.itemId ? <Button className="col-span-2" data-testid="reimbursement-item-delete" disabled={itemSaveMutation.isPending} onClick={() => setConfirmDeleteItemId(itemForm.itemId ?? "")} type="button" variant="destructive">删除明细</Button> : null}</div>
|
||||
</div>
|
||||
) : <p className="text-xs text-zinc-500">选择上方动作维护明细、发票或补充附件。</p>}
|
||||
</section>
|
||||
|
||||
@@ -327,6 +327,8 @@ export function ReimbursementDetailPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }),
|
||||
]);
|
||||
setItemEditor(null);
|
||||
setConfirmDeleteItemId("");
|
||||
setItemError("");
|
||||
toast.success("明细已删除");
|
||||
},
|
||||
@@ -1056,7 +1058,6 @@ export function ReimbursementDetailPage() {
|
||||
const sourceItem = detail.items.find((candidate) => candidate.id === item.id);
|
||||
if (sourceItem) startEditItem(sourceItem);
|
||||
} : undefined,
|
||||
onDeleteItem: canMaintainReimbursement ? (item: { id: string }) => setConfirmDeleteItemId(item.id) : undefined,
|
||||
onLinkInvoice: canMaintainReimbursement ? (invoice: { id: string }, linkedItemId: string) => linkInvoiceItemMutation.mutate({ invoiceId: invoice.id, linkedItemId }) : undefined,
|
||||
linkInvoicePending: linkInvoiceItemMutation.isPending,
|
||||
linkInvoiceErrors: invoiceItemLinkErrors,
|
||||
@@ -1081,7 +1082,7 @@ export function ReimbursementDetailPage() {
|
||||
{itemError ? <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">{itemError}</p> : null}
|
||||
{canMaintainReimbursement ? (
|
||||
<SectionPanel title="维护报销内容" actions={<div className="flex flex-wrap gap-2"><Button data-testid="reimbursement-item-add" disabled={itemSaveMutation.isPending} onClick={startCreateItem} type="button" variant="secondary">新增明细</Button><Button data-testid={TEST_IDS.reimbursement.invoiceAdd} disabled={!canOpenInvoiceBindDialog} onClick={() => handleInvoiceDialogOpenChange(true)} type="button" variant="secondary">添加发票</Button><label className="inline-flex min-h-11 cursor-pointer items-center rounded-md border border-zinc-300 bg-white px-4 text-sm font-medium text-zinc-700" htmlFor="reimbursement-attachment-upload">选择附件</label><input className="hidden" id="reimbursement-attachment-upload" type="file" accept="application/pdf,image/jpeg,image/png,image/webp" multiple onChange={(event) => { const files = Array.from(event.target.files ?? []); if (files.length > MAX_REIMBURSEMENT_ATTACHMENT_FILES) { setSelectedAttachmentFiles([]); event.target.value = ""; toast.error(`单次最多上传 ${MAX_REIMBURSEMENT_ATTACHMENT_FILES} 个附件,请分批上传。`); return; } setSelectedAttachmentFiles(files); }} /><Button disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending} onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)} type="button" variant="secondary">{uploadAttachmentsMutation.isPending ? "上传中..." : selectedAttachmentFiles.length ? `上传 ${selectedAttachmentFiles.length} 个附件` : "上传附件"}</Button></div>}>
|
||||
{itemEditor ? <ReimbursementItemEditor mode={itemEditor.mode} expenseTypes={expenseTypes} expenseTypesLoading={expenseTypesQuery.isLoading} expenseTypesError={expenseTypesQuery.isError} departmentId={detail.departmentId} initialValues={editorInitialValues} onSave={handleItemSave} onCancel={handleItemCancel} isPending={itemSaveMutation.isPending} /> : <p className="text-sm text-zinc-500">选择上方动作维护明细、发票或补充附件。</p>}
|
||||
{itemEditor ? <ReimbursementItemEditor mode={itemEditor.mode} expenseTypes={expenseTypes} expenseTypesLoading={expenseTypesQuery.isLoading} expenseTypesError={expenseTypesQuery.isError} departmentId={detail.departmentId} initialValues={editorInitialValues} onSave={handleItemSave} onCancel={handleItemCancel} onDelete={itemEditor.mode === "edit" ? () => setConfirmDeleteItemId(itemEditor.itemId) : undefined} isPending={itemSaveMutation.isPending} /> : <p className="text-sm text-zinc-500">选择上方动作维护明细、发票或补充附件。</p>}
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface ReimbursementItemEditorProps {
|
||||
initialValues: ItemEditorInitialValues;
|
||||
onSave: (data: ItemEditorSaveData) => void;
|
||||
onCancel: () => void;
|
||||
onDelete?: () => void;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
@@ -44,6 +45,7 @@ export function ReimbursementItemEditor({
|
||||
initialValues,
|
||||
onSave,
|
||||
onCancel,
|
||||
onDelete,
|
||||
isPending,
|
||||
}: ReimbursementItemEditorProps) {
|
||||
const [expenseTypeId, setExpenseTypeId] = useState(initialValues.expenseTypeId);
|
||||
@@ -216,6 +218,11 @@ export function ReimbursementItemEditor({
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
{mode === "edit" && onDelete ? (
|
||||
<Button data-testid="reimbursement-item-delete" disabled={isPending} onClick={onDelete} type="button" variant="destructive">
|
||||
删除明细
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -25,6 +25,7 @@ import { StatusBadge } from "../../components/common/StatusBadge";
|
||||
import { useAttachmentDownloadReasonDialog } from "../../components/common/useAttachmentDownloadReasonDialog";
|
||||
import { ReimbursementDesktopWorkspaceShell } from "../../components/reimbursements/ReimbursementDesktopWorkspaceShell";
|
||||
import { ReimbursementMobileWorkspaceShell } from "../../components/reimbursements/ReimbursementMobileWorkspaceShell";
|
||||
import { ReimbursementReturnReferencePicker } from "../../components/reimbursements/ReimbursementReturnReferencePicker";
|
||||
import { BusinessDetailActionPanel } from "../../components/patterns/BusinessDetailTemplate";
|
||||
import { MobileStickyActionBar } from "../../components/patterns/MobileStickyActionBar";
|
||||
import { SectionPanel } from "../../components/patterns/SectionPanel";
|
||||
@@ -34,10 +35,7 @@ import { Textarea } from "../../components/ui/textarea";
|
||||
import { approvalRoleLabel, workflowActionLabel } from "../../lib/approvalDisplay";
|
||||
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../../lib/attachmentGovernance";
|
||||
import { formatDisplayDateTime } from "../../lib/displayFormat";
|
||||
import {
|
||||
buildReimbursementApprovalEvidence,
|
||||
buildReimbursementReturnReference,
|
||||
} from "../../lib/reimbursementApprovalProjection";
|
||||
import { buildReimbursementApprovalEvidence } from "../../lib/reimbursementApprovalProjection";
|
||||
import {
|
||||
buildReimbursementWorkspaceHeader,
|
||||
buildReimbursementWorkspaceIssueAnchors,
|
||||
@@ -295,6 +293,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
<Label htmlFor={`${layout}-reimbursement-return-comment`}>退回意见</Label>
|
||||
<p className="text-xs text-zinc-500">退回后原单可修改并重新提交,单号和审批历史会保留。</p>
|
||||
<Textarea data-testid={layout === "desktop" ? "workflow-task-return-comment" : "mobile-approvals-comment"} id={`${layout}-reimbursement-return-comment`} value={returnComment} onChange={(event) => setReturnComment(event.target.value)} />
|
||||
<ReimbursementReturnReferencePicker evidence={model.evidence} onReference={appendReturnReference} />
|
||||
<Button className="min-h-11 w-full" data-testid={layout === "desktop" ? "workflow-task-return" : "mobile-approvals-return-submit"} disabled={actionDisabled || !returnComment.trim()} onClick={() => requestAction("return")} type="button" variant="secondary">确认退回</Button>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -341,8 +340,6 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
onOpenInvoice: (invoice: { id: string }) => openInvoiceMutation.mutate(invoice.id),
|
||||
onOpenAttachment: (attachment: { id: string }) => void openAttachment(attachment.id),
|
||||
onOpenMaterialBundle: hasMaterials ? () => void openAllMaterials() : undefined,
|
||||
onReferenceItem: (item: Parameters<typeof buildReimbursementReturnReference>[0]["item"]) => appendReturnReference(buildReimbursementReturnReference({ item })),
|
||||
onReferenceInvoice: (invoice: Parameters<typeof buildReimbursementReturnReference>[0]["invoice"]) => appendReturnReference(buildReimbursementReturnReference({ invoice })),
|
||||
};
|
||||
|
||||
const dialogs = (
|
||||
|
||||
@@ -1257,6 +1257,8 @@ test("已完成审批只保留中文只读摘要", async ({ page }) => {
|
||||
await page.goto("/workflow/tasks/task-wave3-completed");
|
||||
|
||||
await expect(page.getByTestId("workflow-task-action-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("reimbursement-expense-items-table")).toBeVisible();
|
||||
await expect(page.getByTestId("approval-item-evidence").getByRole("button")).toHaveCount(0);
|
||||
await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible();
|
||||
await expect(page.getByTestId("workflow-task-action-panel")).toContainText("审批角色:财务");
|
||||
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("2026年5月27日 18:10");
|
||||
@@ -1308,18 +1310,19 @@ test("报销详情固定使用上海时区并收敛只读信息", async ({ page
|
||||
await expect(page.getByTestId("reimbursement-back")).toContainText("返回报销列表");
|
||||
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("¥1,860.00");
|
||||
await expect(page.getByTestId("reimbursement-approval-evidence")).toContainText("¥860.00");
|
||||
await expect(page.getByTestId("approval-item-evidence").getByRole("button")).toHaveCount(0);
|
||||
await expect(page.getByTestId("approval-attachments")).toContainText("现场说明.pdf");
|
||||
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看发票原件/ })).toHaveCount(1);
|
||||
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看发票:/ })).toHaveCount(1);
|
||||
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1);
|
||||
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /移除|删除/ })).toHaveCount(0);
|
||||
const pcInvoicePreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看发票原件/ });
|
||||
const pcInvoicePreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看发票:/ });
|
||||
const pcMaterialPreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: "查看完整材料" });
|
||||
const pcAttachmentPreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看补充附件/ });
|
||||
await pcInvoicePreview.focus();
|
||||
await expect(pcInvoicePreview).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await pcMaterialPreview.focus();
|
||||
await expect(pcMaterialPreview).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(pcInvoicePreview).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(pcAttachmentPreview).toBeFocused();
|
||||
await expect(page.getByTestId("reimbursement-workflow-progress")).toContainText("负责人复核");
|
||||
await expect(page.getByTestId("reimbursement-workflow-progress")).toContainText("付款复核");
|
||||
@@ -1332,24 +1335,25 @@ test("报销详情固定使用上海时区并收敛只读信息", async ({ page
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/mobile/reimbursements/reim-wave3");
|
||||
await expect(page.getByTestId("mobile-reimbursement-detail-header")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "明细与票据" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "报销内容", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "当前办理摘要" })).toHaveCount(0);
|
||||
await expect(page.getByTestId("reimbursement-mobile-workspace")).toContainText(visualMe.name);
|
||||
await expect(page.getByTestId("reimbursement-mobile-workspace")).toContainText("¥1,860.00");
|
||||
await expect(page.getByTestId("reimbursement-approval-evidence")).toContainText("¥860.00");
|
||||
await expect(page.getByTestId("approval-item-evidence").getByRole("button")).toHaveCount(0);
|
||||
await expect(page.getByTestId("mobile-reimbursement-processing-progress")).toContainText("负责人复核");
|
||||
await expect(page.getByTestId("approval-attachments")).toContainText("现场说明.pdf");
|
||||
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看发票原件/ })).toHaveCount(1);
|
||||
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看发票:/ })).toHaveCount(1);
|
||||
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1);
|
||||
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /移除|删除/ })).toHaveCount(0);
|
||||
const mobileInvoicePreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看发票原件/ });
|
||||
const mobileInvoicePreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看发票:/ });
|
||||
const mobileMaterialPreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: "查看完整材料" });
|
||||
const mobileAttachmentPreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看补充附件/ });
|
||||
await mobileInvoicePreview.focus();
|
||||
await expect(mobileInvoicePreview).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await mobileMaterialPreview.focus();
|
||||
await expect(mobileMaterialPreview).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(mobileInvoicePreview).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(mobileAttachmentPreview).toBeFocused();
|
||||
await expect(page.getByTestId("mobile-reimbursement-processing-progress")).toContainText("付款完成");
|
||||
await expect(page.getByTestId("mobile-reimbursement-actions")).toHaveCount(0);
|
||||
@@ -1364,7 +1368,7 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
|
||||
await page.goto("/reimbursements/reim-wave3");
|
||||
await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: `报销单 ${reimbursementDetail.formNumber}` })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "明细与票据" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "报销内容" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "当前状态与下一步" })).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { name: "业务摘要" })).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { name: "预算与审核提示" })).toHaveCount(0);
|
||||
@@ -1551,7 +1555,7 @@ test("Wave3 移动关键页视觉截图", async ({ page }) => {
|
||||
|
||||
await page.goto("/mobile/reimbursements/reim-wave3");
|
||||
await expect(page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "明细与票据" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "报销内容", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "当前办理摘要" })).toHaveCount(0);
|
||||
await expect(page.getByTestId("reimbursement-mobile-workspace")).toContainText(visualMe.name);
|
||||
await expect(page.getByTestId("approval-attachments")).toContainText("现场说明.pdf");
|
||||
|
||||
@@ -415,11 +415,11 @@ test.describe("R102 reimbursement main flow", () => {
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
|
||||
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("¥168.30");
|
||||
await expect(page.getByTestId("approval-item-evidence")).toContainText("明细 1");
|
||||
await expect(page.getByTestId("approval-item-evidence")).toContainText("所属票据(0)");
|
||||
await expect(page.getByTestId("reimbursement-expense-items-table").getByRole("columnheader")).toHaveText(["序号", "费用类型", "发生日期", "费用说明", "金额", "票据", "操作"]);
|
||||
await expect(page.getByTestId("approval-item-evidence")).toContainText("未关联");
|
||||
await expect(page.getByTestId("approval-item-evidence")).toContainText("这条明细尚无对应票据");
|
||||
|
||||
await page.getByRole("button", { name: "编辑", exact: true }).click();
|
||||
await page.getByRole("button", { name: "编辑", exact: true }).first().click();
|
||||
const editor = page.getByTestId("reimbursement-header-editor");
|
||||
await editor.getByLabel("报销日期").fill("2026-05-07");
|
||||
await editor.getByLabel("报销事由").fill("办公网络费用");
|
||||
@@ -533,7 +533,7 @@ test.describe("R102 reimbursement main flow", () => {
|
||||
await expect(page.getByTestId("reimbursement-item-add")).toHaveCount(0);
|
||||
await expect(page.getByRole("combobox", { name: "关联项目" })).toHaveCount(0);
|
||||
await expect(page.getByTestId("approval-unlinked-invoices")).toContainText("R102-OTHER");
|
||||
await expect(page.getByTestId("approval-item-evidence")).toContainText("所属票据(0)");
|
||||
await expect(page.getByTestId("approval-item-evidence")).toContainText("未关联");
|
||||
await expect(page.getByText("草稿或驳回状态可移除")).toHaveCount(0);
|
||||
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
@@ -566,7 +566,7 @@ test.describe("R102 reimbursement main flow", () => {
|
||||
await expect(page.getByText("只读查看")).toBeVisible();
|
||||
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /新增|编辑|删除/ })).toHaveCount(0);
|
||||
await expect(page.getByTestId("approval-unlinked-invoices")).toBeVisible();
|
||||
await expect(page.getByTestId("approval-item-evidence")).toContainText("所属票据(0)");
|
||||
await expect(page.getByTestId("approval-item-evidence")).toContainText("票据:未关联");
|
||||
await expect(page.getByRole("button", { name: "移除" })).toHaveCount(0);
|
||||
const routeState = await page.evaluate(() => window.history.state?.usr ?? null);
|
||||
expect(routeState?.from).toContain("status=reviewing");
|
||||
@@ -584,7 +584,7 @@ test.describe("R102 reimbursement main flow", () => {
|
||||
test("报销明细记录切换时支持取消保持与确认放弃", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
await page.getByTestId("approval-item-evidence").getByRole("button", { name: "编辑明细" }).click();
|
||||
await page.getByTestId("approval-item-evidence").getByRole("button", { name: "编辑", exact: true }).click();
|
||||
|
||||
const addItem = page.getByTestId("reimbursement-item-add");
|
||||
await addItem.click();
|
||||
|
||||
@@ -819,7 +819,7 @@ async function exerciseInvoiceGuardRecoveryAfterPrecheckConfirm(
|
||||
|
||||
const itemSelect = page.getByLabel("选择报销明细");
|
||||
await itemSelect.selectOption(targetItemId);
|
||||
await itemSelect.locator("..").getByRole("button", { name: "确认关联", exact: true }).click();
|
||||
await page.getByTestId("approval-unlinked-invoices").getByRole("button", { name: "确认关联", exact: true }).click();
|
||||
await expect.poll(() => state.invoiceItemLinkRequests).toEqual([{
|
||||
invoiceId: invoice.id,
|
||||
linkedItemId: targetItemId,
|
||||
@@ -877,7 +877,9 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
await expect(page.getByTestId("reimbursement-approval-evidence")).toContainText("12345679");
|
||||
await expect(page.getByTestId("reimbursement-approval-evidence")).not.toContainText("无代码");
|
||||
await expect(page.getByTestId("reimbursement-approval-evidence")).not.toContainText("未识别销售方");
|
||||
await expect(page.getByRole("button", { name: /查看发票原件/ })).toHaveCount(2);
|
||||
await expect(page.getByRole("button", { name: /查看发票:/ })).toHaveCount(2);
|
||||
await expect(page.getByRole("button", { name: "查看原件", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "查看附件", exact: true })).toHaveCount(0);
|
||||
|
||||
await page.locator("#reimbursement-attachment-upload").setInputFiles({
|
||||
name: "付款截图.png",
|
||||
@@ -1103,7 +1105,7 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
await page.getByTestId("reimbursement-submit").click();
|
||||
|
||||
await expect(page.getByTestId("reimbursement-action-error").first()).toContainText("1 张发票未选择报销明细");
|
||||
await expect(page.getByText("这条明细尚无对应票据,暂时无法提交;请添加票据并选择这条明细。")).toBeVisible();
|
||||
await expect(page.getByText("这条明细尚无对应票据,暂时无法提交;请在票据区添加票据并选择这条明细。")).toBeVisible();
|
||||
await expect(page.getByText("当前申报金额高于已读取的票面金额合计,暂时不能据此通过;请核对明细金额和票据是否完整。")).toBeVisible();
|
||||
await expect(page.getByText("这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。")).toBeVisible();
|
||||
await expect(page.getByText("票面金额需财务查看原件核对;这是一项审批提示,不会阻止提交。")).toBeVisible();
|
||||
@@ -1129,7 +1131,7 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
invoiceId: unlinkedInvoiceId,
|
||||
linkedItemId: "item-missing-r111",
|
||||
}]);
|
||||
await expect(page.getByText("这条明细尚无对应票据,暂时无法提交;请添加票据并选择这条明细。")).toHaveCount(0);
|
||||
await expect(page.getByText("这条明细尚无对应票据,暂时无法提交;请在票据区添加票据并选择这条明细。")).toHaveCount(0);
|
||||
await expect(page.getByText("当前申报金额高于已读取的票面金额合计,暂时不能据此通过;请核对明细金额和票据是否完整。")).toBeVisible();
|
||||
|
||||
const structuredGuardDetails = state.submitGuardDetails;
|
||||
@@ -1155,7 +1157,7 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
await page.goto(`/mobile/reimbursements/${reimbursementId}`);
|
||||
await page.getByTestId("reimbursement-submit").click();
|
||||
await expect(page.getByTestId("reimbursement-action-error")).toContainText("票据检查未通过");
|
||||
await expect(page.getByText("这条明细尚无对应票据,暂时无法提交;请添加票据并选择这条明细。")).toBeVisible();
|
||||
await expect(page.getByText("这条明细尚无对应票据,暂时无法提交;请在票据区添加票据并选择这条明细。")).toBeVisible();
|
||||
await expect(page.getByText("这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。")).toBeVisible();
|
||||
await expect(page.getByText("票面金额需财务查看原件核对;这是一项审批提示,不会阻止提交。")).toBeVisible();
|
||||
await expect(page.getByText(
|
||||
@@ -1196,7 +1198,7 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
await login(mobile.page);
|
||||
await mobile.page.goto(`/mobile/reimbursements/${reimbursementId}`);
|
||||
await expect(mobile.page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
|
||||
await expect(mobile.page.getByRole("heading", { name: "明细与票据" })).toBeVisible();
|
||||
await expect(mobile.page.getByRole("heading", { name: "报销内容", exact: true })).toBeVisible();
|
||||
|
||||
const beforeDialogMetrics = await collectVisualMetrics(mobile.page);
|
||||
expect(beforeDialogMetrics.scrollWidth).toBeLessThanOrEqual(beforeDialogMetrics.width + 2);
|
||||
@@ -1260,11 +1262,12 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
await expect(mobileEvidence).not.toContainText("无代码");
|
||||
await expect(mobileEvidence).not.toContainText("未识别销售方");
|
||||
const mobileItem = mobile.page.getByTestId("approval-item-evidence").filter({ hasText: "客户招待" }).first();
|
||||
await expect(mobileItem).toContainText("12345678");
|
||||
await expect(mobileItem).not.toContainText("12345678");
|
||||
await expect(mobileItem).not.toContainText("12345679");
|
||||
await expect(mobile.page.getByTestId("approval-unlinked-invoices")).toContainText("12345679");
|
||||
await expect(mobileEvidence.getByRole("button", { name: /查看发票原件/ })).toHaveCount(2);
|
||||
await expect(mobileEvidence.getByRole("button", { name: /查看发票:/ })).toHaveCount(2);
|
||||
await expect(mobile.page.getByRole("button", { name: "查看原件", exact: true })).toHaveCount(0);
|
||||
await expect(mobile.page.getByRole("button", { name: "查看附件", exact: true })).toHaveCount(0);
|
||||
|
||||
const maintenanceSection = mobile.page.locator("section").filter({ has: mobile.page.getByRole("heading", { name: "维护报销内容" }) });
|
||||
await expect(maintenanceSection.getByRole("button", { name: "选择附件" })).toBeVisible();
|
||||
|
||||
@@ -432,10 +432,14 @@ test.describe("R7E 审批动作二次确认", () => {
|
||||
await expect(page.getByTestId("workflow-task-summary")).toHaveCount(0);
|
||||
await expect(page.getByText("打开完整原始表单")).toHaveCount(0);
|
||||
await expect(page.getByText(/0 条/)).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "编辑明细" })).toHaveCount(0);
|
||||
await expect(page.getByTestId("approval-item-evidence").getByRole("button")).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "删除明细" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "选择明细" })).toHaveCount(0);
|
||||
await expect(page.getByText("引用到退回意见")).toHaveCount(0);
|
||||
await expect(page.getByTestId("reimbursement-return-reference-picker")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("workflow-task-return-toggle").click();
|
||||
await expect(page.getByTestId("reimbursement-return-reference-picker")).toBeVisible();
|
||||
await page.getByTestId("approval-reference-item").click();
|
||||
await expect(page.getByTestId("workflow-task-return-comment")).toHaveValue(/【明细:交通费 · 2026-09-01 · ¥180\.00】/);
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户