From bfc7b79f724998cc71a5e3307a1266f31b2e5731 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Thu, 30 Jul 2026 10:04:32 +0700 Subject: [PATCH] [CLAUDE] FE-User+FE-Admin: cay 4 folder GD duoi tung goi thau (Duyet NCC / Ke hoach HD / Duyet HD / HD cung) x2 app + 5 fix review F-1/3/4/6/7 Co-Authored-By: Claude Fable 5 --- .../agent-memory/frontend-designer/MEMORY.md | 2 +- .claude/governance/.session-counter.json | 13 +- .../2026-07-30-S162-cay-4-folder-gd/run.md | 7 +- .../ship-synthesis.md | 26 ++ .../sub-reviewer-1.md | 61 ++++ .../pipeline/PipelineStageFolders.tsx | 237 +++++++++++++ .../components/pipeline/PipelineTreePanel.tsx | 321 ++++++++++++++++++ fe-admin/src/hooks/usePipelineStages.ts | 266 +++++++++++++++ .../src/pages/contracts/HardCopiesPage.tsx | 13 + fe-admin/src/pages/khkk/KhkkListPage.tsx | 16 +- .../pages/pe/PurchaseEvaluationsListPage.tsx | 18 +- .../pipeline/PipelineStageFolders.tsx | 237 +++++++++++++ .../components/pipeline/PipelineTreePanel.tsx | 321 ++++++++++++++++++ fe-user/src/hooks/usePipelineStages.ts | 266 +++++++++++++++ .../src/pages/contracts/HardCopiesPage.tsx | 13 + fe-user/src/pages/khkk/KhkkListPage.tsx | 16 +- .../pages/pe/PurchaseEvaluationsListPage.tsx | 18 +- 17 files changed, 1839 insertions(+), 12 deletions(-) create mode 100644 .claude/workflows/runs/2026-07-30-S162-cay-4-folder-gd/ship-synthesis.md create mode 100644 fe-admin/src/components/pipeline/PipelineStageFolders.tsx create mode 100644 fe-admin/src/components/pipeline/PipelineTreePanel.tsx create mode 100644 fe-admin/src/hooks/usePipelineStages.ts create mode 100644 fe-user/src/components/pipeline/PipelineStageFolders.tsx create mode 100644 fe-user/src/components/pipeline/PipelineTreePanel.tsx create mode 100644 fe-user/src/hooks/usePipelineStages.ts diff --git a/.claude/agent-memory/frontend-designer/MEMORY.md b/.claude/agent-memory/frontend-designer/MEMORY.md index b97b11e..d2bb028 100644 --- a/.claude/agent-memory/frontend-designer/MEMORY.md +++ b/.claude/agent-memory/frontend-designer/MEMORY.md @@ -45,7 +45,7 @@ - **Lucide alias-dodge:** icon từ export-aggregation-line (`SendHorizonal`) / mojibake token (`网络Placeholder` autocomplete) → dùng proven-safe export (`Send`), node-check icons trước build. ## Activity log -- **S162 (2026-07-30) Cây 4 folder GIAI ĐOẠN dưới từng gói thầu — 3 file MỚI + 3 trang, MIRROR đủ 2 app, build PASS ×2.** Owner (screenshot khoanh đỏ dải 4 GĐ → cây trái trang Duyệt NCC) + AskUser chốt: folder đặt DƯỚI TỪNG GÓI THẦU · áp cả trang GĐ khác. Nhãn VERBATIM "Duyệt NCC · Kế hoạch Hợp đồng · Duyệt Hợp đồng · Hợp đồng cứng" (GĐ2 CỐ Ý khác nhãn strip `PePipelineStrip` = lời-mới-nhất-thắng; KHÔNG sửa strip/menu). **3 file mới:** `components/pipeline/PipelineStageFolders.tsx` (thuần trình bày; `
` controlled `useState(n===currentStage)`; prop `content` cho GĐ1 giữ leaf phiếu cũ; `locked`/`failed`/`loading`/`hideEmpty`) · `hooks/usePipelineStages.ts` (2 query index key MỚI `pipeline-khkk-index`/`pipeline-contract-index` pageSize 200 — `PagedResult.cs:20` KẸP ≤200 không 400; `buildStages(peItems)` nối GĐ2 qua `khkk.purchaseEvaluationId`, GĐ3 qua `pe.contractId`, GĐ4 = HĐ đó có `hasSealedCopy`) · `components/pipeline/PipelineTreePanel.tsx` (panel cây trái cho trang GĐ2/GĐ4; `buildTree` RIÊNG, KHÔNG đụng memo `yearGroups` trang PE). **Wire:** PE list (`currentStage=1`, khối `
  • ` phiếu giữ NGUYÊN TỪNG BYTE kể cả thụt lề ⇒ diff chỉ 2 mẩu bọc = bằng chứng không đụng behavior) · KhkkListPage (=2) · HardCopiesPage (=4), 2 trang sau bọc lưới `lg:grid-cols-[19rem_minmax(0,1fr)]` + `min-w-0` + `` bọc `overflow-x-auto` + KPI `lg:grid-cols-6`→`xl:`. **Permission 2 tầng (S118/#82):** gate TRƯỚC fetch, `enabled:false` ⇒ 0 rác 403; ⚠️ phải OR NHIỀU KEY vì `GetMyMenuTreeQuery.cs:96` trả root canRead=false khi con có quyền, `Ct_*` kế thừa `Contracts` nhưng `Khkk_*`/`Hdc_*` KHÔNG. Tag [s162, pipeline-stage-folders, 4-folder-gd, permission-or-keys, index-query-key-moi, leaf-byte-verbatim, sha-pair-6-file]. +- **S162 (2026-07-30) Cây 4 folder GIAI ĐOẠN dưới từng gói thầu — 3 file MỚI + 3 trang, MIRROR đủ 2 app, build PASS ×2.** Owner (screenshot khoanh đỏ dải 4 GĐ → cây trái trang Duyệt NCC) + AskUser chốt: folder đặt DƯỚI TỪNG GÓI THẦU · áp cả trang GĐ khác. Nhãn VERBATIM "Duyệt NCC · Kế hoạch Hợp đồng · Duyệt Hợp đồng · Hợp đồng cứng" (GĐ2 CỐ Ý khác nhãn strip `PePipelineStrip` = lời-mới-nhất-thắng; KHÔNG sửa strip/menu). **3 file mới:** `components/pipeline/PipelineStageFolders.tsx` (thuần trình bày; `
    ` controlled `useState(n===currentStage)`; prop `content` cho GĐ1 giữ leaf phiếu cũ; `locked`/`failed`/`loading`/`hideEmpty`) · `hooks/usePipelineStages.ts` (2 query index key MỚI `pipeline-khkk-index`/`pipeline-contract-index` pageSize 200 — `PagedResult.cs:20` KẸP ≤200 không 400; `buildStages(peItems)` nối GĐ2 qua `khkk.purchaseEvaluationId`, GĐ3 qua `pe.contractId`, GĐ4 = HĐ đó có `hasSealedCopy`) · `components/pipeline/PipelineTreePanel.tsx` (panel cây trái cho trang GĐ2/GĐ4; `buildTree` RIÊNG, KHÔNG đụng memo `yearGroups` trang PE). **Wire:** PE list (`currentStage=1`, khối `
  • ` phiếu giữ NGUYÊN TỪNG BYTE kể cả thụt lề ⇒ diff chỉ 2 mẩu bọc = bằng chứng không đụng behavior) · KhkkListPage (=2) · HardCopiesPage (=4), 2 trang sau bọc lưới `lg:grid-cols-[19rem_minmax(0,1fr)]` + `min-w-0` + `
  • ` bọc `overflow-x-auto` + KPI `lg:grid-cols-6`→`xl:`. **Permission 2 tầng (S118/#82):** gate TRƯỚC fetch, `enabled:false` ⇒ 0 rác 403 **VỚI ĐIỀU KIỆN gate = ĐÚNG key policy của endpoint** *(reviewer F-1 @S162 bắt, lead sửa cùng lượt: GĐ2 `/contract-signing-plans` = `KeHoachKyKet.Read`, `MenuPermissionHandler.cs:40` so khớp CHÍNH XÁC 1 key — KHÔNG kế thừa/OR-con ⇒ OR key con sẽ fetch rồi ăn 403 khi role thiếu key root; gate đã đổi về `['KeHoachKyKet']`. GĐ3/4 `/contracts` `[Authorize]` trần nên OR key hiển-thị vô hại)*. ⚠️ menu-tree fallback quyền cha CHỈ khi menu con KHÔNG có row riêng (`GetMyMenuTreeQuery.cs:66`) — seeder TẠO row `Ct_*` (`DbInitializer.cs:2168-2176`) ⇒ `Ct_*` thực tế KHÔNG kế thừa `Contracts` *(câu "Ct_* kế thừa Contracts" bản đầu entry này SAI — reviewer F-8, đã sửa)*. **+5 fix hậu-review cùng lượt (lead áp):** F-1 gate-policy-key · F-3 leaf ngoài-index nhãn "(ngoài chỉ mục 200 — mở để xem)" tách nghĩa "(chưa cấp mã)" · F-4 index giữ `total` ⇒ cờ `truncated` khai cửa-sổ-200 ngay trên cây (footer folder + notice panel) · F-6 `hideEmpty` KHÔNG nuốt folder failed/loading · F-7 upload bản cứng invalidate thêm `pipeline-contract-index`. Giới hạn khai thẳng: F-2 phiếu liên-danh N-HĐ chỉ nối được HĐ[0] qua `pe.contractId` (chờ W5 link KHKK→HĐ) · F-5 lưới 19rem 2 trang chưa render thật (harness chỉ import 2 component) — nghiệm thu prod. Tag [s162, pipeline-stage-folders, 4-folder-gd, permission-policy-key-gate, index-query-key-moi, leaf-byte-verbatim, sha-pair-6-file, review-f1-f8]. - **S159 (2026-07-29) ARC-2 "Tổng quan quy trình" ×2 lượt — `UserDashboardPage.tsx` fe-user (đợt-1 hero pipeline + đợt-4 redesign list toàn-trình).** *(Entry on-behalf bởi lead @S160 closeout M9 — VERIFY từ 2 sub-file + commit; cả 2 lượt vai ghi-đĩa-trong-lúc-làm nên #53 ×2 first-return mất 0 B.)* **Đợt-1** (`b5799fc`, `sub-frontend-designer-tongquan.md` 15.803 B): file 309 dòng → THÊM hero pipeline 4 GĐ trên cùng, GIỮ trọn 3 query (`my-dashboard`/`my-contracts-recent`/`my-pe-recent`) + 5 StatCard + 2 list (owner: *"đừng vứt"*); đổi PageHeader title "Tổng quan tất cả luồng duyệt". **Đợt-4** (`a2bbcb9`, `sub-frontend-designer-dot4.md` 13.956 B): file lúc đó 766 dòng — đọc đĩa KHÔNG dùng bản nhớ (đã qua 3 đợt sửa nhãn giữa 2 lượt); bỏ khối owner khoanh (5 StatCard + 2 list "gần đây") → **list chi tiết toàn-trình**; GIỮ hero (owner không khoanh — *đợt-5 vai khác mới bỏ hero theo lệnh anh*); nhãn 4 GĐ echo bản MỚI NHẤT từ comment đầu file (GĐ2 "Kế hoạch ký kết HĐ", KHÔNG lấy tên đợt-1 "Đề xuất ký kết hợp đồng" đã superseded); +2 query `['pipeline-pe-approved']` (phase=7 pageSize=1 lấy `.total`) + `['pe-inbox']` **dùng `select: d => d.length` để KHÔNG vỡ cache dùng chung InboxPage**; verify param BE bằng đọc controller thật. 🔴 **Môi trường spawn KHÔNG có dev-server/Playwright ⇒ FD2 visual-loop bất-khả — thay bằng `npm run build` PASS + tự-review tĩnh FD4, KHAI THẲNG không bịa screenshot** (khuôn NO-SCREENSHOT này tái dùng cho mọi spawn thiếu rig). Bài giữ lại: (i) giữa 2 lượt cùng file, dòng-đếm/nhãn đổi ⇒ **khảo sát lại từ đĩa mỗi lượt**; (ii) đổi consumer của endpoint dùng-chung → `select` transform thay đổi shape cache. Tag [s159, tong-quan-quy-trinh, hero-pipeline-4gd, dot4-list-toan-trinh, no-screenshot-khai-that, select-transform-cache-safe, doc-dia-moi-luot, on-behalf-m9]. - _(**S79 (2026-06-19) PE list fe-user — DECOUPLE chọn/mở-rộng: `?expand=1` cạnh `?id`, nút Maximize2 mỗi dòng, ALL logic verbatim, harness-FD2 6 shot.** verbatim → `archive/2026-06.md` §"S79 (2026-06-19)")_ - _(**S69 (2026-06-17) OfficeDashboardPage.tsx fe-user — E-Office landing PURO, COMPOSES 3 shared widgets, reuse hooks verbatim 0 new API, build PASS 0 TS.** verbatim → `archive/2026-06.md` substring:"S69 (2026-06-17) OfficeDashboardPage.tsx")_ diff --git a/.claude/governance/.session-counter.json b/.claude/governance/.session-counter.json index a4a0e2e..6c89bd0 100644 --- a/.claude/governance/.session-counter.json +++ b/.claude/governance/.session-counter.json @@ -11,10 +11,10 @@ }, "_tick_invariant_note": "Tick invariant (hub dede7ec5 Delta-1, verbatim): moi LAN-CHOT +1; mot cap dung-noi tang DUNG +1, khong +2, khong +0. SE form = tick-at-entry-gate idempotent-per-label (hub Delta-2 recovery-gate +1 = permitted form) - NET +1/session-label EQUIV hub +1/cap on CLOSED pairs; an OPEN pair is transiently +0 until its entry gate fires (hub blessed, 9a35405b block-1 phep dung-noi). Guard song-con = label-convention (session-start 2.1.8: new conversation = new S\u003cnn\u003e label, NEVER reuse). history[] is append-unbounded BY DESIGN =\u003e absence of a marker = never-happened (safe semantics); IF a FIFO cap is ever added, eviction MUST be handled explicitly (absence-vi-bi-day != absence-vi-chua-xay-ra - log-BOUNDED design-note dede7ec5).", "_seed_honesty": "Seeded UNTICKED on purpose. counter=0 and last_ticked_* = null mean \u0027no tick has ever happened\u0027, which is the truth at S121 - the ritual that performs the tick lands in W3. Seeding a fake first tick here would make the very first cadence reading a lie, and H24 exists to catch exactly that kind of invented number.", - "counter": 35, - "last_ticked_session": "S161", - "last_ticked_head": "8f37910552a4af0e995eaa304d5298f586068d0c", - "last_ticked_at": "2026-07-29", + "counter": 36, + "last_ticked_session": "S162", + "last_ticked_head": "2554845b7f98b30fc980289cde4760a55b6ee600", + "last_ticked_at": "2026-07-30", "last_audit": { "light_at_counter": 27, "deep_at_counter": 25, @@ -181,6 +181,11 @@ "at": "2026-07-29", "session": "S161", "event": "CLEAN tick (session-counter-tick.ps1 M2): counter 34->35, session S160->S161, head febe6b1->8f37910. Classify-before-tick: no regression (n=161 >= stored n=160); last_ticked_head reachable (merge-base --is-ancestor exit 0). 4 fields updated, 1 history entry appended, written atomically (temp + Move-Item -Force)." + }, + { + "at": "2026-07-30", + "session": "S162", + "event": "CLEAN tick (session-counter-tick.ps1 M2): counter 35->36, session S161->S162, head 8f37910->2554845. Classify-before-tick: no regression (n=162 >= stored n=161); last_ticked_head reachable (merge-base --is-ancestor exit 0). 4 fields updated, 1 history entry appended, written atomically (temp + Move-Item -Force)." } ] } diff --git a/.claude/workflows/runs/2026-07-30-S162-cay-4-folder-gd/run.md b/.claude/workflows/runs/2026-07-30-S162-cay-4-folder-gd/run.md index b5eb9b8..17d70c6 100644 --- a/.claude/workflows/runs/2026-07-30-S162-cay-4-folder-gd/run.md +++ b/.claude/workflows/runs/2026-07-30-S162-cay-4-folder-gd/run.md @@ -19,6 +19,7 @@ goal: Lệnh anh 30-07 sáng (mid-turn, kèm screenshot khoanh đỏ dải 4 GĐ - T3 cicd-monitor: post-push verify (background) ## Stages -- [ ] T1 designer → sub-frontend-designer-0.md -- [ ] T2 reviewer → sub-reviewer-1.md -- [ ] T3 ship + cicd → ship-synthesis.md +- [x] T1 designer → sub-frontend-designer-0.md (VERDICT: SHIP-READY; 2 lần dừng-sớm resume từ diary — file trên đĩa nguyên vẹn, diagnostics giữa-Edit là snapshot dở #68; tự soi ảnh 3 vòng bắt 4 lỗi UX) +- [x] T2 reviewer → sub-reviewer-1.md (VERDICT: PASS-WITH-FLAGS — 0 blocker, 8 flag F-1..F-8) +- [x] T2-bis lead áp 5 fix rẻ F-1/F-3/F-4/F-6/F-7 (hook + 2 component + HardCopiesPage, mirror cp 4 file) + F-8 sửa 2 câu memory designer (VERIFY→APPEND); F-2 (liên-danh — chờ W5) + F-5 (layout 19rem — nghiệm thu prod) = giới hạn khai thẳng. TSC 0 · build ×2 EXIT 0 · SHA-pair 6/6 sau fix +- [x] T3 ship → ship-synthesis.md (cicd verify sau push → sub-cicd-monitor-2.md) diff --git a/.claude/workflows/runs/2026-07-30-S162-cay-4-folder-gd/ship-synthesis.md b/.claude/workflows/runs/2026-07-30-S162-cay-4-folder-gd/ship-synthesis.md new file mode 100644 index 0000000..2682f82 --- /dev/null +++ b/.claude/workflows/runs/2026-07-30-S162-cay-4-folder-gd/ship-synthesis.md @@ -0,0 +1,26 @@ +# ship-synthesis — cây 4-folder GĐ dưới từng gói thầu (S162, 2026-07-30) + +VERDICT: SHIP — build ×2 EXIT 0 · TSC 0 · SHA-pair 6/6 · reviewer PASS-WITH-FLAGS (0 blocker) · 5/8 flag vá trước ship, 2 giới-hạn khai thẳng, 1 sửa-memory. + +## Lệnh + 2 quyết owner +Lệnh anh 30-07 sáng (mid-turn, screenshot khoanh đỏ dải 4 GĐ → cây trái trang Duyệt NCC): "Tạo folder: Duyệt NCC / Kế hoạch Hợp đồng. / Duyệt Hợp đồng / Hợp đồng cứng." AskUser: **(1) folder DƯỚI TỪNG GÓI THẦU · (2) áp cả các trang GĐ khác.** Nhãn folder VERBATIM lời anh (GĐ2 "Kế hoạch Hợp đồng" ≠ strip "Kế hoạch ký kết HĐ" — cố ý, lời-mới-nhất-thắng, 1 lệnh đổi được). + +## Cái ship +- 3 file MỚI ×2 app: `components/pipeline/PipelineStageFolders.tsx` (4 folder `
    `, folder trang hiện tại mở sẵn, locked/failed/loading/truncated/hideEmpty) · `hooks/usePipelineStages.ts` (permission-gate → 2 index query key mới `pipeline-khkk-index`/`pipeline-contract-index` pageSize 200 → `buildStages()` nối GĐ2 qua `khkk.purchaseEvaluationId`, GĐ3 qua `pe.contractId`, GĐ4 = hasSealedCopy) · `components/pipeline/PipelineTreePanel.tsx` (panel cây trái trang GĐ2/GĐ4). +- 3 trang sửa ×2 app: PE list (folder dưới WorkItemGroup — khối `
  • ` phiếu 0 dòng xoá, reviewer xác nhận từng hunk) · KhkkListPage (lưới 19rem + panel, `currentStage=2`) · HardCopiesPage (`currentStage=4` + F-7 invalidate index). +- Toàn bộ THUẦN FE: 0 API mới · 0 migration · 0 đụng menu/seeder (#84) · 0 đụng strip · 0 đụng BE. + +## Chuỗi verdict +1. designer `VERDICT: SHIP-READY` — 2 lần dừng-sớm, resume-từ-diary 2/2 OK (artifact-design chống #53 ăn tiền lần nữa; "file rách" giữa chừng = diagnostics snapshot dở, tsc sau đó 0 lỗi — gotcha #68 ×3 trong 1 đợt, cả với lead-edit-batch). +2. reviewer `VERDICT: PASS-WITH-FLAGS` — 8 flag, 0 blocker; tự đo lại SHA 6/6 + diff-hunk-level. +3. lead: áp F-1 (gate = đúng key policy `KeHoachKyKet` — MenuPermissionHandler so khớp CHÍNH XÁC 1 key, không kế thừa) · F-3 (nhãn "(ngoài chỉ mục 200 — mở để xem)") · F-4 (index giữ `total` → cờ `truncated` + notice — không tụt-số im lặng) · F-6 (hideEmpty giữ folder failed/loading) · F-7 (invalidate `pipeline-contract-index` sau upload) · F-8 (sửa 2 câu memory designer sai: "0 rác 403" thêm điều kiện + "Ct_* kế thừa Contracts" là SAI vì seeder tạo row riêng). +4. Gate cuối: `npx tsc -b --force` = 0 · `npm run build` ×2 = EXIT 0 (Rolldown/Vite-8 warm ~0.5s) · SHA-pair 6/6 sau fix (4 hash mới: `403777e3…`/`88967a38…`/`5334a124…`/`e4cb0bbb…`; PE `1d8d8f75…` + Khkk `40a650bf…` giữ). + +## Giới hạn CÒN LẠI (khai thẳng, không phải quên) +- **F-2:** phiếu liên-danh đẻ N HĐ chỉ nối HĐ[0] (`CreateContractFromEvaluationFeatures.cs:145` ghi 1 `ContractId`) ⇒ GĐ3/4 hụt N−1 — lớp cardinality S87/S88; đường vá THẬT = W5 (link KHKK→HĐ). Prod hiện Contracts=0 ⇒ 0 tác động tức thời. +- **F-5:** lưới `19rem` 2 trang chưa từng render thật (harness chỉ import 2 component; dev-rig authed bị chặn gotcha #3) ⇒ nghiệm thu prod bằng mắt anh — đúng cadence {đợt nhỏ → ship → anh nhìn prod}. +- Nhẹ (reviewer, không đánh số): 2 index query fire dù chưa mở folder · view "Đã xóa" vẫn hiện GĐ2-4 sống · KPI đổi dải 1024-1279px. +- Dev-data: PE=4 · KHKK=0 · Contracts=7 · PE.contractId=0/4 ⇒ Dev nhìn thấy toàn "SẮP" là ĐÚNG dữ liệu, không phải bug. + +## cicd +Post-push: cicd-monitor 3 chân (Actions run + test-gate 590 + bundle-hash-delta ×2 + smoke) → `sub-cicd-monitor-2.md`. FE-only ⇒ không có chân migration. diff --git a/.claude/workflows/runs/2026-07-30-S162-cay-4-folder-gd/sub-reviewer-1.md b/.claude/workflows/runs/2026-07-30-S162-cay-4-folder-gd/sub-reviewer-1.md index a1f7f18..fb9bc11 100644 --- a/.claude/workflows/runs/2026-07-30-S162-cay-4-folder-gd/sub-reviewer-1.md +++ b/.claude/workflows/runs/2026-07-30-S162-cay-4-folder-gd/sub-reviewer-1.md @@ -36,3 +36,64 @@ Key FE (`usePipelineStages.ts:41-44`) vs BE: 🟡 luận-cứ trong comment `usePipelineStages.ts:10-13` ("gate phải OR nhiều key vì root có thể canRead=false") đúng cho TẦNG HIỂN THỊ nhưng sai đích cho tầng API: query nào cũng nên gate bằng ĐÚNG key policy của endpoint (`KeHoachKyKet` cho `/contract-signing-plans`). 🟢 `/contracts` KHÔNG có policy action-level (`ContractsController.cs:16-17`, chỉ `[Authorize]` trần class) ⇒ nhánh `enabled: canContract || canHardCopy` KHÔNG sinh 403. Đổi lại: đây là bare-`[Authorize]` sẵn có của hệ (#82), diff này không làm tệ thêm nhưng có nới hiển-thị: user chỉ được cấp `Hdc_ThauPhu` vẫn thấy leaf GĐ4 của MỌI loại HĐ (`usePipelineStages.ts:211`) vì gate là OR-toàn-nhóm chứ không theo `c.type`. 🟢 Không quyền ⇒ `enabled:false` (`:77`, `:91`, `PipelineTreePanel.tsx:108`) ⇒ KHÔNG gọi API; folder khoá render nhánh riêng KHÔNG `
    ` (`PipelineStageFolders.tsx:99-113`). Fetch lỗi ⇒ `failed` ⇒ badge "—" + body "Không tải được…" (`:140-156`) ⇒ cây không vỡ. ĐẠT. + +## MỤC 2 — Semantics filter / `hideEmpty` — ĐẠT phần cốt lõi, 3 gợn nhẹ + +- Nguồn cờ: PE page `filterActive = search.trim()!=='' || phase!=='' || pendingMe || deletedView` (`PurchaseEvaluationsListPage.tsx:58`); panel `hideEmpty = term.length > 0` (`PipelineTreePanel.tsx:138`). Không lọc ⇒ `buildStages` luôn trả ĐỦ 4 (`usePipelineStages.ts:240`) ⇒ "không filter = đủ 4 folder" ĐÚNG. +- **Ca "GĐ1 rỗng chiếm chỗ" KHÔNG tồn tại:** `wg.items` là nguồn dựng nhóm nên mọi gói thầu có ≥1 phiếu ⇒ `stage1.count = wg.items.length ≥ 1` (`usePipelineStages.ts:153`). Thêm chốt chặn `if (visible.length===0) return null` (`PipelineStageFolders.tsx:220`) ⇒ không có wrapper rỗng, không vỡ layout. +- 🟡 gợn 1: bộ lọc `hideEmpty` chấm bằng `stageCount(s) > 0` (`:219`) nên nó nuốt LUÔN folder `locked` và folder `failed` (cả hai đều count=0). Đang lọc mà GĐ2 lỗi tải ⇒ dòng "—" BIẾN MẤT thay vì báo lỗi (spec dòng 12 muốn "—"). Cũng nuốt folder đang `loading` ⇒ folder nhảy vào sau khi query xong (giật layout nhẹ). +- 🟡 gợn 2: count GĐ2/3/4 KHÔNG chịu bộ lọc của trang (index nạp riêng, không truyền `search`/`phase`) ⇒ "chỉ hiện folder có nội dung khớp" chỉ đúng theo nghĩa YẾU (folder khác rỗng), không phải "nội dung khớp từ khoá". +- 🟡 gợn 3: ở view "Đã xóa" (`deletedView=1`) cây vẫn treo GĐ2/3/4 SỐNG dưới phiếu đã xoá (index không lọc theo phiếu xoá) ⇒ bấm được sang KHKK/HĐ còn sống từ màn thùng rác. + +## MỤC 4 — Query hygiene + degrade `pageSize:200` + +**Không đụng cache trang cũ — ĐẠT.** Key mới `['pipeline-khkk-index']` / `['pipeline-contract-index']` (`usePipelineStages.ts:70,84`) + `['pipeline-pe-index']` (`PipelineTreePanel.tsx:101`). Key đang sống: `['khkk-list',{…}]` (`KhkkListPage.tsx:70`) · `['hard-copies',{…}]` + `['contract',id]` (`HardCopiesPage.tsx:75,268`) · `['pe-list',{…}]` + `['pe-detail',id]` (`PurchaseEvaluationsListPage.tsx:75,114`) · dashboard `['pipeline-pe-total']`/`['pipeline-contract-total']` (`UserDashboardPage.tsx:906,913`). Tất cả là mảng 1 phần tử CHUỖI KHÁC NHAU ⇒ 0 giao, 0 prefix-match. `invalidateQueries()` trống (nuốt tất) = 0 chỗ trong fe-user. +🟡 mặt trái: `qc.invalidateQueries({queryKey:['hard-copies']})` sau khi lưu bản cứng (`HardCopiesPage.tsx:290`) KHÔNG chạm `pipeline-contract-index` ⇒ badge GĐ4 trên cây giữ số cũ tới lần mount/refocus kế (staleTime 60s, `usePipelineStages.ts:34`). + +**Degrade khi vượt 200 — graceful CÓ, im lặng CŨNG CÓ.** +- Kẹp là THẬT: `PagedResult.cs:20` `PageSize = value switch { <1 => 20, >200 => 200, _ => value }`. +- Thứ tự lấy: 3 controller đều mặc định `sortDesc = true` (`ContractsController.cs:19` · `ContractSigningPlansController.cs:45` · `PurchaseEvaluationsController.cs:21`) ⇒ 200 bản **MỚI NHẤT**, không phải 200 bản cũ nhất. Tốt. +- 🔴 nhưng hook VỨT `total`: `.data.items` (`usePipelineStages.ts:76,90` · `PipelineTreePanel.tsx:107`) ⇒ mã KHÔNG BIẾT mình bị cắt ⇒ không đâu có dấu "còn N nữa". GĐ2 mất leaf + tụt count; GĐ4 tụt count; header panel "{packageCount} gói" (`PipelineTreePanel.tsx:188`) cũng tụt. Đây là **sai-số-im-lặng**, không phải chỉ degrade. +- 🔴 GĐ3 tệ hơn 1 nấc: HĐ ngoài index vẫn ra leaf nhưng **DÁN NHÃN SAI** `primary: c?.maHopDong ?? '(chưa cấp mã)'` (`usePipelineStages.ts:204`) — HĐ có mã thật vẫn bị ghi "(chưa cấp mã)". Trộn 2 nguyên nhân khác hẳn nhau (chưa cấp mã ⟂ ngoài cửa sổ 200) vào một câu khẳng định sai. +- 🔴 độc lập với 200: `pe.ContractId = contracts[0].Id` (`CreateContractFromEvaluationFeatures.cs:145`, comment `:143` ghi rõ "N HĐ liên-danh truy qua changelog") ⇒ phiếu multi-winner đẻ N hợp đồng nhưng cây chỉ nối ĐÚNG 1 (`usePipelineStages.ts:197`) ⇒ GĐ3/GĐ4 hụt N−1 và badge in "1". Đúng lớp bài `feedback_cardinality_change_grep_consumers` (S87/S88): consumer mới đọc field single trong miền multi. +- Cỡ dữ liệu đo được: Dev `PE=4 · KHKK=0 · Contracts=7 · PE có ContractId=0/4` ⇒ chưa chạm trần 200, nhưng cũng có nghĩa **GĐ3/GĐ4 trên Dev sẽ rỗng ("SẮP") toàn bộ**; prod không đo được từ máy này. + +## MỤC 5 — Lưới 19rem 2 trang (KhkkListPage + HardCopiesPage) + +- **PageHeader còn full-width — ĐÚNG.** KHKK: header đóng `/>` ở `:133`, lưới mở ở `:138` (ngoài lưới). HardCopies: PageHeader + ghi chú 1-MỐC amber đứng trước, lưới mở sau (`HardCopiesPage.tsx` hunk `@@ -110,6 +111,12`). +- **`min-w-0` có đủ:** cột phải `
    ` ở cả 2 trang; track lưới dùng `minmax(0,1fr)` ⇒ bảng không thổi vỡ cột. ĐẠT. +- **Mobile:** `` gần như VÔ TÁC DỤNG như lời chú thích: `
  • ` + `whitespace-nowrap` = **0 lần** ở cả 2 file ⇒ bảng co cột/xuống dòng chứ không sinh cuộn ngang. Vô hại, nhưng chú thích `KhkkListPage.tsx:175` ("bảng 7 cột cuộn ngang trong thẻ") mô tả một hành vi không xảy ra; thứ thật sự chặn tràn là `minmax(0,1fr)`. +- 🟡 đổi hành vi có thật (nhỏ): hàng KpiCard `lg:grid-cols-6` → `xl:grid-cols-6` (`KhkkListPage.tsx:137`) ⇒ ở dải 1024–1279px KPI từ 6 cột rớt về 3 cột. Hợp lý vì cột trái ăn 19rem, nhưng đây là thay đổi hiển thị ngoài phạm vi "chỉ thêm cây". +- 🟡 `HardCopyPanel` (`HardCopiesPage.tsx:220`, định nghĩa `:259`) nằm TRONG `` nên nay nằm trong lớp `overflow-x-auto` mới. Không mất chức năng (upload/tải/ý kiến vẫn nguyên, 0 dòng `-` trong diff), nhưng nó bị bó vào bề rộng cột phải hẹp hơn trước. +- 🔴 **Bằng chứng thị giác cho 2 trang này = KHÔNG CÓ.** Harness chỉ import `PipelineStageFolders` + `PipelineTreePanel` (`harness-s162.tsx.txt:10-11`), tự "sao markup" vỏ cây (`:61`), và `grep 'grid-cols-\[19rem'` trong harness = **0 hit** ⇒ chưa bao giờ render KhkkListPage/HardCopiesPage/lưới 19rem. Vậy dòng rubric FD4 "responsive ✓ (1440/1366/375 … bảng không bị đẩy)" trong `sub-frontend-designer-0.md:94` là **nhãn ĐO mà phép đo không tồn tại** — phải hoặc đo lại, hoặc bỏ dấu ✓ cho trục đó. +- (sạch) Harness đã xoá khỏi `src` 2 app — `ls fe-*/src/harness*` = NONE. + +## MỤC 7 — Self-write memory frontend-designer + +ĐÚNG SỰ THẬT (tự đo lại, không tin lời khai): +- `PagedResult.cs:20` kẹp ≤200 — ĐÚNG nguyên văn. +- `GetMyMenuTreeQuery.cs:96` = `HasAccess(n) => n.CanRead || n.Children.Any(HasAccess)` — ĐÚNG số dòng. +- "Sau nén 14.6KB" — `wc -c MEMORY.md` = **14.652 B**. ĐÚNG. +- Archive **moved-not-cut** (bài S102): 3 mục chuyển đi đều CÓ MẶT trong `archive/2026-06.md` (`WidgetCard` 3 hit · `OfficeDashboardPage` 3 hit · `Maximize2` 2 hit) + 3 dòng trỏ `substring` còn ở L1. KHÔNG phải cut-giả-vờ-archive. +- "MIRROR đủ 2 app / build PASS ×2 / `
  • ` giữ nguyên từng byte" — đã tự chứng ở MỤC 1 + MỤC 6. +2 chỗ SAI/NỬA-ĐÚNG sẽ đóng băng vào trí nhớ vai nếu commit nguyên: +- 🔴 "`enabled:false` ⇒ **0 rác 403**" viết như tính chất vô điều kiện — sai cho đường KHKK (xem F-1): gate OR `Khkk_List` trong khi policy API là `KeHoachKyKet.Read`. +- 🟡 "`Ct_*` **kế thừa** `Contracts`" — chỉ đúng khi menu con KHÔNG có row quyền (`GetMyMenuTreeQuery.cs:66` `!resolved.ContainsKey(m.Key)`), mà seeder LẠI tạo row cho `Ct_*` (`DbInitializer.cs:2168-2176`) ⇒ trên DB đã seed, kế thừa gần như không bao giờ chạy. Câu này sẽ dẫn sai phiên sau. +- 🟡 nhật ký `sub-frontend-designer-0.md:82` "mount CHÍNH component thật (không vẽ lại markup)" — đúng với 2 component mới, nhưng vỏ cây thì có vẽ lại ("sao markup", `harness-s162.tsx.txt:61`). + +--- + +# VERDICT: PASS-WITH-FLAGS + +Hợp đồng spec giữ đúng ở mọi ranh giới kiểm được (nhãn verbatim 4/4 · mirror SHA 6/6 · khối `
  • ` phiếu 0 dòng xoá · 0 chạm BE/menu/seeder/strip/tests · named-export) và các đường hỏng đều có nhánh đỡ (khoá/lỗi/rỗng). Không có blocker. 8 flag: nặng nhất là **im lặng về độ chính xác của con số** (cắt 200 không dấu vết, `contracts[0]` nuốt HĐ liên-danh, nhãn "(chưa cấp mã)" sai nguyên nhân) và **1 nhãn ✓ responsive chưa từng được đo**. + +- **F-1** `usePipelineStages.ts:42` — gate OR `['Khkk_List','Khkk_G1','KeHoachKyKet']` trong khi API là `[Authorize(Policy="KeHoachKyKet.Read")]` (`ContractSigningPlansController.cs:36`) và `MenuPermissionHandler.cs:40` so khớp CHÍNH XÁC 1 key ⇒ role có `Khkk_List=1`/`KeHoachKyKet=0` sẽ 403 và GĐ2 hiện "—" khắp cây. Dev 13/13 role hiện đều =1 nên chưa lộ. +- **F-2** `usePipelineStages.ts:197` + `CreateContractFromEvaluationFeatures.cs:145` — phiếu liên-danh N HĐ chỉ nối 1 (`pe.ContractId` = HĐ đầu) ⇒ GĐ3/GĐ4 hụt N−1 mà badge vẫn in số như thật. +- **F-3** `usePipelineStages.ts:204` — HĐ ngoài cửa sổ 200 bị dán nhãn "(chưa cấp mã)"; trộn "chưa có mã" với "không nằm trong index". +- **F-4** `usePipelineStages.ts:76,90` + `PipelineTreePanel.tsx:107` — vứt `total` ⇒ không phát hiện được việc bị cắt 200 ⇒ mọi count/`{n} gói` tụt im lặng, không dấu "còn N nữa". +- **F-5** `sub-frontend-designer-0.md:94` — rubric "responsive ✓ … bảng không bị đẩy" không có phép đo: harness chỉ mount 2 component (`harness-s162.tsx.txt:10-11`), `grid-cols-[19rem` = 0 hit ⇒ 2 trang lưới chưa từng render. +- **F-6** `PipelineStageFolders.tsx:219` — `hideEmpty` lọc theo count>0 nên nuốt cả folder `locked`/`failed`/`loading`; đang lọc thì dấu "—" báo lỗi biến mất. +- **F-7** `HardCopiesPage.tsx:290` — invalidate `['hard-copies']` không chạm `['pipeline-contract-index']` ⇒ badge GĐ4 lệch tới lần mount/refocus kế. +- **F-8** memory vai: "0 rác 403" (vô điều kiện — sai theo F-1) + "`Ct_*` kế thừa `Contracts`" (chỉ đúng khi con không có row; seeder có tạo row `Ct_*`) — nên sửa trước khi commit vì đây là trí nhớ dài hạn. +- (nhẹ, không đánh số) `overflow-x-auto` vô tác dụng vì `table w-full` + 0 `whitespace-nowrap`; KPI `lg:`→`xl:` đổi hiển thị dải 1024–1279; view "Đã xóa" vẫn treo GĐ2/3/4 sống; 2 query index chạy sớm ngay cả khi người dùng không mở folder nào. diff --git a/fe-admin/src/components/pipeline/PipelineStageFolders.tsx b/fe-admin/src/components/pipeline/PipelineStageFolders.tsx new file mode 100644 index 0000000..624d524 --- /dev/null +++ b/fe-admin/src/components/pipeline/PipelineStageFolders.tsx @@ -0,0 +1,237 @@ +// [S162 — 2026-07-30] Cây 4 folder GIAI ĐOẠN, đặt DƯỚI TỪNG GÓI THẦU. +// Owner 30-07 (screenshot khoanh đỏ dải 4 GĐ trỏ xuống cây trái trang Duyệt NCC): +// "Tạo folder: Duyệt NCC / Kế hoạch Hợp đồng / Duyệt Hợp đồng / Hợp đồng cứng", +// AskUser chốt (1) folder nằm DƯỚI TỪNG GÓI THẦU · (2) áp CẢ các trang GĐ khác. +// +// 🔴 Nhãn folder VERBATIM lời owner — GĐ2 CỐ Ý khác nhãn dải strip +// (`components/pe/PePipelineStrip.tsx` = "Kế hoạch ký kết HĐ"): lời-mới-nhất-thắng. +// KHÔNG sửa strip, KHÔNG sửa nhãn menu. +// +// File MIRROR SHA256 identical với fe-admin counterpart. +// +// Component này THUẦN TRÌNH BÀY: nhận `stages` đã dựng sẵn (xem +// `hooks/usePipelineStages.ts`) rồi vẽ. GĐ1 trên trang Duyệt NCC truyền `content` +// = đúng khối leaf phiếu CŨ ⇒ mọi behavior (chọn phiếu / chip GẤP / nút mở rộng) +// giữ nguyên, chỉ được RE-PARENT vào trong folder. +import { useState, type ReactNode } from 'react' +import { ChevronRight, ClipboardCheck, FileCheck, FileSignature, Lock, Stamp } from 'lucide-react' +import { cn } from '@/lib/cn' + +export type PipelineStageNo = 1 | 2 | 3 | 4 + +export const PIPELINE_STAGE_LABELS: Record = { + 1: 'Duyệt NCC', + 2: 'Kế hoạch Hợp đồng', + 3: 'Duyệt Hợp đồng', + 4: 'Hợp đồng cứng', +} + +const STAGE_ICON: Record = { + 1: ClipboardCheck, + 2: FileCheck, + 3: FileSignature, + 4: Stamp, +} + +// Pattern 14 (Tailwind JIT): class dạng LITERAL đầy đủ, không nội suy mảnh. +// Palette `@theme` (index.css:29-51) chỉ có stop 50/100/500/600/700 cho teal / +// violet / amberx ⇒ head dùng -700 (riêng brand có tới -900). +const STAGE_STYLE: Record = { + 1: { chip: 'bg-brand-50 text-brand-600', head: 'text-brand-800', count: 'bg-brand-50 text-brand-700', code: 'text-brand-700' }, + 2: { chip: 'bg-violet-50 text-violet-600', head: 'text-violet-700', count: 'bg-violet-50 text-violet-700', code: 'text-violet-700' }, + 3: { chip: 'bg-amberx-50 text-amberx-600', head: 'text-amberx-700', count: 'bg-amberx-50 text-amberx-700', code: 'text-amberx-700' }, + 4: { chip: 'bg-teal-50 text-teal-600', head: 'text-teal-700', count: 'bg-teal-50 text-teal-700', code: 'text-teal-700' }, +} + +export type PipelineLeaf = { + id: string + /** Dòng 1 — mã (font-mono). */ + primary: string + /** Dòng 2 — tên gói thầu / tên HĐ. */ + secondary?: string | null + badgeLabel?: string | null + /** Class ĐẦY ĐỦ cho badge (Pattern 14). */ + badgeClass?: string | null + onOpen?: () => void +} + +export type PipelineStage = { + n: PipelineStageNo + leaves: PipelineLeaf[] + /** Không có quyền xem module GĐ này ⇒ folder khoá + "Sắp", KHÔNG gọi API. */ + locked?: boolean + /** Query GĐ này lỗi ⇒ đếm "—", cây KHÔNG vỡ. */ + failed?: boolean + loading?: boolean + /** Nội dung tự vẽ (GĐ1 trang Duyệt NCC dùng để giữ nguyên leaf phiếu cũ). */ + content?: ReactNode + /** Bắt buộc khi dùng `content` (component không đếm hộ được). */ + count?: number + /** Index nguồn chạm trần cửa sổ 200 (`PagedResult.cs:20`) ⇒ đếm CÓ THỂ thiếu. */ + truncated?: boolean +} + +function stageCount(stage: PipelineStage): number { + return stage.content !== undefined ? (stage.count ?? 0) : stage.leaves.length +} + +function SoonChip() { + // Token lấy verbatim từ dải strip (PePipelineStrip.tsx:50-52) — uppercase CSS + // nên chữ "Sắp" hiển thị "SẮP" đúng như owner gõ. + return ( + + Sắp + + ) +} + +function StageFolder({ stage, currentStage }: { stage: PipelineStage; currentStage: PipelineStageNo }) { + // Folder của TRANG hiện tại mở sẵn; 3 folder kia đóng. Vẫn collapse/expand được. + const [open, setOpen] = useState(stage.n === currentStage) + const label = PIPELINE_STAGE_LABELS[stage.n] + const style = STAGE_STYLE[stage.n] + const Icon = STAGE_ICON[stage.n] + const count = stageCount(stage) + const muted = !!stage.locked || (!stage.failed && count === 0) + + // Khoá quyền: KHÔNG render
    (không có gì để mở, và cũng không được + // phép gọi API để biết) — chỉ 1 dòng khoá + chip "Sắp". + // 🔴 Giữ ĐÚNG 3 khoang như dòng folder thường (khoang chevron ⇢ ổ khoá · icon-chip + // · nhãn) để 4 dòng thẳng hàng tuyệt đối; bỏ khoang là lệch 26px (soi FD2 r1-D). + if (stage.locked) { + return ( +
    + + + + + {label} + +
    + ) + } + + return ( +
    setOpen((e.currentTarget as HTMLDetailsElement).open)} + className="group/stage" + > + + + + + + {/* Folder rỗng VẪN bấm mở được ⇒ nhãn là chữ ĐANG HOẠT ĐỘNG: slate-400 + (#94a3b8) chỉ 2.8:1 — dưới sàn AA 4.5:1 ⇒ dùng slate-500 (4.8:1). + Chip "Sắp"/icon giữ slate-400 theo token dải strip (trạng thái trơ). */} + + {label} + + {stage.failed ? ( + + — + + ) : count > 0 ? ( + {count} + ) : ( + + )} + + +
    + {stage.failed ? ( +
    Không tải được dữ liệu giai đoạn này.
    + ) : stage.loading ? ( +
    +
    +
    +
    + ) : stage.content !== undefined ? ( + stage.content + ) : stage.leaves.length === 0 ? ( +
    Chưa có dữ liệu ở giai đoạn này.
    + ) : ( +
      + {stage.leaves.map(leaf => ( +
    • + +
    • + ))} +
    + )} + {stage.truncated && !stage.failed && !stage.loading && ( +
    + Chỉ mục tải 200 bản ghi mới nhất — số đếm có thể thiếu. +
    + )} +
    +
    + ) +} + +export function PipelineStageFolders({ + stages, + currentStage, + hideEmpty = false, + className, +}: { + stages: PipelineStage[] + currentStage: PipelineStageNo + /** Đang lọc (search / trạng thái) ⇒ chỉ hiện folder CÓ nội dung khớp. */ + hideEmpty?: boolean + className?: string +}) { + // [F-6 S162] Đang lọc vẫn PHẢI thấy folder lỗi/đang tải — ẩn dấu "—" đi là + // nói dối trạng thái; chỉ ẩn folder RỖNG THẬT (ok/locked, count 0). + const visible = hideEmpty ? stages.filter(s => stageCount(s) > 0 || s.failed || s.loading) : stages + if (visible.length === 0) return null + return ( +
    + {visible.map(s => ( + + ))} +
    + ) +} diff --git a/fe-admin/src/components/pipeline/PipelineTreePanel.tsx b/fe-admin/src/components/pipeline/PipelineTreePanel.tsx new file mode 100644 index 0000000..e19211e --- /dev/null +++ b/fe-admin/src/components/pipeline/PipelineTreePanel.tsx @@ -0,0 +1,321 @@ +// [S162 — 2026-07-30] Panel cây trái DÙNG CHUNG cho các trang giai đoạn khác +// trang Duyệt NCC (owner chốt qua AskUser: "áp cả các trang GĐ khác"). +// Cấu trúc y hệt cây trang Duyệt NCC — 📅 Năm > 📁 Dự án > 🧱 Hạng mục > 4 folder +// giai đoạn — nên người dùng thấy CÙNG một cây ở mọi trang; folder của TRANG +// ĐANG ĐỨNG mở sẵn. +// File MIRROR SHA256 identical với fe-admin counterpart. +// +// 🔴 Cây trang Duyệt NCC (`pages/pe/PurchaseEvaluationsListPage.tsx:210-252`) KHÔNG +// bị đụng vào: nó có bộ lọc `pendingMe` + `?deleted=1` riêng. Bộ dựng nhóm ở đây +// là bản độc lập (duplicate CÓ CHỦ ĐÍCH) để trang đang chạy giữ nguyên hành vi. +import { useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { ListTree, Search } from 'lucide-react' +import { api } from '@/lib/api' +import { cn } from '@/lib/cn' +import { usePipelineStages } from '@/hooks/usePipelineStages' +import { PipelineStageFolders, type PipelineStageNo } from '@/components/pipeline/PipelineStageFolders' +import type { Paged } from '@/types/master' +import type { PeListItem } from '@/types/purchaseEvaluation' + +const STORAGE_KEY = 'pipeline_tree_expanded_v1' +const PE_INDEX_PAGE_SIZE = 200 + +type WorkItemNode = { workItemId: string | null; workItemName: string; items: PeListItem[] } +type ProjectNode = { + projectId: string | null + projectName: string + projectCode: string + workItems: WorkItemNode[] + totalCount: number +} +type YearNode = { year: number; projects: ProjectNode[]; totalCount: number } + +function buildTree(rows: PeListItem[]): YearNode[] { + const yearMap = new Map() + for (const p of rows) { + const year = new Date(p.createdAt).getFullYear() + let yg = yearMap.get(year) + if (!yg) { + yg = { year, projects: [], totalCount: 0 } + yearMap.set(year, yg) + } + const projKey = p.projectId ?? '__no_project__' + let pg = yg.projects.find(g => (g.projectId ?? '__no_project__') === projKey) + if (!pg) { + pg = { + projectId: p.projectId ?? null, + projectName: p.projectName?.trim() || '(Dự án đã xoá)', + projectCode: p.projectCode || '', + workItems: [], + totalCount: 0, + } + yg.projects.push(pg) + } + const wiKey = p.workItemId ?? '__no_workitem__' + let wg = pg.workItems.find(w => (w.workItemId ?? '__no_workitem__') === wiKey) + if (!wg) { + wg = { + workItemId: p.workItemId ?? null, + workItemName: p.workItemName?.trim() || '(Chưa gắn hạng mục)', + items: [], + } + pg.workItems.push(wg) + } + wg.items.push(p) + pg.totalCount++ + yg.totalCount++ + } + const arr = Array.from(yearMap.values()) + arr.sort((a, b) => b.year - a.year) + for (const yg of arr) { + yg.projects.sort((a, b) => + (a.projectCode || a.projectName).localeCompare(b.projectCode || b.projectName, 'vi'), + ) + for (const pg of yg.projects) { + // numeric:true — tên hạng mục mở đầu bằng STT không pad ("2 Mat…" < "10 Mat…"). + pg.workItems.sort((a, b) => a.workItemName.localeCompare(b.workItemName, 'vi', { numeric: true })) + for (const wg of pg.workItems) { + wg.items.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + } + } + } + return arr +} + +export function PipelineTreePanel({ + currentStage, + className, +}: { + currentStage: PipelineStageNo + className?: string +}) { + const { canPe, buildStages } = usePipelineStages() + const [search, setSearch] = useState('') + const [mobileOpen, setMobileOpen] = useState(false) + + // Cây cần TOÀN BỘ phiếu (không lọc trạng thái) để nhìn được toàn trình. + // Key riêng — KHÔNG dùng `pe-list` của trang Duyệt NCC (khác tham số, dùng + // chung sẽ đá nhau). + const peIndex = useQuery({ + queryKey: ['pipeline-pe-index'], + // [F-4 S162] giữ cả `total` — chạm trần cửa sổ thì khai ngay trên cây. + queryFn: async () => { + const d = ( + await api.get>('/purchase-evaluations', { + params: { page: 1, pageSize: PE_INDEX_PAGE_SIZE }, + }) + ).data + return { items: d.items, total: d.total } + }, + enabled: canPe, + staleTime: 60_000, + retry: 1, + }) + + // `null` = người dùng CHƯA từng chỉnh ⇒ dùng nhánh mở mặc định bên dưới. + // 🔴 KHÔNG dùng mẹo "set rỗng = mặc định": đóng node cuối lại cho ra set rỗng + // ⇒ mặc định bật lại ⇒ node KHÔNG đóng được (soi FD2 r1). + const [expandedSet, setExpandedSet] = useState | null>(() => { + try { + const raw = localStorage.getItem(STORAGE_KEY) + return raw ? new Set(JSON.parse(raw) as string[]) : null + } catch { + return null + } + }) + + const term = search.trim().toLowerCase() + const rows = useMemo(() => { + const all = peIndex.data?.items ?? [] + if (!term) return all + return all.filter(p => + [p.projectCode, p.projectName, p.workItemName, p.maPhieu, p.tenGoiThau] + .some(v => (v ?? '').toLowerCase().includes(term)), + ) + }, [peIndex.data, term]) + + const years = useMemo(() => buildTree(rows), [rows]) + const packageCount = years.reduce((n, y) => n + y.projects.reduce((m, p) => m + p.workItems.length, 0), 0) + // Đang tìm kiếm ⇒ chỉ hiện folder CÓ nội dung khớp (không đổ 4 folder rỗng). + const hideEmpty = term.length > 0 + + // Mặc định mở HẾT MỘT NHÁNH (năm mới nhất > dự án đầu > hạng mục đầu) để vừa + // vào trang đã THẤY NGAY folder của giai đoạn đang đứng — mở mỗi năm thì folder + // nằm sâu 3 lớp, người dùng phải bấm 2 lần mới thấy (soi FD2 r1-B/C). + const defaultOpen = useMemo(() => { + const keys = new Set() + const y = years[0] + if (!y) return keys + const yearKey = `y${y.year}` + keys.add(yearKey) + const p = y.projects[0] + if (!p) return keys + const projKey = `${yearKey}::p${p.projectId ?? '_none_'}` + keys.add(projKey) + const w = p.workItems[0] + if (w) keys.add(`${projKey}::w${w.workItemId ?? '_none_'}`) + return keys + }, [years]) + + const effectiveOpen = expandedSet ?? defaultOpen + // Đang lọc ⇒ mở hết để thấy kết quả khớp (cây thu gọn khi tìm = trông như hỏng). + const isExpanded = (key: string) => (term ? true : effectiveOpen.has(key)) + const toggleExpand = (key: string, open: boolean) => { + if (term) return // đang lọc thì cây bị ép mở — đừng ghi đè lựa chọn cũ + setExpandedSet(prev => { + const next = new Set(prev ?? defaultOpen) + if (open) next.add(key) + else next.delete(key) + try { localStorage.setItem(STORAGE_KEY, JSON.stringify([...next])) } catch { /* quota/private mode */ } + return next + }) + } + + return ( + + ) +} diff --git a/fe-admin/src/hooks/usePipelineStages.ts b/fe-admin/src/hooks/usePipelineStages.ts new file mode 100644 index 0000000..7857f78 --- /dev/null +++ b/fe-admin/src/hooks/usePipelineStages.ts @@ -0,0 +1,266 @@ +// [S162 — 2026-07-30] Nguồn dữ liệu cho cây 4 folder giai đoạn +// (`components/pipeline/PipelineStageFolders.tsx`). +// File MIRROR SHA256 identical với fe-admin counterpart. +// +// 🔴 PERMISSION 2 TẦNG (bài S118 / gotcha #82): tầng hiển thị (menu CanRead) và +// tầng API-authz ĐỘC LẬP. Ở đây gate TẦNG HIỂN THỊ **trước khi fetch** — không +// quyền ⇒ `enabled:false` ⇒ KHÔNG gọi API (tránh rác 403 trên mọi gói thầu), +// folder hiện dạng khoá + "Sắp". Query lỗi ⇒ `failed` ⇒ đếm "—", cây KHÔNG vỡ. +// +// ⚠️ Gate theo ĐÚNG tầng của endpoint [F-1 S162]: GĐ2 `/contract-signing-plans` +// có policy per-action `KeHoachKyKet.Read` (ContractSigningPlansController.cs:36) +// và `MenuPermissionHandler.cs:40` so khớp CHÍNH XÁC 1 key — KHÔNG kế thừa, +// KHÔNG OR key con ⇒ gate GĐ2 = đúng key đó (OR `Khkk_List…` sẽ fetch rồi ăn +// 403 khi role thiếu key root). GĐ3/4 `/contracts` chỉ `[Authorize]` trần (#82) +// ⇒ OR key HIỂN-THỊ `Ct_*`/`Hdc_*` vô hại (không có policy per-action để đá). +// Menu-tree fallback quyền cha CHỈ khi menu con KHÔNG có row riêng +// (`GetMyMenuTreeQuery.cs:66`) — seeder TẠO row `Ct_*`/`Khkk_*`/`Hdc_*` riêng +// (DbInitializer.cs:2168-2176/2187/2423) ⇒ thực tế KHÔNG có kế thừa nào ở đây. +import { useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useNavigate } from 'react-router-dom' +import type { ReactNode } from 'react' +import { api } from '@/lib/api' +import { usePermission } from '@/hooks/usePermission' +import type { PipelineStage } from '@/components/pipeline/PipelineStageFolders' +import { ContractPhaseColor, ContractPhaseLabel, type ContractListItem } from '@/types/contracts' +import { KHKK_PHASE_BADGE, KHKK_PHASE_LABELS, type KhkkListItemDto, type PagedResult } from '@/types/khkk' +import type { Paged } from '@/types/master' +import { + PeDisplayStatusColor, + PeDisplayStatusLabel, + getPeDisplayStatus, + type PeListItem, +} from '@/types/purchaseEvaluation' + +// `PagedResult.cs:20` — pageSize kẹp ≤200 (không 400). Index nạp 1 phát rồi +// nối cây client-side; KHÔNG N+1 theo từng gói thầu. +const INDEX_PAGE_SIZE = 200 +const INDEX_STALE_MS = 60_000 + +// `MenuKeys.cs:34` ContractTypeCodes — dùng cho Ct_*_List (GĐ3) và Hdc_* (GĐ4). +const CONTRACT_TYPE_CODES = [ + 'ThauPhu', 'GiaoKhoan', 'NhaCungCap', 'DichVu', 'MuaBan', 'NguyenTacNcc', 'NguyenTacDv', +] as const + +const PE_MENU_KEYS = ['PurchaseEvaluations', 'Pe_DuyetNcc_List', 'Pe_DuyetNccPhuongAn_List'] +// [F-1 S162] = đúng key policy của endpoint GĐ2 (xem ⚠️ đầu file). +const KHKK_MENU_KEYS = ['KeHoachKyKet'] +const CONTRACT_MENU_KEYS = ['Contracts', ...CONTRACT_TYPE_CODES.map(c => `Ct_${c}_List`)] +const HARD_COPY_MENU_KEYS = ['HopDongCung', ...CONTRACT_TYPE_CODES.map(c => `Hdc_${c}`)] + +export type PipelineLinkage = { + canPe: boolean + canKhkk: boolean + canContract: boolean + canHardCopy: boolean + khkkByPeId: Map + contractById: Map + khkkFailed: boolean + contractFailed: boolean + khkkLoading: boolean + contractLoading: boolean + /** ≥1 index chạm trần cửa sổ 200 ⇒ count GĐ2-4 CÓ THỂ thiếu (khai trên cây). */ + indexTruncated: boolean +} + +/** Nạp 1 lần index KHKK + HĐ để nối GĐ2/3/4 vào từng gói thầu. */ +export function usePipelineLinkage(): PipelineLinkage { + const { can } = usePermission() + const canPe = PE_MENU_KEYS.some(k => can(k)) + const canKhkk = KHKK_MENU_KEYS.some(k => can(k)) + const canContract = CONTRACT_MENU_KEYS.some(k => can(k)) + const canHardCopy = HARD_COPY_MENU_KEYS.some(k => can(k)) + + // Query key MỚI (không dùng lại `khkk-list` / `hard-copies` / `contracts`): + // đổi shape cache dùng chung sẽ vỡ trang khác (bài S159). + const khkkIndex = useQuery({ + queryKey: ['pipeline-khkk-index'], + // [F-4 S162] giữ cả `total`: chạm trần cửa sổ 200 phải KHAI trên cây + // (truncated), không để count tụt im lặng. + queryFn: async () => { + const d = ( + await api.get>('/contract-signing-plans', { + params: { page: 1, pageSize: INDEX_PAGE_SIZE }, + }) + ).data + return { items: d.items, total: d.total } + }, + enabled: canKhkk, + staleTime: INDEX_STALE_MS, + retry: 1, + }) + + // GĐ3 và GĐ4 cùng ăn 1 danh sách HĐ (GĐ4 = tập con `hasSealedCopy`). + const contractIndex = useQuery({ + queryKey: ['pipeline-contract-index'], + queryFn: async () => { + const d = ( + await api.get>('/contracts', { + params: { page: 1, pageSize: INDEX_PAGE_SIZE }, + }) + ).data + return { items: d.items, total: d.total } + }, + enabled: canContract || canHardCopy, + staleTime: INDEX_STALE_MS, + retry: 1, + }) + + const khkkByPeId = useMemo(() => { + const map = new Map() + for (const k of khkkIndex.data?.items ?? []) { + const bucket = map.get(k.purchaseEvaluationId) + if (bucket) bucket.push(k) + else map.set(k.purchaseEvaluationId, [k]) + } + for (const bucket of map.values()) { + bucket.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + } + return map + }, [khkkIndex.data]) + + const contractById = useMemo(() => { + const map = new Map() + for (const c of contractIndex.data?.items ?? []) map.set(c.id, c) + return map + }, [contractIndex.data]) + + return { + canPe, + canKhkk, + canContract, + canHardCopy, + khkkByPeId, + contractById, + khkkFailed: khkkIndex.isError, + contractFailed: contractIndex.isError, + // v5: `isLoading = isPending && isFetching` ⇒ query bị `enabled:false` cho + // false sẵn, không cần chèn thêm điều kiện quyền. + khkkLoading: khkkIndex.isLoading, + contractLoading: contractIndex.isLoading, + indexTruncated: + (khkkIndex.data ? khkkIndex.data.total > khkkIndex.data.items.length : false) || + (contractIndex.data ? contractIndex.data.total > contractIndex.data.items.length : false), + } +} + +export type BuildStagesOptions = { + /** GĐ1 tự vẽ (trang Duyệt NCC truyền khối leaf phiếu CŨ vào đây). */ + stage1Content?: ReactNode + stage1Count?: number +} + +/** + * Trả `buildStages(peItems)` — dựng 4 folder cho MỘT gói thầu (1 cặp Dự án × + * Hạng mục) từ danh sách phiếu PE của gói đó. + * GĐ1 = chính các phiếu ấy + * GĐ2 = KHKK có `purchaseEvaluationId` thuộc tập phiếu + * GĐ3 = HĐ trỏ bởi `pe.contractId` + * GĐ4 = đúng các HĐ ấy khi `hasSealedCopy === true` + */ +export function usePipelineStages() { + const linkage = usePipelineLinkage() + const navigate = useNavigate() + + const buildStages = (peItems: PeListItem[], opts: BuildStagesOptions = {}): PipelineStage[] => { + // ── GĐ1 — Duyệt NCC ──────────────────────────────────────────────────── + const stage1: PipelineStage = + opts.stage1Content !== undefined + ? { n: 1, leaves: [], content: opts.stage1Content, count: opts.stage1Count ?? peItems.length } + : { + n: 1, + leaves: peItems.map(p => { + const display = getPeDisplayStatus(p.phase) + return { + id: p.id, + primary: p.maPhieu ?? '(chưa cấp mã)', + secondary: p.tenGoiThau, + badgeLabel: PeDisplayStatusLabel[display], + badgeClass: PeDisplayStatusColor[display], + // `expand=1` để mở overlay chi tiết — deep-link từ trang khác + // chạy được ở mọi bề rộng (panel giữa của trang PE ẩn dưới lg). + onOpen: () => navigate(`/purchase-evaluations?id=${p.id}&expand=1`), + } + }), + } + + // ── GĐ2 — Kế hoạch Hợp đồng ──────────────────────────────────────────── + const khkkLeaves = linkage.canKhkk + ? peItems.flatMap(p => linkage.khkkByPeId.get(p.id) ?? []).map(k => ({ + id: k.id, + primary: k.maKeHoach ?? '(chưa cấp mã)', + secondary: k.peTenGoiThau, + badgeLabel: KHKK_PHASE_LABELS[k.phase], + badgeClass: KHKK_PHASE_BADGE[k.phase], + onOpen: () => navigate(`/khkk/${k.id}`), + })) + : [] + const stage2: PipelineStage = { + n: 2, + leaves: khkkLeaves, + locked: !linkage.canKhkk, + failed: linkage.khkkFailed, + loading: linkage.khkkLoading, + truncated: linkage.indexTruncated, + } + + // ── GĐ3 / GĐ4 — HĐ trỏ bởi phiếu (GĐ4 = tập con đã có bản cứng) ───────── + // HĐ nằm ngoài index (list kẹp 200) vẫn được nêu bằng leaf tối giản thay vì + // biến mất im lặng — nhưng KHÔNG suy được `hasSealedCopy` ⇒ chỉ vào GĐ3. + const seen = new Set() + const stage3Leaves: PipelineStage['leaves'] = [] + const stage4Leaves: PipelineStage['leaves'] = [] + for (const p of peItems) { + if (!p.contractId || seen.has(p.contractId)) continue + seen.add(p.contractId) + const contractId = p.contractId + const c = linkage.contractById.get(contractId) + if (linkage.canContract) { + stage3Leaves.push({ + id: contractId, + // [F-3 S162] c vắng = HĐ NGOÀI cửa sổ index 200 — đừng dán nhãn + // "(chưa cấp mã)" (trộn 2 nghĩa khác nhau). + primary: c ? (c.maHopDong ?? '(chưa cấp mã)') : '(ngoài chỉ mục 200 — mở để xem)', + secondary: c?.tenHopDong ?? p.tenGoiThau, + badgeLabel: c ? ContractPhaseLabel[c.phase] : null, + badgeClass: c ? ContractPhaseColor[c.phase] : null, + onOpen: () => navigate(`/contracts/${contractId}`), + }) + } + if (linkage.canHardCopy && c?.hasSealedCopy) { + stage4Leaves.push({ + id: contractId, + primary: c.maHopDong ?? '(chưa cấp mã)', + secondary: c.tenHopDong, + badgeLabel: 'Đã lưu bản cứng', + badgeClass: 'bg-emerald-100 text-emerald-800', + // Trang "Hợp đồng cứng" đọc `?type=` từ URL (HardCopiesPage.tsx:64-67) + // ⇒ mở đúng nhóm loại HĐ của bộ này. + onOpen: () => navigate(`/hard-copies?type=${c.type}`), + }) + } + } + + const stage3: PipelineStage = { + n: 3, + leaves: stage3Leaves, + locked: !linkage.canContract, + failed: linkage.contractFailed, + loading: linkage.contractLoading, + truncated: linkage.indexTruncated, + } + const stage4: PipelineStage = { + n: 4, + leaves: stage4Leaves, + locked: !linkage.canHardCopy, + failed: linkage.contractFailed, + loading: linkage.contractLoading, + truncated: linkage.indexTruncated, + } + + return [stage1, stage2, stage3, stage4] + } + + return { ...linkage, buildStages } +} diff --git a/fe-admin/src/pages/contracts/HardCopiesPage.tsx b/fe-admin/src/pages/contracts/HardCopiesPage.tsx index 1c6660e..6b40332 100644 --- a/fe-admin/src/pages/contracts/HardCopiesPage.tsx +++ b/fe-admin/src/pages/contracts/HardCopiesPage.tsx @@ -26,6 +26,7 @@ import { PageHeader } from '@/components/ui/PageHeader' import { Button } from '@/components/ui/Button' import { Input } from '@/components/ui/Input' import { Textarea } from '@/components/ui/Textarea' +import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel' import { api, TOKEN_KEY } from '@/lib/api' import { getErrorMessage } from '@/lib/apiError' import { cn } from '@/lib/cn' @@ -110,6 +111,12 @@ export function HardCopiesPage() { Có file scan là xem như xong. + {/* [S162] Cây toàn trình bám trái (owner chốt AskUser 30-07: folder GĐ áp + cả các trang giai đoạn khác) — folder "Hợp đồng cứng" mở sẵn ở đây. */} +
    + + +
    + {/* [S162] Cột trái ăn ~19rem ⇒ bảng cuộn ngang trong thẻ, không tràn lưới. */} +
  • @@ -217,6 +226,7 @@ export function HardCopiesPage() { })}
    + {totalPages > 1 && (
    @@ -238,6 +248,8 @@ export function HardCopiesPage() {
    )}
    + + ) } @@ -276,6 +288,7 @@ function HardCopyPanel({ contractId }: { contractId: string }) { setNote('') qc.invalidateQueries({ queryKey: ['contract', contractId] }) qc.invalidateQueries({ queryKey: ['hard-copies'] }) // badge cột "Bản cứng" lật + qc.invalidateQueries({ queryKey: ['pipeline-contract-index'] }) // [F-7 S162] badge GĐ4 trên cây lật cùng toast.success('Đã lưu bản cứng') }, onError: (err) => toast.error(`Tải lên lỗi: ${getErrorMessage(err)}`), diff --git a/fe-admin/src/pages/khkk/KhkkListPage.tsx b/fe-admin/src/pages/khkk/KhkkListPage.tsx index c64eb95..18a099a 100644 --- a/fe-admin/src/pages/khkk/KhkkListPage.tsx +++ b/fe-admin/src/pages/khkk/KhkkListPage.tsx @@ -21,6 +21,7 @@ import { PageHeader } from '@/components/ui/PageHeader' import { KpiCard } from '@/components/ui/KpiCard' import { Button } from '@/components/ui/Button' import { Input } from '@/components/ui/Input' +import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel' import { api } from '@/lib/api' import { cn } from '@/lib/cn' import { @@ -131,8 +132,15 @@ export function KhkkListPage() { } /> + {/* [S162] Cây toàn trình bám trái + nội dung cũ giữ nguyên bên phải (owner + chốt AskUser 30-07: "áp cả các trang GĐ khác"). `minmax(0,1fr)` để bảng + 7 cột không thổi vỡ lưới ở 1366; ` + + +
    {!deletedView && ( -
    +
    {phaseCards.map((c) => (
    + {/* [S162] Cột trái ăn ~19rem ⇒ bảng 7 cột cuộn ngang trong thẻ thay vì + tràn ra ngoài lưới trên laptop 1366. */} +
    @@ -244,6 +255,7 @@ export function KhkkListPage() { ))}
    +
    {totalPages > 1 && (
    @@ -260,6 +272,8 @@ export function KhkkListPage() {
    )}
    +
    +
    ) } diff --git a/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx b/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx index b8bd060..a0aa23c 100644 --- a/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx +++ b/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx @@ -29,6 +29,8 @@ import { PeDetailTabs } from '@/components/pe/PeDetailTabs' import { PeUrgentChips } from '@/components/pe/PeUrgentChips' import { PeFinalizeChip } from '@/components/pe/PeFinalizeChip' import { PeWorkflowPanel } from '@/components/pe/PeWorkflowPanel' +import { PipelineStageFolders } from '@/components/pipeline/PipelineStageFolders' +import { usePipelineStages } from '@/hooks/usePipelineStages' export function PurchaseEvaluationsListPage() { const qc = useQueryClient() @@ -47,6 +49,14 @@ export function PurchaseEvaluationsListPage() { const selectedId = sp.get('id') const isExpand = sp.get('expand') === '1' // S79 — overlay mở rộng + // [S162] Nguồn 4 folder giai đoạn dưới từng gói thầu (GĐ2 KHKK / GĐ3 HĐ / + // GĐ4 HĐ cứng). Hook tự gate quyền TRƯỚC khi fetch — không quyền thì không + // gọi API, folder hiện dạng khoá. + const { buildStages } = usePipelineStages() + // Đang lọc (tìm kiếm / trạng thái / "Chờ tôi" / "Đã xóa") ⇒ chỉ hiện folder + // CÓ nội dung khớp, tránh đổ 4 folder rỗng dưới mọi gói thầu. + const filterActive = search.trim() !== '' || phase !== '' || pendingMe || deletedView + // Mig 23 — list quy trình duyệt V2 cho dropdown filter (filter theo type screen) const approvalWorkflows = useQuery({ queryKey: ['approval-workflows-v2-filter', typeFilter], @@ -507,7 +517,12 @@ export function PurchaseEvaluationsListPage() { {wg.workItemName} {wg.items.length} -
      + {/* [S162] 4 folder giai đoạn dưới GÓI THẦU (owner chốt AskUser 30-07). + Khối
    • phiếu bên dưới GIỮ NGUYÊN từng byte — kể cả thụt lề — để + diff chứng minh không đụng behavior chọn phiếu / chip GẤP / nút + "Xem mở rộng"; nó chỉ được RE-PARENT vào folder GĐ1 "Duyệt NCC". */} + {wg.items.map(p => (
    • {/* S79 — dòng phiếu: vùng bấm chính (chọn → inline) + nút @@ -607,6 +622,7 @@ export function PurchaseEvaluationsListPage() {
    • ))}
    + ) })} />
  • ) })} diff --git a/fe-user/src/components/pipeline/PipelineStageFolders.tsx b/fe-user/src/components/pipeline/PipelineStageFolders.tsx new file mode 100644 index 0000000..624d524 --- /dev/null +++ b/fe-user/src/components/pipeline/PipelineStageFolders.tsx @@ -0,0 +1,237 @@ +// [S162 — 2026-07-30] Cây 4 folder GIAI ĐOẠN, đặt DƯỚI TỪNG GÓI THẦU. +// Owner 30-07 (screenshot khoanh đỏ dải 4 GĐ trỏ xuống cây trái trang Duyệt NCC): +// "Tạo folder: Duyệt NCC / Kế hoạch Hợp đồng / Duyệt Hợp đồng / Hợp đồng cứng", +// AskUser chốt (1) folder nằm DƯỚI TỪNG GÓI THẦU · (2) áp CẢ các trang GĐ khác. +// +// 🔴 Nhãn folder VERBATIM lời owner — GĐ2 CỐ Ý khác nhãn dải strip +// (`components/pe/PePipelineStrip.tsx` = "Kế hoạch ký kết HĐ"): lời-mới-nhất-thắng. +// KHÔNG sửa strip, KHÔNG sửa nhãn menu. +// +// File MIRROR SHA256 identical với fe-admin counterpart. +// +// Component này THUẦN TRÌNH BÀY: nhận `stages` đã dựng sẵn (xem +// `hooks/usePipelineStages.ts`) rồi vẽ. GĐ1 trên trang Duyệt NCC truyền `content` +// = đúng khối leaf phiếu CŨ ⇒ mọi behavior (chọn phiếu / chip GẤP / nút mở rộng) +// giữ nguyên, chỉ được RE-PARENT vào trong folder. +import { useState, type ReactNode } from 'react' +import { ChevronRight, ClipboardCheck, FileCheck, FileSignature, Lock, Stamp } from 'lucide-react' +import { cn } from '@/lib/cn' + +export type PipelineStageNo = 1 | 2 | 3 | 4 + +export const PIPELINE_STAGE_LABELS: Record = { + 1: 'Duyệt NCC', + 2: 'Kế hoạch Hợp đồng', + 3: 'Duyệt Hợp đồng', + 4: 'Hợp đồng cứng', +} + +const STAGE_ICON: Record = { + 1: ClipboardCheck, + 2: FileCheck, + 3: FileSignature, + 4: Stamp, +} + +// Pattern 14 (Tailwind JIT): class dạng LITERAL đầy đủ, không nội suy mảnh. +// Palette `@theme` (index.css:29-51) chỉ có stop 50/100/500/600/700 cho teal / +// violet / amberx ⇒ head dùng -700 (riêng brand có tới -900). +const STAGE_STYLE: Record = { + 1: { chip: 'bg-brand-50 text-brand-600', head: 'text-brand-800', count: 'bg-brand-50 text-brand-700', code: 'text-brand-700' }, + 2: { chip: 'bg-violet-50 text-violet-600', head: 'text-violet-700', count: 'bg-violet-50 text-violet-700', code: 'text-violet-700' }, + 3: { chip: 'bg-amberx-50 text-amberx-600', head: 'text-amberx-700', count: 'bg-amberx-50 text-amberx-700', code: 'text-amberx-700' }, + 4: { chip: 'bg-teal-50 text-teal-600', head: 'text-teal-700', count: 'bg-teal-50 text-teal-700', code: 'text-teal-700' }, +} + +export type PipelineLeaf = { + id: string + /** Dòng 1 — mã (font-mono). */ + primary: string + /** Dòng 2 — tên gói thầu / tên HĐ. */ + secondary?: string | null + badgeLabel?: string | null + /** Class ĐẦY ĐỦ cho badge (Pattern 14). */ + badgeClass?: string | null + onOpen?: () => void +} + +export type PipelineStage = { + n: PipelineStageNo + leaves: PipelineLeaf[] + /** Không có quyền xem module GĐ này ⇒ folder khoá + "Sắp", KHÔNG gọi API. */ + locked?: boolean + /** Query GĐ này lỗi ⇒ đếm "—", cây KHÔNG vỡ. */ + failed?: boolean + loading?: boolean + /** Nội dung tự vẽ (GĐ1 trang Duyệt NCC dùng để giữ nguyên leaf phiếu cũ). */ + content?: ReactNode + /** Bắt buộc khi dùng `content` (component không đếm hộ được). */ + count?: number + /** Index nguồn chạm trần cửa sổ 200 (`PagedResult.cs:20`) ⇒ đếm CÓ THỂ thiếu. */ + truncated?: boolean +} + +function stageCount(stage: PipelineStage): number { + return stage.content !== undefined ? (stage.count ?? 0) : stage.leaves.length +} + +function SoonChip() { + // Token lấy verbatim từ dải strip (PePipelineStrip.tsx:50-52) — uppercase CSS + // nên chữ "Sắp" hiển thị "SẮP" đúng như owner gõ. + return ( + + Sắp + + ) +} + +function StageFolder({ stage, currentStage }: { stage: PipelineStage; currentStage: PipelineStageNo }) { + // Folder của TRANG hiện tại mở sẵn; 3 folder kia đóng. Vẫn collapse/expand được. + const [open, setOpen] = useState(stage.n === currentStage) + const label = PIPELINE_STAGE_LABELS[stage.n] + const style = STAGE_STYLE[stage.n] + const Icon = STAGE_ICON[stage.n] + const count = stageCount(stage) + const muted = !!stage.locked || (!stage.failed && count === 0) + + // Khoá quyền: KHÔNG render
    (không có gì để mở, và cũng không được + // phép gọi API để biết) — chỉ 1 dòng khoá + chip "Sắp". + // 🔴 Giữ ĐÚNG 3 khoang như dòng folder thường (khoang chevron ⇢ ổ khoá · icon-chip + // · nhãn) để 4 dòng thẳng hàng tuyệt đối; bỏ khoang là lệch 26px (soi FD2 r1-D). + if (stage.locked) { + return ( +
    + + + + + {label} + +
    + ) + } + + return ( +
    setOpen((e.currentTarget as HTMLDetailsElement).open)} + className="group/stage" + > + + + + + + {/* Folder rỗng VẪN bấm mở được ⇒ nhãn là chữ ĐANG HOẠT ĐỘNG: slate-400 + (#94a3b8) chỉ 2.8:1 — dưới sàn AA 4.5:1 ⇒ dùng slate-500 (4.8:1). + Chip "Sắp"/icon giữ slate-400 theo token dải strip (trạng thái trơ). */} + + {label} + + {stage.failed ? ( + + — + + ) : count > 0 ? ( + {count} + ) : ( + + )} + + +
    + {stage.failed ? ( +
    Không tải được dữ liệu giai đoạn này.
    + ) : stage.loading ? ( +
    +
    +
    +
    + ) : stage.content !== undefined ? ( + stage.content + ) : stage.leaves.length === 0 ? ( +
    Chưa có dữ liệu ở giai đoạn này.
    + ) : ( +
      + {stage.leaves.map(leaf => ( +
    • + +
    • + ))} +
    + )} + {stage.truncated && !stage.failed && !stage.loading && ( +
    + Chỉ mục tải 200 bản ghi mới nhất — số đếm có thể thiếu. +
    + )} +
    +
    + ) +} + +export function PipelineStageFolders({ + stages, + currentStage, + hideEmpty = false, + className, +}: { + stages: PipelineStage[] + currentStage: PipelineStageNo + /** Đang lọc (search / trạng thái) ⇒ chỉ hiện folder CÓ nội dung khớp. */ + hideEmpty?: boolean + className?: string +}) { + // [F-6 S162] Đang lọc vẫn PHẢI thấy folder lỗi/đang tải — ẩn dấu "—" đi là + // nói dối trạng thái; chỉ ẩn folder RỖNG THẬT (ok/locked, count 0). + const visible = hideEmpty ? stages.filter(s => stageCount(s) > 0 || s.failed || s.loading) : stages + if (visible.length === 0) return null + return ( +
    + {visible.map(s => ( + + ))} +
    + ) +} diff --git a/fe-user/src/components/pipeline/PipelineTreePanel.tsx b/fe-user/src/components/pipeline/PipelineTreePanel.tsx new file mode 100644 index 0000000..e19211e --- /dev/null +++ b/fe-user/src/components/pipeline/PipelineTreePanel.tsx @@ -0,0 +1,321 @@ +// [S162 — 2026-07-30] Panel cây trái DÙNG CHUNG cho các trang giai đoạn khác +// trang Duyệt NCC (owner chốt qua AskUser: "áp cả các trang GĐ khác"). +// Cấu trúc y hệt cây trang Duyệt NCC — 📅 Năm > 📁 Dự án > 🧱 Hạng mục > 4 folder +// giai đoạn — nên người dùng thấy CÙNG một cây ở mọi trang; folder của TRANG +// ĐANG ĐỨNG mở sẵn. +// File MIRROR SHA256 identical với fe-admin counterpart. +// +// 🔴 Cây trang Duyệt NCC (`pages/pe/PurchaseEvaluationsListPage.tsx:210-252`) KHÔNG +// bị đụng vào: nó có bộ lọc `pendingMe` + `?deleted=1` riêng. Bộ dựng nhóm ở đây +// là bản độc lập (duplicate CÓ CHỦ ĐÍCH) để trang đang chạy giữ nguyên hành vi. +import { useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { ListTree, Search } from 'lucide-react' +import { api } from '@/lib/api' +import { cn } from '@/lib/cn' +import { usePipelineStages } from '@/hooks/usePipelineStages' +import { PipelineStageFolders, type PipelineStageNo } from '@/components/pipeline/PipelineStageFolders' +import type { Paged } from '@/types/master' +import type { PeListItem } from '@/types/purchaseEvaluation' + +const STORAGE_KEY = 'pipeline_tree_expanded_v1' +const PE_INDEX_PAGE_SIZE = 200 + +type WorkItemNode = { workItemId: string | null; workItemName: string; items: PeListItem[] } +type ProjectNode = { + projectId: string | null + projectName: string + projectCode: string + workItems: WorkItemNode[] + totalCount: number +} +type YearNode = { year: number; projects: ProjectNode[]; totalCount: number } + +function buildTree(rows: PeListItem[]): YearNode[] { + const yearMap = new Map() + for (const p of rows) { + const year = new Date(p.createdAt).getFullYear() + let yg = yearMap.get(year) + if (!yg) { + yg = { year, projects: [], totalCount: 0 } + yearMap.set(year, yg) + } + const projKey = p.projectId ?? '__no_project__' + let pg = yg.projects.find(g => (g.projectId ?? '__no_project__') === projKey) + if (!pg) { + pg = { + projectId: p.projectId ?? null, + projectName: p.projectName?.trim() || '(Dự án đã xoá)', + projectCode: p.projectCode || '', + workItems: [], + totalCount: 0, + } + yg.projects.push(pg) + } + const wiKey = p.workItemId ?? '__no_workitem__' + let wg = pg.workItems.find(w => (w.workItemId ?? '__no_workitem__') === wiKey) + if (!wg) { + wg = { + workItemId: p.workItemId ?? null, + workItemName: p.workItemName?.trim() || '(Chưa gắn hạng mục)', + items: [], + } + pg.workItems.push(wg) + } + wg.items.push(p) + pg.totalCount++ + yg.totalCount++ + } + const arr = Array.from(yearMap.values()) + arr.sort((a, b) => b.year - a.year) + for (const yg of arr) { + yg.projects.sort((a, b) => + (a.projectCode || a.projectName).localeCompare(b.projectCode || b.projectName, 'vi'), + ) + for (const pg of yg.projects) { + // numeric:true — tên hạng mục mở đầu bằng STT không pad ("2 Mat…" < "10 Mat…"). + pg.workItems.sort((a, b) => a.workItemName.localeCompare(b.workItemName, 'vi', { numeric: true })) + for (const wg of pg.workItems) { + wg.items.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + } + } + } + return arr +} + +export function PipelineTreePanel({ + currentStage, + className, +}: { + currentStage: PipelineStageNo + className?: string +}) { + const { canPe, buildStages } = usePipelineStages() + const [search, setSearch] = useState('') + const [mobileOpen, setMobileOpen] = useState(false) + + // Cây cần TOÀN BỘ phiếu (không lọc trạng thái) để nhìn được toàn trình. + // Key riêng — KHÔNG dùng `pe-list` của trang Duyệt NCC (khác tham số, dùng + // chung sẽ đá nhau). + const peIndex = useQuery({ + queryKey: ['pipeline-pe-index'], + // [F-4 S162] giữ cả `total` — chạm trần cửa sổ thì khai ngay trên cây. + queryFn: async () => { + const d = ( + await api.get>('/purchase-evaluations', { + params: { page: 1, pageSize: PE_INDEX_PAGE_SIZE }, + }) + ).data + return { items: d.items, total: d.total } + }, + enabled: canPe, + staleTime: 60_000, + retry: 1, + }) + + // `null` = người dùng CHƯA từng chỉnh ⇒ dùng nhánh mở mặc định bên dưới. + // 🔴 KHÔNG dùng mẹo "set rỗng = mặc định": đóng node cuối lại cho ra set rỗng + // ⇒ mặc định bật lại ⇒ node KHÔNG đóng được (soi FD2 r1). + const [expandedSet, setExpandedSet] = useState | null>(() => { + try { + const raw = localStorage.getItem(STORAGE_KEY) + return raw ? new Set(JSON.parse(raw) as string[]) : null + } catch { + return null + } + }) + + const term = search.trim().toLowerCase() + const rows = useMemo(() => { + const all = peIndex.data?.items ?? [] + if (!term) return all + return all.filter(p => + [p.projectCode, p.projectName, p.workItemName, p.maPhieu, p.tenGoiThau] + .some(v => (v ?? '').toLowerCase().includes(term)), + ) + }, [peIndex.data, term]) + + const years = useMemo(() => buildTree(rows), [rows]) + const packageCount = years.reduce((n, y) => n + y.projects.reduce((m, p) => m + p.workItems.length, 0), 0) + // Đang tìm kiếm ⇒ chỉ hiện folder CÓ nội dung khớp (không đổ 4 folder rỗng). + const hideEmpty = term.length > 0 + + // Mặc định mở HẾT MỘT NHÁNH (năm mới nhất > dự án đầu > hạng mục đầu) để vừa + // vào trang đã THẤY NGAY folder của giai đoạn đang đứng — mở mỗi năm thì folder + // nằm sâu 3 lớp, người dùng phải bấm 2 lần mới thấy (soi FD2 r1-B/C). + const defaultOpen = useMemo(() => { + const keys = new Set() + const y = years[0] + if (!y) return keys + const yearKey = `y${y.year}` + keys.add(yearKey) + const p = y.projects[0] + if (!p) return keys + const projKey = `${yearKey}::p${p.projectId ?? '_none_'}` + keys.add(projKey) + const w = p.workItems[0] + if (w) keys.add(`${projKey}::w${w.workItemId ?? '_none_'}`) + return keys + }, [years]) + + const effectiveOpen = expandedSet ?? defaultOpen + // Đang lọc ⇒ mở hết để thấy kết quả khớp (cây thu gọn khi tìm = trông như hỏng). + const isExpanded = (key: string) => (term ? true : effectiveOpen.has(key)) + const toggleExpand = (key: string, open: boolean) => { + if (term) return // đang lọc thì cây bị ép mở — đừng ghi đè lựa chọn cũ + setExpandedSet(prev => { + const next = new Set(prev ?? defaultOpen) + if (open) next.add(key) + else next.delete(key) + try { localStorage.setItem(STORAGE_KEY, JSON.stringify([...next])) } catch { /* quota/private mode */ } + return next + }) + } + + return ( + + ) +} diff --git a/fe-user/src/hooks/usePipelineStages.ts b/fe-user/src/hooks/usePipelineStages.ts new file mode 100644 index 0000000..7857f78 --- /dev/null +++ b/fe-user/src/hooks/usePipelineStages.ts @@ -0,0 +1,266 @@ +// [S162 — 2026-07-30] Nguồn dữ liệu cho cây 4 folder giai đoạn +// (`components/pipeline/PipelineStageFolders.tsx`). +// File MIRROR SHA256 identical với fe-admin counterpart. +// +// 🔴 PERMISSION 2 TẦNG (bài S118 / gotcha #82): tầng hiển thị (menu CanRead) và +// tầng API-authz ĐỘC LẬP. Ở đây gate TẦNG HIỂN THỊ **trước khi fetch** — không +// quyền ⇒ `enabled:false` ⇒ KHÔNG gọi API (tránh rác 403 trên mọi gói thầu), +// folder hiện dạng khoá + "Sắp". Query lỗi ⇒ `failed` ⇒ đếm "—", cây KHÔNG vỡ. +// +// ⚠️ Gate theo ĐÚNG tầng của endpoint [F-1 S162]: GĐ2 `/contract-signing-plans` +// có policy per-action `KeHoachKyKet.Read` (ContractSigningPlansController.cs:36) +// và `MenuPermissionHandler.cs:40` so khớp CHÍNH XÁC 1 key — KHÔNG kế thừa, +// KHÔNG OR key con ⇒ gate GĐ2 = đúng key đó (OR `Khkk_List…` sẽ fetch rồi ăn +// 403 khi role thiếu key root). GĐ3/4 `/contracts` chỉ `[Authorize]` trần (#82) +// ⇒ OR key HIỂN-THỊ `Ct_*`/`Hdc_*` vô hại (không có policy per-action để đá). +// Menu-tree fallback quyền cha CHỈ khi menu con KHÔNG có row riêng +// (`GetMyMenuTreeQuery.cs:66`) — seeder TẠO row `Ct_*`/`Khkk_*`/`Hdc_*` riêng +// (DbInitializer.cs:2168-2176/2187/2423) ⇒ thực tế KHÔNG có kế thừa nào ở đây. +import { useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useNavigate } from 'react-router-dom' +import type { ReactNode } from 'react' +import { api } from '@/lib/api' +import { usePermission } from '@/hooks/usePermission' +import type { PipelineStage } from '@/components/pipeline/PipelineStageFolders' +import { ContractPhaseColor, ContractPhaseLabel, type ContractListItem } from '@/types/contracts' +import { KHKK_PHASE_BADGE, KHKK_PHASE_LABELS, type KhkkListItemDto, type PagedResult } from '@/types/khkk' +import type { Paged } from '@/types/master' +import { + PeDisplayStatusColor, + PeDisplayStatusLabel, + getPeDisplayStatus, + type PeListItem, +} from '@/types/purchaseEvaluation' + +// `PagedResult.cs:20` — pageSize kẹp ≤200 (không 400). Index nạp 1 phát rồi +// nối cây client-side; KHÔNG N+1 theo từng gói thầu. +const INDEX_PAGE_SIZE = 200 +const INDEX_STALE_MS = 60_000 + +// `MenuKeys.cs:34` ContractTypeCodes — dùng cho Ct_*_List (GĐ3) và Hdc_* (GĐ4). +const CONTRACT_TYPE_CODES = [ + 'ThauPhu', 'GiaoKhoan', 'NhaCungCap', 'DichVu', 'MuaBan', 'NguyenTacNcc', 'NguyenTacDv', +] as const + +const PE_MENU_KEYS = ['PurchaseEvaluations', 'Pe_DuyetNcc_List', 'Pe_DuyetNccPhuongAn_List'] +// [F-1 S162] = đúng key policy của endpoint GĐ2 (xem ⚠️ đầu file). +const KHKK_MENU_KEYS = ['KeHoachKyKet'] +const CONTRACT_MENU_KEYS = ['Contracts', ...CONTRACT_TYPE_CODES.map(c => `Ct_${c}_List`)] +const HARD_COPY_MENU_KEYS = ['HopDongCung', ...CONTRACT_TYPE_CODES.map(c => `Hdc_${c}`)] + +export type PipelineLinkage = { + canPe: boolean + canKhkk: boolean + canContract: boolean + canHardCopy: boolean + khkkByPeId: Map + contractById: Map + khkkFailed: boolean + contractFailed: boolean + khkkLoading: boolean + contractLoading: boolean + /** ≥1 index chạm trần cửa sổ 200 ⇒ count GĐ2-4 CÓ THỂ thiếu (khai trên cây). */ + indexTruncated: boolean +} + +/** Nạp 1 lần index KHKK + HĐ để nối GĐ2/3/4 vào từng gói thầu. */ +export function usePipelineLinkage(): PipelineLinkage { + const { can } = usePermission() + const canPe = PE_MENU_KEYS.some(k => can(k)) + const canKhkk = KHKK_MENU_KEYS.some(k => can(k)) + const canContract = CONTRACT_MENU_KEYS.some(k => can(k)) + const canHardCopy = HARD_COPY_MENU_KEYS.some(k => can(k)) + + // Query key MỚI (không dùng lại `khkk-list` / `hard-copies` / `contracts`): + // đổi shape cache dùng chung sẽ vỡ trang khác (bài S159). + const khkkIndex = useQuery({ + queryKey: ['pipeline-khkk-index'], + // [F-4 S162] giữ cả `total`: chạm trần cửa sổ 200 phải KHAI trên cây + // (truncated), không để count tụt im lặng. + queryFn: async () => { + const d = ( + await api.get>('/contract-signing-plans', { + params: { page: 1, pageSize: INDEX_PAGE_SIZE }, + }) + ).data + return { items: d.items, total: d.total } + }, + enabled: canKhkk, + staleTime: INDEX_STALE_MS, + retry: 1, + }) + + // GĐ3 và GĐ4 cùng ăn 1 danh sách HĐ (GĐ4 = tập con `hasSealedCopy`). + const contractIndex = useQuery({ + queryKey: ['pipeline-contract-index'], + queryFn: async () => { + const d = ( + await api.get>('/contracts', { + params: { page: 1, pageSize: INDEX_PAGE_SIZE }, + }) + ).data + return { items: d.items, total: d.total } + }, + enabled: canContract || canHardCopy, + staleTime: INDEX_STALE_MS, + retry: 1, + }) + + const khkkByPeId = useMemo(() => { + const map = new Map() + for (const k of khkkIndex.data?.items ?? []) { + const bucket = map.get(k.purchaseEvaluationId) + if (bucket) bucket.push(k) + else map.set(k.purchaseEvaluationId, [k]) + } + for (const bucket of map.values()) { + bucket.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + } + return map + }, [khkkIndex.data]) + + const contractById = useMemo(() => { + const map = new Map() + for (const c of contractIndex.data?.items ?? []) map.set(c.id, c) + return map + }, [contractIndex.data]) + + return { + canPe, + canKhkk, + canContract, + canHardCopy, + khkkByPeId, + contractById, + khkkFailed: khkkIndex.isError, + contractFailed: contractIndex.isError, + // v5: `isLoading = isPending && isFetching` ⇒ query bị `enabled:false` cho + // false sẵn, không cần chèn thêm điều kiện quyền. + khkkLoading: khkkIndex.isLoading, + contractLoading: contractIndex.isLoading, + indexTruncated: + (khkkIndex.data ? khkkIndex.data.total > khkkIndex.data.items.length : false) || + (contractIndex.data ? contractIndex.data.total > contractIndex.data.items.length : false), + } +} + +export type BuildStagesOptions = { + /** GĐ1 tự vẽ (trang Duyệt NCC truyền khối leaf phiếu CŨ vào đây). */ + stage1Content?: ReactNode + stage1Count?: number +} + +/** + * Trả `buildStages(peItems)` — dựng 4 folder cho MỘT gói thầu (1 cặp Dự án × + * Hạng mục) từ danh sách phiếu PE của gói đó. + * GĐ1 = chính các phiếu ấy + * GĐ2 = KHKK có `purchaseEvaluationId` thuộc tập phiếu + * GĐ3 = HĐ trỏ bởi `pe.contractId` + * GĐ4 = đúng các HĐ ấy khi `hasSealedCopy === true` + */ +export function usePipelineStages() { + const linkage = usePipelineLinkage() + const navigate = useNavigate() + + const buildStages = (peItems: PeListItem[], opts: BuildStagesOptions = {}): PipelineStage[] => { + // ── GĐ1 — Duyệt NCC ──────────────────────────────────────────────────── + const stage1: PipelineStage = + opts.stage1Content !== undefined + ? { n: 1, leaves: [], content: opts.stage1Content, count: opts.stage1Count ?? peItems.length } + : { + n: 1, + leaves: peItems.map(p => { + const display = getPeDisplayStatus(p.phase) + return { + id: p.id, + primary: p.maPhieu ?? '(chưa cấp mã)', + secondary: p.tenGoiThau, + badgeLabel: PeDisplayStatusLabel[display], + badgeClass: PeDisplayStatusColor[display], + // `expand=1` để mở overlay chi tiết — deep-link từ trang khác + // chạy được ở mọi bề rộng (panel giữa của trang PE ẩn dưới lg). + onOpen: () => navigate(`/purchase-evaluations?id=${p.id}&expand=1`), + } + }), + } + + // ── GĐ2 — Kế hoạch Hợp đồng ──────────────────────────────────────────── + const khkkLeaves = linkage.canKhkk + ? peItems.flatMap(p => linkage.khkkByPeId.get(p.id) ?? []).map(k => ({ + id: k.id, + primary: k.maKeHoach ?? '(chưa cấp mã)', + secondary: k.peTenGoiThau, + badgeLabel: KHKK_PHASE_LABELS[k.phase], + badgeClass: KHKK_PHASE_BADGE[k.phase], + onOpen: () => navigate(`/khkk/${k.id}`), + })) + : [] + const stage2: PipelineStage = { + n: 2, + leaves: khkkLeaves, + locked: !linkage.canKhkk, + failed: linkage.khkkFailed, + loading: linkage.khkkLoading, + truncated: linkage.indexTruncated, + } + + // ── GĐ3 / GĐ4 — HĐ trỏ bởi phiếu (GĐ4 = tập con đã có bản cứng) ───────── + // HĐ nằm ngoài index (list kẹp 200) vẫn được nêu bằng leaf tối giản thay vì + // biến mất im lặng — nhưng KHÔNG suy được `hasSealedCopy` ⇒ chỉ vào GĐ3. + const seen = new Set() + const stage3Leaves: PipelineStage['leaves'] = [] + const stage4Leaves: PipelineStage['leaves'] = [] + for (const p of peItems) { + if (!p.contractId || seen.has(p.contractId)) continue + seen.add(p.contractId) + const contractId = p.contractId + const c = linkage.contractById.get(contractId) + if (linkage.canContract) { + stage3Leaves.push({ + id: contractId, + // [F-3 S162] c vắng = HĐ NGOÀI cửa sổ index 200 — đừng dán nhãn + // "(chưa cấp mã)" (trộn 2 nghĩa khác nhau). + primary: c ? (c.maHopDong ?? '(chưa cấp mã)') : '(ngoài chỉ mục 200 — mở để xem)', + secondary: c?.tenHopDong ?? p.tenGoiThau, + badgeLabel: c ? ContractPhaseLabel[c.phase] : null, + badgeClass: c ? ContractPhaseColor[c.phase] : null, + onOpen: () => navigate(`/contracts/${contractId}`), + }) + } + if (linkage.canHardCopy && c?.hasSealedCopy) { + stage4Leaves.push({ + id: contractId, + primary: c.maHopDong ?? '(chưa cấp mã)', + secondary: c.tenHopDong, + badgeLabel: 'Đã lưu bản cứng', + badgeClass: 'bg-emerald-100 text-emerald-800', + // Trang "Hợp đồng cứng" đọc `?type=` từ URL (HardCopiesPage.tsx:64-67) + // ⇒ mở đúng nhóm loại HĐ của bộ này. + onOpen: () => navigate(`/hard-copies?type=${c.type}`), + }) + } + } + + const stage3: PipelineStage = { + n: 3, + leaves: stage3Leaves, + locked: !linkage.canContract, + failed: linkage.contractFailed, + loading: linkage.contractLoading, + truncated: linkage.indexTruncated, + } + const stage4: PipelineStage = { + n: 4, + leaves: stage4Leaves, + locked: !linkage.canHardCopy, + failed: linkage.contractFailed, + loading: linkage.contractLoading, + truncated: linkage.indexTruncated, + } + + return [stage1, stage2, stage3, stage4] + } + + return { ...linkage, buildStages } +} diff --git a/fe-user/src/pages/contracts/HardCopiesPage.tsx b/fe-user/src/pages/contracts/HardCopiesPage.tsx index 1c6660e..6b40332 100644 --- a/fe-user/src/pages/contracts/HardCopiesPage.tsx +++ b/fe-user/src/pages/contracts/HardCopiesPage.tsx @@ -26,6 +26,7 @@ import { PageHeader } from '@/components/ui/PageHeader' import { Button } from '@/components/ui/Button' import { Input } from '@/components/ui/Input' import { Textarea } from '@/components/ui/Textarea' +import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel' import { api, TOKEN_KEY } from '@/lib/api' import { getErrorMessage } from '@/lib/apiError' import { cn } from '@/lib/cn' @@ -110,6 +111,12 @@ export function HardCopiesPage() { Có file scan là xem như xong. + {/* [S162] Cây toàn trình bám trái (owner chốt AskUser 30-07: folder GĐ áp + cả các trang giai đoạn khác) — folder "Hợp đồng cứng" mở sẵn ở đây. */} +
    + + +
    + {/* [S162] Cột trái ăn ~19rem ⇒ bảng cuộn ngang trong thẻ, không tràn lưới. */} +
    @@ -217,6 +226,7 @@ export function HardCopiesPage() { })}
    +
    {totalPages > 1 && (
    @@ -238,6 +248,8 @@ export function HardCopiesPage() {
    )}
    +
    +
    ) } @@ -276,6 +288,7 @@ function HardCopyPanel({ contractId }: { contractId: string }) { setNote('') qc.invalidateQueries({ queryKey: ['contract', contractId] }) qc.invalidateQueries({ queryKey: ['hard-copies'] }) // badge cột "Bản cứng" lật + qc.invalidateQueries({ queryKey: ['pipeline-contract-index'] }) // [F-7 S162] badge GĐ4 trên cây lật cùng toast.success('Đã lưu bản cứng') }, onError: (err) => toast.error(`Tải lên lỗi: ${getErrorMessage(err)}`), diff --git a/fe-user/src/pages/khkk/KhkkListPage.tsx b/fe-user/src/pages/khkk/KhkkListPage.tsx index c64eb95..18a099a 100644 --- a/fe-user/src/pages/khkk/KhkkListPage.tsx +++ b/fe-user/src/pages/khkk/KhkkListPage.tsx @@ -21,6 +21,7 @@ import { PageHeader } from '@/components/ui/PageHeader' import { KpiCard } from '@/components/ui/KpiCard' import { Button } from '@/components/ui/Button' import { Input } from '@/components/ui/Input' +import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel' import { api } from '@/lib/api' import { cn } from '@/lib/cn' import { @@ -131,8 +132,15 @@ export function KhkkListPage() { } /> + {/* [S162] Cây toàn trình bám trái + nội dung cũ giữ nguyên bên phải (owner + chốt AskUser 30-07: "áp cả các trang GĐ khác"). `minmax(0,1fr)` để bảng + 7 cột không thổi vỡ lưới ở 1366; ` + + +
    {!deletedView && ( -
    +
    {phaseCards.map((c) => (
    + {/* [S162] Cột trái ăn ~19rem ⇒ bảng 7 cột cuộn ngang trong thẻ thay vì + tràn ra ngoài lưới trên laptop 1366. */} +
    @@ -244,6 +255,7 @@ export function KhkkListPage() { ))}
    +
    {totalPages > 1 && (
    @@ -260,6 +272,8 @@ export function KhkkListPage() {
    )}
    +
    +
    ) } diff --git a/fe-user/src/pages/pe/PurchaseEvaluationsListPage.tsx b/fe-user/src/pages/pe/PurchaseEvaluationsListPage.tsx index b8bd060..a0aa23c 100644 --- a/fe-user/src/pages/pe/PurchaseEvaluationsListPage.tsx +++ b/fe-user/src/pages/pe/PurchaseEvaluationsListPage.tsx @@ -29,6 +29,8 @@ import { PeDetailTabs } from '@/components/pe/PeDetailTabs' import { PeUrgentChips } from '@/components/pe/PeUrgentChips' import { PeFinalizeChip } from '@/components/pe/PeFinalizeChip' import { PeWorkflowPanel } from '@/components/pe/PeWorkflowPanel' +import { PipelineStageFolders } from '@/components/pipeline/PipelineStageFolders' +import { usePipelineStages } from '@/hooks/usePipelineStages' export function PurchaseEvaluationsListPage() { const qc = useQueryClient() @@ -47,6 +49,14 @@ export function PurchaseEvaluationsListPage() { const selectedId = sp.get('id') const isExpand = sp.get('expand') === '1' // S79 — overlay mở rộng + // [S162] Nguồn 4 folder giai đoạn dưới từng gói thầu (GĐ2 KHKK / GĐ3 HĐ / + // GĐ4 HĐ cứng). Hook tự gate quyền TRƯỚC khi fetch — không quyền thì không + // gọi API, folder hiện dạng khoá. + const { buildStages } = usePipelineStages() + // Đang lọc (tìm kiếm / trạng thái / "Chờ tôi" / "Đã xóa") ⇒ chỉ hiện folder + // CÓ nội dung khớp, tránh đổ 4 folder rỗng dưới mọi gói thầu. + const filterActive = search.trim() !== '' || phase !== '' || pendingMe || deletedView + // Mig 23 — list quy trình duyệt V2 cho dropdown filter (filter theo type screen) const approvalWorkflows = useQuery({ queryKey: ['approval-workflows-v2-filter', typeFilter], @@ -507,7 +517,12 @@ export function PurchaseEvaluationsListPage() { {wg.workItemName} {wg.items.length} -
      + {/* [S162] 4 folder giai đoạn dưới GÓI THẦU (owner chốt AskUser 30-07). + Khối
    • phiếu bên dưới GIỮ NGUYÊN từng byte — kể cả thụt lề — để + diff chứng minh không đụng behavior chọn phiếu / chip GẤP / nút + "Xem mở rộng"; nó chỉ được RE-PARENT vào folder GĐ1 "Duyệt NCC". */} + {wg.items.map(p => (
    • {/* S79 — dòng phiếu: vùng bấm chính (chọn → inline) + nút @@ -607,6 +622,7 @@ export function PurchaseEvaluationsListPage() {
    • ))}
    + ) })} />
    ) })}