diff --git a/.trellis/spec/hyb-oa/frontend-react-vite-design.md b/.trellis/spec/hyb-oa/frontend-react-vite-design.md index b6507169..1c991194 100644 --- a/.trellis/spec/hyb-oa/frontend-react-vite-design.md +++ b/.trellis/spec/hyb-oa/frontend-react-vite-design.md @@ -518,6 +518,7 @@ reference, not a copy of Feishu or Ant Design branding. - Item/invoice return references are not persistent evidence capabilities. Only the expanded return editor mounts `ReimbursementReturnReferencePicker`; abnormal candidates appear first, normal candidates require `展开全部`, and selecting a candidate appends an editable reference to the return comment. - Submission-time fee-standard and budget facts come from the workflow context snapshot. Current item/invoice grouping, amount evidence and attachments come from the reimbursement detail. Approval pages do not call reimbursement precheck or present current budget data as a submission snapshot. - Reimbursement approval actions fail closed until workflow snapshot parsing and current reimbursement evidence are ready. Historical missing budget snapshots are explained; malformed snapshots disable all decision paths. +- The normal desktop reimbursement decision panel stays quiet: do not repeat the current node, workflow status, approval role or a successful readiness message beside the actions. When approval is fail-closed, render the one accurate blocking reason immediately before the disabled action group and reuse that same reason projection in the action handler's secondary guard. A missing-budget notice remains independent and appears only for `budgetCheckStatus=missing`. - Mobile workflow caching uses an explicit persisted projection. Reimbursement items, invoices, attachments, amount evidence, material access data, workflow snapshot checks and shared component projections do not enter IndexedDB. Offline reimbursement approve/reject/return/draft is forbidden, while other workflow entity offline behavior remains unchanged. - The information order is compact header and reason, expense items, invoices, supplementary attachments, real issues/differences when present, then history/progress. Normal state creates no `0 条` check card. Issue copy identifies the object, reason, impact and next step rather than exposing internal counters alone. - Expense totals in the header, desktop table footer, mobile summary row and print view all display the server `totalAmount`. A shared workspace-model boundary may sum strictly parsed integer minor units only to classify integrity; that derived sum is never rendered or submitted. A mismatch or unparseable amount is a data-integrity failure, uses distinct non-color-only copy and fails reimbursement approval closed; normal records show no difference explanation. diff --git a/.trellis/tasks/09-03-approval-action-panel-kiss-layering/check.jsonl b/.trellis/tasks/09-03-approval-action-panel-kiss-layering/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/09-03-approval-action-panel-kiss-layering/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/09-03-approval-action-panel-kiss-layering/design.md b/.trellis/tasks/09-03-approval-action-panel-kiss-layering/design.md new file mode 100644 index 00000000..e39d7774 --- /dev/null +++ b/.trellis/tasks/09-03-approval-action-panel-kiss-layering/design.md @@ -0,0 +1,49 @@ +# Design:审批动作面板 KISS 收敛与层叠遗留清理 + +## 1. Scope Boundary + +保持页面壳、workflow API、allowedActions、权限、mutation、确认弹窗、转审入口和移动端行为不变。产品代码只允许修改报销审批 adapter、共享动作面板的最小展示插槽和旧 CSS;通用审批页不得产生产品代码 diff。 + +## 2. Action-Adjacent Blocking Notice + +`BusinessDetailActionPanel` 增加可选的动作前展示插槽,例如 `beforeActions?: ReactNode`,渲染顺序为: + +```text +title/status +beforeActions(异常锚点 + 阻断原因;空则不渲染) +primary / secondary / more actions +children(点击后展开的理由或转审输入) +summary(其他业务页现有摘要) +``` + +插槽只负责布局,不读取 mode、角色、权限或 readiness。它使用既有 `hasVisibleNode` 逻辑,未传插槽的消费者 DOM 顺序不变。 + +报销 PC adapter 删除 `statusSummary`,使用: + +```tsx +beforeActions = issueAnchors + decisionBlockNotice +summary = null +children = actionError + active action editor +``` + +正常 ready 时 `decisionBlockNotice=null`。模型尚未形成的早退失败卡继续维持“原因 → 禁用动作”顺序。 + +## 3. Single Blocking-Reason Projection + +在报销 adapter 内从既有 readiness 事实投影唯一主因:离线、金额完整性异常、快照解析失败、详情查询失败/加载中。金额继续复用 `reimbursementAmountIntegrityMessage`;详情查询失败使用新批准文案。 + +展示和 `requestAction`/mutation 二次拦截复用同一原因函数或值,不改变 `decisionReady` 条件,避免按钮说明与实际拦截漂移。 + +## 4. Budget Snapshot Compatibility + +预算快照提示仍只由 `snapshot?.budgetCheckStatus === "missing"` 控制,不并入新的 readiness 文案投影。测试固定 `missing` 正例与 `available/not_applicable` 负例;不修改费用政策、预算或快照数据流。 + +## 5. Dead Layering Cleanup + +删除 `.workflow-action-panel` 和完整 `.workflow-action-more` CSS。两者没有产品消费者,删除不会改变当前 DOM。付款进度单测和 wave3 结构测试移除旧 class 正向断言,保留对共享动作面板 `lg:sticky lg:top-24` 的有效合同。 + +不创建 Radix 菜单,不进行菜单展开测试。四类通用审批用现有合成 fixture 验证页面/动作可见和无横向溢出即可。 + +## 6. Rollout and Rollback + +报销减法、邻近阻断提示、死 CSS 和测试更新作为一个候选。部署前 UI strict;部署后用公网真实 bundle + 合成 API 复核正常/失败报销和四类通用审批。失败时整体回滚到 `d63ccdf`,不操作数据、migration 或 flag。 diff --git a/.trellis/tasks/09-03-approval-action-panel-kiss-layering/implement.jsonl b/.trellis/tasks/09-03-approval-action-panel-kiss-layering/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/09-03-approval-action-panel-kiss-layering/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/09-03-approval-action-panel-kiss-layering/implement.md b/.trellis/tasks/09-03-approval-action-panel-kiss-layering/implement.md new file mode 100644 index 00000000..d5ca6cf6 --- /dev/null +++ b/.trellis/tasks/09-03-approval-action-panel-kiss-layering/implement.md @@ -0,0 +1,34 @@ +# Implementation Plan:审批动作面板 KISS 收敛与层叠遗留清理 + +## Entry Gate + +- [x] 用户审阅并批准收缩后的 `prd.md`、`design.md` 与本实施计划。 +- [x] 批准后运行 `task.py start`;实施前加载 `trellis-before-dev` 的 frontend、workflow、reimbursement 和 UI QA 规约。 + +## Implementation Order + +1. [x] 为 `BusinessDetailActionPanel` 增加无业务判断的动作前插槽,空节点不产生容器;确认其他消费者 DOM 顺序不变。 +2. [x] 报销 adapter 建立单一阻断原因投影,让显示提示与 handler 二次拦截复用;不改变 `decisionReady` 条件。 +3. [x] 把异常锚点/阻断原因放到动作前插槽,删除 `statusSummary`,显式传 `summary={null}`;保持理由输入按点击渐进展开。 +4. [x] 保持预算 `missing` 判断原样,补 `available/not_applicable` 负向测试。 +5. [x] 删除 `.workflow-action-panel` 与 `.workflow-action-more` 整组 CSS,更新付款进度和 wave3 漂移断言。 +6. [x] 补正常/五类失败报销回归,以及请假、补卡、采购、差旅 1280px 静态无变化回归;不修改通用审批页产品代码。 + +## Verification + +- [x] `cd client && npm run typecheck && npm run build`。 +- [ ] 报销动作确认、内容一致性、R102、R111、移动离线和四类通用审批相关 Playwright。(除缺失 WebKit 的移动离线 3 项外均通过。) +- [x] 付款进度聚焦测试通过,旧“既有失败”消失。 +- [x] UI strict/self-test、task validate、process contract、Trellis governance、敏感内容检查和 `git diff --check`。 +- [x] diff 证明无 server 产品代码、通用审批页产品代码、schema、migration、flag、seed 或生产数据改动。 + +## Delivery Gates + +- [ ] diff 复核后 commit;commit 复核后 push;provenance 复核后部署。 +- [ ] 只形成一个候选、一次部署;切换前运行 `check-ui-design-rules --strict`。 +- [ ] 部署后核 build marker、公网文案、正常/失败报销动作面板、四类通用审批,以及 B2/三张 rejected 单据只读状态。 + +## Rollback + +- 动作布局、fail-closed 解释或通用审批回归失败:停止候选或回滚应用到 `d63ccdf`。 +- 不回滚数据、migration 或 flag,因为本任务不产生这些变更。 diff --git a/.trellis/tasks/09-03-approval-action-panel-kiss-layering/prd.md b/.trellis/tasks/09-03-approval-action-panel-kiss-layering/prd.md new file mode 100644 index 00000000..c1c686fc --- /dev/null +++ b/.trellis/tasks/09-03-approval-action-panel-kiss-layering/prd.md @@ -0,0 +1,103 @@ +# 审批动作面板 KISS 收敛与层叠遗留清理 + +## Goal + +收敛 PC 报销审批右栏:正常状态只保留决策动作和按需展开的理由输入,异常状态在禁用动作紧邻上方解释阻断原因;同时删除已无产品消费者的旧“更多”层叠 CSS 与漂移断言。保持预算快照三态、转审入口、通用审批页面和所有业务行为不变,以最小改动形成一个候选、一次部署。 + +## Background and Confirmed Facts + +- 生产 `d63ccdf77c60` 的报销审批右栏仍显示“节点 / 状态 / 审批角色 / 完整内容已就绪”。处理历史已位于右栏动作之后,状态已由报销页头 `StatusBadge` 表达,审批角色属于权限实现细节;正常 readiness 不需要解释。 +- `ReimbursementApprovalAdapter.tsx:283` 构造固定 `statusSummary`,在 `:428` 传给 `BusinessDetailActionPanel.summary`。`BusinessDetailActionPanel` 已能识别空节点,但调用方仍应以 `null`/省略明确表达无摘要,避免休眠 UI 路径。 +- 预算快照提示已经只在 `snapshot?.budgetCheckStatus === "missing"` 时出现;`available` 与 `not_applicable` 不提示。本任务只补负向保护,不修改判断。 +- 内容未加载、快照解析失败、离线、金额 mismatch 或金额 unavailable 时,报销审批已有 fail-closed;当前成功/失败 readiness 位于动作后的 summary,失败原因需要移到禁用动作紧邻上方。 +- 历史提交 `94f1bad` 为原生 `
` 的“更多”浮层增加 `.workflow-action-panel { relative z-30 }`,并以 `.workflow-action-more` 的绝对定位和 z-index 防遮挡。 +- B-2b 提交 `abdd250` 已删除两个审批页的 `
`/“更多”菜单。当前 `BusinessDetailActionPanel.moreActions` 只平铺调用方传入的“转审”按钮;产品代码没有 `.workflow-action-panel` 或 `.workflow-action-more` 消费者。 +- 因当前不存在菜单或浮层,本任务不需要菜单展开/层叠测试。`.workflow-action-panel` 和整组 `.workflow-action-more` CSS 已成为死代码;`paymentProcessingProgress.test.js:137` 与 wave3 对旧 class/CSS 的正向断言属于测试漂移。 +- 既有 spec 写有“转审默认进入更多”,而当前实现为常驻按钮。转审是否被真实使用、应保持常驻、进入更多或按条件隐藏,是独立产品判断;本任务只登记,不改变当前入口。 + +## Scope + +- 删除 PC 报销审批动作面板的三行重复元数据和正常 readiness 文案。 +- 将各类 fail-closed 原因移到禁用动作紧邻上方,保持原因准确且与二次拦截一致。 +- 保持预算快照 `missing` 逻辑不动,补 `available/not_applicable` 负向断言。 +- 删除 `.workflow-action-panel`、`.workflow-action-more` 整组死 CSS 及对应漂移断言。 +- 静态/合成复核请假、补卡、采购、差旅通用审批页在删除死 CSS 后视觉和动作可达性无变化。 + +## Requirements + +### R1 正常报销审批右栏只保留决策内容 + +- PC 报销审批动作面板不得显示“节点:…”“状态:…”“审批角色:…”或“完整内容已就绪”。 +- 无异常、无展开编辑器且决策已就绪时,只显示现有动作,不增加“0 项”“一切正常”或系统说明。 +- 报销 adapter 不再构造 `statusSummary`,并对 `BusinessDetailActionPanel.summary` 显式传 `null` 或省略;动作区不得留下空 summary 容器、分隔线或 padding。 +- 报销页头状态和右栏处理历史保持原位置,不为删除的三行另找位置重复展示。 +- “退回 / 驳回 / 转审”继续按现有点击行为渐进展开理由/目标输入;不得新增常驻审批意见框。 + +### R2 fail-closed 原因紧邻禁用动作 + +- 内容未加载完整、提交快照无法解析、离线、权威总额不一致、明细金额无法解析五类情况继续使通过、退回、驳回、转审 fail-closed,并保留 handler 二次拦截。 +- 阻断提示必须位于对应禁用动作组的紧邻上方,在同一动作卡或同一失败卡中,无需到主列或右栏其他区块寻找原因。 +- 内容查询失败使用“报销内容未加载完整,暂不能审批,请刷新后重试”;快照、离线、mismatch、unavailable 继续使用各自准确原因,不得统一成模糊的“内容未就绪”。 +- 展示提示与 handler 二次拦截应由同一阻断原因投影产生,避免两个位置口径漂移;不增加或删除 `decisionReady` 条件。 +- 正常 ready 不显示阻断或成功提示,四类动作按原 allowedActions 与权限可用。 + +### R3 预算快照三态保持原合同 + +- `budgetCheckStatus === "missing"` 时继续显示一次“该次提交未留存预算快照”。 +- `available` 与 `not_applicable` 必须保持零预算缺失提示,不得因本轮条件收敛被误判。 +- 预算提示位置、快照解析和费用/预算业务口径均不修改。 + +### R4 删除无消费者的旧层叠实现 + +- 删除 `.workflow-action-panel` 规则和 `.workflow-action-more` 及其 `summary/open/button` 子规则,不保留“以防万一”的休眠样式。 +- 更新 `server/tests/unit/services/paymentProcessingProgress.test.js:137` 和 `client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts:1252`,删除对旧 class/CSS 的正向要求;继续验证 `BusinessDetailActionPanel` 的既有 sticky 合同。 +- 产品代码、CSS 和结构测试中 `.workflow-action-panel`、`.workflow-action-more` 的定义/消费应为 0。 +- 不补回 class,不新增 DropdownMenu,不恢复“更多”菜单,也不改变当前常驻“转审”按钮。 + +### R5 通用审批页只做无变化回归 + +- 请假、补卡、采购、差旅仍由通用审批页渲染,1280px 下详情、处理历史和动作面板正常可见,无横向溢出或遮挡。 +- 因四类页面当前都不消费被删 CSS,只需静态零消费者证明和代表性合成页面回归;不要求不存在的菜单展开态或顶部/滚动中段十态测试。 +- 不修改通用审批页 JSX、动作排列、摘要、权限或 mutation。 + +### R6 交付与业务边界 + +- 以上改动形成一个候选、一次部署;提交后 diff 复核、push/provenance 和部署后复核按既有门禁连续推进,但不得省略任何门禁。 +- 切换前运行 `check-ui-design-rules --strict`;部署后核公网 build marker、文案、正常/失败报销动作面板及四类通用审批。 +- 不修改 API、workflow、权限、状态机、schema、migration、feature flag、seed 或生产数据。 +- 本任务不替代 `0016/0017/0018` 员工重提与财务复审;父任务 AC11 继续开放。 + +## Acceptance Criteria + +- [x] AC1:PC 正常报销审批右栏不包含“节点 / 状态 / 审批角色 / 完整内容已就绪”,动作区无空 summary 容器或分隔线;adapter 静态证明 `statusSummary` 已删除且 summary 为 `null`/省略。 +- [x] AC2:正常报销无 readiness 解释且四动作按权限可用;五类 fail-closed fixture 均显示准确原因,原因位于禁用动作紧邻上方,按钮禁用和 handler 二次拦截同时成立。 +- [x] AC3:预算 `available/not_applicable` 零缺失提示,只有 `missing` 显示一次既有文案;实现中的三态判断未改。 +- [x] AC4:产品代码、CSS 和结构测试中 `.workflow-action-panel`、`.workflow-action-more` 定义/消费均为 0;付款进度既有失败消失,sticky 动作面板合同仍有有效断言。 +- [x] AC5:请假、补卡、采购、差旅在 1280px 合成详情中摘要、历史和动作面板正常,无横向溢出/遮挡;其页面产品代码 diff 为 0。 +- [ ] AC6:报销处理历史位置、正文、退回引用、B2 未归属恢复、移动审批、离线 fail-closed 和当前转审入口均无回归。 +- [ ] AC7:通过 typecheck/build、相关 Playwright、`paymentProcessingProgress.test.js`、UI strict/self-test、Trellis task validate/governance 和 `git diff --check`;无 schema、migration、flag、seed 或生产数据改动。 + +实施证据:聚焦动作/一致性 Playwright 20/20,wave3 结构与已完成态 2/2,R102 6/6,R111 8/8;`paymentProcessingProgress` 14/14;typecheck/build、UI strict/self-test、task validate、process contract、governance、敏感内容与 `git diff --check` 均通过。移动离线专项 3 项因本机缺少 Playwright WebKit 可执行文件未进入产品流程,沿用既有环境边界,故 AC6/AC7 留待具备 WebKit 的门禁或部署复核收口。 + +## Test Plan + +- 结构:报销 adapter 无四类正常态解释和 `statusSummary`;summary 无空 DOM;旧 class/CSS/断言零残留;通用审批页源文件无改动。 +- 报销:正常 ready 负向断言;五类失败的原因顺序、四动作禁用与 handler 二次拦截;退回引用和转审现状回归。 +- 预算:`missing` 正例,`available/not_applicable` 负向断言。 +- 通用审批:请假、补卡、采购、差旅 1280px 合成页面可见性、动作可达和无横向溢出;不执行不存在的菜单展开测试。 +- 工程:前端 typecheck/build、相关 Playwright、付款进度聚焦测试、UI strict/self-test、task validate、process contract、Trellis governance 和 `git diff --check`。 +- 部署后:`/ready`、bundle marker、0 公网文案 findings;正常报销零解释/动作可用、失败原因邻近动作;四类通用审批正常;B2 与三张 rejected 单据只读状态不变。 + +## Risks and Deferred Items + +- `BusinessDetailActionPanel` 是共享组件;若为阻断原因增加动作前插槽,必须保持未注入该插槽的现有消费者 DOM 顺序不变。 +- 转审入口与现行 spec 存在漂移,但没有真实使用证据支持“恢复更多”或“隐藏”。另行确认转审使用记录后,再在独立产品任务中决定:保持常驻、进入更多或按条件隐藏。 +- 通用审批页为密集 JSX;本任务不借静态回归顺手格式化或重构。 + +## Out of Scope + +- 新增或恢复“更多”菜单、Radix DropdownMenu 或任何浮层。 +- 改变转审可见性、权限、理由规则、确认流程或 mutation。 +- 修改通用审批页布局/摘要/动作,或重做移动端动作区。 +- 重做处理历史、报销正文、预算判断、金额完整性或 B2 门禁。 +- 处理 `--no-deps`、`.gitignore`、错误色 token 或其他技术债。 diff --git a/.trellis/tasks/09-03-approval-action-panel-kiss-layering/task.json b/.trellis/tasks/09-03-approval-action-panel-kiss-layering/task.json new file mode 100644 index 00000000..d412222f --- /dev/null +++ b/.trellis/tasks/09-03-approval-action-panel-kiss-layering/task.json @@ -0,0 +1,26 @@ +{ + "id": "approval-action-panel-kiss-layering", + "name": "approval-action-panel-kiss-layering", + "title": "审批动作面板 KISS 收敛与层叠遗留清理", + "description": "收敛报销审批动作面板正常态解释和阻断提示位置,并删除已无消费者的旧更多菜单层叠样式与漂移断言。", + "status": "in_progress", + "dev_type": null, + "scope": "trellis,client,tests", + "package": null, + "priority": "P2", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-09-03", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "已按批准范围完成实施并进入 diff 复核;不恢复更多菜单、不改变转审入口。移动离线专项因本机缺少 Playwright WebKit 未执行,其他聚焦与工程门禁通过;本任务不替代父任务 AC11。", + "meta": {} +} diff --git a/client/src/components/patterns/BusinessDetailTemplate.tsx b/client/src/components/patterns/BusinessDetailTemplate.tsx index 1c08997d..64e7b621 100644 --- a/client/src/components/patterns/BusinessDetailTemplate.tsx +++ b/client/src/components/patterns/BusinessDetailTemplate.tsx @@ -83,6 +83,7 @@ function hasVisibleNode(node: ReactNode): boolean { export function BusinessDetailActionPanel({ title = "操作", status, + beforeActions, primaryAction, secondaryActions, moreActions, @@ -93,6 +94,7 @@ export function BusinessDetailActionPanel({ }: { title?: string; status?: ReactNode; + beforeActions?: ReactNode; primaryAction?: ReactNode; secondaryActions?: ReactNode; moreActions?: ReactNode; @@ -102,21 +104,23 @@ export function BusinessDetailActionPanel({ testId?: string; }) { const hasStatus = hasVisibleNode(status); + const hasBeforeActions = hasVisibleNode(beforeActions); const hasPrimaryAction = hasVisibleNode(primaryAction); const hasSecondaryActions = hasVisibleNode(secondaryActions); const hasMoreActions = hasVisibleNode(moreActions); const hasSummary = hasVisibleNode(summary); const hasChildren = hasVisibleNode(children); const hasActionContent = hasPrimaryAction || hasSecondaryActions || hasMoreActions; - const hasContentBeforeSummary = hasActionContent || hasChildren; + const hasContentBeforeSummary = hasBeforeActions || hasActionContent || hasChildren; - if (!hasStatus && !hasActionContent && !hasSummary && !hasChildren) { + if (!hasStatus && !hasBeforeActions && !hasActionContent && !hasSummary && !hasChildren) { return null; } return (
+ {hasBeforeActions ?
{beforeActions}
: null} {hasPrimaryAction ?
{primaryAction}
: null} {hasSecondaryActions ?
{secondaryActions}
: null} {hasMoreActions ?
{moreActions}
: null} diff --git a/client/src/index.css b/client/src/index.css index 881cc123..b6993a82 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -665,43 +665,10 @@ @apply border p-3.5; } - .workflow-action-panel { - @apply relative z-30; - } - .workflow-action-bar { @apply flex flex-wrap items-center gap-2; } - .workflow-action-more { - @apply relative z-40; - } - - .workflow-action-more > summary { - border: 1px solid rgba(203, 213, 225, 0.92); - background: rgba(255, 255, 255, 0.96); - color: var(--color-text-primary); - border-radius: var(--radius-sm); - @apply flex h-9 cursor-pointer list-none items-center px-3 text-sm font-medium transition-colors hover:bg-slate-50; - } - - .workflow-action-more > summary::-webkit-details-marker { - display: none; - } - - .workflow-action-more[open] > summary { - @apply bg-slate-50; - } - - .workflow-action-more > div, - .workflow-action-more > button { - border: 1px solid rgba(226, 232, 240, 0.94); - background: #fff; - border-radius: var(--radius-sm); - box-shadow: var(--shadow-md); - @apply absolute right-0 top-10 z-50 min-w-28 px-3 py-2 text-left text-sm font-medium text-zinc-800 hover:bg-slate-50; - } - .notification-inbox { @apply space-y-3; } diff --git a/client/src/lib/reimbursementApprovalReadiness.ts b/client/src/lib/reimbursementApprovalReadiness.ts new file mode 100644 index 00000000..b074273f --- /dev/null +++ b/client/src/lib/reimbursementApprovalReadiness.ts @@ -0,0 +1,27 @@ +import { reimbursementAmountIntegrityMessage, type ReimbursementAmountSummary } from "./reimbursementWorkspace"; + +export function resolveReimbursementApprovalBlockReason({ + amountIntegrity, + detailState, + online, + snapshotParseStatus, +}: { + amountIntegrity: ReimbursementAmountSummary["integrity"] | null; + detailState: "ready" | "loading" | "error"; + online: boolean; + snapshotParseStatus: string | null | undefined; +}) { + if (!online) return "报销审批需联网核对完整报销内容;当前不能通过、退回、驳回或转审。"; + if (amountIntegrity && amountIntegrity !== "ready") { + return reimbursementAmountIntegrityMessage(amountIntegrity); + } + if (snapshotParseStatus === "invalid") { + return "提交时检查记录无法解析,暂时不能审批;请联系系统管理员核对。"; + } + if (detailState === "error") return "报销内容未加载完整,暂不能审批,请刷新后重试"; + if (detailState === "loading") return "正在加载完整报销内容和票据;加载完成前不能作出审批决定。"; + if (snapshotParseStatus !== "ready") { + return "提交时检查记录无法解析,暂时不能审批;请联系系统管理员核对。"; + } + return null; +} diff --git a/client/src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx b/client/src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx index dd101f5a..68595341 100644 --- a/client/src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx +++ b/client/src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx @@ -32,11 +32,12 @@ import { SectionPanel } from "../../components/patterns/SectionPanel"; import { Button } from "../../components/ui/button"; import { Label } from "../../components/ui/label"; import { Textarea } from "../../components/ui/textarea"; -import { approvalRoleLabel, workflowActionLabel } from "../../lib/approvalDisplay"; +import { workflowActionLabel } from "../../lib/approvalDisplay"; import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../../lib/attachmentGovernance"; import { formatDisplayDateTime } from "../../lib/displayFormat"; import { buildReimbursementApprovalEvidence } from "../../lib/reimbursementApprovalProjection"; -import { buildReimbursementWorkspaceModel, reimbursementAmountIntegrityMessage } from "../../lib/reimbursementWorkspace"; +import { resolveReimbursementApprovalBlockReason } from "../../lib/reimbursementApprovalReadiness"; +import { buildReimbursementWorkspaceModel } from "../../lib/reimbursementWorkspace"; import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage"; import { isWecomWebView } from "../../lib/runtimeContext"; import { getWorkflowConfirmCopy, type WorkflowConfirmAction } from "../../lib/workflowActionConfirm"; @@ -56,20 +57,6 @@ function isAlreadyProcessedTaskError(error: unknown) { return isApiError(error) && error.details?.task === "already_processed"; } -function workflowInstanceStatusLabel(status: string | undefined | null) { - const labels: Record = { - pending: "审批中", - active: "审批中", - reviewing: "审批中", - approved: "已通过", - rejected: "已驳回", - terminated: "已终止", - cancelled: "已取消", - completed: "已完成", - }; - return status ? labels[status] ?? status : "-"; -} - function workflowTaskStatusLabel(status: string) { const labels: Record = { pending: "待处理", @@ -129,6 +116,14 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task && model.amountSummary.integrity === "ready" && snapshot?.parseStatus === "ready", ); + const projectedDecisionBlockReason = resolveReimbursementApprovalBlockReason({ + amountIntegrity: model?.amountSummary.integrity ?? null, + detailState: reimbursementDetailQuery.isError ? "error" : reimbursementDetailQuery.data ? "ready" : "loading", + online, + snapshotParseStatus: snapshot?.parseStatus, + }); + const decisionBlockReason = projectedDecisionBlockReason + ?? "完整报销内容或提交时检查尚未就绪,请重试加载后再决定。"; const allowedActions = useMemo(() => new Set(detail.allowedActions), [detail.allowedActions]); const canTransfer = allowedActions.has("transfer") && hasPermission(permissions, "expense.transfer"); const approverQuery = useQuery({ @@ -175,11 +170,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task const actionMutation = useMutation({ mutationFn: async (action: "approve" | "reject" | "return" | "transfer") => { if (!decisionReady) { - throw new Error( - model - ? reimbursementAmountIntegrityMessage(model.amountSummary.integrity) ?? "完整报销内容或提交时检查尚未就绪,暂时不能处理" - : "完整报销内容或提交时检查尚未就绪,暂时不能处理", - ); + throw new Error(decisionBlockReason); } if (action === "approve") return approveWorkflowTask(taskId); if (action === "reject") return rejectWorkflowTask(taskId, rejectComment.trim()); @@ -209,11 +200,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task function requestAction(action: WorkflowConfirmAction) { if (!decisionReady) { - setActionError(!online - ? "报销审批需联网核对完整报销内容后才能处理。" - : model - ? reimbursementAmountIntegrityMessage(model.amountSummary.integrity) ?? "完整报销内容或提交时检查尚未就绪,请重试加载后再决定。" - : "完整报销内容或提交时检查尚未就绪,请重试加载后再决定。"); + setActionError(decisionBlockReason); return; } const comment = action === "reject" ? rejectComment : action === "return" ? returnComment : ""; @@ -245,13 +232,6 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task } if (!model) { - const message = !online - ? "报销审批需联网核对完整报销内容;当前不能通过、退回、驳回或转审。" - : snapshot?.parseStatus === "invalid" - ? "提交时检查记录无法解析,暂时不能审批;请联系系统管理员核对。" - : reimbursementDetailQuery.isError - ? resolveUserErrorMessage(reimbursementDetailQuery.error, "完整报销内容加载失败,暂时不能审批。") - : "正在加载完整报销内容和票据;加载完成前不能作出审批决定。"; const disabledActions = (
@@ -262,7 +242,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task ); return (
-

{message}

+

{decisionBlockReason}

{disabledActions} {online && reimbursementDetailQuery.isError ? : null} @@ -272,25 +252,26 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task const confirmCopy = confirmAction ? getWorkflowConfirmCopy(confirmAction) : null; const actionDisabled = actionMutation.isPending || !decisionReady; - const issueAnchors = model.issueAnchors.length ? ( + const visibleIssueAnchors = model.issueAnchors.filter((issue) => issue.label !== decisionBlockReason); + const issueAnchors = visibleIssueAnchors.length ? (

需要关注

- {model.issueAnchors.map((issue) => ( + {visibleIssueAnchors.map((issue) => ( {issue.label} ))}
) : null; - const statusSummary = ( - <> -
节点:{detail.task.stepName}
-
状态:{workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus)}
-
审批角色:{approvalRoleLabel(detail.task.assigneeRole)}
-
{decisionReady ? "完整内容已就绪" : "内容未就绪,审批动作已停用"}
- - ); + const decisionBlockNotice = !decisionReady && decisionBlockReason ? ( +

+ {decisionBlockReason} +

+ ) : null; + const actionPanelBeforeActions = issueAnchors || decisionBlockNotice ? ( +
{issueAnchors}{decisionBlockNotice}
+ ) : null; const offlineNotice = !online ? (

- 报销审批需联网核对完整报销内容;当前不能通过、退回、驳回或转审。 + {decisionBlockReason}

) : null; const commentEditor = ( @@ -417,6 +398,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task const actionPanel = ( requestAction("approve")} type="button">通过 : null} secondaryActions={( <> @@ -425,10 +407,10 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task )} moreActions={canTransfer ? : null} - summary={statusSummary} + summary={null} testId="workflow-task-action-panel" > -
{issueAnchors}{commentEditor}
+ {actionError || activeSecondaryAction ?
{commentEditor}
: null}
); return ( diff --git a/client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts b/client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts index 24ad5ae9..3502ba49 100644 --- a/client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts +++ b/client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts @@ -1247,20 +1247,18 @@ test("PC 壳层与详情断点保持结构一致", async () => { expect(topbar).toContain('className="xl:hidden"'); expect(topbar).not.toContain('className="lg:hidden" size="icon" variant="secondary" aria-label="打开菜单"'); expect(detailTemplate).toContain("detail-action-panel lg:sticky lg:top-24"); - expect(detailTemplate).toMatch(/if \(!hasStatus && !hasActionContent && !hasSummary && !hasChildren\) \{\s*return null;/u); + expect(detailTemplate).toMatch(/if \(!hasStatus && !hasBeforeActions && !hasActionContent && !hasSummary && !hasChildren\) \{\s*return null;/u); expect(styles).toMatch(/\.detail-workspace-grid\s*\{[\s\S]*?lg:grid-cols-/u); - expect(styles).toMatch(/\.workflow-action-panel\s*\{[\s\S]*?relative z-30/u); }); -test("已完成审批只保留中文只读摘要", async ({ page }) => { +test("已完成审批不保留空动作面板", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 800 }); await page.goto("/workflow/tasks/task-wave3-completed"); - await expect(page.getByTestId("workflow-task-action-panel")).toBeVisible(); + await expect(page.getByTestId("workflow-task-action-panel")).toHaveCount(0); await expect(page.getByTestId("reimbursement-expense-items-table")).toBeVisible(); await expect(page.getByTestId("approval-item-evidence").getByRole("button")).toHaveCount(0); await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible(); - await expect(page.getByTestId("workflow-task-action-panel")).toContainText("审批角色:财务"); await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("2026年5月27日 18:10"); await expect(page.getByTestId("workflow-task-approve")).toHaveCount(0); await expect(page.getByTestId("workflow-task-return-toggle")).toHaveCount(0); diff --git a/client/tests/e2e/reimbursement-content-consistency.spec.ts b/client/tests/e2e/reimbursement-content-consistency.spec.ts index b824c9a3..a90e1bd9 100644 --- a/client/tests/e2e/reimbursement-content-consistency.spec.ts +++ b/client/tests/e2e/reimbursement-content-consistency.spec.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { expect, test } from "@playwright/test"; import { buildReimbursementApprovalEvidence } from "../../src/lib/reimbursementApprovalProjection"; +import { resolveReimbursementApprovalBlockReason } from "../../src/lib/reimbursementApprovalReadiness"; import { buildReimbursementAmountSummary, parseMoneyToMinorUnits, @@ -42,6 +43,53 @@ test("报销金额使用整数分判断完整性且不产生展示用第二总 }); }); +test("报销审批五类阻断原因由同一投影给出且正常态保持安静", () => { + const base = { + amountIntegrity: "ready" as const, + detailState: "ready" as const, + online: true, + snapshotParseStatus: "ready", + }; + + expect(resolveReimbursementApprovalBlockReason(base)).toBeNull(); + expect(resolveReimbursementApprovalBlockReason({ ...base, online: false })).toBe("报销审批需联网核对完整报销内容;当前不能通过、退回、驳回或转审。"); + expect(resolveReimbursementApprovalBlockReason({ ...base, detailState: "loading" })).toBe("正在加载完整报销内容和票据;加载完成前不能作出审批决定。"); + expect(resolveReimbursementApprovalBlockReason({ ...base, detailState: "error" })).toBe("报销内容未加载完整,暂不能审批,请刷新后重试"); + expect(resolveReimbursementApprovalBlockReason({ ...base, snapshotParseStatus: "invalid" })).toBe("提交时检查记录无法解析,暂时不能审批;请联系系统管理员核对。"); + expect(resolveReimbursementApprovalBlockReason({ ...base, snapshotParseStatus: undefined })).toBe("提交时检查记录无法解析,暂时不能审批;请联系系统管理员核对。"); + expect(resolveReimbursementApprovalBlockReason({ ...base, amountIntegrity: "mismatch" })).toBe("报销总额与明细汇总不一致,请联系管理员"); + expect(resolveReimbursementApprovalBlockReason({ ...base, amountIntegrity: "unavailable" })).toBe("明细金额无法完整核对,请联系管理员"); +}); + +test("动作面板复用唯一阻断投影且预算三态判断保持原合同", () => { + const adapter = source("src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx"); + + expect(adapter.match(/resolveReimbursementApprovalBlockReason\(/g)).toHaveLength(1); + expect(adapter).toContain("throw new Error(decisionBlockReason"); + expect(adapter).toContain("setActionError(decisionBlockReason"); + expect(adapter).toContain("{decisionBlockReason}"); + expect(adapter.match(/snapshot\?\.budgetCheckStatus === "missing"/g)).toHaveLength(3); + expect(adapter).not.toContain("完整内容已就绪"); + expect(adapter).not.toContain("内容未就绪,审批动作已停用"); + expect(adapter).not.toContain("statusSummary"); + expect(adapter).toContain("actionError || activeSecondaryAction ?"); +}); + +test("旧审批层叠 class 与更多菜单样式无定义或消费者", () => { + const styles = source("src/index.css"); + const productSources = [ + source("src/components/patterns/BusinessDetailTemplate.tsx"), + source("src/pages/WorkflowTaskDetailPage.tsx"), + source("src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx"), + ].join("\n"); + + for (const legacyClass of ["workflow-action-panel", "workflow-action-more"]) { + expect(styles).not.toContain(legacyClass); + expect(productSources).not.toContain(legacyClass); + } + expect(productSources).toContain("detail-action-panel lg:sticky lg:top-24"); +}); + test("PC 与移动明细只展示共享模型中的服务端权威合计", () => { const evidenceList = source("src/components/reimbursements/ReimbursementItemEvidenceList.tsx"); const desktopDetail = source("src/pages/ReimbursementDetailPage.tsx"); diff --git a/client/tests/e2e/workflow-action-confirm-ui.spec.ts b/client/tests/e2e/workflow-action-confirm-ui.spec.ts index 145d5d9f..85a13d7d 100644 --- a/client/tests/e2e/workflow-action-confirm-ui.spec.ts +++ b/client/tests/e2e/workflow-action-confirm-ui.spec.ts @@ -358,6 +358,77 @@ test.describe("R7E 审批动作二次确认", () => { await expect.poll(() => actionCalls.return).toBe(1); }); + for (const scenario of [ + { + entityType: "leave", + label: "请假", + detail: { leaveRequest: pendingTaskDetail.leaveRequest }, + expectedText: "R7E 审批动作确认", + }, + { + entityType: "attendance_correction", + label: "补卡", + detail: { + attendanceCorrection: { + id: "attendance-confirm-1", status: "reviewing", kind: "missed_punch", workDate: "2026-09-02", + missedPunchAt: "09:05", startAt: null, endAt: null, locationSummary: "公司", compensationPolicy: "按实际打卡修正", + reason: "忘记打卡", owner: { id: "employee-confirm-applicant", employeeNo: "E-confirm", name: "陈工程" }, + }, + }, + expectedText: "忘记打卡", + }, + { + entityType: "purchase_request", + label: "采购", + detail: { + purchaseRequest: { + id: "purchase-confirm-1", requestNo: "CG-CONFIRM-001", title: "办公用品采购", status: "reviewing", + totalAmount: "800.00", currencyCode: "CNY", expectedDeliveryDate: "2026-09-10", reason: "补充日常库存", + applicant: { id: "employee-confirm-applicant", employeeNo: "E-confirm", name: "陈工程" }, + supplier: { id: "supplier-confirm", name: "合成供应商" }, legalEntity: { id: "entity-confirm", name: "示例公司" }, + costCenter: { id: "cost-center-confirm", name: "研发中心" }, + }, + }, + expectedText: "办公用品采购", + }, + { + entityType: "travel_request", + label: "差旅", + detail: { + travelRequest: { + id: "travel-confirm-1", requestNo: "CL-CONFIRM-001", status: "reviewing", tripType: "domestic", + startDate: "2026-09-10", endDate: "2026-09-12", estimatedAmount: "1800.00", purpose: "客户现场交流", + applicant: { id: "employee-confirm-applicant", employeeNo: "E-confirm", name: "陈工程" }, + legalEntity: { id: "entity-confirm", name: "示例公司" }, costCenter: { id: "cost-center-confirm", name: "研发中心" }, + }, + }, + expectedText: "客户现场交流", + }, + ]) { + test(`1280px ${scenario.label}审批保持通用详情与动作可达`, async ({ page }) => { + const taskDetail = { + ...pendingTaskDetail, + workflowInstance: { ...pendingTaskDetail.workflowInstance, entityType: scenario.entityType }, + reimbursement: null, + leaveRequest: null, + attendanceCorrection: null, + purchaseRequest: null, + travelRequest: null, + ...scenario.detail, + }; + await mockWorkflowConfirmApis(page, { taskDetail }); + await page.setViewportSize({ width: 1280, height: 800 }); + await login(page, MANAGER); + await page.goto(`/workflow/tasks/${pendingTask.id}`); + + await expect(page.getByTestId("workflow-task-summary")).toContainText(scenario.expectedText); + await expect(page.getByRole("heading", { name: "处理历史" })).toBeVisible(); + await expect(page.getByTestId("workflow-task-action-panel")).toBeVisible(); + await expect(page.getByTestId("workflow-task-approve")).toBeEnabled(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2)).toBe(true); + }); + } + test("PC 报销审批同屏展示证据、引用退回意见且不暴露维护动作", async ({ page }) => { const reimbursementId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; const taskDetail = { @@ -437,13 +508,18 @@ test.describe("R7E 审批动作二次确认", () => { await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("¥180.00"); await expect(page.getByText("报销总额与明细汇总不一致,请联系管理员")).toHaveCount(0); await expect(page.getByText("明细金额无法完整核对,请联系管理员")).toHaveCount(0); + const actionPanel = page.getByTestId("workflow-task-action-panel"); + await expect(actionPanel.getByText("完整内容已就绪", { exact: true })).toHaveCount(0); + await expect(actionPanel.getByText(/^节点:/)).toHaveCount(0); + await expect(actionPanel.getByText(/^状态:/)).toHaveCount(0); + await expect(actionPanel.getByText(/^审批角色:/)).toHaveCount(0); await expect(page.getByTestId("workflow-task-approve")).toBeEnabled(); await expect(page.getByTestId("workflow-task-return-toggle")).toBeEnabled(); await expect(page.getByTestId("workflow-task-reject-toggle")).toBeEnabled(); await expect(page.getByTestId("workflow-task-transfer-menu-item")).toBeEnabled(); await expect(page.getByTestId("reimbursement-workflow-history").locator(".detail-timeline-item")).toHaveCount(6); await expect(page.getByRole("heading", { name: "处理历史" })).toHaveCount(1); - const actionBox = await page.getByTestId("workflow-task-action-panel").boundingBox(); + const actionBox = await actionPanel.boundingBox(); const historyBox = await page.getByTestId("reimbursement-workflow-history").boundingBox(); expect(actionBox).not.toBeNull(); expect(historyBox).not.toBeNull(); @@ -474,7 +550,7 @@ test.describe("R7E 审批动作二次确认", () => { workflowInstance: { ...pendingTaskDetail.workflowInstance, entityType: "reimbursement", - contextData: { standardViolations: [], budgetCheck: null, budgetCheckStatus: "not_applicable", parseStatus: "ready" }, + contextData: { standardViolations: [], budgetCheck: null, budgetCheckStatus: "available", parseStatus: "ready" }, }, reimbursement: { id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", formNumber: "BX-FAIL-001", status: "reviewing", @@ -487,7 +563,7 @@ test.describe("R7E 审批动作二次确认", () => { await login(page, MANAGER); await page.goto(`/workflow/tasks/${pendingTask.id}`); - await expect(page.getByRole("alert")).toContainText("服务暂不可用"); + await expect(page.getByRole("alert")).toContainText("报销内容未加载完整,暂不能审批,请刷新后重试"); await expect(page.getByTestId("workflow-task-approve")).toBeDisabled(); await expect(page.getByTestId("workflow-task-return-toggle")).toBeDisabled(); await expect(page.getByTestId("workflow-task-reject-toggle")).toBeDisabled(); @@ -501,7 +577,7 @@ test.describe("R7E 审批动作二次确认", () => { workflowInstance: { ...pendingTaskDetail.workflowInstance, entityType: "reimbursement", - contextData: { standardViolations: [], budgetCheck: null, budgetCheckStatus: "not_applicable", parseStatus: "ready" }, + contextData: { standardViolations: [], budgetCheck: null, budgetCheckStatus: "available", parseStatus: "ready" }, }, reimbursement: { id: reimbursementId, @@ -549,6 +625,7 @@ test.describe("R7E 审批动作二次确认", () => { await page.goto(`/mobile/approvals/${pendingTask.id}`); await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("¥100.00"); + await expect(page.locator("#reimbursement-workspace-snapshot")).toHaveCount(0); await expect(page.getByTestId("reimbursement-workflow-history").locator(".detail-timeline-item")).toHaveCount(6); const assertMobileGeometry = async () => { expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2)).toBe(true); @@ -627,7 +704,15 @@ test.describe("R7E 审批动作二次确认", () => { await page.goto(`/workflow/tasks/${pendingTask.id}`); await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("¥100.00"); - await expect(page.getByRole("alert").filter({ hasText: scenario.expectedMessage })).toBeVisible(); + await expect(page.locator("#reimbursement-workspace-snapshot")).toHaveCount(0); + const blockReason = page.getByTestId("reimbursement-approval-block-reason"); + await expect(blockReason).toHaveText(scenario.expectedMessage); + const primaryAction = page.getByTestId("workflow-task-approve"); + const blockReasonBox = await blockReason.boundingBox(); + const primaryActionBox = await primaryAction.boundingBox(); + expect(blockReasonBox).not.toBeNull(); + expect(primaryActionBox).not.toBeNull(); + expect(blockReasonBox!.y + blockReasonBox!.height).toBeLessThanOrEqual(primaryActionBox!.y); await expect(page.getByText("差额", { exact: true })).toHaveCount(0); await expect(page.getByTestId("workflow-task-approve")).toBeDisabled(); await expect(page.getByTestId("workflow-task-return-toggle")).toBeDisabled(); diff --git a/server/tests/unit/services/paymentProcessingProgress.test.js b/server/tests/unit/services/paymentProcessingProgress.test.js index ac5d85e9..8e57c37f 100644 --- a/server/tests/unit/services/paymentProcessingProgress.test.js +++ b/server/tests/unit/services/paymentProcessingProgress.test.js @@ -123,21 +123,7 @@ describe("paymentProcessingProgress", () => { path.resolve(__dirname, "../../../../client/src/components/patterns/BusinessDetailTemplate.tsx"), "utf8", ); - const workflowSource = fs.readFileSync( - path.resolve(__dirname, "../../../../client/src/pages/WorkflowTaskDetailPage.tsx"), - "utf8", - ); - const cssSource = fs.readFileSync( - path.resolve(__dirname, "../../../../client/src/index.css"), - "utf8", - ); - const workflowPanelRule = cssSource.match(/\.workflow-action-panel\s*\{([^}]*)\}/)?.[1] ?? ""; - expect(templateSource).toContain("detail-action-panel lg:sticky lg:top-24"); - expect(workflowSource).toContain('className="workflow-action-panel"'); - expect(workflowSource).not.toMatch(/className="workflow-action-panel[^"]*lg:top-/); - expect(workflowPanelRule).toContain("@apply relative z-30;"); - expect(workflowPanelRule).not.toMatch(/lg:(?:sticky|top-)/); }); }); const fs = require("node:fs");