feat(management-navigation-single-company-ux): 收敛管理导航与单公司配置体验
这个提交包含在:
@@ -2107,3 +2107,82 @@ const actions = collectVisibleActions();
|
||||
expect(actions.map(({ key, label }) => ({ key, label }))).toEqual(EXPECTED_ACTIONS);
|
||||
for (const action of actions) expect(action.height).toBeGreaterThanOrEqual(44);
|
||||
```
|
||||
|
||||
## Scenario: Management Route Metadata And Permission Disclosure
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: adding or changing a PC management landing item, management subpage,
|
||||
nested management detail, breadcrumb, sidebar ownership or recent-visit title.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- Registry: `client/src/features/navigation/managementRoutes.ts`.
|
||||
- Resolver inputs: `pathname: string` plus the current `permissions: string[]`.
|
||||
- Router permissions: `getManagementRoutePermissions(key)`.
|
||||
- Recent visits: `writeRecentVisit(path, title, permissions)` and
|
||||
`readRecentVisits(permissions)`.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- The management landing cards, root-navigation permission union, sidebar
|
||||
active state, Topbar title/breadcrumb and recent-visit filtering derive from
|
||||
the same registry entry.
|
||||
- React Router keeps explicit component declarations and consumes the same
|
||||
registry permissions. Server authorization remains the hard access boundary;
|
||||
navigation metadata only enforces least disclosure.
|
||||
- Match the most specific route deliberately. A nested employee profile must
|
||||
resolve after its exact siblings and must not inherit a broader prefix by
|
||||
accident.
|
||||
- A user without the registered permission receives no restricted title,
|
||||
breadcrumb or recent-visit link. Revoked permissions also clean stale local
|
||||
recent-visit records.
|
||||
- The sidebar keeps one `管理` root entry. Do not spread low-frequency child
|
||||
pages back into the primary navigation.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- Registered route + allowed permission -> natural Chinese title, parent chain
|
||||
and management sidebar active state.
|
||||
- Registered route + denied permission -> no restricted metadata; Router and
|
||||
server guards still reject access.
|
||||
- Unknown registry key passed to the Router helper -> throw during development
|
||||
instead of silently using an empty permission list.
|
||||
- Newly declared application route missing from the route manifest -> local
|
||||
candidate route gate fails.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: `/people/:employeeId` uses the registry for both
|
||||
`管理 / 人员 / 员工档案` and its `system.users` Router guard.
|
||||
- Base: `/system/settings` displays only `管理`, with no self-referencing
|
||||
breadcrumb.
|
||||
- Bad: add a path to `SystemSettingsPage`, `NavLinks`, `PAGE_TITLES` and Router
|
||||
as four hand-maintained facts.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Registry contract covers unique keys, expected path inventory, parents,
|
||||
permissions, landing metadata and root permission derivation.
|
||||
- Permission-negative coverage asserts no title/breadcrumb disclosure and
|
||||
filters a previously stored recent visit after permission revocation.
|
||||
- Focused Playwright covers direct management deep links, sidebar active state,
|
||||
nested breadcrumbs and the management-root return link.
|
||||
- Run `cd client && npm run typecheck && npm run build` and
|
||||
`bash scripts/qa/run-local-candidate.sh list`.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```tsx
|
||||
const MANAGEMENT_ACTIVE_PATHS = ["/people", "/system/users"];
|
||||
<RequirePermission anyOf={["system.users"]}>...</RequirePermission>
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```tsx
|
||||
isManagementPath(pathname, permissions);
|
||||
<RequirePermission anyOf={getManagementRoutePermissions("people")}>...</RequirePermission>
|
||||
```
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,96 @@
|
||||
# 管理导航闭环与单公司体验收敛设计
|
||||
|
||||
## 1. 设计边界
|
||||
|
||||
本任务修复管理信息架构的单一事实源和单公司配置体验,不把管理区改造成产品化后台。侧栏继续只有一个「管理」入口;管理首页继续承担低频配置入口聚合。统一层只提供路由元数据、父子关系和导航表达,不接管 React Router 组件装配或服务端权限校验。
|
||||
|
||||
## 2. 管理路由注册表
|
||||
|
||||
在 `client/src/features/navigation/` 建立共享管理路由注册表。每项建议包含:
|
||||
|
||||
- `key`:稳定标识。
|
||||
- `path`:可导航地址;详情项可只提供 matcher 和父级。
|
||||
- `match`:exact/prefix 或显式匹配规则,避免 `/people` 与 `/people/:id` 标题混淆。
|
||||
- `title`:Topbar、面包屑和最近访问使用的自然中文。
|
||||
- `parentKey`:父级管理项;顶层配置页父级固定为 management root。
|
||||
- `group`、`icon`、`description`、`anyOf`:管理首页卡片的分组、展示和权限合同。
|
||||
- `showOnLanding`:详情层级不在聚合页重复显示。
|
||||
|
||||
共享解析函数:
|
||||
|
||||
- `resolveManagementRoute(pathname, permissions)`:只返回当前有权识别的最具体匹配项。
|
||||
- `resolveManagementBreadcrumbs(pathname, permissions)`:在当前权限范围内沿 `parentKey` 生成可点击路径。
|
||||
- `isManagementPath(pathname, permissions)`:供侧栏选中判断,避免无权路径激活管理导航。
|
||||
- `getVisibleManagementItems(permissions)`:供管理首页渲染。
|
||||
|
||||
管理根入口的 `anyOf` 从所有可见管理子项权限求并集派生,避免 `NavLinks.tsx` 再维护一份权限列表。页面可见性仍以现有 Router 权限守卫和服务端权限校验为硬边界;注册表权限负责导航信息最小披露,不能替代访问控制。
|
||||
|
||||
`features/navigation/schema.ts` 先调用管理解析器,再回退到现有业务页面元数据;`NavLinks.tsx` 删除 `MANAGEMENT_ACTIVE_PATHS`;`SystemSettingsPage.tsx` 删除本地 `ITEMS`。
|
||||
|
||||
## 3. Topbar 面包屑
|
||||
|
||||
Topbar 已是所有 PC 页面稳定存在的壳层,比逐页增加返回按钮更可靠。管理路径命中时,把当前“标题 + 管理分组胶囊”表达为紧凑面包屑:
|
||||
|
||||
```text
|
||||
管理 / 企业银行账户
|
||||
管理 / 人员 / 员工档案
|
||||
```
|
||||
|
||||
- 非当前节点使用 `Link`;当前节点使用文本并带 `aria-current="page"`。
|
||||
- 管理首页仍显示普通标题「管理」。
|
||||
- 非管理业务页面保持现有 Topbar,不在本任务推广全系统面包屑。
|
||||
- 页面内部 `PageHeader` / `WorkspaceHeader` 不重复增加返回按钮,避免一页两套层级。
|
||||
- Topbar 从 `useAuth()` 取得当前权限后解析管理元数据;无权命中时不渲染受限标题或父链,避免权限守卫跳转前的短暂信息泄露。
|
||||
|
||||
## 4. 路由覆盖门禁
|
||||
|
||||
管理首页注册表是可见入口事实源,React Router 仍显式声明页面。测试用显式允许的管理路由清单核对:
|
||||
|
||||
1. 每个 landing item 都能被 resolver 精确识别。
|
||||
2. 每个已注册详情路径返回正确父链。
|
||||
3. 所有管理页面标题均为自然中文。
|
||||
4. `SystemSettingsPage` 和 `NavLinks` 不再出现第二份管理路径数组。
|
||||
5. `/system/payment-accounts` 明确回归侧栏 active。
|
||||
6. 同一路径在权限具备/缺失时分别验证可见元数据与不披露行为。
|
||||
|
||||
不在运行时解析 Router JSX,也不引入通用动态路由框架。
|
||||
|
||||
## 5. 最近访问权限收口
|
||||
|
||||
当前 `AppShellLayout` 会把路径和 `resolvePageMeta()` 标题写入 localStorage,但仓库中尚无读取最近访问的 UI。共享注册表接入后:
|
||||
|
||||
- 写入管理路径前按当前权限解析;无权路径不写入受限标题。
|
||||
- 提供按当前权限过滤的读取函数,未来消费者只能读取过滤后的结果,不能直接展示 localStorage 原值。
|
||||
- 权限被撤销后,过滤结果立即隐藏受限标题和链接;可同步清理失效记录,避免旧数据长期残留。
|
||||
- 不把最近访问改成服务端存储,也不在本任务新增入口或页面。
|
||||
|
||||
## 6. 单公司 UI
|
||||
|
||||
后端模型保持不变。前端把 `LegalEntity` 解释为当前公司资料:
|
||||
|
||||
- 0 条 active:显示「完善公司信息」初始化区。
|
||||
- 1 条 active:显示公司摘要和现有维护能力,隐藏多公司选择语义。
|
||||
- 多条 active:显示异常提示,不猜测默认公司,不自动清理。
|
||||
|
||||
企业银行账户页面复用同一查询:
|
||||
|
||||
- 0 条:只显示空状态和去公司信息的链接。
|
||||
- 1 条:内部自动使用其 ID,页面只读展示名称。
|
||||
- 多条:禁用保存并提示异常。
|
||||
|
||||
暂不改变服务端 `legalEntityId` 请求字段,以保持 API、审计、付款快照和历史数据兼容。
|
||||
|
||||
## 7. 文档口径
|
||||
|
||||
`docs/产品口径.md` 当前编号到 R-5,因此新增 R-6,而不是留下缺号直接写 R-7。内容固化:单公司、内部 OA、非 SaaS、无公司切换;保留内部关系模型不等于对用户暴露多公司能力。
|
||||
|
||||
## 8. 兼容与回滚
|
||||
|
||||
- 无数据库迁移,无生产数据写入。
|
||||
- 回滚只需恢复前端注册表消费者和旧文案;API/数据不受影响。
|
||||
- 异常多公司场景保持 fail-closed,不会因前端自动选择错误公司影响付款账户。
|
||||
- 生产公司信息、付款账户和员工银行机构已通过既有路径完成配置;本任务只修复后续管理体验,不回读、不迁移也不改写这些真实资料。
|
||||
|
||||
## 9. 视觉与技能边界
|
||||
|
||||
项目要求页面视觉与交互以 `frontend-design` skill 裁决,但当前会话未提供该 skill。实施前若仍不可用,沿用现有 Topbar、Link、空状态、表单和管理卡片组件,不新增视觉体系;任何明显视觉重排需再次说明该限制。
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,64 @@
|
||||
# 管理导航闭环与单公司体验收敛实施计划
|
||||
|
||||
## 1. 实施前门禁
|
||||
|
||||
- [x] 用户审查并明确批准 PRD、设计和本实施计划后,才运行 `task.py start`。
|
||||
- [x] 运行 `trellis-before-dev`,读取 frontend、hyb-oa、项目治理和产品体验相关规约。
|
||||
- [x] 实施前再次检查 `frontend-design` skill 是否可用;不可用时只复用现有视觉组件,不自行发明新样式。
|
||||
- [x] 保护当前生产公司/银行资料;不连接生产、不写真实数据、不执行部署。
|
||||
|
||||
## 2. 共享管理路由
|
||||
|
||||
- [x] 建立管理路由注册表和最具体路径解析、父链、权限过滤函数。
|
||||
- [x] 迁移 `SystemSettingsPage` 的入口列表到共享注册表。
|
||||
- [x] 迁移 `NavLinks` 的管理选中判断并删除 `MANAGEMENT_ACTIVE_PATHS`。
|
||||
- [x] 让 `resolvePageMeta` 从共享注册表解析管理标题,补齐当前遗漏页面和人员档案详情。
|
||||
- [x] 让管理根入口权限从注册表派生,并让 resolver、面包屑和侧栏 active 接收当前权限;无权路径不披露受限元数据。
|
||||
- [x] 收口最近访问写入/读取:写入前校验当前权限,读取时按当前权限过滤并覆盖权限撤销场景。
|
||||
- [x] 增加注册表完整性、重复 key/path、父级循环、自然中文标题和权限字段测试。
|
||||
|
||||
## 3. Topbar 管理面包屑
|
||||
|
||||
- [x] 在 Topbar 命中管理子页时渲染 `管理 / 当前页`,管理首页不自指。
|
||||
- [x] 覆盖 `管理 / 人员 / 员工档案` 等二级父链、可点击链接和 `aria-current`。
|
||||
- [x] 验证直接深链、刷新、浏览器前进后退和窄屏换行,不依赖 location state。
|
||||
- [x] 保持非管理页面 Topbar 行为与最近访问记录兼容。
|
||||
|
||||
## 4. 单公司体验
|
||||
|
||||
- [x] 把主数据中的「法体」用户文案改为「公司信息」,保留 API/类型/数据库命名。
|
||||
- [x] 0/1/多条 active 公司信息分别渲染初始化、单公司维护和异常提示。
|
||||
- [x] 企业银行账户页在 0 条时提供公司信息入口,1 条时自动关联并只读显示,多条时阻断保存。
|
||||
- [x] 保持付款账户权限、完整账号输入、替换/停用、审计和通知调用不变。
|
||||
|
||||
## 5. 测试与检查
|
||||
|
||||
- [x] 增加共享 resolver、管理 landing、侧栏 active、Topbar breadcrumb 的聚焦测试。
|
||||
- [x] 增加无权深链不显示受限面包屑,以及权限撤销后最近访问不返回旧标题/链接的测试。
|
||||
- [x] 增加企业银行账户 0/1/多公司状态测试,断言无真实账号或 payload 进入证据。
|
||||
- [x] 运行 `cd client && npm run typecheck && npm run build`。
|
||||
- [x] 运行受影响前端测试及必要 Playwright 合成路径;不使用生产账号或真实银行资料。
|
||||
- [x] 运行 `git diff --check`、敏感内容、Trellis governance、process/docs 门禁。
|
||||
|
||||
## 6. 文档、审查与提交
|
||||
|
||||
- [x] 在 `docs/产品口径.md` 以 R-6 固化单公司内部 OA 边界。
|
||||
- [x] 若路由注册表形成稳定通用合同,用 `trellis-update-spec` 更新现有 frontend spec;不新建无必要文档。
|
||||
- [x] 只读复核路由覆盖、权限可见性、面包屑父链和 0/1/多公司 fail-closed 行为。
|
||||
- [x] 中文提交并引用 `management-navigation-single-company-ux`;不自动部署。
|
||||
- [ ] 另行批准生产部署后运行公网检查,只有通过后才通知用户线上验收。
|
||||
|
||||
## 7. 回滚点
|
||||
|
||||
- 共享路由迁移必须先有 resolver 测试,再删除旧数组;任一入口丢失时恢复消费者,不修改服务端。
|
||||
- 单公司 UI 只改变展示与默认选择;若异常场景处理不符合预期,回滚前端即可,`LegalEntity` 和付款账户数据不变。
|
||||
|
||||
## 8. 本地实施与验证记录
|
||||
|
||||
- 共享注册表已驱动管理首页、管理根权限、Router 权限、侧栏 active、Topbar 标题/面包屑和最近访问过滤。
|
||||
- 只读复核发现并修正 `/people/:employeeId` 缺少显式前端权限守卫的问题;服务端权限边界未改。
|
||||
- 企业银行账户页已覆盖零个、一个和异常多个启用公司状态;异常状态 fail-closed。
|
||||
- 聚焦 Playwright:8/8 通过;此前完整 PC 财务/管理回归:9/9 通过。
|
||||
- `npm run typecheck`、`npm run build`、路由清单门禁、Trellis validate/governance、process contract、docs audit、敏感内容和 `git diff --check` 均通过;docs audit 保留既有 22 个未登记文档警告,无错误。
|
||||
- 路由清单顺带补齐既有 `/mobile/payment-batches` 分类缺口,仅登记现有路由和现有测试,不改变产品行为。
|
||||
- 未部署、未连接生产、未写入真实公司或银行资料。
|
||||
@@ -0,0 +1,118 @@
|
||||
# 管理导航闭环与单公司体验收敛
|
||||
|
||||
## Goal
|
||||
|
||||
让管理人员从「管理」进入任一配置页后始终知道当前位置并能一键返回管理首页,同时把管理入口、侧栏归属和页面标题收敛到同一份路由事实源;在此基础上按“单公司、内部 OA、非产品化”边界简化公司信息和企业银行账户配置,不让用户面对无业务价值的多公司选择。
|
||||
|
||||
## Background
|
||||
|
||||
- 侧栏管理分组只有一个顶层入口:`client/src/features/navigation/schema.ts` 的 `ADMIN_NAV_ORDER` 仅包含 `/system/settings`。这符合“管理聚合入口”的产品方向,本任务不把每个配置页重新铺回侧栏。
|
||||
- 子页归属目前是重复且不完整的手工事实:`SystemSettingsPage.tsx` 维护管理首页入口;`NavLinks.tsx` 维护独立的 `MANAGEMENT_ACTIVE_PATHS`;`features/navigation/schema.ts` 的 `PAGE_TITLES` 又维护 Topbar 标题。三者已经漂移。
|
||||
- `/system/payment-accounts` 未加入 `MANAGEMENT_ACTIVE_PATHS`,因此当前页面不保持侧栏「管理」高亮。`PAGE_TITLES` 还缺少用户、部门、费用标准、报销模板、主数据、企业银行账户和通知渠道等多个管理页,缺失时可能回退为原始路径。
|
||||
- 管理子页没有统一父级面包屑。用户从聚合页进入配置页后,只能自行重新寻找侧栏「管理」,深链或刷新后更缺少上下文。
|
||||
- 本任务规划期间,生产曾因没有 `LegalEntity` 数据而使企业银行账户页面出现无选项的法人主体下拉框;管理员现已通过既有路径完成公司信息、付款账户和员工银行机构配置。该业务阻塞已经解除,但零公司状态仍需修复,避免以后初始化或测试环境再次走入死路。
|
||||
- 本系统只服务当前公司内部、小于 10 人团队,不作为产品发布,不支持多公司、多租户或公司切换。`LegalEntity` 仍作为付款账户、成本中心等数据的内部关联锚点保留,不因前端简化而重做数据库模型。
|
||||
- `docs/产品口径.md` 的权限口径目前编号到 R-5;单公司长期边界应作为下一条 R-6 固化,避免制造缺号的 R-7。
|
||||
|
||||
## Priority
|
||||
|
||||
1. 高:管理导航闭环与共享路由事实源,覆盖每次管理页面访问。
|
||||
2. 中:企业银行账户空状态与唯一公司自动关联,避免配置死路。
|
||||
3. 低:公司信息单例化表达和「法体」自然中文改名;不阻塞当前一次性生产录入。
|
||||
|
||||
## Scope
|
||||
|
||||
- 前端管理路由元数据、管理聚合页入口、PC 侧栏选中状态、Topbar 标题/面包屑和最近访问标题。
|
||||
- 主数据中的公司信息单例表达,以及企业银行账户页面在零个、一个或异常多个启用公司时的交互。
|
||||
- 与上述行为直接相关的前端类型、单元/组件测试和现有产品口径文档。
|
||||
- 不修改后端数据模型、权限、银行资料加密/审计、制单逻辑或生产数据;不包含付款详情结构统一。
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1 管理路由单一事实源
|
||||
|
||||
- 新建一份前端管理路由注册表,至少包含稳定 key、路径匹配规则、标题、父级、管理首页分组、图标、说明和权限要求。
|
||||
- 「管理」聚合页入口、侧栏「管理」选中判断、Topbar 页面标题/分组和管理面包屑必须从该注册表派生,不再分别维护 `ITEMS`、`MANAGEMENT_ACTIVE_PATHS` 和不完整的管理 `PAGE_TITLES`。
|
||||
- 注册表中的权限条件是路由元数据合同的一部分;管理根入口的权限集合从可见子项派生,不再在 `NavLinks.tsx` 单独维护另一份 `anyOf`。
|
||||
- React Router 的页面组件与权限边界继续在 `router/index.tsx` 显式声明;本任务不为追求配置化而把懒加载组件塞进产品元数据。
|
||||
- 注册表必须覆盖当前管理首页全部入口及必要详情层级,包括人员档案;新增管理页如果未登记,测试必须失败,而不是回退为原始路径后静默上线。
|
||||
|
||||
### R2 管理导航闭环
|
||||
|
||||
- PC 管理首页仍只占一个侧栏入口,不把低频配置页铺回主导航。
|
||||
- 任一管理子页的 Topbar 显示可点击父级路径,例如 `管理 / 企业银行账户`;点击「管理」固定回到 `/system/settings`,不依赖浏览器历史。
|
||||
- 人员档案等二级页面显示 `管理 / 人员 / 员工档案`;中间「人员」可返回人员列表。
|
||||
- 直接访问深链、刷新页面或从全局搜索进入时,面包屑和侧栏高亮仍正确。
|
||||
- 管理标题和父链解析必须接收当前用户权限;无权访问某个管理页时不得通过 Topbar 面包屑、父链或管理入口泄露该页面名称。
|
||||
- 管理首页本身只显示当前页标题,不显示自指的「返回管理」。
|
||||
|
||||
### R3 页面标题与侧栏状态
|
||||
|
||||
- `/system/payment-accounts` 及所有已登记管理子页均保持侧栏「管理」选中。
|
||||
- Topbar 不得把管理子页显示为原始 URL;管理页标题和分组必须与聚合页卡片一致。
|
||||
- 最近访问记录只写入当前有权访问的管理路由,并继续使用自然中文标题,不因注册表重构写入原始路径。
|
||||
- 读取最近访问时必须按当前权限重新过滤;用户权限被收回后,旧记录不得继续显示受限管理页面标题或链接。当前实现只有 localStorage 写入、尚无读取 UI,本任务修正写入/读取合同但不新建最近访问页面。
|
||||
|
||||
### R4 单公司长期边界
|
||||
|
||||
- 在 `docs/产品口径.md` 增加下一条可用编号 R-6:系统只服务当前公司内部,不做多公司、多租户、公司切换或面向外部客户的产品化配置。
|
||||
- 不新增 tenant/company switcher、跨公司权限、公司维度工作区或多公司初始化流程。
|
||||
- 保留 `LegalEntity` 表和现有关联作为内部账务/主数据锚点;本任务不重命名数据库表、不迁移外键、不删除历史能力。
|
||||
- 前端用户文案把「法体」统一改为「公司信息」或「公司」,不向普通管理员暴露会计/产品化术语。
|
||||
|
||||
### R5 公司信息单例表达
|
||||
|
||||
- 公司信息为空时,主数据页显示一次性初始化表单和明确说明。
|
||||
- 正常存在一条启用公司信息时,以单公司查看/维护方式展示,不提供公司切换器或鼓励继续创建第二家公司。
|
||||
- 若历史或测试环境异常存在多条启用记录,不静默选择,不自动合并或删除;显示管理员可理解的异常提示,并保留现有数据供单独治理。
|
||||
- 当前生产通过既有「主数据 → 法体」录入不被本任务阻塞;本任务不执行任何生产公司资料写入。
|
||||
|
||||
### R6 企业银行账户配置不走死路
|
||||
|
||||
- 没有公司信息时,不显示无解释的空法人主体下拉框;显示「请先完善公司信息」和可点击入口。
|
||||
- 恰有一条启用公司信息时自动关联并只读显示公司名称,不要求用户重复选择。
|
||||
- 异常存在多条启用公司信息时禁止猜测默认值,明确提示先处理公司信息。
|
||||
- 公司银行账户、员工银行资料、权限、加密、审计和通知合同保持不变;本任务不读取、回填或迁移真实完整账号。
|
||||
|
||||
### R7 范围与设备
|
||||
|
||||
- 本任务服务 PC 管理配置体验;按照现有产品规则,不新建复杂移动端管理台。
|
||||
- 窄屏通过现有 Topbar 菜单和可换行面包屑保持可用,但不新增移动专属公司/银行配置页面。
|
||||
- 付款详情结构统一、处理进度时间线、粘性操作区和银行制单业务逻辑不纳入本任务。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] 管理首页全部可见入口、侧栏归属、Topbar 标题和面包屑来自同一管理路由注册表;不存在第二份手工管理路径列表。
|
||||
- [x] 自动化逐项证明当前管理入口均有标题、权限、父级和路径匹配;新增未登记管理页会导致测试失败。
|
||||
- [x] 无权限用户看不到对应管理入口;深链访问期间 Topbar 不渲染受限页面名称或面包屑。
|
||||
- [x] 所有管理页面均显示 `管理 / 当前页面`,且「管理」可返回聚合页;员工档案显示 `管理 / 人员 / 员工档案`。
|
||||
- [x] 直接深链、刷新和前进后退后父链仍正确;管理首页不显示自指面包屑。
|
||||
- [x] 所有管理子页保持侧栏「管理」高亮,`/system/payment-accounts` 回归测试覆盖此前遗漏。
|
||||
- [x] Topbar 与最近访问记录不再为管理子页回退显示原始路径。
|
||||
- [x] 最近访问按当前权限过滤:撤销权限后,既有 localStorage 记录不再向用户显示受限页面标题或链接;聚焦测试覆盖权限变更场景。
|
||||
- [x] 主数据 UI 使用「公司信息」而不是「法体」,正常单公司状态不出现公司切换器或新增第二公司的主导入口。
|
||||
- [x] 零公司信息时,企业银行账户页提供明确说明和「完善公司信息」入口;一条时自动关联;多条异常时不静默选择。
|
||||
- [x] `LegalEntity` 数据模型、现有外键、付款账户加密/审计/通知和生产数据保持不变,无数据库迁移、无真实资料写入。
|
||||
- [x] `docs/产品口径.md` 以连续编号 R-6 固化单公司内部 OA 边界。
|
||||
- [x] 前端相关单元/组件测试、TypeScript 检查和生产构建通过;Trellis、敏感内容和 diff 门禁通过。
|
||||
- [x] 只有部署与公网检查通过后才通知用户线上验收;生产公司资料录入另由管理员执行。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- 注册表契约测试:逐项校验稳定 key、唯一匹配、父级、标题、权限和管理首页可见项;覆盖未登记管理入口的失败用例。
|
||||
- 导航组件测试:覆盖管理首页、企业银行账户、人员列表与员工档案深链,验证侧栏高亮、Topbar 标题、面包屑链接和最近访问中文标题;同一路径分别验证有权和无权状态。
|
||||
- 权限变更测试:先以有权状态写入最近访问,再撤销权限读取,断言受限页面标题和链接均不返回;无权深链不渲染受限面包屑。
|
||||
- 公司状态组件测试:分别覆盖零个、一个和多个启用公司;验证引导入口、自动关联、只读公司名称和异常 fail-closed 行为。
|
||||
- 回归检查:确认管理页权限边界及 React Router 显式路由未改变,付款账户敏感字段不会进入测试快照或日志。
|
||||
- 前端门禁:运行相关 Vitest、`npm run typecheck` 与 `npm run build`。
|
||||
- 治理门禁:运行 Trellis task validate、Trellis governance、敏感内容检查(如当前流程要求)与 `git diff --check`。
|
||||
- 面向线上用户的验收仅在另行批准部署且公网检查通过后进行;生产配置和真实数据写入不属于自动化测试。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 多租户、多公司切换、跨公司数据隔离、集团组织、合并报表或 SaaS 产品化。
|
||||
- 重命名或删除 `LegalEntity` 数据模型、外键迁移、历史数据合并和生产数据修复。
|
||||
- 把所有管理子页重新放进侧栏,或建设复杂的二级侧栏导航。
|
||||
- 移动端管理配置台、付款详情结构统一、审批/处理进度重构和银行制单业务变更。
|
||||
- 代替管理员录入公司全称、税号、银行账户或员工收款资料。
|
||||
- 取消或重建既有付款批次、生成制单、网银导入、上传回单或登记付款完成;这些属于独立的生产操作与制单验收。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "management-navigation-single-company-ux",
|
||||
"name": "management-navigation-single-company-ux",
|
||||
"title": "管理导航闭环与单公司体验收敛",
|
||||
"description": "统一管理首页、侧栏高亮与顶部面包屑的路由事实源,并按单公司内部 OA 口径简化公司信息和企业银行账户配置。",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": "client 管理导航共享事实源、公司信息单例体验、企业银行账户空状态/自动关联及产品边界文档",
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-08-03",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -261,11 +261,11 @@ export function mapApiErrorMessage(
|
||||
}
|
||||
|
||||
if (details?.legalEntityId === "not_found") {
|
||||
return "所选法体不存在。";
|
||||
return "所选公司不存在。";
|
||||
}
|
||||
|
||||
if (details?.legalEntityId === "archived") {
|
||||
return "所选法体已归档,无法继续使用。";
|
||||
return "所选公司已归档,无法继续使用。";
|
||||
}
|
||||
|
||||
if (details?.ownerEmployeeId === "not_found") {
|
||||
@@ -289,11 +289,11 @@ export function mapApiErrorMessage(
|
||||
}
|
||||
|
||||
if (details?.parentId === "cross_legal_entity_not_allowed") {
|
||||
return "上级成本中心必须与当前法体一致。";
|
||||
return "上级成本中心必须与当前公司一致。";
|
||||
}
|
||||
|
||||
if (details?.legalEntity === "has_cost_centers") {
|
||||
return "当前法体下仍存在未归档的成本中心,无法归档。";
|
||||
return "当前公司下仍存在未归档的成本中心,无法归档。";
|
||||
}
|
||||
|
||||
if (details?.costCenter === "has_children") {
|
||||
@@ -301,7 +301,7 @@ export function mapApiErrorMessage(
|
||||
}
|
||||
|
||||
if (details?.holidayCalendar === "bound_as_default") {
|
||||
return `当前日历仍被法体 ${details?.legalEntityCode ?? ""} 作为默认日历引用,无法归档。`;
|
||||
return `当前日历仍被公司 ${details?.legalEntityCode ?? ""} 作为默认日历引用,无法归档。`;
|
||||
}
|
||||
|
||||
if (details?.date === "duplicate") {
|
||||
|
||||
@@ -258,7 +258,7 @@ export function HolidayCalendarTab({ canManage, includeArchived, legalEntities }
|
||||
value={legalEntityFilter}
|
||||
onChange={(event) => setLegalEntityFilter(event.target.value)}
|
||||
>
|
||||
<option value="">全部法体</option>
|
||||
<option value="">全部公司</option>
|
||||
{legalEntities
|
||||
.filter((item) => !item.archivedAt)
|
||||
.map((item) => (
|
||||
@@ -302,7 +302,7 @@ export function HolidayCalendarTab({ canManage, includeArchived, legalEntities }
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-3">
|
||||
<Label htmlFor="holidayCalendarLegalEntity">所属法体(可选)</Label>
|
||||
<Label htmlFor="holidayCalendarLegalEntity">所属公司(可选)</Label>
|
||||
<select
|
||||
id="holidayCalendarLegalEntity"
|
||||
className="select-std"
|
||||
@@ -335,7 +335,7 @@ export function HolidayCalendarTab({ canManage, includeArchived, legalEntities }
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">
|
||||
首个绑定到法体的日历会自动成为该法体默认日历;后续切换默认日历将放在后续阶段补齐。
|
||||
首个绑定到公司的日历会自动成为该公司默认日历;后续切换默认日历将放在后续阶段补齐。
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -356,7 +356,7 @@ export function HolidayCalendarTab({ canManage, includeArchived, legalEntities }
|
||||
<TableRow>
|
||||
<TableHead>编码</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>法体</TableHead>
|
||||
<TableHead>公司</TableHead>
|
||||
<TableHead>日期数</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
@@ -377,7 +377,7 @@ export function HolidayCalendarTab({ canManage, includeArchived, legalEntities }
|
||||
<TableCell>
|
||||
<div>{item.name}</div>
|
||||
{item.isDefaultForLegalEntity ? (
|
||||
<div className="text-xs text-emerald-600">默认法体日历</div>
|
||||
<div className="text-xs text-emerald-600">默认公司日历</div>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>{item.legalEntity ? `${item.legalEntity.code} / ${item.legalEntity.name}` : "全局"}</TableCell>
|
||||
|
||||
@@ -167,7 +167,7 @@ export function SupplierTab({ canManage, includeArchived, legalEntities }: Suppl
|
||||
value={legalEntityFilter}
|
||||
onChange={(event) => setLegalEntityFilter(event.target.value)}
|
||||
>
|
||||
<option value="">全部法体</option>
|
||||
<option value="">全部公司</option>
|
||||
{activeLegalEntities.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.code} / {item.name}
|
||||
@@ -251,7 +251,7 @@ export function SupplierTab({ canManage, includeArchived, legalEntities }: Suppl
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label htmlFor="supplierLegalEntity">所属法体(可选)</Label>
|
||||
<Label htmlFor="supplierLegalEntity">所属公司(可选)</Label>
|
||||
<select
|
||||
id="supplierLegalEntity"
|
||||
className="select-std"
|
||||
@@ -294,7 +294,7 @@ export function SupplierTab({ canManage, includeArchived, legalEntities }: Suppl
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>编码 / 名称</TableHead>
|
||||
<TableHead>法体</TableHead>
|
||||
<TableHead>公司</TableHead>
|
||||
<TableHead>联系人</TableHead>
|
||||
<TableHead>税号 / 账期</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
BACKUP_NAV_ORDER,
|
||||
MAIN_NAV_ORDER,
|
||||
} from "../../features/navigation/schema";
|
||||
import {
|
||||
isManagementPath,
|
||||
MANAGEMENT_ROOT_PERMISSIONS,
|
||||
} from "../../features/navigation/managementRoutes";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export type NavItem = {
|
||||
@@ -27,7 +31,7 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
{ to: "/weekly-reports", label: "周报", icon: "article" },
|
||||
{ to: "/announcements", label: "公告", icon: "campaign" },
|
||||
{ to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_own", "project.view_all", "project.manage"] },
|
||||
{ to: "/system/settings", label: "管理", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view", "payment.account.read", "payment.account.manage"] },
|
||||
{ to: "/system/settings", label: "管理", icon: "settings", anyOf: MANAGEMENT_ROOT_PERMISSIONS },
|
||||
{ to: "/workflow/tasks", label: "待我处理", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] },
|
||||
{ to: "/contracts-registry", label: "合同", icon: "description", anyOf: ["project.view_all", "project.manage", "system.settings"] },
|
||||
{ to: "/purchase-requests", label: "采购", icon: "shopping_cart", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] },
|
||||
@@ -46,24 +50,6 @@ const ACCESSIBLE_LABELS: Record<string, string> = {
|
||||
"/assets": "资产",
|
||||
};
|
||||
|
||||
const MANAGEMENT_ACTIVE_PATHS = [
|
||||
"/system/settings",
|
||||
"/system/workspace",
|
||||
"/system/workflow-definitions",
|
||||
"/system/feature-flags",
|
||||
"/system/approval-rules",
|
||||
"/system/expense-standards",
|
||||
"/system/reimbursement-templates",
|
||||
"/system/master-data",
|
||||
"/system/notification-channels",
|
||||
"/system/users",
|
||||
"/system/departments",
|
||||
"/system/cutover",
|
||||
"/people",
|
||||
"/people/stats",
|
||||
"/organization",
|
||||
"/audit-logs",
|
||||
];
|
||||
const TEAM_NAVIGATION_ROLE_CODES = ["manager", "ceo", "finance_mgr", "hr", "admin"];
|
||||
const PAYMENT_REVIEWER_ROLE_CODES = ["manager", "ceo"];
|
||||
|
||||
@@ -79,15 +65,15 @@ type ResolvedNavItem = {
|
||||
icon: string;
|
||||
};
|
||||
|
||||
function isGroupedActive(item: ResolvedNavItem, pathname: string) {
|
||||
function isGroupedActive(item: ResolvedNavItem, pathname: string, permissions: string[]) {
|
||||
if (item.href === "/system/settings") {
|
||||
return MANAGEMENT_ACTIVE_PATHS.some((path) => pathname === path || pathname.startsWith(`${path}/`));
|
||||
return isManagementPath(pathname, permissions);
|
||||
}
|
||||
|
||||
return pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
}
|
||||
|
||||
function renderNavItem(item: ResolvedNavItem, pathname: string, onNavigate?: () => void) {
|
||||
function renderNavItem(item: ResolvedNavItem, pathname: string, permissions: string[], onNavigate?: () => void) {
|
||||
const accessibleLabel = ACCESSIBLE_LABELS[item.href] ?? item.label;
|
||||
|
||||
return (
|
||||
@@ -98,11 +84,11 @@ function renderNavItem(item: ResolvedNavItem, pathname: string, onNavigate?: ()
|
||||
title={accessibleLabel}
|
||||
onClick={() => onNavigate?.()}
|
||||
className={({ isActive }) =>
|
||||
cn("group nav-link-item", isActive || isGroupedActive(item, pathname) ? "nav-link-item--active" : "nav-link-item--idle")
|
||||
cn("group nav-link-item", isActive || isGroupedActive(item, pathname, permissions) ? "nav-link-item--active" : "nav-link-item--idle")
|
||||
}
|
||||
>
|
||||
{({ isActive }) => {
|
||||
const active = isActive || isGroupedActive(item, pathname);
|
||||
const active = isActive || isGroupedActive(item, pathname, permissions);
|
||||
return (
|
||||
<>
|
||||
<span className={cn("nav-link-item__icon", active ? "nav-link-item__icon--active" : "nav-link-item__icon--idle")}>
|
||||
@@ -116,19 +102,19 @@ function renderNavItem(item: ResolvedNavItem, pathname: string, onNavigate?: ()
|
||||
);
|
||||
}
|
||||
|
||||
function renderPrimarySection(items: ResolvedNavItem[], pathname: string, onNavigate?: () => void) {
|
||||
function renderPrimarySection(items: ResolvedNavItem[], pathname: string, permissions: string[], onNavigate?: () => void) {
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="nav-section">
|
||||
<div className="space-y-1.5">{items.map((item) => renderNavItem(item, pathname, onNavigate))}</div>
|
||||
<div className="space-y-1.5">{items.map((item) => renderNavItem(item, pathname, permissions, onNavigate))}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function renderBackupSection(items: ResolvedNavItem[], pathname: string, onNavigate?: () => void) {
|
||||
function renderBackupSection(items: ResolvedNavItem[], pathname: string, permissions: string[], onNavigate?: () => void) {
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
@@ -142,7 +128,7 @@ function renderBackupSection(items: ResolvedNavItem[], pathname: string, onNavig
|
||||
<span className="min-w-0 flex-1 truncate font-medium">更多</span>
|
||||
<span className="material-symbols-outlined nav-more-section__chevron">expand_more</span>
|
||||
</summary>
|
||||
<div className="mt-1.5 space-y-1.5 pb-1">{items.map((item) => renderNavItem(item, pathname, onNavigate))}</div>
|
||||
<div className="mt-1.5 space-y-1.5 pb-1">{items.map((item) => renderNavItem(item, pathname, permissions, onNavigate))}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -202,6 +188,20 @@ function normalizeSections(sections: { main: NavigationItem[]; admin: Navigation
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeManagementRoot(
|
||||
sections: { main: ResolvedNavItem[]; admin: ResolvedNavItem[]; backup: ResolvedNavItem[] },
|
||||
permissions: string[],
|
||||
) {
|
||||
const canAccessManagement = hasAnyPermission(permissions, MANAGEMENT_ROOT_PERMISSIONS);
|
||||
const withoutManagement = sections.admin.filter((item) => item.href !== "/system/settings");
|
||||
return {
|
||||
...sections,
|
||||
admin: canAccessManagement
|
||||
? [{ key: "/system/settings", href: "/system/settings", label: "管理", icon: "settings" }, ...withoutManagement]
|
||||
: withoutManagement,
|
||||
};
|
||||
}
|
||||
|
||||
export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const { me, permissions } = useAuth();
|
||||
const location = useLocation();
|
||||
@@ -211,15 +211,16 @@ export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const sections = navigationQuery.data?.sections
|
||||
const baseSections = navigationQuery.data?.sections
|
||||
? normalizeSections(navigationQuery.data.sections)
|
||||
: buildFallbackItems(permissions, me?.roleCodes ?? []);
|
||||
const sections = normalizeManagementRoot(baseSections, permissions);
|
||||
const primaryItems = [...sections.main, ...sections.admin];
|
||||
|
||||
return (
|
||||
<nav className="space-y-3.5">
|
||||
{renderPrimarySection(primaryItems, location.pathname, onNavigate)}
|
||||
{renderBackupSection(sections.backup, location.pathname, onNavigate)}
|
||||
{renderPrimarySection(primaryItems, location.pathname, permissions, onNavigate)}
|
||||
{renderBackupSection(sections.backup, location.pathname, permissions, onNavigate)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { NotificationItem } from "../../api/notifications";
|
||||
import { fetchNotifications, markAllNotificationsRead, markNotificationRead } from "../../api/notifications";
|
||||
import { useAuth } from "../../auth/AuthContext";
|
||||
import { resolvePageMeta } from "../../features/navigation/schema";
|
||||
import { resolveManagementBreadcrumbs } from "../../features/navigation/managementRoutes";
|
||||
import { resolveNotificationTargetLink, formatNotificationRelativeTime } from "../../lib/notificationLinks";
|
||||
import { resolveErrorMessage } from "../../lib/resolveErrorMessage";
|
||||
import { isWecomWebView } from "../../lib/runtimeContext";
|
||||
@@ -45,7 +46,7 @@ function BellIcon() {
|
||||
}
|
||||
|
||||
export function Topbar() {
|
||||
const { me, logout } = useAuth();
|
||||
const { me, logout, permissions } = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -53,7 +54,9 @@ export function Topbar() {
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||
const wecomEntry = isWecomWebView();
|
||||
const pageMeta = resolvePageMeta(location.pathname);
|
||||
const pageMeta = resolvePageMeta(location.pathname, permissions);
|
||||
const managementBreadcrumbs = resolveManagementBreadcrumbs(location.pathname, permissions);
|
||||
const showManagementBreadcrumbs = managementBreadcrumbs.length > 1;
|
||||
|
||||
const unreadQuery = useQuery({
|
||||
enabled: Boolean(me),
|
||||
@@ -165,7 +168,28 @@ export function Topbar() {
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="truncate text-base font-semibold text-[var(--color-text-primary)] md:text-lg">{pageMeta.title}</p>
|
||||
{showManagementBreadcrumbs ? (
|
||||
<nav
|
||||
aria-label="管理路径"
|
||||
className="flex min-w-0 flex-wrap items-center gap-1 text-base font-semibold text-[var(--color-text-primary)] md:text-lg"
|
||||
data-testid="topbar-management-breadcrumbs"
|
||||
>
|
||||
{managementBreadcrumbs.map((item, index) => (
|
||||
<span key={item.key} className="flex min-w-0 items-center gap-1">
|
||||
{index > 0 ? <span aria-hidden="true" className="text-zinc-400">/</span> : null}
|
||||
{item.href ? (
|
||||
<Link className="rounded-sm text-zinc-600 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30" to={item.href}>
|
||||
{item.title}
|
||||
</Link>
|
||||
) : (
|
||||
<span aria-current="page" className="truncate">{item.title}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
) : (
|
||||
<p className="truncate text-base font-semibold text-[var(--color-text-primary)] md:text-lg">{pageMeta.title}</p>
|
||||
)}
|
||||
<span className="hidden rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-[var(--color-text-secondary)] sm:inline-flex">
|
||||
{pageMeta.group}
|
||||
</span>
|
||||
|
||||
@@ -154,7 +154,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!createLegalEntityId || !createPeriodStart || !createPeriodEnd) {
|
||||
throw new Error('请先填写法体与对账周期');
|
||||
throw new Error('请先填写公司与对账周期');
|
||||
}
|
||||
return createTravelReconciliationBatch({
|
||||
legalEntityId: createLegalEntityId,
|
||||
@@ -272,7 +272,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<div>
|
||||
<Label htmlFor="travel-recon-create-legal-entity">法体</Label>
|
||||
<Label htmlFor="travel-recon-create-legal-entity">公司</Label>
|
||||
<select
|
||||
id="travel-recon-create-legal-entity"
|
||||
className="select-std mt-1"
|
||||
@@ -282,7 +282,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
|
||||
setCreateCostCenterId('');
|
||||
}}
|
||||
>
|
||||
<option value="">请选择法体</option>
|
||||
<option value="">请选择公司</option>
|
||||
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>{item.name}</option>
|
||||
))}
|
||||
@@ -343,7 +343,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="travel-recon-filter-legal-entity">法体</Label>
|
||||
<Label htmlFor="travel-recon-filter-legal-entity">公司</Label>
|
||||
<select
|
||||
id="travel-recon-filter-legal-entity"
|
||||
className="select-std mt-1"
|
||||
@@ -354,7 +354,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">全部法体</option>
|
||||
<option value="">全部公司</option>
|
||||
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>{item.name}</option>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export type SingleCompanyState<T> =
|
||||
| { kind: "empty" }
|
||||
| { kind: "single"; company: T }
|
||||
| { kind: "multiple"; companies: T[] };
|
||||
|
||||
export function resolveSingleCompanyState<T>(companies: T[]): SingleCompanyState<T> {
|
||||
if (companies.length === 0) {
|
||||
return { kind: "empty" };
|
||||
}
|
||||
if (companies.length === 1) {
|
||||
return { kind: "single", company: companies[0] };
|
||||
}
|
||||
return { kind: "multiple", companies };
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import { hasAnyPermission } from "../../auth/permissions";
|
||||
|
||||
export type ManagementGroup = "人员与组织" | "流程与规则" | "系统与审计" | "功能管理";
|
||||
|
||||
type ManagementRouteMatch =
|
||||
| { type: "exact"; path: string }
|
||||
| { type: "prefix"; path: string }
|
||||
| { type: "pattern"; pattern: RegExp };
|
||||
|
||||
export type ManagementRouteDefinition = {
|
||||
key: string;
|
||||
path: string;
|
||||
title: string;
|
||||
parentKey: string | null;
|
||||
group: ManagementGroup | null;
|
||||
icon: string;
|
||||
description: string;
|
||||
anyOf: readonly string[];
|
||||
showOnLanding: boolean;
|
||||
match: ManagementRouteMatch;
|
||||
};
|
||||
|
||||
const MANAGEMENT_CHILD_ROUTES: ManagementRouteDefinition[] = [
|
||||
{
|
||||
key: "people",
|
||||
path: "/people",
|
||||
title: "人员",
|
||||
parentKey: "management",
|
||||
group: "人员与组织",
|
||||
icon: "badge",
|
||||
description: "员工档案、入转调离和人员状态。",
|
||||
anyOf: ["system.users"],
|
||||
showOnLanding: true,
|
||||
match: { type: "exact", path: "/people" },
|
||||
},
|
||||
{
|
||||
key: "people_stats",
|
||||
path: "/people/stats",
|
||||
title: "人员统计",
|
||||
parentKey: "people",
|
||||
group: null,
|
||||
icon: "query_stats",
|
||||
description: "",
|
||||
anyOf: ["system.users"],
|
||||
showOnLanding: false,
|
||||
match: { type: "exact", path: "/people/stats" },
|
||||
},
|
||||
{
|
||||
key: "employee_profile",
|
||||
path: "/people/:employeeId",
|
||||
title: "员工档案",
|
||||
parentKey: "people",
|
||||
group: null,
|
||||
icon: "badge",
|
||||
description: "",
|
||||
anyOf: ["system.users"],
|
||||
showOnLanding: false,
|
||||
match: { type: "pattern", pattern: /^\/people\/[^/]+$/ },
|
||||
},
|
||||
{
|
||||
key: "organization",
|
||||
path: "/organization",
|
||||
title: "组织",
|
||||
parentKey: "management",
|
||||
group: "人员与组织",
|
||||
icon: "account_tree",
|
||||
description: "组织结构、部门和汇报关系。",
|
||||
anyOf: ["system.users", "report.dashboard.view"],
|
||||
showOnLanding: true,
|
||||
match: { type: "prefix", path: "/organization" },
|
||||
},
|
||||
{
|
||||
key: "user_accounts",
|
||||
path: "/system/users",
|
||||
title: "账号",
|
||||
parentKey: "management",
|
||||
group: "人员与组织",
|
||||
icon: "manage_accounts",
|
||||
description: "账号、角色和登录权限。",
|
||||
anyOf: ["system.accounts.manage", "system.roles.manage"],
|
||||
showOnLanding: true,
|
||||
match: { type: "prefix", path: "/system/users" },
|
||||
},
|
||||
{
|
||||
key: "departments",
|
||||
path: "/system/departments",
|
||||
title: "部门",
|
||||
parentKey: "management",
|
||||
group: "人员与组织",
|
||||
icon: "account_tree",
|
||||
description: "部门基础数据维护。",
|
||||
anyOf: ["system.users"],
|
||||
showOnLanding: true,
|
||||
match: { type: "prefix", path: "/system/departments" },
|
||||
},
|
||||
{
|
||||
key: "approval_rules",
|
||||
path: "/system/approval-rules",
|
||||
title: "审批规则",
|
||||
parentKey: "management",
|
||||
group: "流程与规则",
|
||||
icon: "rule",
|
||||
description: "金额段、审批层级和处理规则。",
|
||||
anyOf: ["system.settings"],
|
||||
showOnLanding: true,
|
||||
match: { type: "prefix", path: "/system/approval-rules" },
|
||||
},
|
||||
{
|
||||
key: "workflow_definitions",
|
||||
path: "/system/workflow-definitions",
|
||||
title: "流程定义",
|
||||
parentKey: "management",
|
||||
group: "流程与规则",
|
||||
icon: "schema",
|
||||
description: "流程模板和节点治理。",
|
||||
anyOf: ["system.settings"],
|
||||
showOnLanding: true,
|
||||
match: { type: "prefix", path: "/system/workflow-definitions" },
|
||||
},
|
||||
{
|
||||
key: "expense_standards",
|
||||
path: "/system/expense-standards",
|
||||
title: "费用标准",
|
||||
parentKey: "management",
|
||||
group: "流程与规则",
|
||||
icon: "rule_settings",
|
||||
description: "费用上限、类别和管控口径。",
|
||||
anyOf: ["system.settings"],
|
||||
showOnLanding: true,
|
||||
match: { type: "prefix", path: "/system/expense-standards" },
|
||||
},
|
||||
{
|
||||
key: "reimbursement_templates",
|
||||
path: "/system/reimbursement-templates",
|
||||
title: "报销模板",
|
||||
parentKey: "management",
|
||||
group: "流程与规则",
|
||||
icon: "receipt_long",
|
||||
description: "常见报销场景模板。",
|
||||
anyOf: ["system.settings"],
|
||||
showOnLanding: true,
|
||||
match: { type: "prefix", path: "/system/reimbursement-templates" },
|
||||
},
|
||||
{
|
||||
key: "workspace",
|
||||
path: "/system/workspace",
|
||||
title: "工作区",
|
||||
parentKey: "management",
|
||||
group: "系统与审计",
|
||||
icon: "dashboard_customize",
|
||||
description: "菜单入口、模块显隐和小团队基线。",
|
||||
anyOf: ["system.settings"],
|
||||
showOnLanding: true,
|
||||
match: { type: "prefix", path: "/system/workspace" },
|
||||
},
|
||||
{
|
||||
key: "audit_logs",
|
||||
path: "/audit-logs",
|
||||
title: "审计日志",
|
||||
parentKey: "management",
|
||||
group: "系统与审计",
|
||||
icon: "manage_search",
|
||||
description: "关键操作留痕和排障。",
|
||||
anyOf: ["system.audit.view"],
|
||||
showOnLanding: true,
|
||||
match: { type: "prefix", path: "/audit-logs" },
|
||||
},
|
||||
{
|
||||
key: "company_information",
|
||||
path: "/system/master-data",
|
||||
title: "主数据",
|
||||
parentKey: "management",
|
||||
group: "功能管理",
|
||||
icon: "dataset",
|
||||
description: "公司信息、成本中心、供应商等基础数据。",
|
||||
anyOf: ["masterdata.view", "masterdata.manage"],
|
||||
showOnLanding: true,
|
||||
match: { type: "prefix", path: "/system/master-data" },
|
||||
},
|
||||
{
|
||||
key: "payment_accounts",
|
||||
path: "/system/payment-accounts",
|
||||
title: "企业银行账户",
|
||||
parentKey: "management",
|
||||
group: "功能管理",
|
||||
icon: "account_balance",
|
||||
description: "维护付款来源账户、脱敏查看与变更留痕。",
|
||||
anyOf: ["payment.account.read", "payment.account.manage"],
|
||||
showOnLanding: true,
|
||||
match: { type: "prefix", path: "/system/payment-accounts" },
|
||||
},
|
||||
{
|
||||
key: "notification_channels",
|
||||
path: "/system/notification-channels",
|
||||
title: "通知渠道",
|
||||
parentKey: "management",
|
||||
group: "功能管理",
|
||||
icon: "notifications_active",
|
||||
description: "企业微信、邮件等补充通知。",
|
||||
anyOf: ["notification.channel.manage"],
|
||||
showOnLanding: true,
|
||||
match: { type: "prefix", path: "/system/notification-channels" },
|
||||
},
|
||||
{
|
||||
key: "feature_flags",
|
||||
path: "/system/feature-flags",
|
||||
title: "功能管理",
|
||||
parentKey: "management",
|
||||
group: "功能管理",
|
||||
icon: "toggle_on",
|
||||
description: "功能开关、灰度和试运行边界。",
|
||||
anyOf: ["system.settings"],
|
||||
showOnLanding: true,
|
||||
match: { type: "prefix", path: "/system/feature-flags" },
|
||||
},
|
||||
{
|
||||
key: "system_announcements",
|
||||
path: "/system/announcements",
|
||||
title: "公告管理",
|
||||
parentKey: "management",
|
||||
group: null,
|
||||
icon: "campaign",
|
||||
description: "",
|
||||
anyOf: ["system.settings"],
|
||||
showOnLanding: false,
|
||||
match: { type: "prefix", path: "/system/announcements" },
|
||||
},
|
||||
{
|
||||
key: "cutover",
|
||||
path: "/system/cutover",
|
||||
title: "切换控制台",
|
||||
parentKey: "management",
|
||||
group: null,
|
||||
icon: "swap_calls",
|
||||
description: "",
|
||||
anyOf: ["system.settings"],
|
||||
showOnLanding: false,
|
||||
match: { type: "prefix", path: "/system/cutover" },
|
||||
},
|
||||
{
|
||||
key: "performance",
|
||||
path: "/performance",
|
||||
title: "绩效管理",
|
||||
parentKey: "management",
|
||||
group: null,
|
||||
icon: "monitoring",
|
||||
description: "",
|
||||
anyOf: ["system.settings"],
|
||||
showOnLanding: false,
|
||||
match: { type: "prefix", path: "/performance" },
|
||||
},
|
||||
];
|
||||
|
||||
function uniquePermissions(routes: readonly ManagementRouteDefinition[]) {
|
||||
return Array.from(new Set(routes.flatMap((route) => route.anyOf)));
|
||||
}
|
||||
|
||||
export const MANAGEMENT_ROOT_PERMISSIONS = uniquePermissions(
|
||||
MANAGEMENT_CHILD_ROUTES.filter((route) => route.showOnLanding),
|
||||
);
|
||||
|
||||
const MANAGEMENT_ROOT_ROUTE: ManagementRouteDefinition = {
|
||||
key: "management",
|
||||
path: "/system/settings",
|
||||
title: "管理",
|
||||
parentKey: null,
|
||||
group: null,
|
||||
icon: "settings",
|
||||
description: "人员、组织、流程、基础资料和系统审计。",
|
||||
anyOf: MANAGEMENT_ROOT_PERMISSIONS,
|
||||
showOnLanding: false,
|
||||
match: { type: "exact", path: "/system/settings" },
|
||||
};
|
||||
|
||||
export const MANAGEMENT_ROUTES: readonly ManagementRouteDefinition[] = [
|
||||
MANAGEMENT_ROOT_ROUTE,
|
||||
...MANAGEMENT_CHILD_ROUTES,
|
||||
];
|
||||
|
||||
export const MANAGEMENT_GROUPS: readonly ManagementGroup[] = [
|
||||
"人员与组织",
|
||||
"流程与规则",
|
||||
"系统与审计",
|
||||
"功能管理",
|
||||
];
|
||||
|
||||
const ROUTES_BY_KEY = new Map(MANAGEMENT_ROUTES.map((route) => [route.key, route]));
|
||||
|
||||
function matchesRoute(route: ManagementRouteDefinition, pathname: string) {
|
||||
if (route.match.type === "pattern") {
|
||||
return route.match.pattern.test(pathname);
|
||||
}
|
||||
if (route.match.type === "exact") {
|
||||
return pathname === route.match.path;
|
||||
}
|
||||
return pathname === route.match.path || pathname.startsWith(`${route.match.path}/`);
|
||||
}
|
||||
|
||||
export function canAccessManagementRoute(route: ManagementRouteDefinition, permissions: string[]) {
|
||||
return hasAnyPermission(permissions, [...route.anyOf]);
|
||||
}
|
||||
|
||||
export function resolveManagementRouteDefinition(pathname: string) {
|
||||
return MANAGEMENT_ROUTES.find((route) => matchesRoute(route, pathname)) ?? null;
|
||||
}
|
||||
|
||||
export function resolveManagementRoute(pathname: string, permissions: string[]) {
|
||||
const route = resolveManagementRouteDefinition(pathname);
|
||||
return route && canAccessManagementRoute(route, permissions) ? route : null;
|
||||
}
|
||||
|
||||
export function isKnownManagementPath(pathname: string) {
|
||||
return Boolean(resolveManagementRouteDefinition(pathname));
|
||||
}
|
||||
|
||||
export function isManagementPath(pathname: string, permissions: string[]) {
|
||||
return Boolean(resolveManagementRoute(pathname, permissions));
|
||||
}
|
||||
|
||||
export function getVisibleManagementItems(permissions: string[]) {
|
||||
return MANAGEMENT_ROUTES.filter(
|
||||
(route) => route.showOnLanding && canAccessManagementRoute(route, permissions),
|
||||
);
|
||||
}
|
||||
|
||||
export type ManagementBreadcrumb = {
|
||||
key: string;
|
||||
title: string;
|
||||
href: string | null;
|
||||
};
|
||||
|
||||
export function resolveManagementBreadcrumbs(pathname: string, permissions: string[]) {
|
||||
const current = resolveManagementRoute(pathname, permissions);
|
||||
if (!current) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const chain: ManagementRouteDefinition[] = [];
|
||||
const visited = new Set<string>();
|
||||
let cursor: ManagementRouteDefinition | undefined = current;
|
||||
while (cursor && !visited.has(cursor.key)) {
|
||||
chain.unshift(cursor);
|
||||
visited.add(cursor.key);
|
||||
cursor = cursor.parentKey ? ROUTES_BY_KEY.get(cursor.parentKey) : undefined;
|
||||
}
|
||||
|
||||
return chain.map((route, index) => ({
|
||||
key: route.key,
|
||||
title: route.title,
|
||||
href: index < chain.length - 1 ? route.path : null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getManagementRoutePermissions(key: string) {
|
||||
const route = ROUTES_BY_KEY.get(key);
|
||||
if (!route) {
|
||||
throw new Error(`Unknown management route key: ${key}`);
|
||||
}
|
||||
return [...route.anyOf];
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
isKnownManagementPath,
|
||||
resolveManagementRoute,
|
||||
} from "./managementRoutes";
|
||||
|
||||
export const MAIN_NAV_ORDER = ["/portal", "/workflow/tasks", "/reimbursements", "/purchase-requests", "/contracts-registry", "/projects", "/assets", "/payment-batches", "/ap-operations", "/invoice-pool", "/team", "/leave-requests"];
|
||||
export const ADMIN_NAV_ORDER = ["/system/settings"];
|
||||
export const BACKUP_NAV_ORDER = ["/weekly-reports", "/announcements", "/budgets", "/attendance", "/wecom-approval-archive", "/travel-requests", "/inventory-checks", "/dashboard"];
|
||||
@@ -38,19 +43,19 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
||||
{ path: "/mobile/reimbursements", title: "移动报销", group: "移动工作台" },
|
||||
{ path: "/mobile", title: "移动首页", group: "移动工作台" },
|
||||
{ path: "/settings/profile", title: "个人设置", group: "账号设置" },
|
||||
{ path: "/system/workspace", title: "工作区设置", group: "管理" },
|
||||
{ path: "/people", title: "人员管理", group: "管理" },
|
||||
{ path: "/organization", title: "组织架构", group: "管理" },
|
||||
{ path: "/system/approval-rules", title: "审批规则", group: "管理" },
|
||||
{ path: "/system/workflow-definitions", title: "流程定义", group: "管理" },
|
||||
{ path: "/system/settings", title: "管理", group: "管理" },
|
||||
{ path: "/system/feature-flags", title: "功能管理", group: "管理" },
|
||||
{ path: "/system/cutover", title: "切换控制台", group: "更多" },
|
||||
{ path: "/dashboard", title: "数据看板", group: "更多" },
|
||||
{ path: "/audit-logs", title: "审计日志", group: "管理" },
|
||||
];
|
||||
|
||||
export function resolvePageMeta(pathname: string) {
|
||||
export function resolvePageMeta(pathname: string, permissions: string[] = []) {
|
||||
const managementRoute = resolveManagementRoute(pathname, permissions);
|
||||
if (managementRoute) {
|
||||
return { title: managementRoute.title, group: "管理" };
|
||||
}
|
||||
|
||||
if (isKnownManagementPath(pathname)) {
|
||||
return { title: "管理", group: "管理" };
|
||||
}
|
||||
|
||||
const matched = [...PAGE_TITLES].sort((left, right) => right.path.length - left.path.length)
|
||||
.find((item) => pathname === item.path || pathname.startsWith(item.path));
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { AppPwaUpdateNotice } from "../components/common/AppPwaUpdateNotice";
|
||||
import { ErrorBoundary } from "../components/common/ErrorBoundary";
|
||||
import { MobileBottomNav } from "../components/mobile/MobileBottomNav";
|
||||
@@ -10,6 +11,7 @@ import { writeRecentVisit } from "../lib/recentVisits";
|
||||
import { resolveMobileEquivalentPath, shouldPreferMobileWorkplace } from "../lib/runtimeContext";
|
||||
|
||||
export function AppShellLayout() {
|
||||
const { permissions } = useAuth();
|
||||
const location = useLocation();
|
||||
const isMobileRoute = location.pathname.startsWith("/mobile");
|
||||
const mobileSearchParams = new URLSearchParams(location.search);
|
||||
@@ -52,8 +54,8 @@ export function AppShellLayout() {
|
||||
if (!pathname || pathname === "/login" || pathname.startsWith("/mobile/")) {
|
||||
return;
|
||||
}
|
||||
writeRecentVisit(pathname, resolvePageMeta(pathname).title);
|
||||
}, [location.pathname]);
|
||||
writeRecentVisit(pathname, resolvePageMeta(pathname, permissions).title, permissions);
|
||||
}, [location.pathname, permissions]);
|
||||
|
||||
if (mobileEquivalentPath && mobileEquivalentPath !== `${location.pathname}${location.search}`) {
|
||||
return <Navigate to={mobileEquivalentPath} replace />;
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
canAccessManagementRoute,
|
||||
resolveManagementRouteDefinition,
|
||||
} from "../features/navigation/managementRoutes";
|
||||
|
||||
export const RECENT_VISITS_STORAGE_KEY = "hyb_oa_recent_visits";
|
||||
|
||||
export type RecentVisit = {
|
||||
@@ -10,7 +15,7 @@ function isWindowAvailable() {
|
||||
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
||||
}
|
||||
|
||||
export function readRecentVisits(): RecentVisit[] {
|
||||
function readStoredRecentVisits(): RecentVisit[] {
|
||||
if (!isWindowAvailable()) return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(RECENT_VISITS_STORAGE_KEY);
|
||||
@@ -22,9 +27,29 @@ export function readRecentVisits(): RecentVisit[] {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeRecentVisit(path: string, title: string) {
|
||||
export function filterRecentVisitsByPermissions(visits: RecentVisit[], permissions: string[]) {
|
||||
return visits.filter((item) => {
|
||||
const managementRoute = resolveManagementRouteDefinition(item.path);
|
||||
return !managementRoute || canAccessManagementRoute(managementRoute, permissions);
|
||||
});
|
||||
}
|
||||
|
||||
export function readRecentVisits(permissions: string[]): RecentVisit[] {
|
||||
const stored = readStoredRecentVisits();
|
||||
const visible = filterRecentVisitsByPermissions(stored, permissions);
|
||||
if (isWindowAvailable() && visible.length !== stored.length) {
|
||||
window.localStorage.setItem(RECENT_VISITS_STORAGE_KEY, JSON.stringify(visible));
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
export function writeRecentVisit(path: string, title: string, permissions: string[]) {
|
||||
if (!isWindowAvailable()) return;
|
||||
const current = readRecentVisits().filter((item) => item.path !== path);
|
||||
const current = readRecentVisits(permissions).filter((item) => item.path !== path);
|
||||
const managementRoute = resolveManagementRouteDefinition(path);
|
||||
if (managementRoute && !canAccessManagementRoute(managementRoute, permissions)) {
|
||||
return;
|
||||
}
|
||||
current.unshift({ path, title, visitedAt: new Date().toISOString() });
|
||||
window.localStorage.setItem(RECENT_VISITS_STORAGE_KEY, JSON.stringify(current.slice(0, 10)));
|
||||
}
|
||||
|
||||
@@ -495,7 +495,7 @@ export function AssetRegistryPage() {
|
||||
throw new Error("请选择资产分类");
|
||||
}
|
||||
if (!assetForm.legalEntityId) {
|
||||
throw new Error("请选择法体");
|
||||
throw new Error("请选择公司");
|
||||
}
|
||||
if (!assetForm.costCenterId) {
|
||||
throw new Error("请选择成本中心");
|
||||
@@ -935,7 +935,7 @@ export function AssetRegistryPage() {
|
||||
setLegalEntityFilter(event.target.value);
|
||||
setCostCenterFilter("");
|
||||
}}>
|
||||
<option value="">全部法体</option>
|
||||
<option value="">全部公司</option>
|
||||
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{formatTextWithCompactLongNumbers(item.name)}
|
||||
@@ -1217,7 +1217,7 @@ export function AssetRegistryPage() {
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">法体</label>
|
||||
<label className="text-sm font-medium text-zinc-700">公司</label>
|
||||
<select data-testid="asset-legal-entity" className="select-std" value={assetForm.legalEntityId} disabled={!canEditCurrent} onChange={(event) => {
|
||||
const legalEntityId = event.target.value;
|
||||
setAssetForm((current) => ({
|
||||
@@ -1228,7 +1228,7 @@ export function AssetRegistryPage() {
|
||||
: "",
|
||||
}));
|
||||
}}>
|
||||
<option value="">请选择法体</option>
|
||||
<option value="">请选择公司</option>
|
||||
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
|
||||
@@ -74,9 +74,9 @@ function describeScope(item: BudgetPool) {
|
||||
}
|
||||
|
||||
if (item.scopeType === "cost_center_scoped") {
|
||||
const legalEntityName = item.legalEntity?.name ?? "未知法体";
|
||||
const legalEntityName = item.legalEntity?.name ?? "未知公司";
|
||||
const costCenterName = item.costCenter?.name ?? "未知成本中心";
|
||||
return `法体:${legalEntityName} / 成本中心:${costCenterName}`;
|
||||
return `公司:${legalEntityName} / 成本中心:${costCenterName}`;
|
||||
}
|
||||
|
||||
if (item.scopeType === "global") {
|
||||
@@ -339,7 +339,7 @@ export function BudgetsPage() {
|
||||
function describeRuleScope(rule: BudgetControlRule) {
|
||||
const parts: string[] = [];
|
||||
if (rule.legalEntityId) {
|
||||
parts.push(`法体:${legalEntityMap.get(rule.legalEntityId) ?? rule.legalEntityId}`);
|
||||
parts.push(`公司:${legalEntityMap.get(rule.legalEntityId) ?? rule.legalEntityId}`);
|
||||
}
|
||||
if (rule.departmentId) {
|
||||
parts.push(`部门:${departmentMap.get(rule.departmentId) ?? rule.departmentId}`);
|
||||
@@ -441,7 +441,7 @@ export function BudgetsPage() {
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">全部法体</option>
|
||||
<option value="">全部公司</option>
|
||||
{(legalEntitiesQuery.data?.items ?? []).map((legalEntity) => (
|
||||
<option key={legalEntity.id} value={legalEntity.id}>
|
||||
{legalEntity.name}
|
||||
@@ -519,7 +519,7 @@ export function BudgetsPage() {
|
||||
>
|
||||
<option value="global">全局预算</option>
|
||||
<option value="department_legacy">部门预算</option>
|
||||
<option value="cost_center_scoped">法体 + 成本中心预算</option>
|
||||
<option value="cost_center_scoped">公司 + 成本中心预算</option>
|
||||
</select>
|
||||
|
||||
{formScopeType === "department_legacy" ? (
|
||||
@@ -547,7 +547,7 @@ export function BudgetsPage() {
|
||||
setFormCostCenterId("");
|
||||
}}
|
||||
>
|
||||
<option value="">选择法体</option>
|
||||
<option value="">选择公司</option>
|
||||
{(legalEntitiesQuery.data?.items ?? []).map((legalEntity) => (
|
||||
<option key={legalEntity.id} value={legalEntity.id}>
|
||||
{legalEntity.name}
|
||||
@@ -763,7 +763,7 @@ export function BudgetsPage() {
|
||||
value={ruleForm.legalEntityId}
|
||||
onChange={(event) => setRuleForm((current) => ({ ...current, legalEntityId: event.target.value }))}
|
||||
>
|
||||
<option value="">不限法体</option>
|
||||
<option value="">不限公司</option>
|
||||
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
|
||||
@@ -566,7 +566,7 @@ export function CutoverConsolePage() {
|
||||
scopeKey: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="部门 ID / 法体 ID / 业务域"
|
||||
placeholder="部门 ID / 公司 ID / 业务域"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ function scopeLabel(scopeType: InventoryCheckScopeType) {
|
||||
case "all":
|
||||
return "全量盘点";
|
||||
case "legal_entity":
|
||||
return "法体盘点";
|
||||
return "公司盘点";
|
||||
case "cost_center":
|
||||
return "成本中心盘点";
|
||||
case "location":
|
||||
@@ -278,7 +278,7 @@ export function InventoryChecksPage() {
|
||||
setScopeRefId("");
|
||||
}}>
|
||||
<option value="all">全量盘点</option>
|
||||
<option value="legal_entity">法体盘点</option>
|
||||
<option value="legal_entity">公司盘点</option>
|
||||
<option value="cost_center">成本中心盘点</option>
|
||||
<option value="location">位置盘点</option>
|
||||
</select>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import { resolveSingleCompanyState } from "../features/company/singleCompany";
|
||||
|
||||
const DOCUMENT_ENTITY_TYPE_OPTIONS: Array<{ value: DocumentCategoryEntityType; label: string }> = [
|
||||
{ value: "reimbursement", label: "报销" },
|
||||
@@ -132,6 +133,12 @@ export function MasterDataPage() {
|
||||
}),
|
||||
});
|
||||
|
||||
const activeCompaniesQuery = useQuery({
|
||||
queryKey: ["master-data", "legal-entities", "active-company-state"],
|
||||
enabled: canView,
|
||||
queryFn: () => fetchLegalEntities({ enabled: true }),
|
||||
});
|
||||
|
||||
const costCentersQuery = useQuery({
|
||||
queryKey: [
|
||||
"master-data",
|
||||
@@ -167,6 +174,8 @@ export function MasterDataPage() {
|
||||
});
|
||||
|
||||
const legalEntities = legalEntitiesQuery.data?.items ?? [];
|
||||
const activeCompanies = activeCompaniesQuery.data?.items ?? [];
|
||||
const companyState = resolveSingleCompanyState(activeCompanies);
|
||||
const costCenters = costCentersQuery.data?.items ?? [];
|
||||
const documentCategories = documentCategoriesQuery.data?.items ?? [];
|
||||
const masterDataStats = {
|
||||
@@ -211,7 +220,7 @@ export function MasterDataPage() {
|
||||
setLegalEntityCurrency("CNY");
|
||||
setLegalEntityTaxNo("");
|
||||
setError("");
|
||||
toast.success("法体已创建");
|
||||
toast.success("公司信息已创建");
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
const message = resolveError(mutationError);
|
||||
@@ -312,7 +321,7 @@ export function MasterDataPage() {
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
title="主数据"
|
||||
subtitle="集中维护法体、成本中心、单据分类、日历与供应商,为预算、采购、请假与资产扩展提供统一骨架。"
|
||||
subtitle="集中维护公司信息、成本中心、单据分类、日历与供应商,为预算、采购、请假与资产扩展提供统一基础资料。"
|
||||
actions={
|
||||
<Button variant="secondary" type="button" onClick={() => setIncludeArchived((value) => !value)}>
|
||||
{includeArchived ? "隐藏归档" : "显示归档"}
|
||||
@@ -325,7 +334,7 @@ export function MasterDataPage() {
|
||||
) : null}
|
||||
|
||||
<div className="finance-status-strip" data-testid="master-data-summary">
|
||||
<div className="finance-metric-card"><div className="finance-metric-card__label">法体</div><div className="finance-metric-card__value">{masterDataStats.legalEntities}</div></div>
|
||||
<div className="finance-metric-card"><div className="finance-metric-card__label">公司信息</div><div className="finance-metric-card__value">{masterDataStats.legalEntities}</div></div>
|
||||
<div className="finance-metric-card"><div className="finance-metric-card__label">成本中心</div><div className="finance-metric-card__value">{masterDataStats.costCenters}</div></div>
|
||||
<div className="finance-metric-card"><div className="finance-metric-card__label">单据分类</div><div className="finance-metric-card__value">{masterDataStats.documentCategories}</div></div>
|
||||
<div className="finance-metric-card"><div className="finance-metric-card__label">归档项</div><div className="finance-metric-card__value">{masterDataStats.archived}</div></div>
|
||||
@@ -334,7 +343,7 @@ export function MasterDataPage() {
|
||||
|
||||
<Tabs value={tab} onValueChange={(value) => setTab(value as typeof tab)}>
|
||||
<TabsList className="ops-command-bar__tabs !w-full !justify-start">
|
||||
<TabsTrigger value="legal-entities">法体</TabsTrigger>
|
||||
<TabsTrigger value="legal-entities">公司信息</TabsTrigger>
|
||||
<TabsTrigger value="cost-centers">成本中心</TabsTrigger>
|
||||
<TabsTrigger value="document-categories">单据分类</TabsTrigger>
|
||||
<TabsTrigger value="holiday-calendars">日历</TabsTrigger>
|
||||
@@ -344,7 +353,7 @@ export function MasterDataPage() {
|
||||
<TabsContent value="legal-entities" className="space-y-4">
|
||||
<FilterBar className="ops-command-bar !p-3 md:!p-3.5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">法体筛选</h3>
|
||||
<h3 className="text-base font-semibold text-zinc-900">公司信息筛选</h3>
|
||||
<select
|
||||
className="select-std max-w-xs"
|
||||
value={legalEntityEnabledFilter}
|
||||
@@ -357,9 +366,18 @@ export function MasterDataPage() {
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
{canManage ? (
|
||||
{activeCompaniesQuery.isError ? (
|
||||
<div className="admin-command-panel border-red-200 bg-red-50/70 text-sm text-red-600">
|
||||
公司信息读取失败,请刷新后重试。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canManage && !activeCompaniesQuery.isLoading && !activeCompaniesQuery.isError && companyState.kind === "empty" ? (
|
||||
<div className="admin-command-panel space-y-4">
|
||||
<h3 className="text-base font-semibold text-zinc-900">新建法体</h3>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">完善公司信息</h3>
|
||||
<p className="mt-1 text-sm text-zinc-600">系统仅服务当前公司。完成后,企业银行账户等配置会自动关联这家公司。</p>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="legalEntityCode">编码</Label>
|
||||
@@ -387,15 +405,29 @@ export function MasterDataPage() {
|
||||
disabled={legalEntityCreateMutation.isPending || !legalEntityCode.trim() || !legalEntityName.trim() || !legalEntityCurrency.trim()}
|
||||
onClick={() => legalEntityCreateMutation.mutate()}
|
||||
>
|
||||
{legalEntityCreateMutation.isPending ? "创建中..." : "创建法体"}
|
||||
{legalEntityCreateMutation.isPending ? "创建中..." : "保存公司信息"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!activeCompaniesQuery.isLoading && !activeCompaniesQuery.isError && companyState.kind === "single" ? (
|
||||
<div className="admin-command-panel">
|
||||
<div className="text-sm font-medium text-zinc-900">当前公司:{companyState.company.name}</div>
|
||||
<div className="mt-1 text-sm text-zinc-600">系统按单公司运行,不需要选择或切换公司。</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!activeCompaniesQuery.isLoading && !activeCompaniesQuery.isError && companyState.kind === "multiple" ? (
|
||||
<div className="admin-command-panel border-red-200 bg-red-50/70">
|
||||
<div className="text-sm font-medium text-red-700">检测到多条启用的公司信息</div>
|
||||
<div className="mt-1 text-sm text-red-600">系统不会自动选择或合并,请先停用多余记录后再继续关联配置。</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="admin-command-panel space-y-4">
|
||||
<h3 className="text-base font-semibold text-zinc-900">法体列表</h3>
|
||||
<h3 className="text-base font-semibold text-zinc-900">公司信息</h3>
|
||||
{legalEntitiesQuery.isLoading ? <TableSkeleton columns={6} rows={4} /> : null}
|
||||
{!legalEntitiesQuery.isLoading ? (
|
||||
<PrimaryTable className="ops-table-focus">
|
||||
@@ -443,7 +475,7 @@ export function MasterDataPage() {
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-sm text-zinc-500">
|
||||
暂无法体数据
|
||||
暂无公司信息
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
@@ -471,7 +503,7 @@ export function MasterDataPage() {
|
||||
value={costCenterLegalEntityFilter}
|
||||
onChange={(event) => setCostCenterLegalEntityFilter(event.target.value)}
|
||||
>
|
||||
<option value="">全部法体</option>
|
||||
<option value="">全部公司</option>
|
||||
{legalEntities
|
||||
.filter((item) => !item.archivedAt)
|
||||
.map((item) => (
|
||||
@@ -488,7 +520,7 @@ export function MasterDataPage() {
|
||||
<h3 className="text-base font-semibold text-zinc-900">新建成本中心</h3>
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label htmlFor="costCenterLegalEntity">所属法体</Label>
|
||||
<Label htmlFor="costCenterLegalEntity">所属公司</Label>
|
||||
<select
|
||||
id="costCenterLegalEntity"
|
||||
className="select-std"
|
||||
@@ -498,7 +530,7 @@ export function MasterDataPage() {
|
||||
setCostCenterParentId("");
|
||||
}}
|
||||
>
|
||||
<option value="">请选择法体</option>
|
||||
<option value="">请选择公司</option>
|
||||
{legalEntities
|
||||
.filter((item) => !item.archivedAt)
|
||||
.map((item) => (
|
||||
@@ -561,7 +593,7 @@ export function MasterDataPage() {
|
||||
<TableRow>
|
||||
<TableHead>编码</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>所属法体</TableHead>
|
||||
<TableHead>所属公司</TableHead>
|
||||
<TableHead>上级</TableHead>
|
||||
<TableHead>负责人</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
|
||||
@@ -102,7 +102,7 @@ function assetTypeLabel(assetType: AssetItem["assetType"]) {
|
||||
|
||||
function inventoryScopeLabel(inventoryCheck: InventoryCheck) {
|
||||
if (inventoryCheck.scopeType === "all") return "全量盘点";
|
||||
if (inventoryCheck.scopeType === "legal_entity") return "法体盘点";
|
||||
if (inventoryCheck.scopeType === "legal_entity") return "公司盘点";
|
||||
if (inventoryCheck.scopeType === "cost_center") return "成本中心盘点";
|
||||
return "位置盘点";
|
||||
}
|
||||
@@ -559,7 +559,7 @@ export function MobileAssetScanPage() {
|
||||
<div className="mobile-info-grid">
|
||||
<div className="mobile-subcard text-sm">类型:{assetTypeLabel(selectedAsset.assetType)}</div>
|
||||
<div className="mobile-subcard text-sm">状态:{statusLabel(selectedAsset.status)}</div>
|
||||
<div className="mobile-subcard text-sm">法体:{selectedAsset.legalEntity?.name ?? "-"}</div>
|
||||
<div className="mobile-subcard text-sm">公司:{selectedAsset.legalEntity?.name ?? "-"}</div>
|
||||
<div className="mobile-subcard text-sm">成本中心:{selectedAsset.costCenter?.name ?? "-"}</div>
|
||||
<div className="mobile-subcard text-sm">位置:{selectedAsset.location ?? "-"}</div>
|
||||
<div className="mobile-subcard text-sm">归属人:{selectedAsset.assignee?.name ?? "未分配"}</div>
|
||||
|
||||
@@ -52,7 +52,7 @@ function resolveMobilePurchaseEvidence(detail: {
|
||||
projectId: string | null;
|
||||
}) {
|
||||
if (!detail.supplierId) return "待补供应商";
|
||||
if (!detail.legalEntityId || !detail.costCenterId) return "待补法体 / 成本中心";
|
||||
if (!detail.legalEntityId || !detail.costCenterId) return "待补公司 / 成本中心";
|
||||
if (!detail.documentCategoryId) return "待补单据分类";
|
||||
if (!detail.budgetPoolId) return "预算池未匹配";
|
||||
if (!detail.projectId) return "未关联项目";
|
||||
@@ -167,7 +167,7 @@ export function MobilePurchaseRequestDetailPage() {
|
||||
<div className="mobile-field-value">{formatDate(detail.expectedDeliveryDate)}</div>
|
||||
</div>
|
||||
<div className="mobile-field-row">
|
||||
<div className="mobile-field-label">法体</div>
|
||||
<div className="mobile-field-label">公司</div>
|
||||
<div className="mobile-field-value">{detail.legalEntity?.name ?? "-"}</div>
|
||||
</div>
|
||||
<div className="mobile-field-row">
|
||||
|
||||
@@ -49,7 +49,7 @@ function resolvePurchaseListNextStep(status: PurchaseRequestStatus) {
|
||||
|
||||
function resolvePurchaseListEvidence(item: PurchaseRequestSummary) {
|
||||
if (!item.supplierId) return "待补供应商";
|
||||
if (!item.legalEntityId || !item.costCenterId) return "待补法体 / 成本中心";
|
||||
if (!item.legalEntityId || !item.costCenterId) return "待补公司 / 成本中心";
|
||||
if (!item.documentCategoryId) return "待补单据分类";
|
||||
if (!item.budgetPoolId) return "预算池未匹配";
|
||||
if (!item.projectId) return "未关联项目";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -14,8 +15,9 @@ import {
|
||||
import { fetchLegalEntities } from "../api/masterData";
|
||||
import { isApiError } from "../api/types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { hasAnyPermission, hasPermission } from "../auth/permissions";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { resolveSingleCompanyState } from "../features/company/singleCompany";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { Button } from "../components/ui/button";
|
||||
@@ -33,6 +35,7 @@ export function PaymentAccountsPage() {
|
||||
const { permissions } = useAuth();
|
||||
const canManage = hasPermission(permissions, "payment.account.manage");
|
||||
const canReveal = hasPermission(permissions, "payment.account.read_full");
|
||||
const canManageCompanyInformation = hasAnyPermission(permissions, ["masterdata.view", "masterdata.manage"]);
|
||||
const [editing, setEditing] = useState<PaymentAccount | null>(null);
|
||||
const [legalEntityId, setLegalEntityId] = useState("");
|
||||
const [bankInstitutionId, setBankInstitutionId] = useState("");
|
||||
@@ -51,9 +54,19 @@ export function PaymentAccountsPage() {
|
||||
enabled: canManage,
|
||||
});
|
||||
|
||||
const activeCompanies = legalEntitiesQuery.data?.items ?? [];
|
||||
const companyState = resolveSingleCompanyState(activeCompanies);
|
||||
const singleCompany = companyState.kind === "single" ? companyState.company : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing && singleCompany) {
|
||||
setLegalEntityId(singleCompany.id);
|
||||
}
|
||||
}, [editing, singleCompany]);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditing(null);
|
||||
setLegalEntityId("");
|
||||
setLegalEntityId(singleCompany?.id ?? "");
|
||||
setBankInstitutionId("");
|
||||
setAccountName("");
|
||||
setAccountNumber("");
|
||||
@@ -93,17 +106,56 @@ export function PaymentAccountsPage() {
|
||||
const accounts = accountsQuery.data?.items ?? [];
|
||||
return (
|
||||
<PageScaffold>
|
||||
<PageHeader title="企业银行账户" subtitle="维护法人主体的付款来源账户;完整账号仅在填写原因后临时显示。" />
|
||||
<PageHeader title="企业银行账户" subtitle="维护当前公司的付款来源账户;完整账号仅在填写原因后临时显示。" />
|
||||
<div className="mx-auto max-w-6xl space-y-5">
|
||||
{canManage ? (
|
||||
{canManage && legalEntitiesQuery.isLoading ? (
|
||||
<SectionPanel title="企业银行账户">
|
||||
<div className="text-sm text-zinc-500">正在读取公司信息...</div>
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
|
||||
{canManage && legalEntitiesQuery.isError ? (
|
||||
<SectionPanel title="企业银行账户">
|
||||
<div className="text-sm text-red-600">公司信息读取失败,请刷新后重试。</div>
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
|
||||
{canManage && !legalEntitiesQuery.isLoading && !legalEntitiesQuery.isError && companyState.kind === "empty" ? (
|
||||
<SectionPanel title="先完善公司信息">
|
||||
<div className="space-y-3 text-sm text-zinc-600">
|
||||
<p>企业银行账户必须关联当前公司。请先完善公司信息,再回来录入付款账户。</p>
|
||||
{canManageCompanyInformation ? (
|
||||
<Button asChild type="button" variant="secondary">
|
||||
<Link to="/system/master-data">完善公司信息</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<p>请联系管理员先完善公司信息。</p>
|
||||
)}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
|
||||
{canManage && companyState.kind === "multiple" ? (
|
||||
<SectionPanel title="公司信息需要处理">
|
||||
<div className="space-y-3 text-sm text-red-600">
|
||||
<p>检测到多条启用的公司信息,系统不会猜测付款账户应关联哪一条。请先处理公司信息。</p>
|
||||
{canManageCompanyInformation ? (
|
||||
<Button asChild type="button" variant="secondary">
|
||||
<Link to="/system/master-data">查看公司信息</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
|
||||
{canManage && singleCompany ? (
|
||||
<SectionPanel title={editing ? "替换企业银行账户" : "新增企业银行账户"}>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment-account-legal-entity">法人主体</Label>
|
||||
<select id="payment-account-legal-entity" className="select-std w-full" value={legalEntityId} disabled={Boolean(editing)} onChange={(event) => setLegalEntityId(event.target.value)}>
|
||||
<option value="">请选择</option>
|
||||
{(legalEntitiesQuery.data?.items ?? []).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
<Label>当前公司</Label>
|
||||
<div className="input-std flex min-h-10 items-center bg-zinc-50 text-sm text-zinc-700" data-testid="payment-account-company">
|
||||
{editing?.legalEntity.name ?? singleCompany.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment-account-bank">开户银行</Label>
|
||||
@@ -158,7 +210,7 @@ export function PaymentAccountsPage() {
|
||||
</Dialog>
|
||||
<Dialog open={Boolean(disableTarget)} onOpenChange={(open) => { if (!open) setDisableTarget(null); }}>
|
||||
<DialogContent className="max-w-md p-5">
|
||||
<DialogHeader><DialogTitle>停用企业银行账户</DialogTitle><DialogDescription>停用后不能为新付款批次生成制单;历史批次仍保留冻结账户摘要。确认停用 {disableTarget?.legalEntity.name ?? "该法人主体"} 的当前账户吗?</DialogDescription></DialogHeader>
|
||||
<DialogHeader><DialogTitle>停用企业银行账户</DialogTitle><DialogDescription>停用后不能为新付款批次生成制单;历史批次仍保留冻结账户摘要。确认停用 {disableTarget?.legalEntity.name ?? "当前公司"} 的当前账户吗?</DialogDescription></DialogHeader>
|
||||
<DialogFooter><Button type="button" variant="secondary" onClick={() => setDisableTarget(null)}>返回</Button><Button type="button" variant="destructive" disabled={disableMutation.isPending} onClick={() => { if (disableTarget) disableMutation.mutate(disableTarget.id); }}>{disableMutation.isPending ? "停用中..." : "确认停用"}</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -178,7 +178,7 @@ function resolvePurchaseEvidenceHint(item: {
|
||||
}) {
|
||||
const missingFields = [
|
||||
item.supplierId ? null : "供应商",
|
||||
item.legalEntityId ? null : "法体",
|
||||
item.legalEntityId ? null : "公司",
|
||||
item.costCenterId ? null : "成本中心",
|
||||
item.documentCategoryId ? null : "单据分类",
|
||||
].filter(Boolean);
|
||||
@@ -590,7 +590,7 @@ export function PurchaseRequestsPage() {
|
||||
throw new Error("提交前请选择供应商");
|
||||
}
|
||||
if (!legalEntityId) {
|
||||
throw new Error("提交前请选择法体");
|
||||
throw new Error("提交前请选择公司");
|
||||
}
|
||||
if (!costCenterId) {
|
||||
throw new Error("提交前请选择成本中心");
|
||||
@@ -991,7 +991,7 @@ export function PurchaseRequestsPage() {
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">全部法体</option>
|
||||
<option value="">全部公司</option>
|
||||
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
@@ -1344,7 +1344,7 @@ export function PurchaseRequestsPage() {
|
||||
<Input value={currencyCode} onChange={(event) => setCurrencyCode(event.target.value.toUpperCase())} disabled={!canEditDraft} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">法体</label>
|
||||
<label className="text-sm font-medium text-zinc-700">公司</label>
|
||||
<select
|
||||
data-testid="purchase-legal-entity"
|
||||
className="select-std"
|
||||
@@ -1358,7 +1358,7 @@ export function PurchaseRequestsPage() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">请选择法体</option>
|
||||
<option value="">请选择公司</option>
|
||||
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
|
||||
@@ -1,40 +1,12 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
|
||||
|
||||
type ManagementItem = {
|
||||
to: string;
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
group: "人员与组织" | "流程与规则" | "系统与审计" | "功能管理";
|
||||
anyOf: string[];
|
||||
};
|
||||
|
||||
const ITEMS: ManagementItem[] = [
|
||||
{ to: "/people", icon: "badge", title: "人员", description: "员工档案、入转调离和人员状态。", group: "人员与组织", anyOf: ["system.users"] },
|
||||
{ to: "/organization", icon: "account_tree", title: "组织", description: "组织结构、部门和汇报关系。", group: "人员与组织", anyOf: ["system.users", "report.dashboard.view"] },
|
||||
{ to: "/system/users", icon: "manage_accounts", title: "账号", description: "账号、角色和登录权限。", group: "人员与组织", anyOf: ["system.accounts.manage", "system.roles.manage"] },
|
||||
{ to: "/system/departments", icon: "account_tree", title: "部门", description: "部门基础数据维护。", group: "人员与组织", anyOf: ["system.users"] },
|
||||
{ to: "/system/approval-rules", icon: "rule", title: "审批规则", description: "金额段、审批层级和处理规则。", group: "流程与规则", anyOf: ["system.settings"] },
|
||||
{ to: "/system/workflow-definitions", icon: "schema", title: "流程定义", description: "流程模板和节点治理。", group: "流程与规则", anyOf: ["system.settings"] },
|
||||
{ to: "/system/expense-standards", icon: "rule_settings", title: "费用标准", description: "费用上限、类别和管控口径。", group: "流程与规则", anyOf: ["system.settings"] },
|
||||
{ to: "/system/reimbursement-templates", icon: "receipt_long", title: "报销模板", description: "常见报销场景模板。", group: "流程与规则", anyOf: ["system.settings"] },
|
||||
{ to: "/system/workspace", icon: "dashboard_customize", title: "工作区", description: "菜单入口、模块显隐和小团队基线。", group: "系统与审计", anyOf: ["system.settings"] },
|
||||
{ to: "/audit-logs", icon: "manage_search", title: "审计日志", description: "关键操作留痕和排障。", group: "系统与审计", anyOf: ["system.audit.view"] },
|
||||
{ to: "/system/master-data", icon: "dataset", title: "主数据", description: "法体、成本中心、供应商等基础数据。", group: "功能管理", anyOf: ["masterdata.view", "masterdata.manage"] },
|
||||
{ to: "/system/payment-accounts", icon: "account_balance", title: "企业银行账户", description: "维护付款来源账户、脱敏查看与变更留痕。", group: "功能管理", anyOf: ["payment.account.read", "payment.account.manage"] },
|
||||
{ to: "/system/notification-channels", icon: "notifications_active", title: "通知渠道", description: "企业微信、邮件等补充通知。", group: "功能管理", anyOf: ["notification.channel.manage"] },
|
||||
{ to: "/system/feature-flags", icon: "toggle_on", title: "功能管理", description: "功能开关、灰度和试运行边界。", group: "功能管理", anyOf: ["system.settings"] },
|
||||
];
|
||||
|
||||
const GROUPS: ManagementItem["group"][] = ["人员与组织", "流程与规则", "系统与审计", "功能管理"];
|
||||
import { getVisibleManagementItems, MANAGEMENT_GROUPS } from "../features/navigation/managementRoutes";
|
||||
|
||||
export function SystemSettingsPage() {
|
||||
const { permissions } = useAuth();
|
||||
const visibleItems = ITEMS.filter((item) => hasAnyPermission(permissions, item.anyOf));
|
||||
const visibleGroups = GROUPS.map((group) => ({
|
||||
const visibleItems = getVisibleManagementItems(permissions);
|
||||
const visibleGroups = MANAGEMENT_GROUPS.map((group) => ({
|
||||
group,
|
||||
items: visibleItems.filter((item) => item.group === group),
|
||||
})).filter((entry) => entry.items.length > 0);
|
||||
@@ -74,7 +46,7 @@ export function SystemSettingsPage() {
|
||||
<h2 className="text-base font-semibold text-zinc-900">{group}</h2>
|
||||
<div className="admin-tile-grid">
|
||||
{items.map((item) => (
|
||||
<Link key={item.to} to={item.to} className="admin-tile-card">
|
||||
<Link key={item.key} to={item.path} className="admin-tile-card">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="admin-tile-card__icon">
|
||||
<span className="material-symbols-outlined text-[20px]">{item.icon}</span>
|
||||
|
||||
@@ -407,7 +407,7 @@ export function TravelRequestsPage() {
|
||||
throw new Error("请选择出差起止日期");
|
||||
}
|
||||
if (!legalEntityId || !costCenterId) {
|
||||
throw new Error("请选择法体与成本中心");
|
||||
throw new Error("请选择公司与成本中心");
|
||||
}
|
||||
if (!estimatedAmount || Number(estimatedAmount) < 0) {
|
||||
throw new Error("请填写有效的预估金额");
|
||||
@@ -614,7 +614,7 @@ export function TravelRequestsPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="travel-filter-legal-entity">法体</Label>
|
||||
<Label htmlFor="travel-filter-legal-entity">公司</Label>
|
||||
<select
|
||||
id="travel-filter-legal-entity"
|
||||
className="select-std mt-1"
|
||||
@@ -625,7 +625,7 @@ export function TravelRequestsPage() {
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">全部法体</option>
|
||||
<option value="">全部公司</option>
|
||||
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>{item.name}</option>
|
||||
))}
|
||||
@@ -828,7 +828,7 @@ export function TravelRequestsPage() {
|
||||
<p className="font-medium text-zinc-900">{detail.budgetPool?.name ?? "待提交时自动解析"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500">法体</p>
|
||||
<p className="text-zinc-500">公司</p>
|
||||
<p className="font-medium text-zinc-900">{detail.legalEntity?.name ?? "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -1030,7 +1030,7 @@ function TravelRequestForm(props: TravelRequestFormProps) {
|
||||
<Input id="travel-end-date" className="mt-1" type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} readOnly={readOnly} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="travel-legal-entity">法体</Label>
|
||||
<Label htmlFor="travel-legal-entity">公司</Label>
|
||||
<select
|
||||
id="travel-legal-entity"
|
||||
className="select-std mt-1"
|
||||
@@ -1041,7 +1041,7 @@ function TravelRequestForm(props: TravelRequestFormProps) {
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<option value="">请选择法体</option>
|
||||
<option value="">请选择公司</option>
|
||||
{legalEntities.map((item) => (
|
||||
<option key={item.id} value={item.id}>{item.name}</option>
|
||||
))}
|
||||
|
||||
@@ -351,7 +351,7 @@ export function WorkflowTaskDetailPage() {
|
||||
<DetailField label="标题" value={detail.purchaseRequest.title || "-"} />
|
||||
<DetailField label="申请人" value={detail.purchaseRequest.applicant?.name ?? "-"} />
|
||||
<DetailField label="供应商" value={detail.purchaseRequest.supplier?.name ?? "-"} />
|
||||
<DetailField label="法体" value={detail.purchaseRequest.legalEntity?.name ?? "-"} />
|
||||
<DetailField label="公司" value={detail.purchaseRequest.legalEntity?.name ?? "-"} />
|
||||
<DetailField label="成本中心" value={detail.purchaseRequest.costCenter?.name ?? "-"} />
|
||||
<DetailField label="单据分类" value={detail.purchaseRequest.documentCategory?.name ?? "-"} />
|
||||
<DetailField label="金额" value={`${detail.purchaseRequest.totalAmount ?? "-"} ${detail.purchaseRequest.currencyCode}`} />
|
||||
@@ -364,7 +364,7 @@ export function WorkflowTaskDetailPage() {
|
||||
<DetailField label="差旅单号" value={detail.travelRequest.requestNo ?? "暂未生成"} />
|
||||
<DetailField label="申请人" value={detail.travelRequest.applicant?.name ?? "-"} />
|
||||
<DetailField label="出差类型" value={detail.travelRequest.tripType === "international" ? "国际" : "国内"} />
|
||||
<DetailField label="法体" value={detail.travelRequest.legalEntity?.name ?? "-"} />
|
||||
<DetailField label="公司" value={detail.travelRequest.legalEntity?.name ?? "-"} />
|
||||
<DetailField label="成本中心" value={detail.travelRequest.costCenter?.name ?? "-"} />
|
||||
<DetailField label="起止日期" value={`${detail.travelRequest.startDate ?? "-"} → ${detail.travelRequest.endDate ?? "-"}`} />
|
||||
<DetailField label="预估金额" value={detail.travelRequest.estimatedAmount ?? "-"} />
|
||||
|
||||
+28
-20
@@ -1,6 +1,7 @@
|
||||
import { Suspense, type ReactNode } from "react";
|
||||
import { createBrowserRouter, createRoutesFromElements, Navigate, Outlet, Route } from "react-router-dom";
|
||||
import { AuthProvider } from "../auth/AuthContext";
|
||||
import { getManagementRoutePermissions } from "../features/navigation/managementRoutes";
|
||||
import { RoutePageFallback } from "../components/common/RoutePageFallback";
|
||||
import { UnsavedChangesProvider } from "../components/common/UnsavedChangesGuard";
|
||||
import { AppShellLayout } from "../layouts/AppShellLayout";
|
||||
@@ -209,7 +210,7 @@ const routeElements = (
|
||||
<Route
|
||||
path="system/reimbursement-templates"
|
||||
element={
|
||||
<RequirePermission anyOf={["system.settings"]}>
|
||||
<RequirePermission anyOf={getManagementRoutePermissions("reimbursement_templates")}>
|
||||
{withRouteFallback(<ReimbursementTemplatesPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -494,7 +495,7 @@ const routeElements = (
|
||||
<Route
|
||||
path="audit-logs"
|
||||
element={
|
||||
<RequirePermission anyOf={["system.audit.view"]}>
|
||||
<RequirePermission anyOf={getManagementRoutePermissions("audit_logs")}>
|
||||
{withRouteFallback(<AuditLogsPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -521,7 +522,7 @@ const routeElements = (
|
||||
<Route
|
||||
path="performance"
|
||||
element={
|
||||
<RequirePermission anyOf={["system.settings"]}>
|
||||
<RequirePermission anyOf={getManagementRoutePermissions("performance")}>
|
||||
{withRouteFallback(<PerformancePage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -530,7 +531,7 @@ const routeElements = (
|
||||
<Route
|
||||
path="organization"
|
||||
element={
|
||||
<RequirePermission anyOf={["system.users", "report.dashboard.view"]}>
|
||||
<RequirePermission anyOf={getManagementRoutePermissions("organization")}>
|
||||
{withRouteFallback(<OrganizationPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -539,7 +540,7 @@ const routeElements = (
|
||||
<Route
|
||||
path="people"
|
||||
element={
|
||||
<RequirePermission anyOf={["system.users"]}>
|
||||
<RequirePermission anyOf={getManagementRoutePermissions("people")}>
|
||||
{withRouteFallback(<PeoplePage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -548,18 +549,25 @@ const routeElements = (
|
||||
<Route
|
||||
path="people/stats"
|
||||
element={
|
||||
<RequirePermission anyOf={["system.users"]}>
|
||||
<RequirePermission anyOf={getManagementRoutePermissions("people_stats")}>
|
||||
{withRouteFallback(<PeopleStatsPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="people/:employeeId" element={withRouteFallback(<EmployeeProfilePage />)} />
|
||||
<Route
|
||||
path="people/:employeeId"
|
||||
element={
|
||||
<RequirePermission anyOf={getManagementRoutePermissions("employee_profile")}>
|
||||
{withRouteFallback(<EmployeeProfilePage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="system/users"
|
||||
element={
|
||||
<RequirePermission anyOf={["system.accounts.manage", "system.roles.manage"]}>
|
||||
<RequirePermission anyOf={getManagementRoutePermissions("user_accounts")}>
|
||||
{withRouteFallback(<UserManagementPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -568,7 +576,7 @@ const routeElements = (
|
||||
<Route
|
||||
path="system/departments"
|
||||
element={
|
||||
<RequirePermission anyOf={["system.users"]}>
|
||||
<RequirePermission anyOf={getManagementRoutePermissions("departments")}>
|
||||
{withRouteFallback(<DepartmentsPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -577,7 +585,7 @@ const routeElements = (
|
||||
<Route
|
||||
path="system/master-data"
|
||||
element={
|
||||
<RequirePermission anyOf={["masterdata.view", "masterdata.manage"]}>
|
||||
<RequirePermission anyOf={getManagementRoutePermissions("company_information")}>
|
||||
{withRouteFallback(<MasterDataPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -586,7 +594,7 @@ const routeElements = (
|
||||
<Route
|
||||
path="system/payment-accounts"
|
||||
element={
|
||||
<RequirePermission anyOf={["payment.account.read", "payment.account.manage"]}>
|
||||
<RequirePermission anyOf={getManagementRoutePermissions("payment_accounts")}>
|
||||
{withRouteFallback(<PaymentAccountsPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -595,7 +603,7 @@ const routeElements = (
|
||||
<Route
|
||||
path="system/notification-channels"
|
||||
element={
|
||||
<RequirePermission anyOf={["notification.channel.manage"]}>
|
||||
<RequirePermission anyOf={getManagementRoutePermissions("notification_channels")}>
|
||||
{withRouteFallback(<NotificationChannelsPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -604,7 +612,7 @@ const routeElements = (
|
||||
<Route
|
||||
path="system/workspace"
|
||||
element={
|
||||
<RequirePermission anyOf={["system.settings"]}>
|
||||
<RequirePermission anyOf={getManagementRoutePermissions("workspace")}>
|
||||
{withRouteFallback(<WorkspaceSetupPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -613,7 +621,7 @@ const routeElements = (
|
||||
<Route
|
||||
path="system/settings"
|
||||
element={
|
||||
<RequirePermission anyOf={["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "payment.account.read", "payment.account.manage"]}>
|
||||
<RequirePermission anyOf={getManagementRoutePermissions("management")}>
|
||||
{withRouteFallback(<SystemSettingsPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -622,7 +630,7 @@ const routeElements = (
|
||||
<Route
|
||||
path="system/approval-rules"
|
||||
element={
|
||||
<RequirePermission anyOf={["system.settings"]}>
|
||||
<RequirePermission anyOf={getManagementRoutePermissions("approval_rules")}>
|
||||
{withRouteFallback(<ApprovalRulesPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -631,7 +639,7 @@ const routeElements = (
|
||||
<Route
|
||||
path="system/expense-standards"
|
||||
element={
|
||||
<RequirePermission anyOf={["system.settings"]}>
|
||||
<RequirePermission anyOf={getManagementRoutePermissions("expense_standards")}>
|
||||
{withRouteFallback(<ExpenseStandardsPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -640,7 +648,7 @@ const routeElements = (
|
||||
<Route
|
||||
path="system/feature-flags"
|
||||
element={
|
||||
<RequirePermission anyOf={["system.settings"]}>
|
||||
<RequirePermission anyOf={getManagementRoutePermissions("feature_flags")}>
|
||||
{withRouteFallback(<FeatureFlagsPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -649,7 +657,7 @@ const routeElements = (
|
||||
<Route
|
||||
path="system/announcements"
|
||||
element={
|
||||
<RequirePermission anyOf={["system.settings"]}>
|
||||
<RequirePermission anyOf={getManagementRoutePermissions("system_announcements")}>
|
||||
{withRouteFallback(<PortalAnnouncementsPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -658,7 +666,7 @@ const routeElements = (
|
||||
<Route
|
||||
path="system/workflow-definitions"
|
||||
element={
|
||||
<RequirePermission anyOf={["system.settings"]}>
|
||||
<RequirePermission anyOf={getManagementRoutePermissions("workflow_definitions")}>
|
||||
{withRouteFallback(<WorkflowDefinitionsPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -667,7 +675,7 @@ const routeElements = (
|
||||
<Route
|
||||
path="system/cutover"
|
||||
element={
|
||||
<RequirePermission anyOf={["system.settings"]}>
|
||||
<RequirePermission anyOf={getManagementRoutePermissions("cutover")}>
|
||||
{withRouteFallback(<CutoverConsolePage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import {
|
||||
getVisibleManagementItems,
|
||||
getManagementRoutePermissions,
|
||||
isKnownManagementPath,
|
||||
isManagementPath,
|
||||
MANAGEMENT_ROOT_PERMISSIONS,
|
||||
MANAGEMENT_ROUTES,
|
||||
resolveManagementBreadcrumbs,
|
||||
resolveManagementRoute,
|
||||
} from "../../src/features/navigation/managementRoutes";
|
||||
import { resolvePageMeta } from "../../src/features/navigation/schema";
|
||||
import {
|
||||
filterRecentVisitsByPermissions,
|
||||
type RecentVisit,
|
||||
} from "../../src/lib/recentVisits";
|
||||
import { resolveSingleCompanyState } from "../../src/features/company/singleCompany";
|
||||
|
||||
const REGISTERED_MANAGEMENT_PATHS = [
|
||||
"/system/settings",
|
||||
"/people",
|
||||
"/people/stats",
|
||||
"/people/:employeeId",
|
||||
"/organization",
|
||||
"/system/users",
|
||||
"/system/departments",
|
||||
"/system/approval-rules",
|
||||
"/system/workflow-definitions",
|
||||
"/system/expense-standards",
|
||||
"/system/reimbursement-templates",
|
||||
"/system/workspace",
|
||||
"/audit-logs",
|
||||
"/system/master-data",
|
||||
"/system/payment-accounts",
|
||||
"/system/notification-channels",
|
||||
"/system/feature-flags",
|
||||
"/system/announcements",
|
||||
"/system/cutover",
|
||||
"/performance",
|
||||
];
|
||||
|
||||
test("管理路由注册表覆盖路径、父级、权限和聚合入口合同", () => {
|
||||
expect(MANAGEMENT_ROUTES.map((route) => route.path)).toEqual(REGISTERED_MANAGEMENT_PATHS);
|
||||
expect(new Set(MANAGEMENT_ROUTES.map((route) => route.key)).size).toBe(MANAGEMENT_ROUTES.length);
|
||||
|
||||
const keys = new Set(MANAGEMENT_ROUTES.map((route) => route.key));
|
||||
const routesByKey = new Map(MANAGEMENT_ROUTES.map((route) => [route.key, route]));
|
||||
for (const route of MANAGEMENT_ROUTES) {
|
||||
expect(route.title.trim()).not.toBe("");
|
||||
expect(route.anyOf.length).toBeGreaterThan(0);
|
||||
if (route.parentKey) {
|
||||
expect(keys.has(route.parentKey)).toBe(true);
|
||||
}
|
||||
if (route.showOnLanding) {
|
||||
expect(route.group).not.toBeNull();
|
||||
expect(route.icon.trim()).not.toBe("");
|
||||
expect(route.description.trim()).not.toBe("");
|
||||
}
|
||||
|
||||
const ancestors = new Set<string>();
|
||||
let cursor = route;
|
||||
while (cursor.parentKey) {
|
||||
expect(ancestors.has(cursor.key)).toBe(false);
|
||||
ancestors.add(cursor.key);
|
||||
cursor = routesByKey.get(cursor.parentKey)!;
|
||||
}
|
||||
}
|
||||
|
||||
const expectedRootPermissions = new Set(
|
||||
MANAGEMENT_ROUTES.filter((route) => route.showOnLanding).flatMap((route) => route.anyOf),
|
||||
);
|
||||
expect(new Set(MANAGEMENT_ROOT_PERMISSIONS)).toEqual(expectedRootPermissions);
|
||||
});
|
||||
|
||||
test("企业银行账户与人员档案按当前权限解析管理父链", () => {
|
||||
const paymentPermissions = ["payment.account.read"];
|
||||
expect(resolveManagementRoute("/system/payment-accounts", paymentPermissions)?.title).toBe("企业银行账户");
|
||||
expect(isManagementPath("/system/payment-accounts", paymentPermissions)).toBe(true);
|
||||
expect(resolveManagementBreadcrumbs("/system/payment-accounts", paymentPermissions)).toEqual([
|
||||
{ key: "management", title: "管理", href: "/system/settings" },
|
||||
{ key: "payment_accounts", title: "企业银行账户", href: null },
|
||||
]);
|
||||
|
||||
const peoplePermissions = ["system.users"];
|
||||
expect(getManagementRoutePermissions("employee_profile")).toEqual(peoplePermissions);
|
||||
expect(resolveManagementBreadcrumbs("/people/synthetic-employee", peoplePermissions)).toEqual([
|
||||
{ key: "management", title: "管理", href: "/system/settings" },
|
||||
{ key: "people", title: "人员", href: "/people" },
|
||||
{ key: "employee_profile", title: "员工档案", href: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test("无权限路径不披露管理标题或面包屑", () => {
|
||||
expect(isKnownManagementPath("/system/payment-accounts")).toBe(true);
|
||||
expect(resolveManagementRoute("/system/payment-accounts", [])).toBeNull();
|
||||
expect(resolveManagementBreadcrumbs("/system/payment-accounts", [])).toEqual([]);
|
||||
expect(resolvePageMeta("/system/payment-accounts", [])).toEqual({ title: "管理", group: "管理" });
|
||||
expect(getVisibleManagementItems([])).toEqual([]);
|
||||
});
|
||||
|
||||
test("最近访问在权限撤销后移除受限管理标题和链接", () => {
|
||||
const visits: RecentVisit[] = [
|
||||
{
|
||||
path: "/system/payment-accounts",
|
||||
title: "企业银行账户",
|
||||
visitedAt: "2026-08-03T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
path: "/reimbursements",
|
||||
title: "报销",
|
||||
visitedAt: "2026-08-02T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
expect(filterRecentVisitsByPermissions(visits, ["payment.account.read"])).toEqual(visits);
|
||||
expect(filterRecentVisitsByPermissions(visits, [])).toEqual([visits[1]]);
|
||||
});
|
||||
|
||||
test("单公司状态对零个、一个和多个启用公司采用明确且保守的分支", () => {
|
||||
expect(resolveSingleCompanyState([])).toEqual({ kind: "empty" });
|
||||
expect(resolveSingleCompanyState([{ id: "company-1" }])).toEqual({
|
||||
kind: "single",
|
||||
company: { id: "company-1" },
|
||||
});
|
||||
expect(resolveSingleCompanyState([{ id: "company-1" }, { id: "company-2" }])).toEqual({
|
||||
kind: "multiple",
|
||||
companies: [{ id: "company-1" }, { id: "company-2" }],
|
||||
});
|
||||
});
|
||||
@@ -432,6 +432,7 @@ type MockVerificationRecord = {
|
||||
};
|
||||
|
||||
type Wave4MockOptions = {
|
||||
legalEntities?: Array<(typeof legalEntities)[number]>;
|
||||
detailVerification?: MockVerificationRecord | null;
|
||||
verificationRecords?: MockVerificationRecord[];
|
||||
triggerVerificationError?: Record<string, unknown>;
|
||||
@@ -924,7 +925,17 @@ async function mockWave4Apis(page: Page, options: Wave4MockOptions = {}) {
|
||||
}
|
||||
|
||||
if (pathName === "/api/master-data/legal-entities") {
|
||||
await fulfillJson(route, { includeArchived: false, items: legalEntities });
|
||||
await fulfillJson(route, { includeArchived: false, items: options.legalEntities ?? legalEntities });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/payment-accounts") {
|
||||
await fulfillJson(route, { items: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/bank-institutions") {
|
||||
await fulfillJson(route, { items: [{ id: "bank-wave4", code: "CCB", name: "中国建设银行" }] });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1016,7 +1027,7 @@ const pages = [
|
||||
{ path: "/organization", summary: "organization-summary", proof: "text=研发部", screenshot: "06-organization" },
|
||||
{ path: "/system/workspace", summary: "workspace-settings-summary", proof: "data-testid=workspace-modules-table", screenshot: "07-workspace" },
|
||||
{ path: "/system/users", summary: "users-summary", proof: "data-testid=users-table", screenshot: "08-users" },
|
||||
{ path: "/system/master-data", summary: "master-data-summary", proof: "text=法体列表", screenshot: "09-master-data" },
|
||||
{ path: "/system/master-data", summary: "master-data-summary", proof: "text=公司信息", screenshot: "09-master-data" },
|
||||
{ path: "/system/notification-channels", summary: "notification-channels-summary", proof: "data-testid=notification-channels-table", screenshot: "10-notification-channels" },
|
||||
{ path: "/system/settings", summary: "system-settings-summary", proof: "text=管理", screenshot: "11-system-settings" },
|
||||
{ path: "/system/feature-flags", summary: "feature-flags-summary", proof: "data-testid=feature-flags-table", screenshot: "12-feature-flags" },
|
||||
@@ -1066,6 +1077,47 @@ test("PC 财务列表使用明确动作入口", async ({ page }) => {
|
||||
await expect(page.getByTestId("invoice-export-csv")).toBeVisible();
|
||||
});
|
||||
|
||||
test("管理子页共享面包屑、侧栏高亮并自动关联唯一公司", async ({ page }) => {
|
||||
await mockWave4Apis(page);
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
await login(page);
|
||||
|
||||
await page.goto("/system/payment-accounts");
|
||||
|
||||
const breadcrumbs = page.getByTestId("topbar-management-breadcrumbs");
|
||||
await expect(breadcrumbs).toContainText("管理");
|
||||
await expect(breadcrumbs).toContainText("企业银行账户");
|
||||
await expect(breadcrumbs.getByRole("link", { name: "管理" })).toHaveAttribute("href", "/system/settings");
|
||||
await expect(page.getByTestId("payment-account-company")).toHaveText("海音达科技");
|
||||
await expect(page.locator("#payment-account-legal-entity")).toHaveCount(0);
|
||||
await expect(page.locator('a.nav-link-item[aria-label="管理"]')).toHaveClass(/nav-link-item--active/);
|
||||
});
|
||||
|
||||
test("企业银行账户在缺少公司信息时给出可执行入口", async ({ page }) => {
|
||||
await mockWave4Apis(page, { legalEntities: [] });
|
||||
await login(page);
|
||||
await page.goto("/system/payment-accounts");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "先完善公司信息" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "完善公司信息" })).toHaveAttribute("href", "/system/master-data");
|
||||
await expect(page.getByTestId("payment-account-company")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("企业银行账户在异常多公司状态下停止自动关联", async ({ page }) => {
|
||||
await mockWave4Apis(page, {
|
||||
legalEntities: [
|
||||
legalEntities[0],
|
||||
{ ...legalEntities[0], id: "legal-wave4-extra", code: "HYD-EXTRA", name: "合成重复公司" },
|
||||
],
|
||||
});
|
||||
await login(page);
|
||||
await page.goto("/system/payment-accounts");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "公司信息需要处理" })).toBeVisible();
|
||||
await expect(page.getByText("系统不会猜测付款账户应关联哪一条")).toBeVisible();
|
||||
await expect(page.getByTestId("payment-account-company")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("审批规则负责人本人兜底默认财务并兼容财务负责人", async ({ page }) => {
|
||||
await mockWave4Apis(page);
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
|
||||
+6
@@ -172,3 +172,9 @@
|
||||
- `ownerSelfFallbackRole` 只允许 active、非本人的 `finance` 或兼容角色 `finance_mgr`;本公司新建默认和生产目标均为 `finance`。
|
||||
- CEO 本人没有可解析直属主管时,只有存在 active 非本人 fallback 持有人才能提交;无持有人或 CEO 同时持有 fallback 角色时继续阻断。不得使用 `admin` 代批或恢复整库绕过。
|
||||
- 既有审批快照不重路由,过去提交失败的单据也不会自动恢复。当前本地候选尚未部署、生产规则尚未回填,因此公网仍按旧配置阻断。
|
||||
|
||||
### R-6 单公司内部 OA 边界
|
||||
- 系统只服务当前公司内部使用,不作为对外产品发布,不支持多公司、多租户、公司切换或集团工作区。
|
||||
- 前端按单公司表达公司信息和企业银行账户:正常状态不要求用户选择公司,也不展示无业务价值的多公司配置复杂度。
|
||||
- `LegalEntity` 及其外键继续作为成本中心、供应商、付款账户等内部账务和主数据关联锚点;单公司体验不等于删除后端关系模型或迁移历史数据。
|
||||
- 异常存在多条启用公司信息时必须停止自动关联并提示管理员治理,不得静默选择、自动合并或删除记录。
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"client/tests/e2e/r103-cross-page-ui-consistency.spec.ts",
|
||||
"client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts",
|
||||
"client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts",
|
||||
"client/tests/e2e/management-navigation-contract.spec.ts",
|
||||
"client/tests/e2e/internal-readiness-ui.spec.ts",
|
||||
"client/tests/e2e/system-action-confirmation.spec.ts",
|
||||
"client/tests/e2e/payment-reviewer-permissions.spec.ts",
|
||||
@@ -143,6 +144,7 @@
|
||||
{ "path": "/mobile/leave/:leaveRequestId", "classification": "supported-noncore" },
|
||||
{ "path": "/mobile/me", "classification": "core", "bundle": "mobile-workspace", "behaviorSpecs": ["client/tests/e2e/r124-mobile-entry-matrix.spec.ts"] },
|
||||
{ "path": "/mobile/notifications", "classification": "core", "bundle": "mobile-workspace", "behaviorSpecs": ["client/tests/e2e/dg7-mobile-information-dedupe.spec.ts"] },
|
||||
{ "path": "/mobile/payment-batches", "classification": "core", "bundle": "payment-ap", "behaviorSpecs": ["client/tests/e2e/dg7-mobile-information-dedupe.spec.ts"] },
|
||||
{ "path": "/mobile/profile", "classification": "supported-noncore" },
|
||||
{ "path": "/mobile/purchase-requests", "classification": "core", "bundle": "procurement", "behaviorSpecs": ["client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts"] },
|
||||
{ "path": "/mobile/purchase-requests/:purchaseRequestId", "classification": "core", "bundle": "procurement", "behaviorSpecs": ["client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts"] },
|
||||
@@ -174,6 +176,7 @@
|
||||
{ "path": "/system/feature-flags", "classification": "supported-noncore" },
|
||||
{ "path": "/system/master-data", "classification": "supported-noncore" },
|
||||
{ "path": "/system/notification-channels", "classification": "supported-noncore" },
|
||||
{ "path": "/system/payment-accounts", "classification": "supported-noncore", "behaviorSpecs": ["client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts", "client/tests/e2e/management-navigation-contract.spec.ts"] },
|
||||
{ "path": "/system/reimbursement-templates", "classification": "supported-noncore" },
|
||||
{ "path": "/system/settings", "classification": "supported-noncore" },
|
||||
{ "path": "/system/users", "classification": "core", "bundle": "people-audit", "behaviorSpecs": ["client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts"] },
|
||||
|
||||
在新工单中引用
屏蔽一个用户