[CLAUDE] FE-Admin+Docs: PE workflow N-stage Designer + UsersPage cấp + Docs (Chunk F)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m0s

FE Admin:
- types/users.ts: User +positionLevel field + PositionLevel const +
  PositionLevelLabel/Short maps (NV/PP/TP).
- PeWorkflowsPage.tsx Designer extend: InnerStepDto + EditInnerStep types,
  copyFromDefinition include, departmentsList query, sub-section "Cấp duyệt
  nhỏ trong phòng" per step card với drag-list { Phòng × Cấp + required }
  + button "+ Thêm cấp duyệt" emerald + payload include (Order asc).
  Empty state hint fallback 2-cấp legacy.
- UsersPage.tsx: column "Cấp" badge NV/PP/TP emerald (— nếu null) +
  action button cycle null→1→2→3→null call PATCH /users/{id}/position-level.

KHÔNG đụng fe-user — admin-only feature (PeWorkflowsPage + UsersPage ở
fe-admin only).

Docs:
- STATUS.md Last updated + Phase summary count (17→19 mig, 83→89 test,
  55→56 bảng) + 1 row Recently Done Session 12 (KEEP narrative cũ).
- HANDOFF.md TL;DR Session 12 prepend + 8 cảnh báo Session 13+ + giữ
  Session phase 2 narrative.
- migration-todos.md Phase 9 + Session 12 block 6 chunk + 5 defer task.
- session log NEW `2026-05-07-2300-n-stage-workflow.md` đầy đủ rationale
  + per-chunk + bug log + plan hierarchy.

Defer cron audit 2026-06-01: schema-diagram §15 Mig 18 + §16 Mig 19,
skill ef-core-migration Mig 18+19 row, skill contract-workflow N-stage
cross-ref section.

Verify:
- npm run build fe-admin pass (✓ built, 0 TS error)
- dotnet test 89 pass (no regression)
- dotnet build 0 error

🎉 SESSION 12 COMPLETE: N-stage workflow approval Phòng × PositionLevel
PE-only. Backward compat 100% với 2-stage Mig 16. 6 commit per-chunk
A→F. Total 89 test pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-07 18:32:56 +07:00
parent 83ffabd0b5
commit 5e5042d717
7 changed files with 558 additions and 9 deletions

View File

@ -1,6 +1,60 @@
# HANDOFF — Brief 5 phút cho session tiếp theo
**Last updated:** 2026-05-08 00:30 (Session phase 2 wrap-up — **B12-B14 PE detail polish iterate. 3 commit FE-only. 26 commit total session 2026-05-07. 83 test pass. UAT iter mode active.**)
**Last updated:** 2026-05-07 (Session 12 — **N-stage workflow approval Phòng × PositionLevel cấu hình động — Mig 18+19. 6 commit per-chunk: Domain → App CQRS → Service logic → Tests +6 → API → FE Designer + UsersPage. 89 test pass. PE-only first.**)
## TL;DR Session 12 (07/05 — N-stage workflow approval per phase × dept × cấp)
User yêu cầu mở rộng từ 2-stage Mig 16 sang N-stage cấu hình động: 1 phase (WorkflowStep cha) có thể cấu hình chuỗi InnerSteps con theo Department × PositionLevel sequential. Mỗi phòng có NV → PP → TP duyệt thứ tự, có thể bypass cùng dept khi role cao + CanBypassReview.
**6 câu spec defaults chốt:**
- Q1 enum `PositionLevel { NhanVien=1, PhoPhong=2, TruongPhong=3 }` + `User.PositionLevel int?`
- Q2 Sequential pure (Order asc, mỗi inner step = 1 cấp duyệt)
- Q3 TP có CanBypassReview → skip NV+PP cùng dept (audit IsBypassed=true cho cấp dưới)
- Q4 Smart reject về DangSoanThao + RejectedFromPhase + clear N-stage rows tại phase reject
- Q5 PE first (Contract+Budget mirror sau khi PE stable UAT)
- Q6(a) Designer UI 1 sub-section "Cấp duyệt nhỏ trong phòng" drag-list per step
**6 chunk per-commit:**
- **Chunk A (`13ab533`)** Domain + Migration 18 — enum PositionLevel + entity InnerStep + ALTER User.PositionLevel + ALTER PEDeptApproval.InnerStepId + EF config Cascade/Restrict + 3-file rule
- **Chunk B (`0e56bd0`)** Application CQRS DTO — extend PeWorkflowStepDto + InnerStepDto + Validator + Handler atomic batch + UserDto +PositionLevel + SetUserPositionLevelCommand (default null backward compat existing PeWorkflowAdminTests)
- **Chunk C (`0c62e24`)** Service N-stage logic + **Migration 19** filtered unique (drop UNIQUE Mig 16 → recreate `WHERE InnerStepId IS NULL` legacy + new `WHERE InnerStepId IS NOT NULL` N-stage). Refactor TransitionAsync: load InnerSteps eager + reject clear rows + hasInnerSteps→N-stage / else→legacy 2-stage. Match firstPending Order asc, exact match upsert 1 row, bypass batch upsert NV+PP+TP audit IsBypassed
- **Chunk D (`3d76c6b`)** 6 test PE N-stage (FirstInner blocks / 3-level sequential pass / TP bypass skips / wrong dept 403 / reject clears rows / legacy fallback no inner). IdentityFixture extend +positionLevel arg. Helper SeedWorkflowDefinitionAsync 2 step adjacent. **83→89 test pass**
- **Chunk E (`83ffabd`)** API `PATCH /users/{id}/position-level` mirror SetBypassReview
- **Chunk F (current)** FE-Admin types/users.ts +positionLevel + PositionLevel const + Label/Short. PeWorkflowsPage Designer + sub-section InnerSteps drag-list { Phòng × Cấp + required } + button "+ Thêm cấp duyệt" emerald + departmentsList query + payload include. UsersPage column "Cấp" badge + cycle button. KHÔNG đụng fe-user (admin-only). Docs/Skill update.
**Verify:** dotnet build pass + dotnet ef database update Mig 18+19 LocalDB applied + dotnet test 89 pass + npm build fe-admin pass.
**Cumulative sau Session 12:**
| | Trước S12 | Sau S12 |
|---|---:|---:|
| BE LOC | ~14850 | ~15300 (+450 — Domain enum + entity + EF config + Service N-stage + App CQRS) |
| Migrations | 17 | **19** (+Mig 18 + Mig 19) |
| DB tables | 55 | **56** (+1 PEWorkflowStepInnerSteps) |
| DB columns mới | — | +2 (User.PositionLevel + PEDeptApproval.InnerStepId) |
| API endpoints | ~133 | **~134** (+1 PATCH /users/{id}/position-level) |
| FE pages | 32 | 32 (no change — extend existing 2 page) |
| Tests | 83 | **89** (+6 N-stage PE) |
| Commits | (after S11+++) | **+6** (A→F per-chunk) |
## ⚠️ CẢNH BÁO Session 13+
1. **N-stage chỉ áp PE first** — Contract + Budget vẫn 2-stage Mig 16 cũ. Mirror sau khi UAT PE 2-3 tuần ổn (giữ pattern, lặp lại Domain entity + Service logic + Tests).
2. **User.PositionLevel chưa seed cho 30 demo user hiện có** — admin phải set tay qua UsersPage cycle button. Hoặc seed migration sau (mapping Position text → PositionLevel int).
3. **Designer InnerSteps optional** — workflow definition KHÔNG có InnerSteps fallback logic 2-stage Mig 16 cũ. Backward compat 100%. Khi muốn enable N-stage cho 1 phase: clone version + add inner steps + Save.
4. **Bypass kế thừa cùng dept only** — TP với CanBypassReview chỉ skip NV+PP CÙNG dept của TP. KHÔNG cross-dept (NV.A bypass không skip NV.B).
5. **Reset N-stage rows khi reject** — clear chỉ rows tại phase reject. Phase trung gian khác vẫn giữ approvals nếu có. Resume sẽ jump tới RejectedFromPhase + cần re-approve N-stage tại phase đó.
6. **Wrong-level error message** — đã localize "phòng X cấp Y không khớp". UAT user cần feedback nếu confused.
7. **schema-diagram §15 Mig 18 + §16 Mig 19** chưa update — defer cho audit cron 2026-06-01 (small drift, không major).
8. **Skill `ef-core-migration` Mig 18+19 row** chưa add — defer audit 2026-06-01.
## TL;DR Session phase 2 (08/05 — B12-B14 polish iterate sau wrap-up `6e7a6db`)

View File

@ -2,9 +2,9 @@
> **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`.
**Last updated:** 2026-05-08 00:30 (Session phase 2 wrap-up — **B12-B14 PE detail polish: Lưu no-close + Xóa phiếu soft-delete + header simplify + NCC col name + winner column highlight + loading overlay/spinner + InfoTab auto re-edit. 3 commit FE-only. 26 commit total session 2026-05-07.**)
**Last updated:** 2026-05-07 (Session 12 — **N-stage workflow approval Phòng × PositionLevel cấu hình động (Mig 18+19): Domain enum + entity + EF + Migration → App CQRS DTO + UpdateUserPositionLevel → Service logic N-stage + legacy 2-stage fallback + smart reject reset → 6 test mới → API endpoint → FE Designer InnerSteps sub-section + UsersPage cột "Cấp". 6 commit per-chunk.**)
## 📍 Phase hiện tại: **Phase 9 active — UAT** — **55 DB tables, 17 migrations, ~133 API endpoints, 32 FE pages. 83 unit test pass** (54 Domain + 29 Infra). 41 gotcha. 30 demo user. 6 skill. **5 PE display status** (Bản nháp / Đã gửi duyệt / Trả lại / Đã duyệt / Từ chối). **9 PE phase enum** (+TraLai = 98).
## 📍 Phase hiện tại: **Phase 9 active — UAT** — **56 DB tables, 19 migrations, ~134 API endpoints, 32 FE pages. 89 unit test pass** (54 Domain + 35 Infra). 41 gotcha. 30 demo user. 6 skill. **5 PE display status** (Bản nháp / Đã gửi duyệt / Trả lại / Đã duyệt / Từ chối). **9 PE phase enum** (+TraLai = 98). **N-stage workflow** Phòng × PositionLevel (NV/PP/TP) cấu hình động per WorkflowStep cha — Mig 18+19.
### 🌐 Production URLs
@ -61,6 +61,7 @@
| Ngày | Ai | Task | Commit |
|---|---|---|---|
| 2026-05-07 | Claude | **🎯 SESSION 12 — N-stage workflow approval Phòng × PositionLevel cấu hình động (PE-only first, 6 commit per-chunk + Mig 18+19)** — User yêu cầu mở rộng từ 2-stage Mig 16 (NV.Review/TPB.Confirm) sang N-stage cấu hình động: mỗi WorkflowStep cha (= 1 phase) có thể cấu hình chuỗi InnerSteps con theo Department × PositionLevel với Order sequential. Spec defaults chốt 6 câu (PositionLevel int 1=NV/2=PP/3=TP, sequential pure, bypass cùng dept TP skip NV+PP, smart reject reset N-stage rows về DangSoanThao, PE-only first, designer 1 sub-section InnerSteps). 6 chunk per-commit (build + ef + test pass mỗi chunk per `feedback_per_chunk_commit.md`): **Chunk A (`13ab533`)** Domain enum `PositionLevel` (NV/PP/TP) + entity `PurchaseEvaluationWorkflowStepInnerStep` + ALTER User.PositionLevel int? + ALTER PEDeptApprovals.InnerStepId Guid? + EF config + **Migration 18** `AddPeWorkflowInnerStepsAndPositionLevel` (1 CREATE TABLE + 2 ALTER + 3 index + FK Cascade Step / Restrict Dept/InnerStep). 3-file rule. **Chunk B (`0e56bd0`)** Application CQRS DTO — `PeWorkflowStepInnerStepDto` + extend `PeWorkflowStepDto` + `CreatePeWorkflowStepInnerStepInput` (default null backward compat existing PeWorkflowAdminTests) + Validator child rules + Handler atomic batch insert + UserDto +PositionLevel field + `SetUserPositionLevelCommand` mirror SetBypassReview. **Chunk C (`0c62e24`)** Service N-stage logic — **Migration 19** `AlterPeDeptApprovalsUniqueFilteredForInnerSteps` (filtered unique: legacy `WHERE InnerStepId IS NULL` + N-stage `WHERE InnerStepId IS NOT NULL`) cho phép multi-row cùng dept khác inner step. PurchaseEvaluationWorkflowService refactor: load definition InnerSteps eager + reject branch clear N-stage rows + dept block split hasInnerSteps→N-stage logic / else→legacy 2-stage. N-stage flow: yêu cầu actor có DeptId+PositionLevel, match firstPending (Order asc IsRequired) same dept + (exact level OR canBypass + level≥), exact match upsert 1 row InnerStepId, bypass batch upsert NV+PP+TP cùng dept ≤ actor level (audit IsBypassed cho cấp dưới skip), recheck stillPending → BLOCK + log "duyệt cấp X (còn Y pending)" / all done → fall through phase transition. Backward compat: workflow no InnerSteps fallback legacy + InnerStepId=null filter unique cũ vẫn enforce. **Chunk D (`3d76c6b`)** Tests N-stage 6 test mới (NV first blocks / 3-level sequential pass / TP bypass skips / wrong dept throws 403 / reject clears rows / legacy fallback no inner) + IdentityFixture extend `+positionLevel` arg + helper SeedWorkflowDefinitionAsync 2 step adjacent (ChoPurchasing+ChoCCM) cho FromDefinition build transition policy guard pass. Total 83→**89 test pass**. **Chunk E (`83ffabd`)** API `PATCH /users/{id}/position-level` mirror SetBypassReview pattern + body `{positionLevel:int?}` Authorize Users.Update. **Chunk F (current)** FE-Admin types/users.ts +positionLevel field + PositionLevel const + Label/Short maps. PeWorkflowsPage Designer extend: InnerStepDto type + EditInnerStep type + copyFromDefinition include + departmentsList query + sub-section "Cấp duyệt nhỏ trong phòng" per step card với drag-drop list { Phòng × Cấp + required checkbox } + button "+ Thêm cấp duyệt" (xanh emerald) + payload include innerSteps Order asc. UsersPage column "Cấp" badge NV/PP/TP emerald + action button cycle null→1→2→3→null call positionLevelMut PATCH. KHÔNG đụng fe-user (admin-only feature). Docs/Skill update. PE-only first. Backward compat 100%: workflow no InnerSteps + data legacy 2-stage rows không phá. | `13ab533` (A) · `0e56bd0` (B) · `0c62e24` (C) · `3d76c6b` (D) · `83ffabd` (E) · (current F) |
| 2026-05-08 00:30 | Claude | **🎯 SESSION PHASE 2 WRAP-UP — B12-B14 PE detail polish iterate (3 commit FE-only sau wrap-up `6e7a6db`)** — User UAT iteration tiếp, áp rule strict verify khi rename/remove (lesson hotfix CI). 3 batch nhỏ: **B12 (`378c993`)** "Lưu" no-close (chỉ toast + invalidate, KHÔNG đóng workspace) + nút "Xóa phiếu" red bottom CHỈ Bản nháp (soft-delete `IsDeleted=true` qua AuditableEntity, không xóa hoàn toàn DB) + bỏ header bar workspace mode "Sửa header"/"Xóa"/"Đóng" (chuyển hết xuống bottom action bar) + Section 4 column header dùng `s.supplierName` thay `displayName` (NCC master) + Section 3 row chặn xóa NCC khi đã có quotes (`hasQuotes` computed) + tooltip "xóa báo giá trước". **B13 (`e320027`)** InfoTab `useEffect` watch `[autoEdit, canEdit, ev.id, ...]` → re-trigger edit mode khi pencil click phiếu khác (fix useState mount-time only) + sync values từ ev mới (tránh stale state) + Pencil "sáng lên" active state khi `editingRowId === p.id` (bg-brand-100 + text-brand-700 + ring-brand-300 + shadow-sm + tooltip cập nhật) + wire `editingRowId={autoEditHeader ? selectedId : null}` từ Workspace → PeListPanel. **B14 (`d2306b8`)** QuoteDialog bỏ checkbox "Chọn NCC này cho hạng mục" (consolidate winner ở Section 2.a NccSelectorRow, isSelected vẫn gửi BE giữ trạng thái cũ) + winner column Section 4 matrix highlight emerald (header `bg-emerald-50` + `✓ ` prefix + cells `bg-emerald-50 font-semibold` cho ENTIRE column, không chỉ cell có quote) + loading overlay full-screen QuoteDialog (`bg-white/70 backdrop-blur-sm` + spinner ring brand-600 + text "Đang lưu báo giá…"/"Đang xóa…") + NccSelectorRow inline spinner "Đang chọn NCC + sync cột giá Section 4…" + disable Hủy/Xóa/Lưu buttons khi `isSaving`. Verify: `npm run build` × 2 app pass mỗi commit · `dotnet test` 83 pass (KHÔNG regression). | `378c993` (B12) · `e320027` (B13) · `d2306b8` (B14) |
| 2026-05-07 | Claude | **🎯 SESSION WRAP-UP S10-11+++++++ — PE Workspace UX overhaul đầy đủ (23 commit / ~3500 LOC FE + Mig 17 BE)** — User UAT live mode iterate liên tục, áp rule `feedback_uat_skip_verify` (skip dotnet test sau mỗi chunk, push ngay). 7 batch chính: **B1 (S10) PE Thao tác 2-panel workspace** — leaf `Pe_*_Create` từ page Create header riêng → workspace 2-panel `[320px_1fr]` mirror HĐ Thầu phụ; PeListPanel pure picker + sticky "+ Thêm mới"; PeDetailTabs `mode='workspace'` ẩn Workflow/Approvals/History + Section 5 disabled "nhập khi duyệt" (4 commit). **B2 (S11) Migration 17** `AddManualBudgetFieldsToPeAndContract` — 4 ALTER (PE + HĐ × `BudgetManualName` nvarchar(200) + `BudgetManualAmount` decimal(18,2)) cho fallback "user nhập tay khi không link Budget entity approved". Domain + EF config + App CQRS Create/Update + DTO + Validator + carry-forward `CreateContractFromEvaluation`. FE toggle "Nhập tay" trong PeHeaderForm + ContractCreatePage NewForm/EditForm × 2 app (5 commit). **B3 (S11+) BudgetFieldRow inline editor** — Section 2 "b. Ngân sách" thay FormRow tĩnh → editable component (toggle + Select OR 2 input + Save dirty + Hủy). canEdit cho cả 3 view (Workspace/Danh sách/Duyệt mode), readOnly chỉ display (3 commit). **B4 (S11++) InfoTab inline edit + PeListPanel pencil hover** — Section 1 "✎ Sửa" button flip display↔inputs (Tên/Địa điểm/Mô tả/Payment editable, Dự án locked). PeListPanel thêm pencil icon group-hover absolute right + URL `?editHeader=1` chain → `autoEditHeader` prop trigger mount-time edit (3 commit). **B5 (S11+++) Workspace "new" sectioned create view**`PeWorkspaceCreateView.tsx` ~230 LOC layout 5 sections giống PeDetailTabs visual. S1 + S2.b editable, S3-5 LockedHint "Lưu phiếu trước". POST trigger create. Replace `PeHeaderForm` trong workspace mode='new' (1 commit). **B6 (S11++++) Danh sách disable toàn bộ interactions** — PurchaseEvaluationsListPage `readOnly={true}` hardcoded cho PeDetailTabs + `readOnly={!pendingMe}` cho PeWorkflowPanel (List view → ẩn Chuyển tiếp + show hint "Vào menu Duyệt"; Pending vẫn approve được) (2 commit). **B7 (S11+++++) Lock Loại quy trình + payment preset** — workspace `<Select>` Loại quy trình → `<Input disabled>` theo URL `?type=N`. `<Textarea>` JSON Điều khoản TT → `<Select>` 8 preset Việt + "Khác (nhập tay)" → text input fallback (1 commit). **B8 (S11++++++) Display status meta**`PeDisplayStatus` enum 4-5 trạng thái UI (Bản nháp / Đã gửi duyệt / Trả lại / Đã duyệt / Từ chối) gom phase chi tiết. `getPeDisplayStatus()` helper. Workflow timeline Panel 3 vẫn giữ phase chi tiết (1 commit). **B9 (S11+++++++) Phase TraLai + pencil always visible + edit gating** — Domain `PurchaseEvaluationPhase` thêm `TraLai = 98` (giữa DaDuyet=7 + TuChoi=99). FE label/color + display status badge. `isEditablePhase()` helper: chỉ DangSoanThao + TraLai. PeListPanel pencil bỏ hover-only → LUÔN visible (bright khi editable / xám disabled khi không) + click guard. Workspace `editableOnly` filter client-side (1 commit). **B10 (hotfix CI)** — TS strict catch fail 2 commit B7+B8 do skip-verify (`forcedPhase` rename quên xóa destructuring args + unused `PurchaseEvaluationType` import). Update memory `feedback_uat_skip_verify.md` thêm exception "rename/remove → BẮT BUỘC `npm run build`" (1 commit). **B11 (last) PE detail polish**`NccSelectorRow` Section 2.a thay FormRow tĩnh → Select dropdown từ ev.suppliers (Section 3 list) wire `/select-winner` API. Section 2.c text rõ "(chọn NCC trước)" / "(chưa nhập báo giá)". Section 3 row khi `isWinner` → ẩn ✏ + 🗑 (chỉ giữ ✓ active). Bottom action bar workspace mode: **2 nút "Lưu (đóng)" + "Lưu & Gửi Duyệt →"** confirm dialog → POST `/transitions` với `targetPhase = first nextPhase` skip TuChoi/TraLai → workflow chuyển từ Bản nháp/Trả lại → Đã gửi duyệt (ChoPurchasing) → onBack đóng workspace (1 commit). | `ee0d360` (S10 C1) → `4c0625c` (B11) — 23 commit total |
| 2026-05-07 | Claude | **PE InfoTab inline edit Section 1 + PeListPanel pencil edit hover** — User feedback 2026-05-07: muốn thêm nút edit kế bên row trong Panel 1, click sáng nội dung Section 1 lên cho user sửa header inline (KHÔNG cần đi "Sửa header" page). 2 chunk per-commit (build pass mỗi chunk): C1 fe-admin (3 file) — InfoTab thêm prop `readOnly + autoEdit`, canEdit=`!readOnly && isDraft`: display mode hiển thị FormRow + button "✎ Sửa" góc trên phải, editing mode card border brand-200 + 4 input (Tên */Dự án locked/Địa điểm/Mô tả/Payment) + Save (PUT /pe/:id full payload + invalidate detail+list)/Hủy. PeListPanel thêm prop `onEditClick`, pencil icon absolute right-2 top-2 mỗi row, opacity-0 group-hover:opacity-100. PurchaseEvaluationWorkspacePage đọc URL `?editHeader=1` → pass `autoEditHeader` xuống PeDetailTabs → trigger edit auto. C2 fe-user mirror y hệt 3 file (rule §3.9). KHÔNG đụng BE. KHÔNG refactor workspace "new" mode (defer — PeHeaderForm hiện tại đủ dùng, làm thêm khi user feedback). | `5a89dd2` (C1) · (current C2) · (current C3 docs) |

View File

@ -157,6 +157,26 @@ Session log: `2026-04-28-chot-session-4-budget.md`.
## 📝 Phase 9 — UAT + Ops + carry over (Session 6+ active)
### ✅ Session 12 done (2026-05-07) — N-stage workflow approval (Mig 18+19, 6 commit per-chunk, PE-only)
User yêu cầu: workflow level cha (= phase) cấu hình được level con (Phòng × Cấp NV/PP/TP) sequential, mỗi cấp = 1 inner step duyệt riêng, có bypass cùng dept. 6 spec defaults chốt + 6 chunk per-commit.
- [x] **Chunk A (`13ab533`)** Domain + Migration 18 `AddPeWorkflowInnerStepsAndPositionLevel` — enum PositionLevel (NV/PP/TP), entity PurchaseEvaluationWorkflowStepInnerStep + nav, User.PositionLevel int? + PEDeptApproval.InnerStepId Guid?. EF config FK Cascade Step / Restrict Dept+InnerStep. 3-file rule.
- [x] **Chunk B (`0e56bd0`)** Application CQRS DTO — PeWorkflowStepInnerStepDto + extend PeWorkflowStepDto + CreatePeWorkflowStepInnerStepInput (default null backward compat) + Validator child rules + Handler atomic batch insert + UserDto +PositionLevel + SetUserPositionLevelCommand mirror SetBypassReview pattern.
- [x] **Chunk C (`0c62e24`)** Service N-stage logic + **Migration 19** `AlterPeDeptApprovalsUniqueFilteredForInnerSteps` (filtered unique legacy `WHERE InnerStepId IS NULL` + new N-stage `WHERE InnerStepId IS NOT NULL`). PurchaseEvaluationWorkflowService refactor — load InnerSteps eager, reject clear N-stage rows tại fromPhase, dept block split hasInnerSteps→N-stage / else→legacy 2-stage. N-stage: match firstPending (Order asc IsRequired) same dept + (exact level OR canBypass + level≥), exact upsert 1 row InnerStepId, bypass batch upsert NV+PP+TP cùng dept ≤ actor (audit IsBypassed cho cấp dưới), recheck stillPending → BLOCK + log "duyệt cấp X (còn Y pending)".
- [x] **Chunk D (`3d76c6b`)** Tests N-stage 6 test mới (FirstInner_NV_blocks / All_3_levels_sequential_pass / TP_bypass_skips_lower / Wrong_dept_403 / Reject_clears_rows / Legacy_fallback_no_inner) + IdentityFixture extend `+positionLevel` + helper SeedWorkflowDefinitionAsync 2 step adjacent. **83→89 test pass**.
- [x] **Chunk E (`83ffabd`)** API `PATCH /users/{id}/position-level` mirror SetBypassReview + body `{positionLevel:int?}` + Authorize Users.Update.
- [x] **Chunk F (current)** FE-Admin types/users.ts + positionLevel field + PositionLevel const + Label/Short maps. PeWorkflowsPage Designer extend InnerStep DTO + EditInnerStep type + sub-section "Cấp duyệt nhỏ trong phòng" drag-list { Phòng × Cấp + required } + button "+ Thêm cấp duyệt" emerald + departmentsList query + payload include. UsersPage column "Cấp" badge NV/PP/TP emerald + action button cycle null→1→2→3→null. KHÔNG đụng fe-user (admin-only).
**Backward compat 100%:** workflow no InnerSteps configured → service fallback legacy 2-stage Mig 16. Data legacy rows InnerStepId=null vẫn enforce unique cũ qua filtered index.
**Defer Session 13+:**
- [ ] Mirror Contract + Budget N-stage (sau khi UAT PE 2-3 tuần ổn). Pattern lặp lại Domain entity + Service logic + Tests reusable từ PE.
- [ ] Seed/migrate User.PositionLevel cho 30 demo user (hiện chỉ admin set qua UsersPage cycle).
- [ ] schema-diagram.md §15 Mig 18 + §16 Mig 19 update (defer cron audit 2026-06-01 — small drift).
- [ ] Skill ef-core-migration row Mig 18+19 (defer cron audit).
- [ ] Skill contract-workflow N-stage cross-ref section (defer cron audit).
### ✅ Session phase 2 done (2026-05-08 00:30) — B12-B14 PE detail polish iterate (3 commit FE-only)
User UAT iteration tiếp sau wrap-up `6e7a6db`. Áp rule strict verify khi rename/remove (lesson hotfix CI).

View File

@ -0,0 +1,248 @@
# Session 2026-05-07 (S12) — N-stage workflow approval Phòng × PositionLevel
**Dev:** Claude
**Duration:** ~3h
**Base commit:** `130903f` (sau Session phase 2 wrap-up `edc6660` + commit nhỏ bỏ button "+ Tạo phiếu mới")
**Final commit:** (current Chunk F)
**Total commits:** 6 per-chunk
## Bối cảnh
User screenshot annotation menu Duyệt NCC: "Phòng A có NV / Phó Phòng / Trưởng Phòng — cấp duyệt nhỏ trong cùng 1 step cha cấu hình được". Mở rộng từ 2-stage Mig 16 (NV.Review/TPB.Confirm) sang N-stage cấu hình động per WorkflowStep:
- Level cha: WorkflowStep (= 1 phase, vd "Chờ Purchasing")
- Level con: chuỗi InnerSteps theo Phòng × PositionLevel với Order sequential
- Vd: NV.PRO → PP.PRO → TP.PRO → NV.CCM → PP.CCM → TP.CCM → next phase
Cả 2 cấp đều cấu hình được qua admin designer.
## 6 câu spec defaults chốt trước code
- **Q1 PositionLevel** — enum 1=NV, 2=PP, 3=TP (gọn, dễ Compare). User.PositionLevel int? nullable cho admin/system/external user.
- **Q2 Sequential** — pure Order asc (KHÔNG parallel inter-phòng). Mỗi inner step = 1 cấp duyệt riêng.
- **Q3 Bypass** — TP có CanBypassReview → skip NV+PP CÙNG dept (audit IsBypassed=true cho cấp dưới). Cross-dept KHÔNG skip.
- **Q4 Reject** — về DangSoanThao + RejectedFromPhase set + clear N-stage rows tại phase reject. Resume jump-back tới RejectedFromPhase + re-approve N-stage.
- **Q5 Scope** — PE-only first (Contract + Budget mirror sau khi UAT PE ổn).
- **Q6(a) Designer UI** — 1 sub-section "Cấp duyệt nhỏ trong phòng" drag-list per step cha, không tách 2 designer.
## 6 chunk per-commit
### Chunk A — Domain + Migration 18 (`13ab533`)
**Files mới (1):**
- `Domain/Identity/PositionLevel.cs` — enum NhanVien=1, PhoPhong=2, TruongPhong=3.
**Files edit (3):**
- `Domain/Identity/User.cs``+ public PositionLevel? PositionLevel { get; set; }` cuối class.
- `Domain/PurchaseEvaluations/PurchaseEvaluationWorkflowDefinition.cs` — class WorkflowStep + nav `List<InnerSteps>` + class mới `PurchaseEvaluationWorkflowStepInnerStep` (Order, DepartmentId, PositionLevel, Name, SlaDays, IsRequired).
- `Domain/PurchaseEvaluations/PurchaseEvaluationDepartmentApproval.cs``+ public Guid? InnerStepId { get; set; }` (null cho legacy 2-stage rows).
**Infra edit (3):**
- `Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs` — config InnerStep table (Cascade Step, Restrict Department, IX (StepId, Order) + IX DeptId).
- `Infrastructure/Persistence/Configurations/DepartmentApprovalsConfiguration.cs` — IX InnerStepId.
- `Infrastructure/Persistence/ApplicationDbContext.cs` — DbSet<InnerStep>.
**Migration 18** `AddPeWorkflowInnerStepsAndPositionLevel`:
- 1 CREATE TABLE PurchaseEvaluationWorkflowStepInnerSteps
- 2 ALTER (User.PositionLevel + PEDeptApproval.InnerStepId)
- 3 INDEX
- FK Cascade Step / Restrict Dept / Restrict InnerStep
3-file rule: Migration .cs + Designer + Snapshot.
**Verify:** dotnet build pass, ef database update LocalDB applied, 83 test pass (no regression).
### Chunk B — Application CQRS DTO (`0e56bd0`)
**`Application/PurchaseEvaluations/PeWorkflowAdminFeatures.cs`:**
- record `PeWorkflowStepInnerStepDto` (Id, Order, DepartmentId, DepartmentName, PositionLevel, Name, SlaDays, IsRequired).
- record `PeWorkflowStepDto` extend `+List<PeWorkflowStepInnerStepDto> InnerSteps`.
- `GetPeWorkflowAdminOverviewQueryHandler` — Include InnerSteps OrderBy Order + resolve DeptNames cho display.
- record `CreatePeWorkflowStepInnerStepInput` (Order, DeptId, PositionLevel, Name, SlaDays, IsRequired).
- record `CreatePeWorkflowStepInput` extend `+List<...InnerSteps>? = null` (default null cho backward compat existing PeWorkflowAdminTests positional `new(...)`).
- Validator child rules cho InnerSteps (Order ≥1, DeptId not empty, PositionLevel 1-3, SlaDays ≥0).
- `CreatePeWorkflowDefinitionCommandHandler` — convert InnerSteps khi build entity (atomic batch insert qua nav collection).
**`Application/Users/UserFeatures.cs`:**
- record `UserDto` `+int? PositionLevel` field.
- ListUsers + GetUser handlers map `(int?)u.PositionLevel`.
- record `SetUserPositionLevelCommand(Guid Id, int? PositionLevel)` mirror SetUserBypassReviewCommand.
- Validator: PositionLevel null OR 1-3.
- Handler: `userManager.UpdateAsync` user.PositionLevel.
**Verify:** dotnet build 0 error, 83 test pass (PeWorkflowAdminTests existing 6 test pass với InnerSteps default null backward compat).
### Chunk C — Service N-stage logic + Migration 19 (`0c62e24`)
**`Infrastructure/Persistence/Configurations/DepartmentApprovalsConfiguration.cs`:**
- Drop UNIQUE `(PEId, Phase, Dept, Stage)` Mig 16.
- Recreate filtered `WHERE [InnerStepId] IS NULL` (legacy 2-stage rows).
- Add new filtered `(PEId, Phase, InnerStepId)` `WHERE [InnerStepId] IS NOT NULL` (N-stage 1 row per inner step per phase).
**Migration 19** `AlterPeDeptApprovalsUniqueFilteredForInnerSteps`:
- DropIndex UX_PEDeptApprovals_PE_Phase_Dept_Stage
- CreateIndex 2 filtered
Lý do: N-stage có thể có 2+ inner step cùng dept (NV/PP/TP) — Stage=Confirm tất cả → violate unique cũ.
**`Infrastructure/Services/PurchaseEvaluationWorkflowService.cs` refactor TransitionAsync:**
1. **Load definition** với `.ThenInclude(s => s.InnerSteps.OrderBy(i => i.Order))` eager + assign `definition` outer scope.
2. **Reject branch** thêm: clear N-stage approval rows tại `fromPhase` (resume sẽ approve lại từ inner đầu).
3. **Department approval block split:**
```
if (decision==Approve && targetPhase != Stao && targetPhase != TuChoi
&& !isResumingAfterReject && !isAdmin && !isSystem
&& actorUserId is Guid actorUid)
{
var step = def?.Steps.FirstOrDefault(s => s.Phase == fromPhase);
var hasInnerSteps = step?.InnerSteps.Count > 0;
if (hasInnerSteps) { /* N-stage logic */ }
else if (actor.DeptId is Guid deptId) { /* legacy 2-stage Mig 16 */ }
}
```
4. **N-stage logic:**
- Yêu cầu actor có DeptId + PositionLevel (else throw 403).
- Load existing approvals tại fromPhase với InnerStepId IN [innerIds] → compute doneInnerIds set.
- pendingRequired = inners.Where(i => i.IsRequired && !doneInnerIds.Contains(i.Id)).
- firstPending = pendingRequired[0].
- Match: actorDept == firstPending.Dept AND (actorPos == firstPending.Level OR canBypass + actorPos ≥ firstPending.Level). Mismatch → 403.
- Exact match: 1 row upsert (Stage=Confirm, InnerStepId=firstPending, IsBypassed=false).
- Bypass: batch upsert tất cả inners cùng dept actor có level từ firstPending.Level đến actorPos (inclusive, IsBypassed=true cho cấp dưới skip).
- Recheck stillPending → BLOCK + log Approval/Changelog "duyệt cấp X (còn Y pending)" + return early.
- All done → fall through phase transition.
5. **Legacy fallback** (else branch) — giữ nguyên logic 2-stage Mig 16 với InnerStepId=null filter.
**Verify:** dotnet build 0 error, ef database update Mig 19 LocalDB applied, 83 test pass (legacy backward compat OK).
### Chunk D — Tests N-stage 6 test (`3d76c6b`)
**`tests/SolutionErp.Infrastructure.Tests/Common/IdentityFixture.cs`** extend:
- `CreateUserAsync` `+PositionLevel? positionLevel = null` arg → set User.PositionLevel.
**`tests/.../Services/PeNStageApprovalTests.cs` (NEW, 6 test):**
| # | Test | Cover |
|---|---|---|
| 1 | NStage_FirstInner_NV_Approve_Blocks_Phase_Transition | NV cấp 1 → 1 row InnerStepId set, phase chưa đổi |
| 2 | NStage_All_3_Levels_Sequential_Pass_Allow_Phase_Transition | NV → PP → TP → 3 rows + phase chuyển |
| 3 | NStage_TP_Bypass_Skips_Lower_Levels_Same_Dept | TP+canBypass → 3 rows (NV+PP IsBypassed, TP exact) |
| 4 | NStage_Wrong_Department_Throws_Forbidden | Actor dept khác → ForbiddenException |
| 5 | NStage_Reject_Clears_InnerStep_Rows_At_Phase | Reject → DangSoanThao + clear N-stage rows |
| 6 | LegacyFallback_NoInnerSteps_Uses_2Stage_Logic | PE no pinned def → fallback 2-stage Stage=Review |
**Helper `SeedWorkflowDefinitionAsync`** tạo definition với 2 step adjacent (ChoPurchasing có inner steps + ChoCCM next, no inner) — đủ cho FromDefinition build transition (ChoPurchasing → ChoCCM) policy guard pass actor role Procurement.
**Bug gặp:** Initial fail 4 test với `ForbiddenException: "Policy không cho phép ChoPurchasing → ChoCCM"`. Root cause: FromDefinition build transitions từ adjacent steps, seed 1 step → no transition. Fix: thêm step 2 ChoCCM mirror role.
**Verify:** 83 → **89 test pass** (54 Domain + 35 Infra: 17 codegen + 6 PE WF versioning + 6 PE 2-stage + 6 PE N-stage).
### Chunk E — API endpoint (`83ffabd`)
**`Api/Controllers/UsersController.cs`:**
- `PATCH /api/users/{id}/position-level` body `{positionLevel: int?}` Authorize Users.Update → SetUserPositionLevelCommand.
- record `SetPositionLevelBody(int? PositionLevel)`.
PE Workflow Designer endpoint Create/Get KHÔNG đụng — DTO record đã extend ở Chunk B, JSON body bind tự động.
**Verify:** dotnet build 0 error 0 warning.
### Chunk F — FE Designer + UsersPage + Docs (current)
**`fe-admin/src/types/users.ts`:**
- User type `+positionLevel: number | null`.
- const `PositionLevel = { NhanVien: 1, PhoPhong: 2, TruongPhong: 3 } as const`.
- `PositionLevelLabel: Record<number, string>` (NV/PP/TP với full description).
- `PositionLevelShort: Record<number, string>` (NV/PP/TP gọn).
**`fe-admin/src/pages/system/PeWorkflowsPage.tsx`:**
- Type `InnerStepDto` + extend `StepDto` `+innerSteps: InnerStepDto[]`.
- Type `EditInnerStep` + extend `EditStep` `+innerSteps: EditInnerStep[]`.
- `copyFromDefinition` include innerSteps map.
- Default new step `+innerSteps: []`.
- `departmentsList` useQuery fetch /departments.
- Save mutation payload: `s.innerSteps.map((ii, ix) => ({ order: ix+1, departmentId, positionLevel, name, slaDays, isRequired }))`.
- UI sub-section "Cấp duyệt nhỏ trong phòng (sequential — Order asc)" trong từng step card sau "Người duyệt" section, với drag-list rows { Order badge emerald + Select Phòng + Select Cấp + checkbox required + Trash button } + button "+ Thêm cấp duyệt" emerald (disabled khi departmentsList empty).
- Empty state hint: "Chưa cấu hình cấp con — workflow fallback logic 2-cấp NV/TPB legacy."
**`fe-admin/src/pages/system/UsersPage.tsx`:**
- Import `PositionLevelShort + PositionLevelLabel`.
- Mutation `positionLevelMut` PATCH /users/{id}/position-level.
- `nextPositionLevel(current)` cycle null → 1 → 2 → 3 → null.
- Column "Cấp" width=w-16 align center: badge emerald NV/PP/TP với tooltip full label, hoặc "—" nếu null.
- Action button (kế bên Bypass): text NV/PP/TP/— với color emerald-active vs slate-inactive, tooltip "click để cycle".
**KHÔNG đụng fe-user** — PeWorkflowsPage + UsersPage admin-only feature.
**Verify:** npm run build fe-admin pass (✓ built), 0 TS error.
## E2E verified
- ✅ `dotnet test SolutionErp.slnx` 89/89 pass (54 Domain + 35 Infra)
- ✅ `dotnet build SolutionErp.slnx` 0 error, 2 pre-existing warning DocxRenderer
- ✅ `dotnet ef database update` Mig 18+19 LocalDB applied OK
- ✅ `npm run build` fe-admin pass — no TS error
- 🔄 Manual UAT — defer cho user thử workflow Designer + create version mới có Inner Steps + duyệt qua các cấp
## Bug + Fix log
| # | Issue | Fix | Commit |
|---|---|---|---|
| 1 | UNIQUE conflict khi N-stage 2+ inner step cùng dept Stage=Confirm | Migration 19 filtered unique split legacy/N-stage | `0c62e24` |
| 2 | 4 N-stage test fail "Policy không cho phép ChoPurchasing → ChoCCM" | Helper SeedWorkflowDefinitionAsync thêm step 2 adjacent ChoCCM cho FromDefinition build transition | (Chunk D inline) |
| 3 | TS6133 unused import PositionLevelShort trong PeWorkflowsPage.tsx | Bỏ khỏi import (dùng ở UsersPage thôi) | (Chunk F inline) |
## Docs updates
- ✅ STATUS.md — Last updated + Phase summary count + 1 row Recently Done (KEEP narrative cũ)
- ✅ HANDOFF.md — TL;DR Session 12 prepend + 8 cảnh báo Session 13+ + giữ Session phase 2 narrative
- ✅ migration-todos.md — Phase 9 thêm Session 12 block 6 chunk + 5 task defer S13+
- ✅ Session log (file này)
- ⏸️ schema-diagram.md §15 Mig 18 + §16 Mig 19 — defer cron audit 2026-06-01
- ⏸️ Skill ef-core-migration Mig 18+19 row + counts — defer cron audit
- ⏸️ Skill contract-workflow Phase 9+ N-stage cross-ref section — defer cron audit
- ❌ gotchas.md — KHÔNG add (không phát sinh bẫy mới đáng cluster, các bug fix Chunk D là test setup issue, Chunk C unique conflict đã fix tại design)
- ❌ rules.md — KHÔNG add (N-stage là feature project-specific, KHÔNG phải coding convention)
## Stats cumulative (sau Session 12)
| | Trước S12 | Sau S12 | Diff |
|---|---:|---:|---:|
| BE LOC | ~14850 | ~15300 | +450 |
| API endpoints | ~133 | ~134 | +1 (PATCH /users/{id}/position-level) |
| Migrations | 17 | **19** | +2 (Mig 18 + Mig 19) |
| DB tables | 55 | **56** | +1 (PEWorkflowStepInnerSteps) |
| DB columns mới | — | +2 | User.PositionLevel + PEDeptApproval.InnerStepId |
| FE pages | 32 | 32 | 0 (extend existing 2 page) |
| FE components mới | — | 0 | Inline UI sub-section trong PeWorkflowsPage |
| Tests | 83 | **89** | +6 (PE N-stage approval) |
| Docs | ~55 | ~56 | +1 (session log này) |
| Commits S12 | — | **+6** | A→F per-chunk |
## Plan organization sau session 12
```
Plan cha: Phase 9 active — UAT
├── Plan con A: Hard blockers (chờ user/ops) — 6 task pending (giữ nguyên)
├── Plan con B-E: Done từ Session 7-11+++++++ (giữ narrative cũ)
├── Plan con S12: N-stage workflow approval ✅ DONE (6 chunk per-commit)
│ ├── ✅ A: Domain + Mig 18
│ ├── ✅ B: App CQRS
│ ├── ✅ C: Service logic + Mig 19 filtered unique
│ ├── ✅ D: 6 test mới
│ ├── ✅ E: API endpoint
│ └── ✅ F: FE Designer + UsersPage + Docs
├── Plan con G: Optional polish (UAT-driven) — 5 task (giữ nguyên)
├── Plan con H: Tests Phase 3-5 — 3 sub (giữ nguyên)
└── Plan con I: Defer cho Session 13+
├── Mirror N-stage sang Contract + Budget (sau UAT PE 2-3 tuần ổn)
├── Seed/migrate User.PositionLevel cho 30 demo user
├── schema-diagram.md §15 Mig 18 + §16 Mig 19
├── Skill ef-core-migration Mig 18+19 row
└── Skill contract-workflow N-stage cross-ref
```
## Handoff
UAT iteration mode. User test live + báo lại nếu cần điều chỉnh. Workflow N-stage backward compat 100% với data legacy 2-stage Mig 16.
**Cron audit kế:** 2026-06-01 (~25 ngày). Lúc đó update schema-diagram + 2 skill drift đã list ở Defer S13+.

View File

@ -15,12 +15,33 @@ import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { AVAILABLE_ROLES, RoleLabel } from '@/types/users'
import { AVAILABLE_ROLES, RoleLabel, PositionLevel, PositionLevelLabel } from '@/types/users'
import type { Department, Paged } from '@/types/master'
// ===== Types (mirror BE PeWorkflowAdminOverviewDto) =====
type ApproverDto = { kind: number; assignmentValue: string; displayName: string | null }
type StepDto = { id: string; order: number; phase: number; phaseLabel: string; name: string; slaDays: number | null; approvers: ApproverDto[] }
// Mig 18 — N-stage inner step DTO
type InnerStepDto = {
id: string
order: number
departmentId: string
departmentName: string | null
positionLevel: number // 1=NV, 2=PP, 3=TP
name: string | null
slaDays: number | null
isRequired: boolean
}
type StepDto = {
id: string
order: number
phase: number
phaseLabel: string
name: string
slaDays: number | null
approvers: ApproverDto[]
innerSteps: InnerStepDto[]
}
type DefinitionDto = {
id: string
code: string
@ -54,7 +75,22 @@ const PHASE_OPTIONS: { value: number; label: string }[] = [
]
type EditStepApprover = { kind: 1 | 2; assignmentValue: string }
type EditStep = { phase: number; name: string; slaDays: number | null; approvers: EditStepApprover[] }
// Mig 18 — Inner step level con
type EditInnerStep = {
order: number
departmentId: string
positionLevel: number // 1/2/3
name: string
slaDays: number | null
isRequired: boolean
}
type EditStep = {
phase: number
name: string
slaDays: number | null
approvers: EditStepApprover[]
innerSteps: EditInnerStep[]
}
function copyFromDefinition(d: DefinitionDto): EditStep[] {
return d.steps.map(s => ({
@ -62,6 +98,14 @@ function copyFromDefinition(d: DefinitionDto): EditStep[] {
name: s.name,
slaDays: s.slaDays,
approvers: s.approvers.map(a => ({ kind: a.kind as 1 | 2, assignmentValue: a.assignmentValue })),
innerSteps: (s.innerSteps ?? []).map(i => ({
order: i.order,
departmentId: i.departmentId,
positionLevel: i.positionLevel,
name: i.name ?? '',
slaDays: i.slaDays,
isRequired: i.isRequired,
})),
}))
}
@ -272,7 +316,7 @@ function PeWorkflowDesigner({
() =>
cloneFrom
? copyFromDefinition(cloneFrom)
: [{ phase: 1, name: 'Soạn thảo', slaDays: 3, approvers: [] }],
: [{ phase: 1, name: 'Soạn thảo', slaDays: 3, approvers: [], innerSteps: [] }],
[cloneFrom],
)
@ -288,6 +332,12 @@ function PeWorkflowDesigner({
(await api.get<{ items: { id: string; fullName: string; email: string }[] }>('/users', { params: { page: 1, pageSize: 200 } })).data.items,
})
const departmentsList = useQuery({
queryKey: ['departments-for-inner-step'],
queryFn: async () =>
(await api.get<Paged<Department>>('/departments', { params: { page: 1, pageSize: 200 } })).data.items,
})
const save = useMutation({
mutationFn: async () => {
await api.post('/pe-workflows', {
@ -301,6 +351,14 @@ function PeWorkflowDesigner({
name: s.name,
slaDays: s.slaDays,
approvers: s.approvers,
innerSteps: s.innerSteps.map((ii, ix) => ({
order: ix + 1,
departmentId: ii.departmentId,
positionLevel: ii.positionLevel,
name: ii.name || null,
slaDays: ii.slaDays,
isRequired: ii.isRequired,
})),
})),
})
},
@ -359,7 +417,7 @@ function PeWorkflowDesigner({
type="button"
size="sm"
variant="outline"
onClick={() => setSteps([...steps, { phase: 2, name: '', slaDays: 3, approvers: [] }])}
onClick={() => setSteps([...steps, { phase: 2, name: '', slaDays: 3, approvers: [], innerSteps: [] }])}
>
<Plus className="h-3.5 w-3.5" />
Thêm bước
@ -482,6 +540,104 @@ function PeWorkflowDesigner({
))}
</div>
</div>
{/* Inner Steps (Mig 18) — N-stage approval Phòng × Cấp chức danh */}
<div className="mt-2 border-t border-slate-200 pt-2">
<div className="mb-1 flex items-center justify-between">
<Label className="text-[11px]">
Cấp duyệt nhỏ trong phòng (sequential Order asc)
{s.innerSteps.length > 0 && <span className="ml-1 text-slate-400">· {s.innerSteps.length} cấp</span>}
</Label>
<button
type="button"
disabled={!departmentsList.data || departmentsList.data.length === 0}
onClick={() => {
const firstDeptId = departmentsList.data?.[0]?.id ?? ''
setSteps(steps.map((x, i) =>
i === idx ? {
...x,
innerSteps: [...x.innerSteps, {
order: x.innerSteps.length + 1,
departmentId: firstDeptId,
positionLevel: PositionLevel.NhanVien,
name: '',
slaDays: null,
isRequired: true,
}],
} : x,
))
}}
className="rounded bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700 hover:bg-emerald-100 disabled:opacity-50"
>
+ Thêm cấp duyệt
</button>
</div>
{s.innerSteps.length === 0 && (
<div className="rounded bg-slate-100 px-2 py-1.5 text-[11px] italic text-slate-500">
Chưa cấu hình cấp con workflow fallback logic 2-cấp NV/TPB legacy.
</div>
)}
{s.innerSteps.length > 0 && (
<div className="space-y-1">
{s.innerSteps.map((ii, ix) => (
<div key={ix} className="flex items-center gap-1 rounded border border-slate-200 bg-white p-1">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-emerald-600 text-[10px] font-bold text-white">
{ix + 1}
</span>
<Select
value={ii.departmentId}
onChange={e =>
setSteps(steps.map((x, i) =>
i === idx ? { ...x, innerSteps: x.innerSteps.map((y, j) => (j === ix ? { ...y, departmentId: e.target.value } : y)) } : x,
))
}
className="h-7 flex-1 text-xs"
>
{departmentsList.data?.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</Select>
<Select
value={ii.positionLevel}
onChange={e =>
setSteps(steps.map((x, i) =>
i === idx ? { ...x, innerSteps: x.innerSteps.map((y, j) => (j === ix ? { ...y, positionLevel: Number(e.target.value) } : y)) } : x,
))
}
className="h-7 w-28 text-xs"
>
<option value={PositionLevel.NhanVien}>{PositionLevelLabel[1]}</option>
<option value={PositionLevel.PhoPhong}>{PositionLevelLabel[2]}</option>
<option value={PositionLevel.TruongPhong}>{PositionLevelLabel[3]}</option>
</Select>
<label className="flex shrink-0 items-center gap-1 text-[11px] text-slate-500">
<input
type="checkbox"
checked={ii.isRequired}
onChange={e =>
setSteps(steps.map((x, i) =>
i === idx ? { ...x, innerSteps: x.innerSteps.map((y, j) => (j === ix ? { ...y, isRequired: e.target.checked } : y)) } : x,
))
}
/>
required
</label>
<button
type="button"
onClick={() =>
setSteps(steps.map((x, i) =>
i === idx ? { ...x, innerSteps: x.innerSteps.filter((_, j) => j !== ix) } : x,
))
}
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
</div>
</div>
))}
</div>

View File

@ -14,7 +14,7 @@ import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { MenuKeys } from '@/lib/menuKeys'
import type { Department, Paged } from '@/types/master'
import { AVAILABLE_ROLES, RoleShortName, RoleLabel, type User } from '@/types/users'
import { AVAILABLE_ROLES, RoleShortName, RoleLabel, PositionLevelShort, PositionLevelLabel, type User } from '@/types/users'
const fmtDate = (s: string) => new Date(s).toLocaleDateString('vi-VN')
@ -163,6 +163,25 @@ export function UsersPage() {
onError: err => toast.error(getErrorMessage(err)),
})
// N-stage workflow inner step (Mig 18): set cấp chức danh user (NV/PP/TP).
// Inline cycle qua 3 cấp + null mỗi click — admin tinh chỉnh sau qua API.
const positionLevelMut = useMutation({
mutationFn: (input: { id: string; positionLevel: number | null }) =>
api.patch(`/users/${input.id}/position-level`, { positionLevel: input.positionLevel }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['users'] })
toast.success('Đã cập nhật cấp chức danh')
},
onError: err => toast.error(getErrorMessage(err)),
})
function nextPositionLevel(current: number | null): number | null {
// Cycle null → 1 (NV) → 2 (PP) → 3 (TP) → null
if (current == null) return 1
if (current === 3) return null
return current + 1
}
function openRoles(u: User) {
setRolesModal(u)
setRoleSelection([...u.roles])
@ -253,6 +272,23 @@ export function UsersPage() {
<span className="text-xs text-slate-400"></span>
),
},
{
key: 'positionLevel',
header: 'Cấp',
width: 'w-16',
align: 'center',
render: u =>
u.positionLevel != null ? (
<span
title={PositionLevelLabel[u.positionLevel]}
className="inline-flex items-center rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-700"
>
{PositionLevelShort[u.positionLevel]}
</span>
) : (
<span className="text-xs text-slate-400"></span>
),
},
{ key: 'createdAt', header: 'Ngày tạo', width: 'w-24', render: u => fmtDate(u.createdAt) },
{
key: 'actions',
@ -284,6 +320,20 @@ export function UsersPage() {
>
<ShieldCheck className={`h-3.5 w-3.5 ${u.canBypassReview ? 'text-fuchsia-600' : 'text-slate-400'}`} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => positionLevelMut.mutate({ id: u.id, positionLevel: nextPositionLevel(u.positionLevel) })}
title={
u.positionLevel == null
? 'Chưa set cấp — click để set NV'
: `Cấp ${PositionLevelLabel[u.positionLevel]} — click để cycle (${u.positionLevel === 3 ? 'NV→PP→TP→clear' : 'next'})`
}
>
<span className={`text-[10px] font-bold ${u.positionLevel != null ? 'text-emerald-600' : 'text-slate-400'}`}>
{u.positionLevel != null ? PositionLevelShort[u.positionLevel] : '—'}
</span>
</Button>
<Button size="sm" variant="ghost" onClick={() => toggleActiveMut.mutate(u)} title={u.isActive ? 'Vô hiệu hóa' : 'Kích hoạt'}>
{u.isActive ? <XCircle className="h-3.5 w-3.5 text-red-500" /> : <CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />}
</Button>

View File

@ -10,6 +10,26 @@ export type User = {
departmentName: string | null
position: string | null
canBypassReview: boolean
positionLevel: number | null // Mig 18 — 1=NV, 2=PP, 3=TP, null=admin/external
}
// Cấp chức danh trong phòng (Mig 18) — phục vụ N-stage workflow inner step.
export const PositionLevel = {
NhanVien: 1,
PhoPhong: 2,
TruongPhong: 3,
} as const
export type PositionLevelValue = typeof PositionLevel[keyof typeof PositionLevel]
export const PositionLevelLabel: Record<number, string> = {
1: 'NV (Nhân viên)',
2: 'PP (Phó phòng)',
3: 'TP (Trưởng phòng)',
}
export const PositionLevelShort: Record<number, string> = {
1: 'NV',
2: 'PP',
3: 'TP',
}
export type CreateUserInput = {