统一业务台账首屏布局(05-30-fix-payment-ledger-layout-consistency)
这个提交包含在:
@@ -280,8 +280,9 @@ pages must pick one primary template before implementation:
|
|||||||
- `业务台账列表`: reimbursement, contracts, projects, payments, AP and invoices.
|
- `业务台账列表`: reimbursement, contracts, projects, payments, AP and invoices.
|
||||||
Page-header primary action, command bar, list/table body and folded ledger
|
Page-header primary action, command bar, list/table body and folded ledger
|
||||||
tools.
|
tools.
|
||||||
- `主从工作台`: procurement, assets and inventory checks. Left/index list plus
|
- `主从工作台`: only for true task execution after an explicit user selection,
|
||||||
right/current-object detail and actions.
|
such as an inventory-check task that has been opened. It is not the default
|
||||||
|
first screen for procurement, assets, payments, AP or invoices.
|
||||||
- `详情工作区`: reimbursement detail, workflow task detail, project detail and
|
- `详情工作区`: reimbursement detail, workflow task detail, project detail and
|
||||||
future contract detail. Overview, facts/evidence, right-side summary/command
|
future contract detail. Overview, facts/evidence, right-side summary/command
|
||||||
panel and history.
|
panel and history.
|
||||||
@@ -297,9 +298,11 @@ pages must pick one primary template before implementation:
|
|||||||
- Mobile is the responsive expression of these templates: top status/focus,
|
- Mobile is the responsive expression of these templates: top status/focus,
|
||||||
card list or sectioned detail, bottom primary action. Do not create a PC shell
|
card list or sectioned detail, bottom primary action. Do not create a PC shell
|
||||||
inside mobile routes.
|
inside mobile routes.
|
||||||
- B-class business pages should usually choose `业务台账列表` or `主从工作台`.
|
- B-class business pages should usually choose `业务台账列表`. Procurement,
|
||||||
Use a ledger when records open into details; use master-detail when users
|
assets, payments, AP and invoices must start as a list and open details only
|
||||||
select one object and edit/process it in the same workspace.
|
through an explicit row action, `?id=` detail mode, drawer or detail route.
|
||||||
|
Use `主从工作台` only for explicit execution tasks, and never auto-select the
|
||||||
|
first item merely to fill a detail column.
|
||||||
- A-class task pages must not drift into configuration surfaces. C-class
|
- A-class task pages must not drift into configuration surfaces. C-class
|
||||||
configuration pages must not appear as ordinary employee task pages.
|
configuration pages must not appear as ordinary employee task pages.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{"file":".trellis/spec/hyb-oa/frontend-react-vite-design.md","reason":"检查业务台账是否保持单列列表、显式详情和紧凑筛选。"}
|
||||||
|
{"file":".trellis/spec/hyb-oa/deployment-trialrun.md","reason":"部署、公网复检和可测试告知边界。"}
|
||||||
|
{"file":".trellis/spec/hyb-oa/project-governance.md","reason":"检查 Trellis task、提交、验证和交付流程是否闭环。"}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{"file":".trellis/spec/hyb-oa/frontend-react-vite-design.md","reason":"业务台账首屏、成熟企业页面基线、按钮命名和详情页布局约束。"}
|
||||||
|
{"file":".trellis/spec/hyb-oa/project-governance.md","reason":"Trellis task、提交、验证和 solo developer 工作流约束。"}
|
||||||
|
{"file":".trellis/spec/hyb-oa/deployment-trialrun.md","reason":"公网部署、公网检查和可测试告知边界。"}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# 实施说明
|
||||||
|
|
||||||
|
## 页面策略
|
||||||
|
|
||||||
|
- 业务台账首屏统一为单列列表:付款、资产、采购、票据、应付账款、报销和项目不得默认渲染详情区。
|
||||||
|
- 详情必须由用户显式进入:使用 `?id=`、详情路由或行级 `查看详情`,不自动选择第一条记录。
|
||||||
|
- `台账工具` 固定为页头右侧入口,展开后占据列表上方全宽区域,不作为左侧栏或右侧提示栏。
|
||||||
|
- 状态摘要使用紧凑 chips/tabs/counts,去掉普通工作流解释句和大型状态卡。
|
||||||
|
|
||||||
|
## 风险控制
|
||||||
|
|
||||||
|
- 不改后端 API、权限校验、审批状态机和付款执行语义。
|
||||||
|
- 旧 E2E 按新交互同步:票据上传/绑定先打开 `台账工具`,付款执行进入批次详情。
|
||||||
|
- 资产盘点保留执行页例外,但首屏不自动选择盘点任务。
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
# 修正业务台账首屏布局一致性
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
修正用户指出的台账统一规则漏改问题,并扩展为所有业务台账页一致性审查。统一采用成熟企业系统常见的列表边界:首屏只做查找、筛选、状态判断和进入详情;详情通过显式入口进入独立详情/编辑视图;台账工具在页头右侧触发并展开为列表上方的工具区,不挤压列表,不在首屏默认塞入详情工作台。
|
||||||
|
|
||||||
|
## What I already know
|
||||||
|
|
||||||
|
- 用户明确反馈:
|
||||||
|
- `付款` 还没按统一规则改。
|
||||||
|
- 台账虽然是列表,但详情在列表下面,仍然不一致。
|
||||||
|
- `台账工具` 还在左侧,和其他页面操作区域不一致。
|
||||||
|
- 需要继续检查其他页面,避免只修局部后同类问题继续存在。
|
||||||
|
- 已确认的统一规则:
|
||||||
|
- 业务台账首屏统一为单列列表。
|
||||||
|
- 不默认左列表/右详情,不自动选中第一条记录。
|
||||||
|
- 不在列表下方自动展开详情。
|
||||||
|
- 状态摘要用 Tab/Chip/counts,不使用大状态卡片。
|
||||||
|
- `台账工具` 属于页头右侧操作,低频工具展开在列表上方或独立面板,不作为左侧栏。
|
||||||
|
- 详情页采用独立详情工作区:PC 顶部摘要、中部事实/证据/明细、右侧或顶部动作区;移动端单列。
|
||||||
|
- 已定位代码:
|
||||||
|
- `client/src/pages/PaymentBatchesPage.tsx`
|
||||||
|
- 仍有 `WorkspaceHeader` 副标题解释文案。
|
||||||
|
- 仍有 `finance-status-strip` 五张大指标卡。
|
||||||
|
- `selectedBatch` 默认回退到 `batches[0]`,会自动选中首条。
|
||||||
|
- `useEffect` 自动写入 `?id=`。
|
||||||
|
- 主体为 `xl:grid-cols-[340px,minmax(0,1fr)]` 的左列表/右详情。
|
||||||
|
- `client/src/pages/AssetRegistryPage.tsx`
|
||||||
|
- `showLedgerTools` 时使用 `xl:grid-cols-[380px,minmax(0,1fr)]`,台账工具成为左侧列。
|
||||||
|
- 点击资产行后保持列表并在下方直接渲染详情/编辑区。
|
||||||
|
- 详情仍依赖 `selectedAssetId || isCreatingAsset` 在列表页面下方展开。
|
||||||
|
- `client/src/pages/PurchaseRequestsPage.tsx`
|
||||||
|
- 仍有 `selectedId`、`?id`、行高亮和列表下方详情区。
|
||||||
|
- `id` 模式仍像内嵌详情,不像独立详情工作区。
|
||||||
|
- `client/src/pages/InvoicePoolPage.tsx`
|
||||||
|
- 会在列表数据返回后自动选中第一张票据。
|
||||||
|
- 仍有页头解释性副标题和五张大状态卡。
|
||||||
|
- 列表后默认渲染 `InvoiceDetailPanel`。
|
||||||
|
- `client/src/pages/InventoryChecksPage.tsx`
|
||||||
|
- 资产盘点是任务执行页而非普通台账,但当前使用大状态卡、左侧新建/列表、右侧详情执行工作台,并自动选中第一条盘点任务。
|
||||||
|
- 本轮需要判断是否按“资产执行页例外”处理,至少不能混入普通资产台账首屏。
|
||||||
|
- 已初步检查但不作为主要改造目标:
|
||||||
|
- `client/src/pages/ReimbursementsPage.tsx`:当前更接近列表优先,详情走独立路由。
|
||||||
|
- `client/src/pages/ContractsRegistryPage.tsx`:台账工具位于上方,但新建/编辑仍在列表前方,需做防回退和局部降噪检查。
|
||||||
|
- `client/src/pages/ProjectsPage.tsx`:项目详情走独立路由,台账工具位于上方;工具区内有低频查看侧块,需做一致性检查。
|
||||||
|
- `client/src/pages/ApOperationsPage.tsx`:应付账款是财务操作页,采用 tabs/list 和上方工具;`财务台账` tab 默认筛选较多,需检查是否压缩到常用筛选/台账工具。
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- `付款`、`资产`、`采购`、`票据` 都是业务台账列表,不是双列工作台。
|
||||||
|
- `资产`、`采购`、`票据` 的详情/编辑表单可以继续复用现有组件代码,但显示方式改为独立视图,不再压在列表下方。
|
||||||
|
- `盘点任务` 可以保留执行页属性,但不能影响普通资产台账;如果保留主从工作台,也必须在 PRD/规范中明确它是任务执行页例外,而不是业务台账默认形态。
|
||||||
|
- 本轮只修 PC 台账一致性;移动资产扫码/移动页面不改业务流程。
|
||||||
|
- 付款批次主链路测试需要同步更新:创建批次后进入详情视图,详情里的复核、回单、执行操作继续可用。
|
||||||
|
|
||||||
|
## Proposed Plan
|
||||||
|
|
||||||
|
1. 全量页面归类:
|
||||||
|
- 业务台账页:`报销`、`采购`、`合同台账`、`项目`、`资产`、`付款`、`应付账款`、`票据`。
|
||||||
|
- 必改页面:`付款`、`资产`、`采购`、`票据`。
|
||||||
|
- 防回退检查页面:`报销`、`合同台账`、`项目`、`应付账款`。
|
||||||
|
- 例外/单独判断页面:`资产盘点` 是任务执行页;`请假记录`、`考勤` 是企业微信边界页;管理配置页不套普通台账首屏规则。
|
||||||
|
2. `付款` 页面:
|
||||||
|
- 去掉页头解释性副标题。
|
||||||
|
- 去掉五张大状态卡,改为列表上方紧凑 Tab/Chip:`全部`、`待复核`、`待付款`、`待补回单`、`异常`。
|
||||||
|
- 首屏只显示批次列表表格/卡片,列包含批次、实体/金额、状态/复核、回单、操作。
|
||||||
|
- 不自动选中首条,不自动写入 `?id=`。
|
||||||
|
- 行操作使用 `查看详情`;可保留低风险快捷动作,但优先详情页处理。
|
||||||
|
- `?id=<batchId>` 时显示独立付款批次详情视图,不再同时显示左列表。
|
||||||
|
- `台账工具` 仍为页头右侧按钮;展开时显示在列表上方全宽工具区。
|
||||||
|
3. `资产` 页面:
|
||||||
|
- `台账工具` 展开为页头下方全宽工具区,不再作为左侧列。
|
||||||
|
- 首屏只显示筛选 + 资产列表;点击行进入 `?id=<assetId>` 的独立资产详情/编辑视图,不在列表下方展开。
|
||||||
|
- `新建资产卡片` 进入独立创建视图,创建完成后进入该资产详情。
|
||||||
|
- 列表页增加右侧操作列 `查看详情`,保持行点击可用但不高亮当前行。
|
||||||
|
4. `采购` 页面:
|
||||||
|
- `/purchase-requests` 首屏只显示筛选 + 采购申请列表。
|
||||||
|
- `?id=<purchaseRequestId>` 改为独立详情/编辑视图,不在列表下方继续展示详情。
|
||||||
|
- 行操作统一为 `查看详情`、`继续编辑`、`查看进度` 等常规动作,审批动作仍进入统一待办/审批详情。
|
||||||
|
- 台账工具仅承接创建、导出、归档/ERP 等低频能力,不作为第二个主体区域。
|
||||||
|
5. `票据` 页面:
|
||||||
|
- `/invoice-pool` 不自动选中第一张票据。
|
||||||
|
- 去掉页头解释性副标题和五张大状态卡,改为紧凑状态 tabs/chips。
|
||||||
|
- 列表首屏只显示票据列表、识别/绑定状态和操作;详情通过显式 `查看详情` 打开独立详情视图或抽屉。
|
||||||
|
6. `资产盘点` 页面:
|
||||||
|
- 先审查并明确页面模板:若作为资产执行页例外保留主从结构,需要在规范中写清“任务执行页例外”;否则改为盘点任务列表 + 独立盘点详情。
|
||||||
|
- 至少去掉默认自动选中和首屏大段解释文案,避免被误认为普通业务台账模板。
|
||||||
|
7. 防回退检查:
|
||||||
|
- `报销`、`合同台账`、`项目`、`应付账款` 保持列表优先、上方工具、详情显式进入。
|
||||||
|
- 若发现同类内嵌详情、默认选中、左侧工具挤压列表或多行默认筛选,纳入同一轮修正。
|
||||||
|
8. 测试同步:
|
||||||
|
- 更新付款批次、资产、采购、票据、视觉一致性相关断言,明确首屏不显示详情面板。
|
||||||
|
- 保持付款批次主链路仍能创建、复核、上传回单、执行。
|
||||||
|
9. 验证与发布:
|
||||||
|
- `cd client && npm run typecheck && npm run build`
|
||||||
|
- 聚焦 Playwright:付款、资产、采购、票据、财务视觉、跨页面一致性。
|
||||||
|
- 通过后提交、部署、公网复检。
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- 前端页面:付款、资产、采购、票据、资产盘点、应付账款、项目、报销。
|
||||||
|
- 测试:同步受布局变更影响的票据、付款、跨页面一致性、移动票据和高风险动作确认 E2E。
|
||||||
|
- 规范:沉淀业务台账首屏为单列列表、详情显式进入、台账工具页头右侧展开的统一规则。
|
||||||
|
- 不触碰后端 API、权限模型、审批状态机、付款执行语义和企业微信边界。
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
- `cd client && npm run typecheck`
|
||||||
|
- `cd client && npm run build`
|
||||||
|
- `npx playwright test tests/e2e/pc-finance-admin-visual-wave4.spec.ts tests/e2e/r103-cross-page-ui-consistency.spec.ts tests/e2e/r92-mobile-invoice-usability.spec.ts --workers=1 --reporter=list`
|
||||||
|
- `npx playwright test tests/e2e/r102-reimbursement-main-flow.spec.ts --workers=1 --reporter=list`
|
||||||
|
- `npx playwright test tests/e2e/system-action-confirmation.spec.ts --workers=1 --reporter=list`
|
||||||
|
- `git diff --check`
|
||||||
|
- `python3 ./.trellis/scripts/task.py validate 05-30-fix-payment-ledger-layout-consistency`
|
||||||
|
- `node scripts/qa/check-trellis-governance.mjs`
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- 付款列表首屏不得出现 `payment-batch-detail-panel`、`payment-batch-receipt-panel` 或批次项详情。
|
||||||
|
- 付款首屏不得出现五张 `finance-metric-card` 大状态卡。
|
||||||
|
- 付款首屏不得自动选择第一条批次。
|
||||||
|
- 资产列表首屏不得出现资产详情/编辑表单。
|
||||||
|
- 资产 `台账工具` 打开后不得作为左侧栏挤压列表;应位于列表上方或作为弹层/抽屉。
|
||||||
|
- 采购列表首屏不得在列表下方渲染采购详情/编辑区。
|
||||||
|
- 票据列表首屏不得自动选择第一张票据,不得默认渲染票据详情面板。
|
||||||
|
- 业务台账首屏不得使用左列表/右详情、左工具/右列表或列表下方详情作为默认形态。
|
||||||
|
- 业务台账首屏不得使用解释普通工作流的大段副标题、提示卡或说明句。
|
||||||
|
- 业务台账默认筛选应控制在一行常用项;高级筛选、导出、批量、统计、测试数据进入 `台账工具` 或高级筛选区。
|
||||||
|
- 所有台账工具按钮位置保持页头右侧。
|
||||||
|
- 不改变付款、资产、采购、票据的后端 API、权限模型和业务状态机。
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] `/payment-batches` 首屏为单列列表,未带 `id` 时不渲染付款详情。
|
||||||
|
- [ ] `/payment-batches?id=...` 为独立付款详情/处理视图,操作仍可复核、上传回单、执行。
|
||||||
|
- [ ] `/assets` 首屏为单列资产列表,未带 `id` 时不渲染资产详情。
|
||||||
|
- [ ] `/assets?id=...` 为独立资产详情/编辑视图。
|
||||||
|
- [ ] `/purchase-requests` 首屏为单列采购列表,未带 `id` 时不渲染采购详情/编辑区。
|
||||||
|
- [ ] `/purchase-requests?id=...` 为独立采购详情/编辑视图。
|
||||||
|
- [ ] `/invoice-pool` 首屏为单列票据列表,未带 `id` 时不渲染票据详情。
|
||||||
|
- [ ] `/invoice-pool?id=...` 或显式详情入口为独立票据详情视图/抽屉,不自动选择第一条。
|
||||||
|
- [ ] `台账工具` 在付款、资产页面均为页头右侧入口,展开后不变成左侧栏。
|
||||||
|
- [ ] `报销`、`合同台账`、`项目`、`应付账款` 经检查无默认内嵌详情、默认选中第一条、左侧工具挤压列表或多行默认筛选的回退。
|
||||||
|
- [ ] `资产盘点` 的页面模板和例外口径明确,不误导为普通业务台账默认样式。
|
||||||
|
- [ ] 相关 E2E 和构建通过。
|
||||||
|
- [ ] 部署并公网复检通过后再告知可线上测试。
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- 不改付款、资产、采购、票据后端模型。
|
||||||
|
- 不新增付款审批/支付能力。
|
||||||
|
- 不改企业微信、考勤、请假边界。
|
||||||
|
- 不重做所有详情页视觉,只处理台账首屏与详情入口一致性。
|
||||||
|
- 不把管理配置页、审计排障页强行改成普通业务台账页。
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
|
||||||
|
- 相关规范:
|
||||||
|
- `.trellis/spec/hyb-oa/frontend-react-vite-design.md`
|
||||||
|
- `.trellis/spec/hyb-oa/project-governance.md`
|
||||||
|
- 相关页面:
|
||||||
|
- `client/src/pages/PaymentBatchesPage.tsx`
|
||||||
|
- `client/src/pages/AssetRegistryPage.tsx`
|
||||||
|
- `client/src/pages/PurchaseRequestsPage.tsx`
|
||||||
|
- `client/src/pages/InvoicePoolPage.tsx`
|
||||||
|
- `client/src/pages/InventoryChecksPage.tsx`
|
||||||
|
- `client/src/pages/ReimbursementsPage.tsx`
|
||||||
|
- `client/src/pages/ContractsRegistryPage.tsx`
|
||||||
|
- `client/src/pages/ProjectsPage.tsx`
|
||||||
|
- `client/src/pages/ApOperationsPage.tsx`
|
||||||
|
- 相关测试候选:
|
||||||
|
- `client/tests/e2e/main-flow.spec.ts`
|
||||||
|
- `client/tests/e2e/reimbursement-payment-batch-flow.spec.ts`
|
||||||
|
- `client/tests/e2e/assets-flow.spec.ts`
|
||||||
|
- `client/tests/e2e/purchase-requests*.spec.ts`
|
||||||
|
- `client/tests/e2e/*invoice*.spec.ts`
|
||||||
|
- `client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts`
|
||||||
|
- `client/tests/e2e/r103-cross-page-ui-consistency.spec.ts`
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
- 代码、测试、规范/任务文档保持一致。
|
||||||
|
- 本地构建和聚焦 E2E 通过。
|
||||||
|
- 提交中文 commit,包含 Trellis task 名称。
|
||||||
|
- 公网部署和复检通过后,才告知用户可以看线上效果。
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"id": "fix-payment-ledger-layout-consistency",
|
||||||
|
"name": "fix-payment-ledger-layout-consistency",
|
||||||
|
"title": "修正付款与台账详情布局一致性",
|
||||||
|
"description": "统一业务台账首屏为单列列表,修正付款、资产、采购、票据、应付账款等页面默认详情和工具区不一致问题。",
|
||||||
|
"status": "in_progress",
|
||||||
|
"dev_type": "frontend",
|
||||||
|
"scope": "client,docs,trellis-spec",
|
||||||
|
"package": "hyb-oa",
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "shunli",
|
||||||
|
"assignee": "shunli",
|
||||||
|
"createdAt": "2026-05-30",
|
||||||
|
"completedAt": null,
|
||||||
|
"branch": null,
|
||||||
|
"base_branch": "main",
|
||||||
|
"worktree_path": null,
|
||||||
|
"commit": null,
|
||||||
|
"pr_url": null,
|
||||||
|
"subtasks": [],
|
||||||
|
"children": [],
|
||||||
|
"parent": null,
|
||||||
|
"relatedFiles": [
|
||||||
|
"client/src/pages/PaymentBatchesPage.tsx",
|
||||||
|
"client/src/pages/AssetRegistryPage.tsx",
|
||||||
|
"client/src/pages/PurchaseRequestsPage.tsx",
|
||||||
|
"client/src/pages/InvoicePoolPage.tsx",
|
||||||
|
"client/src/pages/ApOperationsPage.tsx",
|
||||||
|
".trellis/spec/hyb-oa/frontend-react-vite-design.md",
|
||||||
|
"docs/产品设计规则.md"
|
||||||
|
],
|
||||||
|
"notes": "",
|
||||||
|
"meta": {}
|
||||||
|
}
|
||||||
+117
-100
@@ -259,6 +259,7 @@ export function ApOperationsPage() {
|
|||||||
const [ledgerSearch, setLedgerSearch] = useState("");
|
const [ledgerSearch, setLedgerSearch] = useState("");
|
||||||
const [ledgerPage, setLedgerPage] = useState(1);
|
const [ledgerPage, setLedgerPage] = useState(1);
|
||||||
const [showLedgerTools, setShowLedgerTools] = useState(false);
|
const [showLedgerTools, setShowLedgerTools] = useState(false);
|
||||||
|
const [showLedgerAdvancedFilters, setShowLedgerAdvancedFilters] = useState(false);
|
||||||
|
|
||||||
const [reviewTarget, setReviewTarget] = useState<PaymentBatch | null>(null);
|
const [reviewTarget, setReviewTarget] = useState<PaymentBatch | null>(null);
|
||||||
const [reviewComment, setReviewComment] = useState("");
|
const [reviewComment, setReviewComment] = useState("");
|
||||||
@@ -384,6 +385,7 @@ export function ApOperationsPage() {
|
|||||||
}),
|
}),
|
||||||
[openRiskFlags, riskBucket],
|
[openRiskFlags, riskBucket],
|
||||||
);
|
);
|
||||||
|
const hasLedgerAdvancedFilters = Boolean(ledgerInvoiceStatus !== "all" || ledgerDateFrom || ledgerDateTo);
|
||||||
|
|
||||||
const failedDetailItem = useMemo(() => {
|
const failedDetailItem = useMemo(() => {
|
||||||
if (!failedDetailTarget) {
|
if (!failedDetailTarget) {
|
||||||
@@ -666,108 +668,123 @@ export function ApOperationsPage() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="ledger" className="mt-6 space-y-4">
|
<TabsContent value="ledger" className="mt-6 space-y-4">
|
||||||
<div className="finance-command-panel grid gap-3 !p-3 md:grid-cols-6">
|
<div className="finance-command-panel space-y-3 !p-3">
|
||||||
<div className="space-y-2">
|
<div className="grid gap-3 xl:grid-cols-[minmax(240px,1fr)_160px_160px_auto_auto]">
|
||||||
<div className="text-sm font-medium text-zinc-700">审批状态</div>
|
<div className="space-y-2">
|
||||||
<select
|
<div className="text-sm font-medium text-zinc-700">搜索</div>
|
||||||
className="select-std"
|
<Input
|
||||||
value={ledgerStatus}
|
placeholder="单号、姓名、部门、项目"
|
||||||
onChange={(event) => {
|
value={ledgerSearch}
|
||||||
setLedgerStatus(event.target.value as FinanceLedgerStatusFilter);
|
onChange={(event) => {
|
||||||
setLedgerPage(1);
|
setLedgerSearch(event.target.value);
|
||||||
}}
|
setLedgerPage(1);
|
||||||
>
|
}}
|
||||||
<option value="all">全部</option>
|
/>
|
||||||
<option value="approved">已通过</option>
|
</div>
|
||||||
<option value="paid">已支付</option>
|
<div className="space-y-2">
|
||||||
<option value="reviewing">审批中</option>
|
<div className="text-sm font-medium text-zinc-700">审批状态</div>
|
||||||
<option value="rejected">已驳回</option>
|
<select
|
||||||
<option value="cancelled">已取消</option>
|
className="select-std"
|
||||||
</select>
|
value={ledgerStatus}
|
||||||
</div>
|
onChange={(event) => {
|
||||||
<div className="space-y-2">
|
setLedgerStatus(event.target.value as FinanceLedgerStatusFilter);
|
||||||
<div className="text-sm font-medium text-zinc-700">付款状态</div>
|
setLedgerPage(1);
|
||||||
<select
|
}}
|
||||||
className="select-std"
|
>
|
||||||
value={ledgerPaymentStatus}
|
<option value="all">全部</option>
|
||||||
onChange={(event) => {
|
<option value="approved">已通过</option>
|
||||||
setLedgerPaymentStatus(event.target.value as FinanceLedgerPaymentFilter);
|
<option value="paid">已支付</option>
|
||||||
setLedgerPage(1);
|
<option value="reviewing">审批中</option>
|
||||||
}}
|
<option value="rejected">已驳回</option>
|
||||||
>
|
<option value="cancelled">已取消</option>
|
||||||
<option value="all">全部</option>
|
</select>
|
||||||
<option value="unpaid">未付款</option>
|
</div>
|
||||||
<option value="paid">已付款</option>
|
<div className="space-y-2">
|
||||||
</select>
|
<div className="text-sm font-medium text-zinc-700">付款状态</div>
|
||||||
</div>
|
<select
|
||||||
<div className="space-y-2">
|
className="select-std"
|
||||||
<div className="text-sm font-medium text-zinc-700">发票状态</div>
|
value={ledgerPaymentStatus}
|
||||||
<select
|
onChange={(event) => {
|
||||||
className="select-std"
|
setLedgerPaymentStatus(event.target.value as FinanceLedgerPaymentFilter);
|
||||||
value={ledgerInvoiceStatus}
|
setLedgerPage(1);
|
||||||
onChange={(event) => {
|
}}
|
||||||
setLedgerInvoiceStatus(event.target.value as FinanceLedgerInvoiceFilter);
|
>
|
||||||
setLedgerPage(1);
|
<option value="all">全部</option>
|
||||||
}}
|
<option value="unpaid">未付款</option>
|
||||||
>
|
<option value="paid">已付款</option>
|
||||||
<option value="all">全部</option>
|
</select>
|
||||||
<option value="none">无发票</option>
|
</div>
|
||||||
<option value="pending_ocr">识别中</option>
|
<div className="flex items-end">
|
||||||
<option value="ocr_failed">识别失败</option>
|
<Button
|
||||||
<option value="available">可添加</option>
|
type="button"
|
||||||
<option value="bound">已绑定</option>
|
variant={showLedgerAdvancedFilters || hasLedgerAdvancedFilters ? "default" : "secondary"}
|
||||||
<option value="archived">已归档</option>
|
onClick={() => setShowLedgerAdvancedFilters((current) => !current)}
|
||||||
</select>
|
>
|
||||||
</div>
|
更多筛选
|
||||||
<div className="space-y-2">
|
</Button>
|
||||||
<div className="text-sm font-medium text-zinc-700">报销日期从</div>
|
</div>
|
||||||
<DateTextInput
|
<div className="flex items-end">
|
||||||
value={ledgerDateFrom}
|
<Button
|
||||||
onChange={(event) => {
|
type="button"
|
||||||
setLedgerDateFrom(event.target.value);
|
variant="ghost"
|
||||||
setLedgerPage(1);
|
onClick={() => {
|
||||||
}}
|
setLedgerStatus("all");
|
||||||
/>
|
setLedgerPaymentStatus("all");
|
||||||
</div>
|
setLedgerInvoiceStatus("all");
|
||||||
<div className="space-y-2">
|
setLedgerDateFrom("");
|
||||||
<div className="text-sm font-medium text-zinc-700">报销日期至</div>
|
setLedgerDateTo("");
|
||||||
<DateTextInput
|
setLedgerSearch("");
|
||||||
value={ledgerDateTo}
|
setLedgerPage(1);
|
||||||
onChange={(event) => {
|
}}
|
||||||
setLedgerDateTo(event.target.value);
|
>
|
||||||
setLedgerPage(1);
|
清空
|
||||||
}}
|
</Button>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-sm font-medium text-zinc-700">搜索</div>
|
|
||||||
<Input
|
|
||||||
placeholder="单号、姓名、部门、项目"
|
|
||||||
value={ledgerSearch}
|
|
||||||
onChange={(event) => {
|
|
||||||
setLedgerSearch(event.target.value);
|
|
||||||
setLedgerPage(1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
{showLedgerAdvancedFilters || hasLedgerAdvancedFilters ? (
|
||||||
<Button
|
<div className="finance-command-panel grid gap-3 !p-3 md:grid-cols-3">
|
||||||
type="button"
|
<div className="space-y-2">
|
||||||
variant="ghost"
|
<div className="text-sm font-medium text-zinc-700">发票状态</div>
|
||||||
onClick={() => {
|
<select
|
||||||
setLedgerStatus("all");
|
className="select-std"
|
||||||
setLedgerPaymentStatus("all");
|
value={ledgerInvoiceStatus}
|
||||||
setLedgerInvoiceStatus("all");
|
onChange={(event) => {
|
||||||
setLedgerDateFrom("");
|
setLedgerInvoiceStatus(event.target.value as FinanceLedgerInvoiceFilter);
|
||||||
setLedgerDateTo("");
|
setLedgerPage(1);
|
||||||
setLedgerSearch("");
|
}}
|
||||||
setLedgerPage(1);
|
>
|
||||||
}}
|
<option value="all">全部</option>
|
||||||
>
|
<option value="none">无发票</option>
|
||||||
清空台账筛选
|
<option value="pending_ocr">识别中</option>
|
||||||
</Button>
|
<option value="ocr_failed">识别失败</option>
|
||||||
</div>
|
<option value="available">可添加</option>
|
||||||
|
<option value="bound">已绑定</option>
|
||||||
|
<option value="archived">已归档</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium text-zinc-700">报销日期从</div>
|
||||||
|
<DateTextInput
|
||||||
|
value={ledgerDateFrom}
|
||||||
|
onChange={(event) => {
|
||||||
|
setLedgerDateFrom(event.target.value);
|
||||||
|
setLedgerPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium text-zinc-700">报销日期至</div>
|
||||||
|
<DateTextInput
|
||||||
|
value={ledgerDateTo}
|
||||||
|
onChange={(event) => {
|
||||||
|
setLedgerDateTo(event.target.value);
|
||||||
|
setLedgerPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="ops-table-focus overflow-x-auto" data-testid="finance-ledger-table">
|
<div className="ops-table-focus overflow-x-auto" data-testid="finance-ledger-table">
|
||||||
<Table>
|
<Table>
|
||||||
|
|||||||
@@ -798,6 +798,14 @@ export function AssetRegistryPage() {
|
|||||||
setIsCreatingAsset(false);
|
setIsCreatingAsset(false);
|
||||||
setSelectedAssetId(assetId);
|
setSelectedAssetId(assetId);
|
||||||
};
|
};
|
||||||
|
const returnToAssetList = () => {
|
||||||
|
setIsCreatingAsset(false);
|
||||||
|
setSelectedAssetId("");
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
next.delete("id");
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
|
};
|
||||||
|
const isAssetDetailMode = isCreatingAsset || Boolean(selectedAssetId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListWorkspace>
|
<ListWorkspace>
|
||||||
@@ -805,17 +813,26 @@ export function AssetRegistryPage() {
|
|||||||
title="资产台账"
|
title="资产台账"
|
||||||
actions={
|
actions={
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button type="button" variant="secondary" onClick={() => navigate("/mobile/assets/scan") }>
|
{isAssetDetailMode ? (
|
||||||
移动扫码查找
|
<Button type="button" variant="secondary" onClick={returnToAssetList}>
|
||||||
</Button>
|
返回列表
|
||||||
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((value) => !value)}>
|
</Button>
|
||||||
台账工具
|
) : (
|
||||||
</Button>
|
<>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => navigate("/mobile/assets/scan") }>
|
||||||
|
移动扫码查找
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((value) => !value)}>
|
||||||
|
台账工具
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={showLedgerTools ? "grid gap-6 xl:grid-cols-[380px_minmax(0,1fr)]" : "grid gap-6"}>
|
{!isAssetDetailMode ? (
|
||||||
|
<div className="grid gap-6">
|
||||||
{showLedgerTools ? (
|
{showLedgerTools ? (
|
||||||
<div className="card-std space-y-4 p-4 md:p-5" data-testid="asset-ledger-tools">
|
<div className="card-std space-y-4 p-4 md:p-5" data-testid="asset-ledger-tools">
|
||||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
|
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
|
||||||
@@ -1025,17 +1042,13 @@ export function AssetRegistryPage() {
|
|||||||
<div className="space-y-3 md:hidden">
|
<div className="space-y-3 md:hidden">
|
||||||
{orderedAssetItems.length ? (
|
{orderedAssetItems.length ? (
|
||||||
orderedAssetItems.map((item) => (
|
orderedAssetItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
data-testid="asset-mobile-row"
|
data-testid="asset-mobile-row"
|
||||||
type="button"
|
type="button"
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => selectAsset(item.id)}
|
onClick={() => selectAsset(item.id)}
|
||||||
className={`w-full rounded-lg border p-3 text-left ${
|
className="w-full rounded-lg border border-zinc-200 bg-white p-3 text-left"
|
||||||
item.id === selectedAssetId && !isCreatingAsset
|
>
|
||||||
? "border-zinc-400 bg-zinc-50"
|
|
||||||
: "border-zinc-200 bg-white"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-semibold text-zinc-900">
|
<div className="truncate text-sm font-semibold text-zinc-900">
|
||||||
@@ -1065,9 +1078,10 @@ export function AssetRegistryPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="text-zinc-400">位置</div>
|
<div className="text-zinc-400">位置</div>
|
||||||
<div className="mt-0.5 truncate text-zinc-700">{item.location ?? "-"}</div>
|
<div className="mt-0.5 truncate text-zinc-700">{item.location ?? "-"}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
<div className="mt-3 text-sm font-medium text-primary">查看详情</div>
|
||||||
|
</button>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border border-zinc-200 p-4 text-sm text-zinc-500">暂无资产卡片</div>
|
<div className="rounded-lg border border-zinc-200 p-4 text-sm text-zinc-500">暂无资产卡片</div>
|
||||||
@@ -1079,17 +1093,18 @@ export function AssetRegistryPage() {
|
|||||||
<tr>
|
<tr>
|
||||||
<TableHead>资产</TableHead>
|
<TableHead>资产</TableHead>
|
||||||
<TableHead>类型</TableHead>
|
<TableHead>类型</TableHead>
|
||||||
<TableHead>责任链</TableHead>
|
<TableHead>责任链</TableHead>
|
||||||
<TableHead>位置</TableHead>
|
<TableHead>位置</TableHead>
|
||||||
<TableHead>状态</TableHead>
|
<TableHead>状态</TableHead>
|
||||||
</tr>
|
<TableHead className="w-24 text-right">操作</TableHead>
|
||||||
|
</tr>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{orderedAssetItems.length ? (
|
{orderedAssetItems.length ? (
|
||||||
orderedAssetItems.map((item) => (
|
orderedAssetItems.map((item) => (
|
||||||
<TableRow data-testid="asset-row" key={item.id} onClick={() => {
|
<TableRow data-testid="asset-row" key={item.id} onClick={() => {
|
||||||
selectAsset(item.id);
|
selectAsset(item.id);
|
||||||
}} className={item.id === selectedAssetId && !isCreatingAsset ? "bg-zinc-50" : "cursor-pointer"}>
|
}} className="cursor-pointer">
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="font-medium text-zinc-900">
|
<div className="font-medium text-zinc-900">
|
||||||
<CompactAssetNo value={item.assetNo} />
|
<CompactAssetNo value={item.assetNo} />
|
||||||
@@ -1107,12 +1122,17 @@ export function AssetRegistryPage() {
|
|||||||
<div>{formatTextWithCompactLongNumbers(item.location ?? "-")}</div>
|
<div>{formatTextWithCompactLongNumbers(item.location ?? "-")}</div>
|
||||||
<div className="text-xs text-zinc-500">盘点 {item.lastVerifiedAt ? formatDisplayDateTime(item.lastVerifiedAt) : "未登记"}</div>
|
<div className="text-xs text-zinc-500">盘点 {item.lastVerifiedAt ? formatDisplayDateTime(item.lastVerifiedAt) : "未登记"}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell><StatusBadge status={item.status} /></TableCell>
|
<TableCell><StatusBadge status={item.status} /></TableCell>
|
||||||
</TableRow>
|
<TableCell className="text-right">
|
||||||
|
<Button type="button" size="sm" variant="secondary" onClick={() => selectAsset(item.id)}>
|
||||||
|
查看详情
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} className="py-4 text-zinc-500">
|
<TableCell colSpan={6} className="py-4 text-zinc-500">
|
||||||
暂无资产卡片
|
暂无资产卡片
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -1121,11 +1141,12 @@ export function AssetRegistryPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
</PrimaryTable>
|
</PrimaryTable>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
</div>
|
||||||
{selectedAssetId || isCreatingAsset ? (
|
</div>
|
||||||
<div className="card-std space-y-4 p-4 md:p-5">
|
) : (
|
||||||
|
<div className="card-std space-y-4 p-4 md:p-5">
|
||||||
{selectedAssetId && !isCreatingAsset && assetDetailQuery.isLoading ? <TableSkeleton columns={2} rows={8} /> : null}
|
{selectedAssetId && !isCreatingAsset && assetDetailQuery.isLoading ? <TableSkeleton columns={2} rows={8} /> : null}
|
||||||
{selectedAssetId && !isCreatingAsset && assetDetailQuery.isError ? (
|
{selectedAssetId && !isCreatingAsset && assetDetailQuery.isError ? (
|
||||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(assetDetailQuery.error)}</p>
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(assetDetailQuery.error)}</p>
|
||||||
@@ -1559,9 +1580,7 @@ export function AssetRegistryPage() {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ListWorkspace>
|
</ListWorkspace>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,15 +109,11 @@ export function InventoryChecksPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextId = inventoryChecksQuery.data?.items?.[0]?.id ?? "";
|
if (!selectedCheckId) {
|
||||||
if (!selectedCheckId && nextId) {
|
|
||||||
setSelectedCheckId(nextId);
|
|
||||||
}
|
|
||||||
if (selectedCheckId && inventoryChecksQuery.data?.items?.some((item) => item.id === selectedCheckId)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (nextId !== selectedCheckId) {
|
if (!inventoryChecksQuery.data?.items?.some((item) => item.id === selectedCheckId)) {
|
||||||
setSelectedCheckId(nextId);
|
setSelectedCheckId("");
|
||||||
}
|
}
|
||||||
}, [inventoryChecksQuery.data?.items, selectedCheckId]);
|
}, [inventoryChecksQuery.data?.items, selectedCheckId]);
|
||||||
|
|
||||||
@@ -247,7 +243,6 @@ export function InventoryChecksPage() {
|
|||||||
<PageScaffold className="space-y-6">
|
<PageScaffold className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="资产盘点"
|
title="资产盘点"
|
||||||
subtitle="按任务执行盘点:先确定范围,再启动、扫码/录入、核对结果并确认完成。"
|
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -260,30 +255,20 @@ export function InventoryChecksPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section className="ops-status-strip grid gap-4 md:grid-cols-4" data-testid="inventory-check-summary">
|
<section className="flex flex-wrap gap-2" data-testid="inventory-check-summary">
|
||||||
<div className="finance-metric-card">
|
{[
|
||||||
<div className="text-sm text-zinc-500">盘点任务</div>
|
["全部", inventoryStats.total],
|
||||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{inventoryStats.total}</div>
|
["草稿", inventoryStats.draft],
|
||||||
<div className="mt-1 text-xs text-zinc-500">按季度或事件触发</div>
|
["盘点中", inventoryStats.counting],
|
||||||
</div>
|
["已完成", inventoryStats.completed],
|
||||||
<div className="finance-metric-card">
|
].map(([label, value]) => (
|
||||||
<div className="text-sm text-zinc-500">草稿</div>
|
<span key={label} className="rounded-full border border-zinc-200 bg-white px-3 py-1 text-sm text-zinc-700">
|
||||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{inventoryStats.draft}</div>
|
{label} <span className="font-semibold text-zinc-900">{value}</span>
|
||||||
<div className="mt-1 text-xs text-zinc-500">待启动后录入</div>
|
</span>
|
||||||
</div>
|
))}
|
||||||
<div className="finance-metric-card">
|
|
||||||
<div className="text-sm text-zinc-500">盘点中</div>
|
|
||||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{inventoryStats.counting}</div>
|
|
||||||
<div className="mt-1 text-xs text-zinc-500">可扫码或手工录入</div>
|
|
||||||
</div>
|
|
||||||
<div className="finance-metric-card">
|
|
||||||
<div className="text-sm text-zinc-500">已完成</div>
|
|
||||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{inventoryStats.completed}</div>
|
|
||||||
<div className="mt-1 text-xs text-zinc-500">保留差异和备注</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
|
<div className={selectedCheck ? "grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]" : "grid gap-6"}>
|
||||||
<div className="card-std space-y-4 p-6">
|
<div className="card-std space-y-4 p-6">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">新建盘点任务</h3>
|
<h3 className="text-base font-semibold text-zinc-900">新建盘点任务</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -338,7 +323,7 @@ export function InventoryChecksPage() {
|
|||||||
<TableRow
|
<TableRow
|
||||||
data-testid="inventory-row"
|
data-testid="inventory-row"
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={item.id === selectedCheckId ? "bg-zinc-50 cursor-pointer" : "cursor-pointer"}
|
className="cursor-pointer"
|
||||||
onClick={() => setSelectedCheckId(item.id)}
|
onClick={() => setSelectedCheckId(item.id)}
|
||||||
>
|
>
|
||||||
<TableCell className="font-medium text-zinc-900">{item.checkNo}</TableCell>
|
<TableCell className="font-medium text-zinc-900">{item.checkNo}</TableCell>
|
||||||
@@ -358,17 +343,12 @@ export function InventoryChecksPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedCheck ? (
|
||||||
<div className="card-std space-y-4 p-6">
|
<div className="card-std space-y-4 p-6">
|
||||||
{inventoryChecksQuery.isLoading ? <TableSkeleton columns={3} rows={6} /> : null}
|
{inventoryChecksQuery.isLoading ? <TableSkeleton columns={3} rows={6} /> : null}
|
||||||
{inventoryChecksQuery.isError ? (
|
{inventoryChecksQuery.isError ? (
|
||||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(inventoryChecksQuery.error)}</p>
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(inventoryChecksQuery.error)}</p>
|
||||||
) : null}
|
) : null}
|
||||||
{!inventoryChecksQuery.isLoading && !selectedCheck ? (
|
|
||||||
<div className="rounded-lg border border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500">
|
|
||||||
请先创建或选择盘点任务。
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{selectedCheck ? (
|
{selectedCheck ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
@@ -464,6 +444,7 @@ export function InventoryChecksPage() {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<ActionConfirmDialog
|
<ActionConfirmDialog
|
||||||
open={confirmCompleteCheck}
|
open={confirmCompleteCheck}
|
||||||
|
|||||||
+143
-125
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import { fetchMyDelegations } from "../api/delegations";
|
import { fetchMyDelegations } from "../api/delegations";
|
||||||
@@ -51,6 +52,7 @@ type UploadBatchResult = {
|
|||||||
|
|
||||||
export function InvoicePoolPage() {
|
export function InvoicePoolPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { permissions, me } = useAuth();
|
const { permissions, me } = useAuth();
|
||||||
const canManageInvoice = hasPermission(permissions, "invoice.manage");
|
const canManageInvoice = hasPermission(permissions, "invoice.manage");
|
||||||
const canOverrideDuplicate = hasPermission(permissions, "expense.finance.override_duplicate");
|
const canOverrideDuplicate = hasPermission(permissions, "expense.finance.override_duplicate");
|
||||||
@@ -71,7 +73,7 @@ export function InvoicePoolPage() {
|
|||||||
const [selectedReimbursementId, setSelectedReimbursementId] = useState("");
|
const [selectedReimbursementId, setSelectedReimbursementId] = useState("");
|
||||||
const [selectedTravelRequestId, setSelectedTravelRequestId] = useState("");
|
const [selectedTravelRequestId, setSelectedTravelRequestId] = useState("");
|
||||||
const [selectedTravelOrderId, setSelectedTravelOrderId] = useState("");
|
const [selectedTravelOrderId, setSelectedTravelOrderId] = useState("");
|
||||||
const [detailInvoiceId, setDetailInvoiceId] = useState("");
|
const [detailInvoiceId, setDetailInvoiceId] = useState(searchParams.get("id") ?? "");
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [editSetAvailable, setEditSetAvailable] = useState(false);
|
const [editSetAvailable, setEditSetAvailable] = useState(false);
|
||||||
const [editRemarks, setEditRemarks] = useState("");
|
const [editRemarks, setEditRemarks] = useState("");
|
||||||
@@ -203,10 +205,25 @@ export function InvoicePoolPage() {
|
|||||||
}, [canManageTravelPayments]);
|
}, [canManageTravelPayments]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!detailInvoiceId && listQuery.data?.items?.length) {
|
const searchInvoiceId = searchParams.get("id") ?? "";
|
||||||
setDetailInvoiceId(listQuery.data.items[0].id);
|
if (searchInvoiceId !== detailInvoiceId) {
|
||||||
|
setDetailInvoiceId(searchInvoiceId);
|
||||||
}
|
}
|
||||||
}, [detailInvoiceId, listQuery.data?.items]);
|
}, [detailInvoiceId, searchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentId = searchParams.get("id") ?? "";
|
||||||
|
if (detailInvoiceId === currentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
if (detailInvoiceId) {
|
||||||
|
next.set("id", detailInvoiceId);
|
||||||
|
} else {
|
||||||
|
next.delete("id");
|
||||||
|
}
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
|
}, [detailInvoiceId, searchParams, setSearchParams]);
|
||||||
|
|
||||||
// --- Helper for error handling ---
|
// --- Helper for error handling ---
|
||||||
|
|
||||||
@@ -453,6 +470,13 @@ export function InvoicePoolPage() {
|
|||||||
detail.ownerEmployeeId === me?.id ||
|
detail.ownerEmployeeId === me?.id ||
|
||||||
detail.uploadedByEmployeeId === me?.id),
|
detail.uploadedByEmployeeId === me?.id),
|
||||||
);
|
);
|
||||||
|
const isInvoiceDetailMode = Boolean(detailInvoiceId);
|
||||||
|
const returnToInvoiceList = () => {
|
||||||
|
setDetailInvoiceId("");
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
next.delete("id");
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
// --- Render ---
|
// --- Render ---
|
||||||
|
|
||||||
@@ -460,11 +484,18 @@ export function InvoicePoolPage() {
|
|||||||
<ListWorkspace>
|
<ListWorkspace>
|
||||||
<WorkspaceHeader
|
<WorkspaceHeader
|
||||||
title="票据中心"
|
title="票据中心"
|
||||||
subtitle="优先看识别状态、失败票据和待绑定票据;导出、全范围和财务交接进入台账工具。"
|
|
||||||
actions={
|
actions={
|
||||||
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((current) => !current)}>
|
<div className="flex flex-wrap gap-2">
|
||||||
台账工具
|
{isInvoiceDetailMode ? (
|
||||||
</Button>
|
<Button type="button" variant="secondary" onClick={returnToInvoiceList}>
|
||||||
|
返回列表
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((current) => !current)}>
|
||||||
|
台账工具
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -474,14 +505,11 @@ export function InvoicePoolPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{showLedgerTools ? (
|
{!isInvoiceDetailMode && showLedgerTools ? (
|
||||||
<div className="finance-command-panel space-y-3" data-testid="invoice-ledger-tools">
|
<div className="finance-command-panel space-y-3" data-testid="invoice-ledger-tools">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-semibold text-zinc-900">台账工具</h3>
|
<h3 className="text-base font-semibold text-zinc-900">台账工具</h3>
|
||||||
<p className="mt-1 text-sm leading-6 text-zinc-500">
|
|
||||||
导出按当前状态和范围生成,只用于财务复核、OCR 排障和代账交接;普通员工日常补票据仍从报销详情或移动上传进入。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -504,124 +532,114 @@ export function InvoicePoolPage() {
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-600">
|
<InvoiceUploadCard
|
||||||
票据原文、预览和下载仍按详情页权限与审计控制处理,列表导出不作为敏感材料分发入口。
|
selectedFiles={selectedFiles}
|
||||||
</div>
|
setSelectedFiles={setSelectedFiles}
|
||||||
|
isUploading={uploadMutation.isPending}
|
||||||
|
uploadProgressLabel={
|
||||||
|
uploadProgress
|
||||||
|
? `上传中 ${uploadProgress.completed}/${uploadProgress.total}`
|
||||||
|
: "上传中..."
|
||||||
|
}
|
||||||
|
onUpload={() => uploadMutation.mutate()}
|
||||||
|
showProxyOwnerSelector={showProxyOwnerSelector}
|
||||||
|
proxyDelegationsQuery={proxyDelegationsQuery}
|
||||||
|
uploadOwnerEmployeeId={uploadOwnerEmployeeId}
|
||||||
|
setUploadOwnerEmployeeId={setUploadOwnerEmployeeId}
|
||||||
|
/>
|
||||||
|
<InvoiceBindCard
|
||||||
|
listQuery={listQuery}
|
||||||
|
reimbursementQuery={reimbursementQuery}
|
||||||
|
travelRequestQuery={travelRequestQuery}
|
||||||
|
travelRequestDetailQuery={travelRequestDetailQuery}
|
||||||
|
bindMutation={bindMutation}
|
||||||
|
bindMode={bindMode}
|
||||||
|
setBindMode={setBindMode}
|
||||||
|
canManageTravelPayments={canManageTravelPayments}
|
||||||
|
selectedInvoiceId={selectedInvoiceId}
|
||||||
|
setSelectedInvoiceId={setSelectedInvoiceId}
|
||||||
|
selectedReimbursementId={selectedReimbursementId}
|
||||||
|
setSelectedReimbursementId={setSelectedReimbursementId}
|
||||||
|
selectedTravelRequestId={selectedTravelRequestId}
|
||||||
|
setSelectedTravelRequestId={setSelectedTravelRequestId}
|
||||||
|
selectedTravelOrderId={selectedTravelOrderId}
|
||||||
|
setSelectedTravelOrderId={setSelectedTravelOrderId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="finance-status-strip" data-testid="invoice-pool-summary">
|
{!isInvoiceDetailMode ? (
|
||||||
<div className="finance-metric-card">
|
<div className="flex flex-wrap gap-2" data-testid="invoice-pool-summary">
|
||||||
<div className="finance-metric-card__label">票据总数</div>
|
{[
|
||||||
<div className="finance-metric-card__value">{invoiceStats.total}</div>
|
["全部", invoiceStats.total],
|
||||||
|
["识别中", invoiceStats.pending],
|
||||||
|
["识别失败", invoiceStats.failed],
|
||||||
|
["可绑定", invoiceStats.available],
|
||||||
|
["已绑定", invoiceStats.bound],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<span key={label} className="rounded-full border border-zinc-200 bg-white px-3 py-1 text-sm text-zinc-700">
|
||||||
|
{label} <span className="font-semibold text-zinc-900">{value}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="finance-metric-card">
|
) : null}
|
||||||
<div className="finance-metric-card__label">识别中</div>
|
|
||||||
<div className="finance-metric-card__value">{invoiceStats.pending}</div>
|
|
||||||
</div>
|
|
||||||
<div className="finance-metric-card">
|
|
||||||
<div className="finance-metric-card__label">识别失败</div>
|
|
||||||
<div className="finance-metric-card__value">{invoiceStats.failed}</div>
|
|
||||||
</div>
|
|
||||||
<div className="finance-metric-card">
|
|
||||||
<div className="finance-metric-card__label">可绑定</div>
|
|
||||||
<div className="finance-metric-card__value">{invoiceStats.available}</div>
|
|
||||||
</div>
|
|
||||||
<div className="finance-metric-card">
|
|
||||||
<div className="finance-metric-card__label">已绑定</div>
|
|
||||||
<div className="finance-metric-card__value">{invoiceStats.bound}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<InvoiceUploadCard
|
{!isInvoiceDetailMode ? (
|
||||||
selectedFiles={selectedFiles}
|
<InvoiceListCard
|
||||||
setSelectedFiles={setSelectedFiles}
|
listQuery={listQuery}
|
||||||
isUploading={uploadMutation.isPending}
|
status={status}
|
||||||
uploadProgressLabel={
|
setStatus={setStatus}
|
||||||
uploadProgress
|
ownerScope={ownerScope}
|
||||||
? `上传中 ${uploadProgress.completed}/${uploadProgress.total}`
|
setOwnerScope={setOwnerScope}
|
||||||
: "上传中..."
|
page={page}
|
||||||
}
|
setPage={setPage}
|
||||||
onUpload={() => uploadMutation.mutate()}
|
totalPages={totalPages}
|
||||||
showProxyOwnerSelector={showProxyOwnerSelector}
|
detailInvoiceId={detailInvoiceId}
|
||||||
proxyDelegationsQuery={proxyDelegationsQuery}
|
setDetailInvoiceId={setDetailInvoiceId}
|
||||||
uploadOwnerEmployeeId={uploadOwnerEmployeeId}
|
signedUrlMutation={signedUrlMutation}
|
||||||
setUploadOwnerEmployeeId={setUploadOwnerEmployeeId}
|
/>
|
||||||
/>
|
) : (
|
||||||
|
<InvoiceDetailPanel
|
||||||
<InvoiceListCard
|
detail={detail}
|
||||||
listQuery={listQuery}
|
detailQuery={detailQuery}
|
||||||
status={status}
|
canEditDetail={canEditDetail}
|
||||||
setStatus={setStatus}
|
canVerifyInvoice={canVerifyInvoice}
|
||||||
ownerScope={ownerScope}
|
canUploadInvoice={canUploadInvoice}
|
||||||
setOwnerScope={setOwnerScope}
|
canOverrideDuplicate={canOverrideDuplicate}
|
||||||
page={page}
|
patchMutation={patchMutation}
|
||||||
setPage={setPage}
|
archiveMutation={archiveMutation}
|
||||||
totalPages={totalPages}
|
unbindMutation={unbindMutation}
|
||||||
detailInvoiceId={detailInvoiceId}
|
retryOcrMutation={retryOcrMutation}
|
||||||
setDetailInvoiceId={setDetailInvoiceId}
|
triggerVerificationMutation={triggerVerificationMutation}
|
||||||
signedUrlMutation={signedUrlMutation}
|
retryVerificationMutation={retryVerificationMutation}
|
||||||
/>
|
overrideDuplicateMutation={overrideDuplicateMutation}
|
||||||
|
verificationQuery={verificationQuery}
|
||||||
<InvoiceBindCard
|
editOpen={editOpen}
|
||||||
listQuery={listQuery}
|
setEditOpen={setEditOpen}
|
||||||
reimbursementQuery={reimbursementQuery}
|
editSetAvailable={editSetAvailable}
|
||||||
travelRequestQuery={travelRequestQuery}
|
setEditSetAvailable={setEditSetAvailable}
|
||||||
travelRequestDetailQuery={travelRequestDetailQuery}
|
editRemarks={editRemarks}
|
||||||
bindMutation={bindMutation}
|
setEditRemarks={setEditRemarks}
|
||||||
bindMode={bindMode}
|
editInvoiceCode={editInvoiceCode}
|
||||||
setBindMode={setBindMode}
|
setEditInvoiceCode={setEditInvoiceCode}
|
||||||
canManageTravelPayments={canManageTravelPayments}
|
editInvoiceNumber={editInvoiceNumber}
|
||||||
selectedInvoiceId={selectedInvoiceId}
|
setEditInvoiceNumber={setEditInvoiceNumber}
|
||||||
setSelectedInvoiceId={setSelectedInvoiceId}
|
editInvoiceDate={editInvoiceDate}
|
||||||
selectedReimbursementId={selectedReimbursementId}
|
setEditInvoiceDate={setEditInvoiceDate}
|
||||||
setSelectedReimbursementId={setSelectedReimbursementId}
|
editSellerName={editSellerName}
|
||||||
selectedTravelRequestId={selectedTravelRequestId}
|
setEditSellerName={setEditSellerName}
|
||||||
setSelectedTravelRequestId={setSelectedTravelRequestId}
|
editBuyerName={editBuyerName}
|
||||||
selectedTravelOrderId={selectedTravelOrderId}
|
setEditBuyerName={setEditBuyerName}
|
||||||
setSelectedTravelOrderId={setSelectedTravelOrderId}
|
editAmount={editAmount}
|
||||||
/>
|
setEditAmount={setEditAmount}
|
||||||
|
editTaxAmount={editTaxAmount}
|
||||||
<InvoiceDetailPanel
|
setEditTaxAmount={setEditTaxAmount}
|
||||||
detail={detail}
|
editTotalAmount={editTotalAmount}
|
||||||
detailQuery={detailQuery}
|
setEditTotalAmount={setEditTotalAmount}
|
||||||
canEditDetail={canEditDetail}
|
confirmAction={confirmAction}
|
||||||
canVerifyInvoice={canVerifyInvoice}
|
setConfirmAction={setConfirmAction}
|
||||||
canUploadInvoice={canUploadInvoice}
|
/>
|
||||||
canOverrideDuplicate={canOverrideDuplicate}
|
)}
|
||||||
patchMutation={patchMutation}
|
|
||||||
archiveMutation={archiveMutation}
|
|
||||||
unbindMutation={unbindMutation}
|
|
||||||
retryOcrMutation={retryOcrMutation}
|
|
||||||
triggerVerificationMutation={triggerVerificationMutation}
|
|
||||||
retryVerificationMutation={retryVerificationMutation}
|
|
||||||
overrideDuplicateMutation={overrideDuplicateMutation}
|
|
||||||
verificationQuery={verificationQuery}
|
|
||||||
editOpen={editOpen}
|
|
||||||
setEditOpen={setEditOpen}
|
|
||||||
editSetAvailable={editSetAvailable}
|
|
||||||
setEditSetAvailable={setEditSetAvailable}
|
|
||||||
editRemarks={editRemarks}
|
|
||||||
setEditRemarks={setEditRemarks}
|
|
||||||
editInvoiceCode={editInvoiceCode}
|
|
||||||
setEditInvoiceCode={setEditInvoiceCode}
|
|
||||||
editInvoiceNumber={editInvoiceNumber}
|
|
||||||
setEditInvoiceNumber={setEditInvoiceNumber}
|
|
||||||
editInvoiceDate={editInvoiceDate}
|
|
||||||
setEditInvoiceDate={setEditInvoiceDate}
|
|
||||||
editSellerName={editSellerName}
|
|
||||||
setEditSellerName={setEditSellerName}
|
|
||||||
editBuyerName={editBuyerName}
|
|
||||||
setEditBuyerName={setEditBuyerName}
|
|
||||||
editAmount={editAmount}
|
|
||||||
setEditAmount={setEditAmount}
|
|
||||||
editTaxAmount={editTaxAmount}
|
|
||||||
setEditTaxAmount={setEditTaxAmount}
|
|
||||||
editTotalAmount={editTotalAmount}
|
|
||||||
setEditTotalAmount={setEditTotalAmount}
|
|
||||||
confirmAction={confirmAction}
|
|
||||||
setConfirmAction={setConfirmAction}
|
|
||||||
/>
|
|
||||||
</ListWorkspace>
|
</ListWorkspace>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
@@ -128,7 +128,7 @@ export function PaymentBatchesPage() {
|
|||||||
const selectedBatchId = searchParams.get("id") ?? "";
|
const selectedBatchId = searchParams.get("id") ?? "";
|
||||||
const batches = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]);
|
const batches = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]);
|
||||||
const selectedBatch = useMemo<PaymentBatch | null>(() => {
|
const selectedBatch = useMemo<PaymentBatch | null>(() => {
|
||||||
return batches.find((item) => item.id === selectedBatchId) ?? batches[0] ?? null;
|
return batches.find((item) => item.id === selectedBatchId) ?? null;
|
||||||
}, [batches, selectedBatchId]);
|
}, [batches, selectedBatchId]);
|
||||||
const batchStats = useMemo(() => {
|
const batchStats = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
@@ -152,17 +152,6 @@ export function PaymentBatchesPage() {
|
|||||||
enabled: Boolean(selectedBatch?.id),
|
enabled: Boolean(selectedBatch?.id),
|
||||||
queryFn: () => fetchPaymentBatchReceiptAttachments(selectedBatch!.id),
|
queryFn: () => fetchPaymentBatchReceiptAttachments(selectedBatch!.id),
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedBatch) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (selectedBatch.id === selectedBatchId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const next = new URLSearchParams(searchParams);
|
|
||||||
next.set("id", selectedBatch.id);
|
|
||||||
setSearchParams(next, { replace: true });
|
|
||||||
}, [searchParams, selectedBatch, selectedBatchId, setSearchParams]);
|
|
||||||
|
|
||||||
const canEditSelectedBatch =
|
const canEditSelectedBatch =
|
||||||
canManageBatch &&
|
canManageBatch &&
|
||||||
@@ -195,6 +184,12 @@ export function PaymentBatchesPage() {
|
|||||||
await queryClient.invalidateQueries({ queryKey: ["ap-operations"] });
|
await queryClient.invalidateQueries({ queryKey: ["ap-operations"] });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const returnToList = () => {
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
next.delete("id");
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: async () =>
|
mutationFn: async () =>
|
||||||
createPaymentBatch({
|
createPaymentBatch({
|
||||||
@@ -363,51 +358,47 @@ export function PaymentBatchesPage() {
|
|||||||
{downloadReasonDialog}
|
{downloadReasonDialog}
|
||||||
<WorkspaceHeader
|
<WorkspaceHeader
|
||||||
title="付款"
|
title="付款"
|
||||||
subtitle="先核对待复核、待付款和异常批次;真实付款在线下完成,OA 只记录复核、回单和付款状态。"
|
|
||||||
actions={
|
actions={
|
||||||
<>
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selectedBatchId ? (
|
||||||
|
<Button type="button" variant="secondary" onClick={returnToList}>
|
||||||
|
返回列表
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button type="button" variant="secondary" onClick={() => void refresh()}>
|
<Button type="button" variant="secondary" onClick={() => void refresh()}>
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant={showBatchTools ? "default" : "secondary"} onClick={() => setShowBatchTools((current) => !current)}>
|
{!selectedBatchId ? (
|
||||||
台账工具
|
<Button type="button" variant={showBatchTools ? "default" : "secondary"} onClick={() => setShowBatchTools((current) => !current)}>
|
||||||
</Button>
|
台账工具
|
||||||
</>
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error ? <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div> : null}
|
{error ? <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div> : null}
|
||||||
|
|
||||||
<div className="finance-status-strip" data-testid="payment-batches-summary">
|
{!selectedBatchId ? (
|
||||||
<div className="finance-metric-card">
|
<div className="flex flex-wrap gap-2" data-testid="payment-batches-summary">
|
||||||
<div className="finance-metric-card__label">待复核</div>
|
{[
|
||||||
<div className="finance-metric-card__value">{batchStats.needsReview}</div>
|
["全部", batchStats.total],
|
||||||
|
["待复核", batchStats.needsReview],
|
||||||
|
["待付款", batchStats.readyToPay],
|
||||||
|
["待补回单", batchStats.needsReceipt],
|
||||||
|
["异常", batchStats.exceptions],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<span key={label} className="rounded-full border border-zinc-200 bg-white px-3 py-1 text-sm text-zinc-700">
|
||||||
|
{label} <span className="font-semibold text-zinc-900">{value}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="finance-metric-card">
|
) : null}
|
||||||
<div className="finance-metric-card__label">待付款标记</div>
|
|
||||||
<div className="finance-metric-card__value">{batchStats.readyToPay}</div>
|
|
||||||
</div>
|
|
||||||
<div className="finance-metric-card">
|
|
||||||
<div className="finance-metric-card__label">待补回单</div>
|
|
||||||
<div className="finance-metric-card__value">{batchStats.needsReceipt}</div>
|
|
||||||
</div>
|
|
||||||
<div className="finance-metric-card">
|
|
||||||
<div className="finance-metric-card__label">异常批次</div>
|
|
||||||
<div className="finance-metric-card__value">{batchStats.exceptions}</div>
|
|
||||||
</div>
|
|
||||||
<div className="finance-metric-card">
|
|
||||||
<div className="finance-metric-card__label">批次数</div>
|
|
||||||
<div className="finance-metric-card__value">{batchStats.total}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showBatchTools ? (
|
{!selectedBatchId && showBatchTools ? (
|
||||||
<div className="finance-command-panel space-y-4" data-testid="payment-batch-create-panel">
|
<div className="finance-command-panel space-y-4" data-testid="payment-batch-create-panel">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-semibold text-zinc-900">台账工具 · 新建批次</h3>
|
<h3 className="text-base font-semibold text-zinc-900">新建批次</h3>
|
||||||
<p className="mt-1 text-sm text-zinc-500">
|
|
||||||
仅把已确认待付款的单据归入批次。真实付款仍在银行或既有财务流程完成,OA 记录复核、回单和状态。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{!canManageBatch ? (
|
{!canManageBatch ? (
|
||||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
|
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
|
||||||
@@ -490,39 +481,8 @@ export function PaymentBatchesPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="finance-workspace-grid">
|
{selectedBatchId ? (
|
||||||
<div className="grid gap-5 xl:col-span-2 xl:grid-cols-[340px,minmax(0,1fr)]">
|
selectedBatch ? (
|
||||||
<div className="finance-command-panel">
|
|
||||||
<h3 className="mb-3 text-base font-semibold text-zinc-900">批次列表</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{batches.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
type="button"
|
|
||||||
className={selectedBatch?.id === item.id ? "finance-list-card finance-list-card--active" : "finance-list-card"}
|
|
||||||
onClick={() => {
|
|
||||||
const next = new URLSearchParams(searchParams);
|
|
||||||
next.set("id", item.id);
|
|
||||||
setSearchParams(next, { replace: true });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="font-medium text-zinc-900">{item.batchNo}</div>
|
|
||||||
<StatusBadge status={item.status} label={batchStatusLabel(item.status)} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-sm text-zinc-600">{item.name}</div>
|
|
||||||
<div className="mt-1 text-xs text-zinc-500">
|
|
||||||
金额 {item.totalAmount}
|
|
||||||
{item.highRisk ? " · 高风险" : ""}
|
|
||||||
{` · 复核 ${item.reviewRequirement.completedReviewCount}/${item.reviewRequirement.requiredReviewCount}`}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{!listQuery.data?.items?.length ? <div className="text-sm text-zinc-500">暂无付款批次</div> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedBatch ? (
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="finance-command-panel space-y-3" data-testid="payment-batch-detail-panel">
|
<div className="finance-command-panel space-y-3" data-testid="payment-batch-detail-panel">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
@@ -727,9 +687,44 @@ export function PaymentBatchesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : (
|
||||||
|
<div className="finance-command-panel text-sm text-zinc-500">未找到付款批次</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="finance-command-panel" data-testid="payment-batches-list-panel">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">批次列表</h3>
|
||||||
|
<span className="text-sm text-zinc-500">共 {batches.length} 条</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{batches.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
className="finance-list-card"
|
||||||
|
onClick={() => {
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
next.set("id", item.id);
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="font-medium text-zinc-900">{item.batchNo}</div>
|
||||||
|
<StatusBadge status={item.status} label={batchStatusLabel(item.status)} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-zinc-600">{item.name}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">
|
||||||
|
金额 {item.totalAmount}
|
||||||
|
{item.highRisk ? " · 高风险" : ""}
|
||||||
|
{` · 复核 ${item.reviewRequirement.completedReviewCount}/${item.reviewRequirement.requiredReviewCount}`}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-sm font-medium text-primary">查看详情</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!listQuery.data?.items?.length ? <div className="text-sm text-zinc-500">暂无付款批次</div> : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<ActionConfirmDialog
|
<ActionConfirmDialog
|
||||||
open={Boolean(confirmPaymentAction)}
|
open={Boolean(confirmPaymentAction)}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|||||||
@@ -187,67 +187,66 @@ export function ProjectsPage() {
|
|||||||
|
|
||||||
{showLedgerTools ? (
|
{showLedgerTools ? (
|
||||||
<SectionPanel title="台账工具">
|
<SectionPanel title="台账工具">
|
||||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr),260px]">
|
<div className="space-y-4">
|
||||||
<div>
|
{!canManageProjects ? (
|
||||||
{!canManageProjects ? (
|
<div className="text-sm text-zinc-500">你当前只有查看权限,项目创建入口仅对老板 / 管理员 / 项目管理角色开放。</div>
|
||||||
<div className="text-sm text-zinc-500">你当前只有查看权限,项目创建入口仅对老板 / 管理员 / 项目管理角色开放。</div>
|
) : (
|
||||||
) : (
|
<>
|
||||||
<div className="space-y-4">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<Label>项目名称</Label>
|
||||||
<Label>项目名称</Label>
|
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:深圳某客户现场实施" />
|
||||||
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:深圳某客户现场实施" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>客户名称</Label>
|
|
||||||
<Input value={clientName} onChange={(event) => setClientName(event.target.value)} placeholder="客户全称" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>客户简称</Label>
|
|
||||||
<Input value={clientAlias} onChange={(event) => setClientAlias(event.target.value)} placeholder="可选" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>项目经理</Label>
|
|
||||||
<select className="select-std" value={managerEmployeeId} onChange={(event) => setManagerEmployeeId(event.target.value)}>
|
|
||||||
<option value="">未指定</option>
|
|
||||||
{managerCandidates.map((item) => (
|
|
||||||
<option key={item.id} value={item.id}>
|
|
||||||
{item.name} · {item.employeeNo}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>开始日期</Label>
|
|
||||||
<DateTextInput value={startsAt} onChange={(event) => setStartsAt(event.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>目标完成</Label>
|
|
||||||
<DateTextInput value={targetEndAt} onChange={(event) => setTargetEndAt(event.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>项目摘要</Label>
|
<Label>客户名称</Label>
|
||||||
<Textarea value={summary} onChange={(event) => setSummary(event.target.value)} placeholder="填项目目标、交付范围、关键约束" />
|
<Input value={clientName} onChange={(event) => setClientName(event.target.value)} placeholder="客户全称" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>客户简称</Label>
|
||||||
|
<Input value={clientAlias} onChange={(event) => setClientAlias(event.target.value)} placeholder="可选" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>项目经理</Label>
|
||||||
|
<select className="select-std" value={managerEmployeeId} onChange={(event) => setManagerEmployeeId(event.target.value)}>
|
||||||
|
<option value="">未指定</option>
|
||||||
|
{managerCandidates.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name} · {item.employeeNo}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm text-zinc-700">
|
|
||||||
<input type="checkbox" checked={isConfidential} onChange={(event) => setIsConfidential(event.target.checked)} />
|
|
||||||
项目含涉密内容(正文与附件继续离线保存)
|
|
||||||
</label>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
disabled={createMutation.isPending || !name.trim() || !clientName.trim()}
|
|
||||||
onClick={() => createMutation.mutate()}
|
|
||||||
>
|
|
||||||
{createMutation.isPending ? "创建中..." : "创建项目"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
<div className="space-y-3 rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
|
<Label>开始日期</Label>
|
||||||
<div className="font-medium text-slate-900">低频查看</div>
|
<DateTextInput value={startsAt} onChange={(event) => setStartsAt(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>目标完成</Label>
|
||||||
|
<DateTextInput value={targetEndAt} onChange={(event) => setTargetEndAt(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>项目摘要</Label>
|
||||||
|
<Textarea value={summary} onChange={(event) => setSummary(event.target.value)} placeholder="填项目目标、交付范围、关键约束" />
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-zinc-700">
|
||||||
|
<input type="checkbox" checked={isConfidential} onChange={(event) => setIsConfidential(event.target.checked)} />
|
||||||
|
项目含涉密内容(正文与附件继续离线保存)
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{canManageProjects ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={createMutation.isPending || !name.trim() || !clientName.trim()}
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? "创建中..." : "创建项目"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button type="button" variant={showTrialData ? "default" : "secondary"} onClick={() => setShowTrialData((value) => !value)}>
|
<Button type="button" variant={showTrialData ? "default" : "secondary"} onClick={() => setShowTrialData((value) => !value)}>
|
||||||
{showTrialData ? "隐藏测试数据" : "显示测试数据"}
|
{showTrialData ? "隐藏测试数据" : "显示测试数据"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -935,6 +935,18 @@ export function PurchaseRequestsPage() {
|
|||||||
);
|
);
|
||||||
const detailEvidenceHint = detail ? resolvePurchaseEvidenceHint(detail) : null;
|
const detailEvidenceHint = detail ? resolvePurchaseEvidenceHint(detail) : null;
|
||||||
const detailNextStepText = detail ? resolvePurchaseNextStep(detail) : "";
|
const detailNextStepText = detail ? resolvePurchaseNextStep(detail) : "";
|
||||||
|
const isPurchaseDetailMode = Boolean(selectedId);
|
||||||
|
const openPurchaseDetail = (id: string) => {
|
||||||
|
setSelectedId(id);
|
||||||
|
setHydratedDetailId("");
|
||||||
|
};
|
||||||
|
const returnToPurchaseList = () => {
|
||||||
|
setSelectedId("");
|
||||||
|
setHydratedDetailId("");
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
next.delete("id");
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListWorkspace>
|
<ListWorkspace>
|
||||||
@@ -942,16 +954,37 @@ export function PurchaseRequestsPage() {
|
|||||||
<WorkspaceHeader
|
<WorkspaceHeader
|
||||||
title="采购申请"
|
title="采购申请"
|
||||||
actions={
|
actions={
|
||||||
canCreate ? (
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
{isPurchaseDetailMode ? (
|
||||||
data-testid="purchase-requests-create"
|
<Button type="button" variant="secondary" onClick={returnToPurchaseList}>
|
||||||
disabled={createMutation.isPending}
|
返回列表
|
||||||
onClick={() => createMutation.mutate()}
|
</Button>
|
||||||
type="button"
|
) : (
|
||||||
>
|
<>
|
||||||
{createMutation.isPending ? "创建中..." : "新建采购"}
|
{canCreate ? (
|
||||||
</Button>
|
<Button
|
||||||
) : undefined
|
data-testid="purchase-requests-create"
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? "创建中..." : "新建采购"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canShowLedgerTools ? (
|
||||||
|
<Button
|
||||||
|
data-testid="purchase-ledger-tools"
|
||||||
|
type="button"
|
||||||
|
variant={showAdvancedTools ? "default" : "secondary"}
|
||||||
|
onClick={() => setShowLedgerTools((current) => !current)}
|
||||||
|
>
|
||||||
|
<Wrench aria-hidden className="mr-1 size-4" />
|
||||||
|
台账工具
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -961,6 +994,8 @@ export function PurchaseRequestsPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{!isPurchaseDetailMode ? (
|
||||||
|
<>
|
||||||
<FilterBar className="ops-command-bar space-y-3 !p-3 md:!p-3.5">
|
<FilterBar className="ops-command-bar space-y-3 !p-3 md:!p-3.5">
|
||||||
<div className="ops-command-bar__row">
|
<div className="ops-command-bar__row">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -990,18 +1025,6 @@ export function PurchaseRequestsPage() {
|
|||||||
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600">
|
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600">
|
||||||
{mine ? "我相关" : "全范围"}
|
{mine ? "我相关" : "全范围"}
|
||||||
</span>
|
</span>
|
||||||
{canShowLedgerTools ? (
|
|
||||||
<Button
|
|
||||||
data-testid="purchase-ledger-tools"
|
|
||||||
size="sm"
|
|
||||||
type="button"
|
|
||||||
variant={showAdvancedTools ? "default" : "secondary"}
|
|
||||||
onClick={() => setShowLedgerTools((current) => !current)}
|
|
||||||
>
|
|
||||||
<Wrench aria-hidden className="mr-1 size-4" />
|
|
||||||
台账工具
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1119,18 +1142,13 @@ export function PurchaseRequestsPage() {
|
|||||||
purchaseItems.map((item) => {
|
purchaseItems.map((item) => {
|
||||||
const evidenceHint = resolvePurchaseEvidenceHint(item);
|
const evidenceHint = resolvePurchaseEvidenceHint(item);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
data-testid="purchase-request-mobile-row"
|
data-testid="purchase-request-mobile-row"
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={`w-full rounded-lg border p-3 text-left ${
|
className="w-full rounded-lg border border-zinc-200 bg-white p-3 text-left"
|
||||||
item.id === selectedId ? "border-zinc-400 bg-zinc-50" : "border-zinc-200 bg-white"
|
onClick={() => openPurchaseDetail(item.id)}
|
||||||
}`}
|
>
|
||||||
onClick={() => {
|
|
||||||
setSelectedId(item.id);
|
|
||||||
setHydratedDetailId("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-semibold text-zinc-900">
|
<div className="truncate text-sm font-semibold text-zinc-900">
|
||||||
@@ -1161,10 +1179,11 @@ export function PurchaseRequestsPage() {
|
|||||||
? `${formatTextWithCompactLongNumbers(item.project.projectNo)} · ${item.project.name}`
|
? `${formatTextWithCompactLongNumbers(item.project.projectNo)} · ${item.project.name}`
|
||||||
: "未关联项目"}
|
: "未关联项目"}
|
||||||
</div>
|
</div>
|
||||||
<div className={`mt-0.5 ${evidenceHint.className}`}>{evidenceHint.label}</div>
|
<div className={`mt-0.5 ${evidenceHint.className}`}>{evidenceHint.label}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
<div className="mt-3 text-sm font-medium text-primary">{resolvePurchaseRowActionLabel(item.status)}</div>
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
@@ -1190,10 +1209,11 @@ export function PurchaseRequestsPage() {
|
|||||||
purchaseItems.map((item) => {
|
purchaseItems.map((item) => {
|
||||||
const evidenceHint = resolvePurchaseEvidenceHint(item);
|
const evidenceHint = resolvePurchaseEvidenceHint(item);
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={item.id === selectedId ? "bg-zinc-50" : undefined}
|
className="cursor-pointer"
|
||||||
>
|
onClick={() => openPurchaseDetail(item.id)}
|
||||||
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="font-medium text-zinc-900">
|
<div className="font-medium text-zinc-900">
|
||||||
<CompactBusinessCode value={resolvePurchaseDisplayCode(item)} />
|
<CompactBusinessCode value={resolvePurchaseDisplayCode(item)} />
|
||||||
@@ -1233,11 +1253,10 @@ export function PurchaseRequestsPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
variant={["draft", "rejected"].includes(item.status) ? "default" : "secondary"}
|
variant={["draft", "rejected"].includes(item.status) ? "default" : "secondary"}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setSelectedId(item.id);
|
openPurchaseDetail(item.id);
|
||||||
setHydratedDetailId("");
|
}}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{resolvePurchaseRowActionLabel(item.status)}
|
{resolvePurchaseRowActionLabel(item.status)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1261,16 +1280,17 @@ export function PurchaseRequestsPage() {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Pagination
|
<Pagination
|
||||||
page={page}
|
page={page}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
testIdPrefix="purchase-requests"
|
testIdPrefix="purchase-requests"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{selectedId ? (
|
</>
|
||||||
<div className="card-std space-y-5 p-4 md:p-5">
|
) : (
|
||||||
|
<div className="card-std space-y-5 p-4 md:p-5">
|
||||||
{selectedId && detailQuery.isLoading ? <TableSkeleton columns={3} rows={8} /> : null}
|
{selectedId && detailQuery.isLoading ? <TableSkeleton columns={3} rows={8} /> : null}
|
||||||
|
|
||||||
{selectedId && detailQuery.isError ? (
|
{selectedId && detailQuery.isError ? (
|
||||||
@@ -2050,8 +2070,7 @@ export function PurchaseRequestsPage() {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
|
||||||
<ActionConfirmDialog
|
<ActionConfirmDialog
|
||||||
open={Boolean(submitPrecheckConfirm)}
|
open={Boolean(submitPrecheckConfirm)}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ArrowRight, Clock3, FilePenLine, RotateCcw, Wrench } from "lucide-react";
|
import { Clock3, FilePenLine, RotateCcw, Wrench } from "lucide-react";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import { fetchMyDelegations } from "../api/delegations";
|
import { fetchMyDelegations } from "../api/delegations";
|
||||||
@@ -53,32 +53,24 @@ const REIMBURSEMENT_TASK_STATUS_OPTIONS = [
|
|||||||
{
|
{
|
||||||
value: "rejected",
|
value: "rejected",
|
||||||
title: "被驳回待补正",
|
title: "被驳回待补正",
|
||||||
idleNote: "暂无需要补正的报销",
|
|
||||||
activeNote: "优先补齐说明或附件后重新提交",
|
|
||||||
icon: RotateCcw,
|
icon: RotateCcw,
|
||||||
accentClass: "border-red-200 bg-red-50 text-red-700",
|
accentClass: "border-red-200 bg-red-50 text-red-700",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "draft",
|
value: "draft",
|
||||||
title: "草稿待完善",
|
title: "草稿待完善",
|
||||||
idleNote: "还没有报销草稿",
|
|
||||||
activeNote: "继续完善明细、发票和附件",
|
|
||||||
icon: FilePenLine,
|
icon: FilePenLine,
|
||||||
accentClass: "border-amber-200 bg-amber-50 text-amber-700",
|
accentClass: "border-amber-200 bg-amber-50 text-amber-700",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "reviewing",
|
value: "reviewing",
|
||||||
title: "审批中进度",
|
title: "审批中进度",
|
||||||
idleNote: "暂无审批中的报销",
|
|
||||||
activeNote: "查看当前节点和处理进度",
|
|
||||||
icon: Clock3,
|
icon: Clock3,
|
||||||
accentClass: "border-sky-200 bg-sky-50 text-sky-700",
|
accentClass: "border-sky-200 bg-sky-50 text-sky-700",
|
||||||
},
|
},
|
||||||
] satisfies Array<{
|
] satisfies Array<{
|
||||||
value: ReimbursementStatus;
|
value: ReimbursementStatus;
|
||||||
title: string;
|
title: string;
|
||||||
idleNote: string;
|
|
||||||
activeNote: string;
|
|
||||||
icon: typeof RotateCcw;
|
icon: typeof RotateCcw;
|
||||||
accentClass: string;
|
accentClass: string;
|
||||||
}>;
|
}>;
|
||||||
@@ -360,7 +352,7 @@ export function ReimbursementsPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="grid gap-3 md:grid-cols-3" aria-label="我的报销任务">
|
<section className="flex flex-wrap gap-2" aria-label="我的报销任务">
|
||||||
{REIMBURSEMENT_TASK_STATUS_OPTIONS.map((item) => {
|
{REIMBURSEMENT_TASK_STATUS_OPTIONS.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const count = taskSummaryTotalByStatus[item.value];
|
const count = taskSummaryTotalByStatus[item.value];
|
||||||
@@ -369,29 +361,19 @@ export function ReimbursementsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`card-std min-h-[124px] p-4 text-left transition hover:border-primary/40 hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-200 ${
|
className={`inline-flex min-h-10 items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition hover:border-primary/40 hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-200 ${
|
||||||
active ? "border-primary/40 bg-primary/5" : ""
|
active ? "border-primary/40 bg-primary/5 text-primary" : "border-zinc-200 bg-white text-zinc-700"
|
||||||
}`}
|
}`}
|
||||||
data-testid={`reimbursements-task-card-${item.value}`}
|
data-testid={`reimbursements-task-card-${item.value}`}
|
||||||
key={item.value}
|
key={item.value}
|
||||||
onClick={() => setSearchParams({ status: item.value, page: "1", scope: "own" })}
|
onClick={() => setSearchParams({ status: item.value, page: "1", scope: "own" })}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<span className={`flex size-7 items-center justify-center rounded-full border ${item.accentClass}`}>
|
||||||
<span className={`flex size-9 items-center justify-center rounded-lg border ${item.accentClass}`}>
|
<Icon aria-hidden className="size-3.5" />
|
||||||
<Icon aria-hidden className="size-4" />
|
</span>
|
||||||
</span>
|
<span className="font-medium">{item.title}</span>
|
||||||
<ArrowRight aria-hidden className="mt-1 size-4 text-zinc-300" />
|
<span className="font-semibold tabular-nums text-zinc-950">{displayCount}</span>
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex items-end justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-sm font-semibold text-zinc-900">{item.title}</div>
|
|
||||||
<div className="mt-1 text-xs leading-5 text-zinc-500">
|
|
||||||
{typeof count === "number" && count > 0 ? item.activeNote : item.idleNote}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-3xl font-semibold tabular-nums text-zinc-950">{displayCount}</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ function verificationMessage(message: string | null | undefined) {
|
|||||||
|
|
||||||
export function InvoiceDetailPanel({
|
export function InvoiceDetailPanel({
|
||||||
detail,
|
detail,
|
||||||
|
detailQuery,
|
||||||
canEditDetail,
|
canEditDetail,
|
||||||
canVerifyInvoice,
|
canVerifyInvoice,
|
||||||
canUploadInvoice,
|
canUploadInvoice,
|
||||||
@@ -531,8 +532,12 @@ export function InvoiceDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
) : detailQuery.isLoading ? (
|
||||||
|
<p className="text-zinc-500 py-6 text-center bg-zinc-50/50 rounded-lg border border-dashed border-zinc-200 mt-2">加载票据详情...</p>
|
||||||
|
) : detailQuery.isError ? (
|
||||||
|
<p className="text-red-600 py-6 text-center bg-red-50 rounded-lg border border-red-100 mt-2">票据详情加载失败</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-zinc-500 py-6 text-center bg-zinc-50/50 rounded-lg border border-dashed border-zinc-200 mt-2">请从左侧列表选择一条票据查看详情与修正选项</p>
|
<p className="text-zinc-500 py-6 text-center bg-zinc-50/50 rounded-lg border border-dashed border-zinc-200 mt-2">未找到票据详情</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ export function InvoiceUploadCard({
|
|||||||
{selectedFiles.length > 4 ? <div>另有 {selectedFiles.length - 4} 个文件</div> : null}
|
{selectedFiles.length > 4 ? <div>另有 {selectedFiles.length - 4} 个文件</div> : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="text-xs text-zinc-500">发票上传仅支持 PDF;附件材料可在对应业务单据中上传图片或其他证明文件。</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,6 +304,7 @@ export async function uploadLiveInvoiceAndWait(page: Page, invoiceFilePath: stri
|
|||||||
const url = new URL(response.url());
|
const url = new URL(response.url());
|
||||||
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
|
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
|
||||||
});
|
});
|
||||||
|
await page.getByRole("button", { name: "台账工具" }).click();
|
||||||
await page.getByTestId("invoice-upload-file").setInputFiles(invoiceFilePath);
|
await page.getByTestId("invoice-upload-file").setInputFiles(invoiceFilePath);
|
||||||
await page.getByTestId("invoice-upload-submit").click();
|
await page.getByTestId("invoice-upload-submit").click();
|
||||||
const uploadPayload = (await (await uploadResponsePromise).json()) as UploadInvoiceResponse;
|
const uploadPayload = (await (await uploadResponsePromise).json()) as UploadInvoiceResponse;
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ test("E2E 轻交互 OCR:票据中心缺关键字段后可在当前页修正并
|
|||||||
await (await openNavLink(page, "票据")).click();
|
await (await openNavLink(page, "票据")).click();
|
||||||
await expect(page.getByTestId("invoice-pool-table")).toBeVisible();
|
await expect(page.getByTestId("invoice-pool-table")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "台账工具" }).click();
|
||||||
await page.getByTestId("invoice-upload-file").setInputFiles({
|
await page.getByTestId("invoice-upload-file").setInputFiles({
|
||||||
name: `r73-missing-${Date.now()}.pdf`,
|
name: `r73-missing-${Date.now()}.pdf`,
|
||||||
mimeType: "application/pdf",
|
mimeType: "application/pdf",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
|
|||||||
const url = new URL(response.url());
|
const url = new URL(response.url());
|
||||||
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
|
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
|
||||||
});
|
});
|
||||||
|
await page.getByRole("button", { name: "台账工具" }).click();
|
||||||
await page.getByTestId("invoice-upload-file").setInputFiles({
|
await page.getByTestId("invoice-upload-file").setInputFiles({
|
||||||
name: invoiceFileName,
|
name: invoiceFileName,
|
||||||
mimeType: "application/pdf",
|
mimeType: "application/pdf",
|
||||||
@@ -57,15 +58,15 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
|
|||||||
|
|
||||||
// 预览(popup 存在即可)
|
// 预览(popup 存在即可)
|
||||||
const popupPromise = page.waitForEvent("popup");
|
const popupPromise = page.waitForEvent("popup");
|
||||||
const invoiceRow = page.getByTestId("invoice-row").filter({ hasText: invoiceFileName }).first();
|
|
||||||
await expect(invoiceRow).toBeVisible();
|
|
||||||
await invoiceRow.getByRole("button").click();
|
|
||||||
await page.getByTestId("invoice-preview-button").click();
|
await page.getByTestId("invoice-preview-button").click();
|
||||||
const popup = await popupPromise;
|
const popup = await popupPromise;
|
||||||
await expect(popup).toHaveURL(new RegExp(`/api/invoice-pool/${invoiceId}`));
|
await expect(popup).toHaveURL(new RegExp(`/api/invoice-pool/${invoiceId}`));
|
||||||
await popup.close();
|
await popup.close();
|
||||||
|
|
||||||
// 3) bind invoice to reimbursement
|
// 3) bind invoice to reimbursement
|
||||||
|
await page.goto("/invoice-pool");
|
||||||
|
await expect(page.getByTestId("invoice-pool-table")).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "台账工具" }).click();
|
||||||
await page.getByTestId("invoice-bind-invoice").selectOption(invoiceId);
|
await page.getByTestId("invoice-bind-invoice").selectOption(invoiceId);
|
||||||
await page.getByTestId("invoice-bind-reimbursement").selectOption(reimbursementId);
|
await page.getByTestId("invoice-bind-reimbursement").selectOption(reimbursementId);
|
||||||
await page.getByTestId("invoice-bind-submit").click();
|
await page.getByTestId("invoice-bind-submit").click();
|
||||||
|
|||||||
@@ -1000,9 +1000,9 @@ async function capture(page: Page, name: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pages = [
|
const pages = [
|
||||||
{ path: "/payment-batches", summary: "payment-batches-summary", proof: "text=批次列表", screenshot: "01-payment-batches" },
|
{ path: "/payment-batches", summary: "payment-batches-summary", proof: "text=批次列表", screenshot: "01-payment-batches", expectStatusStrip: false },
|
||||||
{ path: "/ap-operations", proof: "text=异常付款", screenshot: "02-ap-operations", expectStatusStrip: false },
|
{ path: "/ap-operations", proof: "text=异常付款", screenshot: "02-ap-operations", expectStatusStrip: false },
|
||||||
{ path: "/invoice-pool", summary: "invoice-pool-summary", proof: "data-testid=invoice-pool-table", screenshot: "03-invoice-pool" },
|
{ path: "/invoice-pool", summary: "invoice-pool-summary", proof: "data-testid=invoice-pool-table", screenshot: "03-invoice-pool", expectStatusStrip: false },
|
||||||
{ path: "/system/approval-rules", summary: "approval-rules-summary", proof: "data-testid=approval-rules-table", screenshot: "04-approval-rules" },
|
{ path: "/system/approval-rules", summary: "approval-rules-summary", proof: "data-testid=approval-rules-table", screenshot: "04-approval-rules" },
|
||||||
{ path: "/people", summary: "people-summary", proof: "data-testid=people-table", screenshot: "05-people" },
|
{ path: "/people", summary: "people-summary", proof: "data-testid=people-table", screenshot: "05-people" },
|
||||||
{ path: "/organization", summary: "organization-summary", proof: "text=研发部", screenshot: "06-organization" },
|
{ path: "/organization", summary: "organization-summary", proof: "text=研发部", screenshot: "06-organization" },
|
||||||
@@ -1043,7 +1043,7 @@ test("PC 财务台账工具默认收起并可按需打开", async ({ page }) =>
|
|||||||
await expect(page.getByTestId("payment-batch-create-panel")).toHaveCount(0);
|
await expect(page.getByTestId("payment-batch-create-panel")).toHaveCount(0);
|
||||||
await page.getByRole("button", { name: "台账工具" }).click();
|
await page.getByRole("button", { name: "台账工具" }).click();
|
||||||
await expect(page.getByTestId("payment-batch-create-panel")).toBeVisible();
|
await expect(page.getByTestId("payment-batch-create-panel")).toBeVisible();
|
||||||
await expect(page.getByTestId("payment-batch-create-panel")).toContainText("台账工具 · 新建批次");
|
await expect(page.getByTestId("payment-batch-create-panel")).toContainText("新建批次");
|
||||||
|
|
||||||
await page.goto("/ap-operations");
|
await page.goto("/ap-operations");
|
||||||
await expect(page.getByRole("button", { name: "导出 CSV" })).toHaveCount(0);
|
await expect(page.getByRole("button", { name: "导出 CSV" })).toHaveCount(0);
|
||||||
@@ -1082,7 +1082,7 @@ test("PC 票据中心辅助核验动作可提交并重试", async ({ page }) =>
|
|||||||
await page.setViewportSize({ width: 1440, height: 960 });
|
await page.setViewportSize({ width: 1440, height: 960 });
|
||||||
await login(page);
|
await login(page);
|
||||||
|
|
||||||
await page.goto("/invoice-pool");
|
await page.goto(`/invoice-pool?id=${invoiceListItem.id}`);
|
||||||
await expect(page.getByTestId("invoice-detail-panel")).toBeVisible();
|
await expect(page.getByTestId("invoice-detail-panel")).toBeVisible();
|
||||||
await expect(page.getByTestId("invoice-trigger-verification")).toBeVisible();
|
await expect(page.getByTestId("invoice-trigger-verification")).toBeVisible();
|
||||||
await expect(page.getByTestId("invoice-retry-verification")).toBeVisible();
|
await expect(page.getByTestId("invoice-retry-verification")).toBeVisible();
|
||||||
@@ -1118,7 +1118,7 @@ test("PC 票据中心辅助核验错误提示可理解", async ({ page }) => {
|
|||||||
await page.setViewportSize({ width: 1440, height: 960 });
|
await page.setViewportSize({ width: 1440, height: 960 });
|
||||||
await login(page);
|
await login(page);
|
||||||
|
|
||||||
await page.goto("/invoice-pool");
|
await page.goto(`/invoice-pool?id=${invoiceListItem.id}`);
|
||||||
await expect(page.getByTestId("invoice-detail-panel")).toBeVisible();
|
await expect(page.getByTestId("invoice-detail-panel")).toBeVisible();
|
||||||
|
|
||||||
await page.getByTestId("invoice-trigger-verification").click();
|
await page.getByTestId("invoice-trigger-verification").click();
|
||||||
|
|||||||
@@ -344,15 +344,20 @@ test.describe("R103 cross page UI consistency", () => {
|
|||||||
|
|
||||||
test("采购页显示中文标题并使用中文附件上传控件", async ({ page }) => {
|
test("采购页显示中文标题并使用中文附件上传控件", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 960 });
|
await page.setViewportSize({ width: 1440, height: 960 });
|
||||||
await page.goto(`/purchase-requests?id=${purchaseId}`);
|
await page.goto("/purchase-requests");
|
||||||
|
|
||||||
await expect(page.locator("header").filter({ hasText: "业务" }).getByText("采购申请")).toBeVisible();
|
await expect(page.locator("header").filter({ hasText: "业务" }).getByText("采购申请")).toBeVisible();
|
||||||
await expect(page.getByRole("heading", { name: "采购申请" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "采购申请" })).toBeVisible();
|
||||||
await expect(page.getByTestId("purchase-requests-table")).toBeVisible();
|
await expect(page.getByTestId("purchase-requests-table")).toBeVisible();
|
||||||
await expect(page.getByTestId("purchase-ledger-tools")).toBeVisible();
|
await expect(page.getByTestId("purchase-ledger-tools")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("purchase-attachment-upload")).toHaveCount(0);
|
||||||
await expect(page.getByTestId("purchase-row-next-step")).toHaveCount(0);
|
await expect(page.getByTestId("purchase-row-next-step")).toHaveCount(0);
|
||||||
await expect(page.getByText("付款处理")).toHaveCount(0);
|
await expect(page.getByText("付款处理")).toHaveCount(0);
|
||||||
await expect(page.getByText("/purchase-requests")).toHaveCount(0);
|
await expect(page.getByText("/purchase-requests")).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.goto(`/purchase-requests?id=${purchaseId}`);
|
||||||
|
await expect(page.getByTestId("purchase-requests-table")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("purchase-ledger-tools")).toHaveCount(0);
|
||||||
await expect(page.getByTestId("purchase-attachment-upload")).toHaveText("上传附件");
|
await expect(page.getByTestId("purchase-attachment-upload")).toHaveText("上传附件");
|
||||||
await expect(page.getByText("Choose File")).toHaveCount(0);
|
await expect(page.getByText("Choose File")).toHaveCount(0);
|
||||||
|
|
||||||
@@ -370,6 +375,8 @@ test.describe("R103 cross page UI consistency", () => {
|
|||||||
await page.goto("/invoice-pool");
|
await page.goto("/invoice-pool");
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "票据中心" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "票据中心" })).toBeVisible();
|
||||||
|
await expect(page.getByTestId("invoice-detail-panel")).toHaveCount(0);
|
||||||
|
await page.getByRole("button", { name: "台账工具" }).click();
|
||||||
await expect(page.getByTestId("invoice-bind-reimbursement")).toContainText("草稿单 · 2026-05-06");
|
await expect(page.getByTestId("invoice-bind-reimbursement")).toContainText("草稿单 · 2026-05-06");
|
||||||
await expect(page.getByTestId("invoice-bind-reimbursement")).not.toContainText(reimbursementId);
|
await expect(page.getByTestId("invoice-bind-reimbursement")).not.toContainText(reimbursementId);
|
||||||
await expect(page.getByText("/invoice-pool")).toHaveCount(0);
|
await expect(page.getByText("/invoice-pool")).toHaveCount(0);
|
||||||
|
|||||||
@@ -326,6 +326,9 @@ test.describe("R92 手机端票据与报销可用性", () => {
|
|||||||
await expect(page.getByText("票据详情")).toBeVisible();
|
await expect(page.getByText("票据详情")).toBeVisible();
|
||||||
await attachScreenshot(page, testInfo, "r97-invoice-pool-mobile.png");
|
await attachScreenshot(page, testInfo, "r97-invoice-pool-mobile.png");
|
||||||
|
|
||||||
|
await page.goto("/invoice-pool");
|
||||||
|
await expect(page.getByTestId("invoice-mobile-row")).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "台账工具" }).click();
|
||||||
await page.getByTestId("invoice-upload-file").setInputFiles([
|
await page.getByTestId("invoice-upload-file").setInputFiles([
|
||||||
{ name: "invoice-a.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-a") },
|
{ name: "invoice-a.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-a") },
|
||||||
{ name: "invoice-b.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-b") },
|
{ name: "invoice-b.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-b") },
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ test.describe("R7E 全站高风险操作二次确认", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await login(page, user);
|
await login(page, user);
|
||||||
await page.goto("/payment-batches");
|
await page.goto(`/payment-batches?id=${paymentBatchId}`);
|
||||||
await expect(page.getByText("PAY-ACTION-001").first()).toBeVisible();
|
await expect(page.getByText("PAY-ACTION-001").first()).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole("button", { name: "执行" }).click();
|
await page.getByRole("button", { name: "执行" }).click();
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ test("E2E 差旅支付主链路:申请→审批→支付→绑票", async ({ p
|
|||||||
const url = new URL(response.url());
|
const url = new URL(response.url());
|
||||||
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
|
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
|
||||||
});
|
});
|
||||||
|
await page.getByRole("button", { name: "台账工具" }).click();
|
||||||
await page.getByTestId("invoice-upload-file").setInputFiles({
|
await page.getByTestId("invoice-upload-file").setInputFiles({
|
||||||
name: invoiceFileName,
|
name: invoiceFileName,
|
||||||
mimeType: "application/pdf",
|
mimeType: "application/pdf",
|
||||||
@@ -76,8 +77,9 @@ test("E2E 差旅支付主链路:申请→审批→支付→绑票", async ({ p
|
|||||||
const invoiceDetailText = await page.getByTestId("invoice-detail-panel").innerText();
|
const invoiceDetailText = await page.getByTestId("invoice-detail-panel").innerText();
|
||||||
const invoiceCode = extractInvoiceCode(invoiceDetailText);
|
const invoiceCode = extractInvoiceCode(invoiceDetailText);
|
||||||
|
|
||||||
await page.reload();
|
await page.goto("/invoice-pool");
|
||||||
await expect(page.getByTestId("invoice-pool-table")).toBeVisible();
|
await expect(page.getByTestId("invoice-pool-table")).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "台账工具" }).click();
|
||||||
const bindCard = page.locator(".card-std").filter({ hasText: "绑定业务单据" }).first();
|
const bindCard = page.locator(".card-std").filter({ hasText: "绑定业务单据" }).first();
|
||||||
await bindCard.getByRole("combobox").first().selectOption("travelOrder");
|
await bindCard.getByRole("combobox").first().selectOption("travelOrder");
|
||||||
await page.getByTestId("invoice-bind-invoice").selectOption(invoiceId);
|
await page.getByTestId("invoice-bind-invoice").selectOption(invoiceId);
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
A 类页面优先保证真实试运行路径顺畅;B 类页面优先保证台账、复核和导出不误导;C 类页面允许展示更多审计和排障信息,但不得泄露无权限数据。
|
A 类页面优先保证真实试运行路径顺畅;B 类页面优先保证台账、复核和导出不误导;C 类页面允许展示更多审计和排障信息,但不得泄露无权限数据。
|
||||||
|
|
||||||
页面模板固定为六类:`工作台总览`、`任务处理列表`、`业务台账列表`、`主从工作台`、`详情工作区`、`管理配置页`。后续新增或重构页面必须先判定页面分级和模板,再设计业务内容;不允许每个页面临时自创布局。移动端是这些模板的移动表达,不单独开一套 PC 壳层。
|
页面模板固定为六类:`工作台总览`、`任务处理列表`、`业务台账列表`、`主从工作台`、`详情工作区`、`管理配置页`。后续新增或重构页面必须先判定页面分级和模板,再设计业务内容;不允许每个页面临时自创布局。移动端是这些模板的移动表达,不单独开一套 PC 壳层。采购、合同、项目、资产、付款、应付账款和票据的首屏默认都是 `业务台账列表`;`主从工作台` 只用于用户显式选择后的执行任务,例如已打开的资产盘点任务,不得在普通台账首屏自动选择第一条记录来填充详情区。
|
||||||
|
|
||||||
全系统操作语义固定:页面主动作只保留一个且出现在用户预期的主操作区;筛选、台账工具、行级动作、详情高风险动作和移动端底部动作各有稳定位置。不可逆或高风险动作不得混在普通查看/编辑动作里,必须显示当前对象摘要并二次确认。
|
全系统操作语义固定:页面主动作只保留一个且出现在用户预期的主操作区;筛选、台账工具、行级动作、详情高风险动作和移动端底部动作各有稳定位置。不可逆或高风险动作不得混在普通查看/编辑动作里,必须显示当前对象摘要并二次确认。
|
||||||
|
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户