[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
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m50s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m50s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -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.
|
- **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
|
## 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; `<details>` 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 `<li>` 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` + `<table>` 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; `<details>` 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 `<li>` 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` + `<table>` 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].
|
- **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)")_
|
- _(**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")_
|
- _(**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")_
|
||||||
|
|||||||
@ -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).",
|
"_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.",
|
"_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,
|
"counter": 36,
|
||||||
"last_ticked_session": "S161",
|
"last_ticked_session": "S162",
|
||||||
"last_ticked_head": "8f37910552a4af0e995eaa304d5298f586068d0c",
|
"last_ticked_head": "2554845b7f98b30fc980289cde4760a55b6ee600",
|
||||||
"last_ticked_at": "2026-07-29",
|
"last_ticked_at": "2026-07-30",
|
||||||
"last_audit": {
|
"last_audit": {
|
||||||
"light_at_counter": 27,
|
"light_at_counter": 27,
|
||||||
"deep_at_counter": 25,
|
"deep_at_counter": 25,
|
||||||
@ -181,6 +181,11 @@
|
|||||||
"at": "2026-07-29",
|
"at": "2026-07-29",
|
||||||
"session": "S161",
|
"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)."
|
"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)."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
- T3 cicd-monitor: post-push verify (background)
|
||||||
|
|
||||||
## Stages
|
## Stages
|
||||||
- [ ] T1 designer → sub-frontend-designer-0.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)
|
||||||
- [ ] T2 reviewer → sub-reviewer-1.md
|
- [x] T2 reviewer → sub-reviewer-1.md (VERDICT: PASS-WITH-FLAGS — 0 blocker, 8 flag F-1..F-8)
|
||||||
- [ ] T3 ship + cicd → ship-synthesis.md
|
- [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)
|
||||||
|
|||||||
@ -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 `<details>`, 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 `<li>` 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.
|
||||||
@ -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`).
|
🟡 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`.
|
🟢 `/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 `<details>` (`PipelineStageFolders.tsx:99-113`). Fetch lỗi ⇒ `failed` ⇒ badge "—" + body "Không tải được…" (`:140-156`) ⇒ cây không vỡ. ĐẠT.
|
🟢 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 `<details>` (`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 `<div className="min-w-0 space-y-5">` ở cả 2 trang; track lưới dùng `minmax(0,1fr)` ⇒ bảng không thổi vỡ cột. ĐẠT.
|
||||||
|
- **Mobile:** `<lg` lưới về 1 cột, panel nằm TRÊN; thân panel `mobileOpen ? 'flex' : 'hidden'` + `lg:flex` (`PipelineTreePanel.tsx:200`) với `mobileOpen` mặc định `false` (`:95`) ⇒ mobile chỉ thêm 1 thanh tiêu đề + nút "Hiện cây", bảng không bị đẩy xuống sâu. ĐẠT.
|
||||||
|
- 🟡 `overflow-x-auto` bọc `<table>` gần như VÔ TÁC DỤNG như lời chú thích: `<table className="w-full">` + `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 `<tbody>` 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 / `<li>` 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 `<li>` 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.
|
||||||
|
|||||||
237
fe-admin/src/components/pipeline/PipelineStageFolders.tsx
Normal file
237
fe-admin/src/components/pipeline/PipelineStageFolders.tsx
Normal file
@ -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<PipelineStageNo, string> = {
|
||||||
|
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<PipelineStageNo, typeof ClipboardCheck> = {
|
||||||
|
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<PipelineStageNo, { chip: string; head: string; count: string; code: string }> = {
|
||||||
|
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 (
|
||||||
|
<span className="shrink-0 rounded bg-slate-100 px-1 text-[9px] font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
Sắp
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <details> (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 (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5"
|
||||||
|
title="Bạn chưa có quyền xem giai đoạn này"
|
||||||
|
>
|
||||||
|
<Lock aria-hidden className="h-3 w-3 shrink-0 text-slate-400" />
|
||||||
|
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded bg-slate-100 text-slate-400">
|
||||||
|
<Icon aria-hidden className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-slate-500">{label}</span>
|
||||||
|
<SoonChip />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details
|
||||||
|
open={open}
|
||||||
|
onToggle={e => setOpen((e.currentTarget as HTMLDetailsElement).open)}
|
||||||
|
className="group/stage"
|
||||||
|
>
|
||||||
|
<summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60 [&::-webkit-details-marker]:hidden">
|
||||||
|
<ChevronRight
|
||||||
|
aria-hidden
|
||||||
|
className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/stage:rotate-90 motion-reduce:transition-none"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-5 w-5 shrink-0 items-center justify-center rounded',
|
||||||
|
muted ? 'bg-slate-100 text-slate-400' : style.chip,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon aria-hidden className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
{/* 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ơ). */}
|
||||||
|
<span className={cn('min-w-0 flex-1 truncate text-[12px] font-medium', muted ? 'text-slate-500' : style.head)}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{stage.failed ? (
|
||||||
|
<span
|
||||||
|
className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-400"
|
||||||
|
title="Không tải được dữ liệu giai đoạn này"
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
) : count > 0 ? (
|
||||||
|
<span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', style.count)}>{count}</span>
|
||||||
|
) : (
|
||||||
|
<SoonChip />
|
||||||
|
)}
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div className="ml-3 border-l border-slate-200">
|
||||||
|
{stage.failed ? (
|
||||||
|
<div className="px-3 py-2 text-[11px] text-slate-400">Không tải được dữ liệu giai đoạn này.</div>
|
||||||
|
) : stage.loading ? (
|
||||||
|
<div className="space-y-1.5 px-3 py-2">
|
||||||
|
<div className="h-3 animate-pulse rounded bg-slate-100 motion-reduce:animate-none" />
|
||||||
|
<div className="h-3 w-2/3 animate-pulse rounded bg-slate-100 motion-reduce:animate-none" />
|
||||||
|
</div>
|
||||||
|
) : stage.content !== undefined ? (
|
||||||
|
stage.content
|
||||||
|
) : stage.leaves.length === 0 ? (
|
||||||
|
<div className="px-3 py-2 text-[11px] italic text-slate-400">Chưa có dữ liệu ở giai đoạn này.</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-slate-100">
|
||||||
|
{stage.leaves.map(leaf => (
|
||||||
|
<li key={leaf.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={leaf.onOpen}
|
||||||
|
disabled={!leaf.onOpen}
|
||||||
|
className={cn(
|
||||||
|
'block w-full px-3 py-2 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60',
|
||||||
|
leaf.onOpen ? 'hover:bg-slate-50' : 'cursor-default',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{/* Mã to hơn dòng phụ 1 nấc: font-mono 11px NHÌN nhỏ ngang
|
||||||
|
sans 11px nên 2 dòng bẹt như nhau (soi FD2 r1-D). */}
|
||||||
|
<span className={cn('min-w-0 flex-1 truncate font-mono text-[12px] font-medium', style.code)}>
|
||||||
|
{leaf.primary}
|
||||||
|
</span>
|
||||||
|
{leaf.badgeLabel && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||||
|
leaf.badgeClass ?? 'bg-slate-100 text-slate-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{leaf.badgeLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{leaf.secondary && <div className="truncate text-[11px] text-slate-500">{leaf.secondary}</div>}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{stage.truncated && !stage.failed && !stage.loading && (
|
||||||
|
<div className="border-t border-slate-100 px-3 py-1 text-[10px] italic text-slate-400">
|
||||||
|
Chỉ mục tải 200 bản ghi mới nhất — số đếm có thể thiếu.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={className}>
|
||||||
|
{visible.map(s => (
|
||||||
|
<StageFolder key={s.n} stage={s} currentStage={currentStage} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
321
fe-admin/src/components/pipeline/PipelineTreePanel.tsx
Normal file
321
fe-admin/src/components/pipeline/PipelineTreePanel.tsx
Normal file
@ -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<number, YearNode>()
|
||||||
|
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<Paged<PeListItem>>('/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<Set<string> | 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<string>()
|
||||||
|
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 (
|
||||||
|
<aside
|
||||||
|
className={cn('card-accent flex flex-col self-start overflow-hidden lg:sticky lg:top-4', className)}
|
||||||
|
style={{ ['--accent' as string]: 'var(--color-brand-500)' }}
|
||||||
|
aria-label="Cây toàn trình theo gói thầu"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 border-b border-slate-200 px-3 py-2.5">
|
||||||
|
<span
|
||||||
|
className="icon-chip h-7! w-7!"
|
||||||
|
style={{ ['--chip-bg' as string]: 'var(--color-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<ListTree className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
<span className="label-eyebrow min-w-0 flex-1 truncate">Cây toàn trình</span>
|
||||||
|
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
|
||||||
|
{packageCount} gói
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMobileOpen(o => !o)}
|
||||||
|
aria-expanded={mobileOpen}
|
||||||
|
className="rounded-md border border-slate-300 px-2 py-1 text-[11px] font-semibold text-slate-600 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/70 lg:hidden"
|
||||||
|
>
|
||||||
|
{mobileOpen ? 'Ẩn cây' : 'Hiện cây'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn('min-h-0 flex-col', mobileOpen ? 'flex' : 'hidden', 'lg:flex')}>
|
||||||
|
<div className="relative border-b border-slate-200 p-2">
|
||||||
|
<Search className="pointer-events-none absolute left-4 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Lọc dự án / hạng mục / mã phiếu…"
|
||||||
|
aria-label="Lọc cây toàn trình"
|
||||||
|
className="h-8 w-full rounded-md border border-slate-200 bg-white pl-7 pr-2 text-[12px] text-slate-700 placeholder:text-slate-400 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[28rem] overflow-y-auto lg:max-h-[calc(100vh-16rem)]">
|
||||||
|
{!canPe && (
|
||||||
|
<p className="px-3 py-4 text-[12px] text-slate-500">
|
||||||
|
Bạn chưa có quyền xem phiếu Duyệt NCC nên cây toàn trình chưa hiển thị được.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{canPe && peIndex.isLoading && (
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-6 animate-pulse rounded bg-slate-100 motion-reduce:animate-none" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{canPe && peIndex.isError && (
|
||||||
|
<div className="space-y-2 px-3 py-4">
|
||||||
|
<p className="text-[12px] text-slate-500">Không tải được cây toàn trình.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => peIndex.refetch()}
|
||||||
|
className="rounded-md border border-slate-300 px-2 py-1 text-[11px] font-semibold text-slate-600 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/70"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{canPe && !peIndex.isLoading && !peIndex.isError && years.length === 0 && (
|
||||||
|
<p className="px-3 py-4 text-[12px] text-slate-500">
|
||||||
|
{term ? 'Không có gói thầu nào khớp từ khoá.' : 'Chưa có phiếu nào để dựng cây.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canPe && peIndex.data && peIndex.data.total > peIndex.data.items.length && (
|
||||||
|
<p className="border-b border-slate-100 px-3 py-1 text-[10px] italic text-slate-400">
|
||||||
|
Cây tải 200 phiếu mới nhất — gói cũ hơn không hiển thị.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="divide-y divide-slate-100">
|
||||||
|
{years.map(yg => {
|
||||||
|
const yearKey = `y${yg.year}`
|
||||||
|
return (
|
||||||
|
<details
|
||||||
|
key={yearKey}
|
||||||
|
open={isExpanded(yearKey)}
|
||||||
|
onToggle={e => toggleExpand(yearKey, (e.currentTarget as HTMLDetailsElement).open)}
|
||||||
|
className="group/year"
|
||||||
|
>
|
||||||
|
<summary className="flex cursor-pointer items-center gap-1.5 bg-slate-50 px-3 py-2 hover:bg-slate-100 [&::-webkit-details-marker]:hidden">
|
||||||
|
<svg className="h-3 w-3 shrink-0 text-slate-500 transition-transform group-open/year:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
||||||
|
<span className="text-base">📅</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[13px] font-semibold text-slate-900">Năm {yg.year}</span>
|
||||||
|
<span className="rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-medium text-slate-700">{yg.totalCount}</span>
|
||||||
|
</summary>
|
||||||
|
<div className="ml-3 border-l border-slate-200">
|
||||||
|
{yg.projects.map(pg => {
|
||||||
|
const projKey = `${yearKey}::p${pg.projectId ?? '_none_'}`
|
||||||
|
return (
|
||||||
|
<details
|
||||||
|
key={projKey}
|
||||||
|
open={isExpanded(projKey)}
|
||||||
|
onToggle={e => toggleExpand(projKey, (e.currentTarget as HTMLDetailsElement).open)}
|
||||||
|
className="group/proj"
|
||||||
|
>
|
||||||
|
<summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 hover:bg-slate-50 [&::-webkit-details-marker]:hidden">
|
||||||
|
<svg className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/proj:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
||||||
|
<span className="text-sm">📁</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-slate-700" title={pg.projectName}>{pg.projectCode || pg.projectName}</span>
|
||||||
|
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{pg.totalCount}</span>
|
||||||
|
</summary>
|
||||||
|
<div className="ml-3 border-l border-slate-200">
|
||||||
|
{pg.workItems.map(wg => {
|
||||||
|
const wiKey = `${projKey}::w${wg.workItemId ?? '_none_'}`
|
||||||
|
return (
|
||||||
|
<details
|
||||||
|
key={wiKey}
|
||||||
|
open={isExpanded(wiKey)}
|
||||||
|
onToggle={e => toggleExpand(wiKey, (e.currentTarget as HTMLDetailsElement).open)}
|
||||||
|
className="group/wi"
|
||||||
|
>
|
||||||
|
<summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 hover:bg-slate-50 [&::-webkit-details-marker]:hidden">
|
||||||
|
<svg className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/wi:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
||||||
|
<span className="text-sm">🧱</span>
|
||||||
|
<span className={cn('min-w-0 flex-1 truncate text-[12px]', wg.workItemId ? 'font-medium text-slate-700' : 'italic text-slate-400')}>{wg.workItemName}</span>
|
||||||
|
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{wg.items.length}</span>
|
||||||
|
</summary>
|
||||||
|
<PipelineStageFolders
|
||||||
|
className="ml-3 border-l border-slate-200"
|
||||||
|
currentStage={currentStage}
|
||||||
|
hideEmpty={hideEmpty}
|
||||||
|
stages={buildStages(wg.items)}
|
||||||
|
/>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
266
fe-admin/src/hooks/usePipelineStages.ts
Normal file
266
fe-admin/src/hooks/usePipelineStages.ts
Normal file
@ -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<string, KhkkListItemDto[]>
|
||||||
|
contractById: Map<string, ContractListItem>
|
||||||
|
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<PagedResult<KhkkListItemDto>>('/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<Paged<ContractListItem>>('/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<string, KhkkListItemDto[]>()
|
||||||
|
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<string, ContractListItem>()
|
||||||
|
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<string>()
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@ -26,6 +26,7 @@ import { PageHeader } from '@/components/ui/PageHeader'
|
|||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import { Textarea } from '@/components/ui/Textarea'
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
|
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
|
||||||
import { api, TOKEN_KEY } from '@/lib/api'
|
import { api, TOKEN_KEY } from '@/lib/api'
|
||||||
import { getErrorMessage } from '@/lib/apiError'
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
@ -110,6 +111,12 @@ export function HardCopiesPage() {
|
|||||||
Có file scan là xem như <span className="font-semibold">xong</span>.
|
Có file scan là xem như <span className="font-semibold">xong</span>.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* [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. */}
|
||||||
|
<div className="grid gap-5 lg:grid-cols-[19rem_minmax(0,1fr)] xl:grid-cols-[21rem_minmax(0,1fr)]">
|
||||||
|
<PipelineTreePanel currentStage={4} />
|
||||||
|
|
||||||
|
<div className="min-w-0 space-y-5">
|
||||||
<div
|
<div
|
||||||
className="card-accent flex items-center gap-3 px-4 py-3"
|
className="card-accent flex items-center gap-3 px-4 py-3"
|
||||||
style={{ ['--accent' as string]: 'var(--color-teal-500)' }}
|
style={{ ['--accent' as string]: 'var(--color-teal-500)' }}
|
||||||
@ -130,6 +137,8 @@ export function HardCopiesPage() {
|
|||||||
className="card-accent overflow-hidden"
|
className="card-accent overflow-hidden"
|
||||||
style={{ ['--accent' as string]: 'var(--color-teal-500)' }}
|
style={{ ['--accent' as string]: 'var(--color-teal-500)' }}
|
||||||
>
|
>
|
||||||
|
{/* [S162] Cột trái ăn ~19rem ⇒ bảng cuộn ngang trong thẻ, không tràn lưới. */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="border-b border-slate-200 bg-slate-50/70">
|
<thead className="border-b border-slate-200 bg-slate-50/70">
|
||||||
<tr>
|
<tr>
|
||||||
@ -217,6 +226,7 @@ export function HardCopiesPage() {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-between border-t border-slate-200 px-4 py-2.5 text-sm">
|
<div className="flex items-center justify-between border-t border-slate-200 px-4 py-2.5 text-sm">
|
||||||
<div className="text-slate-500">
|
<div className="text-slate-500">
|
||||||
@ -239,6 +249,8 @@ export function HardCopiesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -276,6 +288,7 @@ function HardCopyPanel({ contractId }: { contractId: string }) {
|
|||||||
setNote('')
|
setNote('')
|
||||||
qc.invalidateQueries({ queryKey: ['contract', contractId] })
|
qc.invalidateQueries({ queryKey: ['contract', contractId] })
|
||||||
qc.invalidateQueries({ queryKey: ['hard-copies'] }) // badge cột "Bản cứng" lật
|
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')
|
toast.success('Đã lưu bản cứng')
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(`Tải lên lỗi: ${getErrorMessage(err)}`),
|
onError: (err) => toast.error(`Tải lên lỗi: ${getErrorMessage(err)}`),
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import { PageHeader } from '@/components/ui/PageHeader'
|
|||||||
import { KpiCard } from '@/components/ui/KpiCard'
|
import { KpiCard } from '@/components/ui/KpiCard'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
|
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
import {
|
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; `<lg` cây xếp trên và tự thu gọn. */}
|
||||||
|
<div className="grid gap-5 lg:grid-cols-[19rem_minmax(0,1fr)] xl:grid-cols-[21rem_minmax(0,1fr)]">
|
||||||
|
<PipelineTreePanel currentStage={2} />
|
||||||
|
|
||||||
|
<div className="min-w-0 space-y-5">
|
||||||
{!deletedView && (
|
{!deletedView && (
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
|
||||||
{phaseCards.map((c) => (
|
{phaseCards.map((c) => (
|
||||||
<KpiCard
|
<KpiCard
|
||||||
key={c.value ?? 'all'}
|
key={c.value ?? 'all'}
|
||||||
@ -164,6 +172,9 @@ export function KhkkListPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
|
<div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
|
||||||
|
{/* [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. */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="border-b border-slate-200 bg-slate-50/70">
|
<thead className="border-b border-slate-200 bg-slate-50/70">
|
||||||
<tr>
|
<tr>
|
||||||
@ -244,6 +255,7 @@ export function KhkkListPage() {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-between border-t border-slate-200 px-4 py-2.5 text-sm">
|
<div className="flex items-center justify-between border-t border-slate-200 px-4 py-2.5 text-sm">
|
||||||
<div className="text-slate-500">
|
<div className="text-slate-500">
|
||||||
@ -261,5 +273,7 @@ export function KhkkListPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,8 @@ import { PeDetailTabs } from '@/components/pe/PeDetailTabs'
|
|||||||
import { PeUrgentChips } from '@/components/pe/PeUrgentChips'
|
import { PeUrgentChips } from '@/components/pe/PeUrgentChips'
|
||||||
import { PeFinalizeChip } from '@/components/pe/PeFinalizeChip'
|
import { PeFinalizeChip } from '@/components/pe/PeFinalizeChip'
|
||||||
import { PeWorkflowPanel } from '@/components/pe/PeWorkflowPanel'
|
import { PeWorkflowPanel } from '@/components/pe/PeWorkflowPanel'
|
||||||
|
import { PipelineStageFolders } from '@/components/pipeline/PipelineStageFolders'
|
||||||
|
import { usePipelineStages } from '@/hooks/usePipelineStages'
|
||||||
|
|
||||||
export function PurchaseEvaluationsListPage() {
|
export function PurchaseEvaluationsListPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
@ -47,6 +49,14 @@ export function PurchaseEvaluationsListPage() {
|
|||||||
const selectedId = sp.get('id')
|
const selectedId = sp.get('id')
|
||||||
const isExpand = sp.get('expand') === '1' // S79 — overlay mở rộng
|
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)
|
// Mig 23 — list quy trình duyệt V2 cho dropdown filter (filter theo type screen)
|
||||||
const approvalWorkflows = useQuery({
|
const approvalWorkflows = useQuery({
|
||||||
queryKey: ['approval-workflows-v2-filter', typeFilter],
|
queryKey: ['approval-workflows-v2-filter', typeFilter],
|
||||||
@ -507,7 +517,12 @@ export function PurchaseEvaluationsListPage() {
|
|||||||
<span className={cn('flex-1 truncate text-[12px]', wg.workItemId ? 'font-medium text-slate-700' : 'italic text-slate-400')}>{wg.workItemName}</span>
|
<span className={cn('flex-1 truncate text-[12px]', wg.workItemId ? 'font-medium text-slate-700' : 'italic text-slate-400')}>{wg.workItemName}</span>
|
||||||
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{wg.items.length}</span>
|
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{wg.items.length}</span>
|
||||||
</summary>
|
</summary>
|
||||||
<ul className="ml-3 divide-y divide-slate-100 border-l border-slate-200">
|
{/* [S162] 4 folder giai đoạn dưới GÓI THẦU (owner chốt AskUser 30-07).
|
||||||
|
Khối <li> 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". */}
|
||||||
|
<PipelineStageFolders className="ml-3 border-l border-slate-200" currentStage={1} hideEmpty={filterActive} stages={buildStages(wg.items, { stage1Content: (
|
||||||
|
<ul className="divide-y divide-slate-100">
|
||||||
{wg.items.map(p => (
|
{wg.items.map(p => (
|
||||||
<li key={p.id}>
|
<li key={p.id}>
|
||||||
{/* S79 — dòng phiếu: vùng bấm chính (chọn → inline) + nút
|
{/* S79 — dòng phiếu: vùng bấm chính (chọn → inline) + nút
|
||||||
@ -607,6 +622,7 @@ export function PurchaseEvaluationsListPage() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
) })} />
|
||||||
</details>
|
</details>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
237
fe-user/src/components/pipeline/PipelineStageFolders.tsx
Normal file
237
fe-user/src/components/pipeline/PipelineStageFolders.tsx
Normal file
@ -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<PipelineStageNo, string> = {
|
||||||
|
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<PipelineStageNo, typeof ClipboardCheck> = {
|
||||||
|
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<PipelineStageNo, { chip: string; head: string; count: string; code: string }> = {
|
||||||
|
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 (
|
||||||
|
<span className="shrink-0 rounded bg-slate-100 px-1 text-[9px] font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
Sắp
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <details> (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 (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5"
|
||||||
|
title="Bạn chưa có quyền xem giai đoạn này"
|
||||||
|
>
|
||||||
|
<Lock aria-hidden className="h-3 w-3 shrink-0 text-slate-400" />
|
||||||
|
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded bg-slate-100 text-slate-400">
|
||||||
|
<Icon aria-hidden className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-slate-500">{label}</span>
|
||||||
|
<SoonChip />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details
|
||||||
|
open={open}
|
||||||
|
onToggle={e => setOpen((e.currentTarget as HTMLDetailsElement).open)}
|
||||||
|
className="group/stage"
|
||||||
|
>
|
||||||
|
<summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60 [&::-webkit-details-marker]:hidden">
|
||||||
|
<ChevronRight
|
||||||
|
aria-hidden
|
||||||
|
className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/stage:rotate-90 motion-reduce:transition-none"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-5 w-5 shrink-0 items-center justify-center rounded',
|
||||||
|
muted ? 'bg-slate-100 text-slate-400' : style.chip,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon aria-hidden className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
{/* 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ơ). */}
|
||||||
|
<span className={cn('min-w-0 flex-1 truncate text-[12px] font-medium', muted ? 'text-slate-500' : style.head)}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{stage.failed ? (
|
||||||
|
<span
|
||||||
|
className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-400"
|
||||||
|
title="Không tải được dữ liệu giai đoạn này"
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
) : count > 0 ? (
|
||||||
|
<span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', style.count)}>{count}</span>
|
||||||
|
) : (
|
||||||
|
<SoonChip />
|
||||||
|
)}
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div className="ml-3 border-l border-slate-200">
|
||||||
|
{stage.failed ? (
|
||||||
|
<div className="px-3 py-2 text-[11px] text-slate-400">Không tải được dữ liệu giai đoạn này.</div>
|
||||||
|
) : stage.loading ? (
|
||||||
|
<div className="space-y-1.5 px-3 py-2">
|
||||||
|
<div className="h-3 animate-pulse rounded bg-slate-100 motion-reduce:animate-none" />
|
||||||
|
<div className="h-3 w-2/3 animate-pulse rounded bg-slate-100 motion-reduce:animate-none" />
|
||||||
|
</div>
|
||||||
|
) : stage.content !== undefined ? (
|
||||||
|
stage.content
|
||||||
|
) : stage.leaves.length === 0 ? (
|
||||||
|
<div className="px-3 py-2 text-[11px] italic text-slate-400">Chưa có dữ liệu ở giai đoạn này.</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-slate-100">
|
||||||
|
{stage.leaves.map(leaf => (
|
||||||
|
<li key={leaf.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={leaf.onOpen}
|
||||||
|
disabled={!leaf.onOpen}
|
||||||
|
className={cn(
|
||||||
|
'block w-full px-3 py-2 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60',
|
||||||
|
leaf.onOpen ? 'hover:bg-slate-50' : 'cursor-default',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{/* Mã to hơn dòng phụ 1 nấc: font-mono 11px NHÌN nhỏ ngang
|
||||||
|
sans 11px nên 2 dòng bẹt như nhau (soi FD2 r1-D). */}
|
||||||
|
<span className={cn('min-w-0 flex-1 truncate font-mono text-[12px] font-medium', style.code)}>
|
||||||
|
{leaf.primary}
|
||||||
|
</span>
|
||||||
|
{leaf.badgeLabel && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||||
|
leaf.badgeClass ?? 'bg-slate-100 text-slate-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{leaf.badgeLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{leaf.secondary && <div className="truncate text-[11px] text-slate-500">{leaf.secondary}</div>}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{stage.truncated && !stage.failed && !stage.loading && (
|
||||||
|
<div className="border-t border-slate-100 px-3 py-1 text-[10px] italic text-slate-400">
|
||||||
|
Chỉ mục tải 200 bản ghi mới nhất — số đếm có thể thiếu.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={className}>
|
||||||
|
{visible.map(s => (
|
||||||
|
<StageFolder key={s.n} stage={s} currentStage={currentStage} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
321
fe-user/src/components/pipeline/PipelineTreePanel.tsx
Normal file
321
fe-user/src/components/pipeline/PipelineTreePanel.tsx
Normal file
@ -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<number, YearNode>()
|
||||||
|
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<Paged<PeListItem>>('/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<Set<string> | 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<string>()
|
||||||
|
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 (
|
||||||
|
<aside
|
||||||
|
className={cn('card-accent flex flex-col self-start overflow-hidden lg:sticky lg:top-4', className)}
|
||||||
|
style={{ ['--accent' as string]: 'var(--color-brand-500)' }}
|
||||||
|
aria-label="Cây toàn trình theo gói thầu"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 border-b border-slate-200 px-3 py-2.5">
|
||||||
|
<span
|
||||||
|
className="icon-chip h-7! w-7!"
|
||||||
|
style={{ ['--chip-bg' as string]: 'var(--color-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<ListTree className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
<span className="label-eyebrow min-w-0 flex-1 truncate">Cây toàn trình</span>
|
||||||
|
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
|
||||||
|
{packageCount} gói
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMobileOpen(o => !o)}
|
||||||
|
aria-expanded={mobileOpen}
|
||||||
|
className="rounded-md border border-slate-300 px-2 py-1 text-[11px] font-semibold text-slate-600 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/70 lg:hidden"
|
||||||
|
>
|
||||||
|
{mobileOpen ? 'Ẩn cây' : 'Hiện cây'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn('min-h-0 flex-col', mobileOpen ? 'flex' : 'hidden', 'lg:flex')}>
|
||||||
|
<div className="relative border-b border-slate-200 p-2">
|
||||||
|
<Search className="pointer-events-none absolute left-4 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Lọc dự án / hạng mục / mã phiếu…"
|
||||||
|
aria-label="Lọc cây toàn trình"
|
||||||
|
className="h-8 w-full rounded-md border border-slate-200 bg-white pl-7 pr-2 text-[12px] text-slate-700 placeholder:text-slate-400 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[28rem] overflow-y-auto lg:max-h-[calc(100vh-16rem)]">
|
||||||
|
{!canPe && (
|
||||||
|
<p className="px-3 py-4 text-[12px] text-slate-500">
|
||||||
|
Bạn chưa có quyền xem phiếu Duyệt NCC nên cây toàn trình chưa hiển thị được.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{canPe && peIndex.isLoading && (
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-6 animate-pulse rounded bg-slate-100 motion-reduce:animate-none" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{canPe && peIndex.isError && (
|
||||||
|
<div className="space-y-2 px-3 py-4">
|
||||||
|
<p className="text-[12px] text-slate-500">Không tải được cây toàn trình.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => peIndex.refetch()}
|
||||||
|
className="rounded-md border border-slate-300 px-2 py-1 text-[11px] font-semibold text-slate-600 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/70"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{canPe && !peIndex.isLoading && !peIndex.isError && years.length === 0 && (
|
||||||
|
<p className="px-3 py-4 text-[12px] text-slate-500">
|
||||||
|
{term ? 'Không có gói thầu nào khớp từ khoá.' : 'Chưa có phiếu nào để dựng cây.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canPe && peIndex.data && peIndex.data.total > peIndex.data.items.length && (
|
||||||
|
<p className="border-b border-slate-100 px-3 py-1 text-[10px] italic text-slate-400">
|
||||||
|
Cây tải 200 phiếu mới nhất — gói cũ hơn không hiển thị.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="divide-y divide-slate-100">
|
||||||
|
{years.map(yg => {
|
||||||
|
const yearKey = `y${yg.year}`
|
||||||
|
return (
|
||||||
|
<details
|
||||||
|
key={yearKey}
|
||||||
|
open={isExpanded(yearKey)}
|
||||||
|
onToggle={e => toggleExpand(yearKey, (e.currentTarget as HTMLDetailsElement).open)}
|
||||||
|
className="group/year"
|
||||||
|
>
|
||||||
|
<summary className="flex cursor-pointer items-center gap-1.5 bg-slate-50 px-3 py-2 hover:bg-slate-100 [&::-webkit-details-marker]:hidden">
|
||||||
|
<svg className="h-3 w-3 shrink-0 text-slate-500 transition-transform group-open/year:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
||||||
|
<span className="text-base">📅</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[13px] font-semibold text-slate-900">Năm {yg.year}</span>
|
||||||
|
<span className="rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-medium text-slate-700">{yg.totalCount}</span>
|
||||||
|
</summary>
|
||||||
|
<div className="ml-3 border-l border-slate-200">
|
||||||
|
{yg.projects.map(pg => {
|
||||||
|
const projKey = `${yearKey}::p${pg.projectId ?? '_none_'}`
|
||||||
|
return (
|
||||||
|
<details
|
||||||
|
key={projKey}
|
||||||
|
open={isExpanded(projKey)}
|
||||||
|
onToggle={e => toggleExpand(projKey, (e.currentTarget as HTMLDetailsElement).open)}
|
||||||
|
className="group/proj"
|
||||||
|
>
|
||||||
|
<summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 hover:bg-slate-50 [&::-webkit-details-marker]:hidden">
|
||||||
|
<svg className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/proj:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
||||||
|
<span className="text-sm">📁</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-slate-700" title={pg.projectName}>{pg.projectCode || pg.projectName}</span>
|
||||||
|
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{pg.totalCount}</span>
|
||||||
|
</summary>
|
||||||
|
<div className="ml-3 border-l border-slate-200">
|
||||||
|
{pg.workItems.map(wg => {
|
||||||
|
const wiKey = `${projKey}::w${wg.workItemId ?? '_none_'}`
|
||||||
|
return (
|
||||||
|
<details
|
||||||
|
key={wiKey}
|
||||||
|
open={isExpanded(wiKey)}
|
||||||
|
onToggle={e => toggleExpand(wiKey, (e.currentTarget as HTMLDetailsElement).open)}
|
||||||
|
className="group/wi"
|
||||||
|
>
|
||||||
|
<summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 hover:bg-slate-50 [&::-webkit-details-marker]:hidden">
|
||||||
|
<svg className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/wi:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
||||||
|
<span className="text-sm">🧱</span>
|
||||||
|
<span className={cn('min-w-0 flex-1 truncate text-[12px]', wg.workItemId ? 'font-medium text-slate-700' : 'italic text-slate-400')}>{wg.workItemName}</span>
|
||||||
|
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{wg.items.length}</span>
|
||||||
|
</summary>
|
||||||
|
<PipelineStageFolders
|
||||||
|
className="ml-3 border-l border-slate-200"
|
||||||
|
currentStage={currentStage}
|
||||||
|
hideEmpty={hideEmpty}
|
||||||
|
stages={buildStages(wg.items)}
|
||||||
|
/>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
266
fe-user/src/hooks/usePipelineStages.ts
Normal file
266
fe-user/src/hooks/usePipelineStages.ts
Normal file
@ -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<string, KhkkListItemDto[]>
|
||||||
|
contractById: Map<string, ContractListItem>
|
||||||
|
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<PagedResult<KhkkListItemDto>>('/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<Paged<ContractListItem>>('/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<string, KhkkListItemDto[]>()
|
||||||
|
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<string, ContractListItem>()
|
||||||
|
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<string>()
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@ -26,6 +26,7 @@ import { PageHeader } from '@/components/ui/PageHeader'
|
|||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import { Textarea } from '@/components/ui/Textarea'
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
|
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
|
||||||
import { api, TOKEN_KEY } from '@/lib/api'
|
import { api, TOKEN_KEY } from '@/lib/api'
|
||||||
import { getErrorMessage } from '@/lib/apiError'
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
@ -110,6 +111,12 @@ export function HardCopiesPage() {
|
|||||||
Có file scan là xem như <span className="font-semibold">xong</span>.
|
Có file scan là xem như <span className="font-semibold">xong</span>.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* [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. */}
|
||||||
|
<div className="grid gap-5 lg:grid-cols-[19rem_minmax(0,1fr)] xl:grid-cols-[21rem_minmax(0,1fr)]">
|
||||||
|
<PipelineTreePanel currentStage={4} />
|
||||||
|
|
||||||
|
<div className="min-w-0 space-y-5">
|
||||||
<div
|
<div
|
||||||
className="card-accent flex items-center gap-3 px-4 py-3"
|
className="card-accent flex items-center gap-3 px-4 py-3"
|
||||||
style={{ ['--accent' as string]: 'var(--color-teal-500)' }}
|
style={{ ['--accent' as string]: 'var(--color-teal-500)' }}
|
||||||
@ -130,6 +137,8 @@ export function HardCopiesPage() {
|
|||||||
className="card-accent overflow-hidden"
|
className="card-accent overflow-hidden"
|
||||||
style={{ ['--accent' as string]: 'var(--color-teal-500)' }}
|
style={{ ['--accent' as string]: 'var(--color-teal-500)' }}
|
||||||
>
|
>
|
||||||
|
{/* [S162] Cột trái ăn ~19rem ⇒ bảng cuộn ngang trong thẻ, không tràn lưới. */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="border-b border-slate-200 bg-slate-50/70">
|
<thead className="border-b border-slate-200 bg-slate-50/70">
|
||||||
<tr>
|
<tr>
|
||||||
@ -217,6 +226,7 @@ export function HardCopiesPage() {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-between border-t border-slate-200 px-4 py-2.5 text-sm">
|
<div className="flex items-center justify-between border-t border-slate-200 px-4 py-2.5 text-sm">
|
||||||
<div className="text-slate-500">
|
<div className="text-slate-500">
|
||||||
@ -239,6 +249,8 @@ export function HardCopiesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -276,6 +288,7 @@ function HardCopyPanel({ contractId }: { contractId: string }) {
|
|||||||
setNote('')
|
setNote('')
|
||||||
qc.invalidateQueries({ queryKey: ['contract', contractId] })
|
qc.invalidateQueries({ queryKey: ['contract', contractId] })
|
||||||
qc.invalidateQueries({ queryKey: ['hard-copies'] }) // badge cột "Bản cứng" lật
|
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')
|
toast.success('Đã lưu bản cứng')
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(`Tải lên lỗi: ${getErrorMessage(err)}`),
|
onError: (err) => toast.error(`Tải lên lỗi: ${getErrorMessage(err)}`),
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import { PageHeader } from '@/components/ui/PageHeader'
|
|||||||
import { KpiCard } from '@/components/ui/KpiCard'
|
import { KpiCard } from '@/components/ui/KpiCard'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
|
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
import {
|
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; `<lg` cây xếp trên và tự thu gọn. */}
|
||||||
|
<div className="grid gap-5 lg:grid-cols-[19rem_minmax(0,1fr)] xl:grid-cols-[21rem_minmax(0,1fr)]">
|
||||||
|
<PipelineTreePanel currentStage={2} />
|
||||||
|
|
||||||
|
<div className="min-w-0 space-y-5">
|
||||||
{!deletedView && (
|
{!deletedView && (
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
|
||||||
{phaseCards.map((c) => (
|
{phaseCards.map((c) => (
|
||||||
<KpiCard
|
<KpiCard
|
||||||
key={c.value ?? 'all'}
|
key={c.value ?? 'all'}
|
||||||
@ -164,6 +172,9 @@ export function KhkkListPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
|
<div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
|
||||||
|
{/* [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. */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="border-b border-slate-200 bg-slate-50/70">
|
<thead className="border-b border-slate-200 bg-slate-50/70">
|
||||||
<tr>
|
<tr>
|
||||||
@ -244,6 +255,7 @@ export function KhkkListPage() {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-between border-t border-slate-200 px-4 py-2.5 text-sm">
|
<div className="flex items-center justify-between border-t border-slate-200 px-4 py-2.5 text-sm">
|
||||||
<div className="text-slate-500">
|
<div className="text-slate-500">
|
||||||
@ -261,5 +273,7 @@ export function KhkkListPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,8 @@ import { PeDetailTabs } from '@/components/pe/PeDetailTabs'
|
|||||||
import { PeUrgentChips } from '@/components/pe/PeUrgentChips'
|
import { PeUrgentChips } from '@/components/pe/PeUrgentChips'
|
||||||
import { PeFinalizeChip } from '@/components/pe/PeFinalizeChip'
|
import { PeFinalizeChip } from '@/components/pe/PeFinalizeChip'
|
||||||
import { PeWorkflowPanel } from '@/components/pe/PeWorkflowPanel'
|
import { PeWorkflowPanel } from '@/components/pe/PeWorkflowPanel'
|
||||||
|
import { PipelineStageFolders } from '@/components/pipeline/PipelineStageFolders'
|
||||||
|
import { usePipelineStages } from '@/hooks/usePipelineStages'
|
||||||
|
|
||||||
export function PurchaseEvaluationsListPage() {
|
export function PurchaseEvaluationsListPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
@ -47,6 +49,14 @@ export function PurchaseEvaluationsListPage() {
|
|||||||
const selectedId = sp.get('id')
|
const selectedId = sp.get('id')
|
||||||
const isExpand = sp.get('expand') === '1' // S79 — overlay mở rộng
|
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)
|
// Mig 23 — list quy trình duyệt V2 cho dropdown filter (filter theo type screen)
|
||||||
const approvalWorkflows = useQuery({
|
const approvalWorkflows = useQuery({
|
||||||
queryKey: ['approval-workflows-v2-filter', typeFilter],
|
queryKey: ['approval-workflows-v2-filter', typeFilter],
|
||||||
@ -507,7 +517,12 @@ export function PurchaseEvaluationsListPage() {
|
|||||||
<span className={cn('flex-1 truncate text-[12px]', wg.workItemId ? 'font-medium text-slate-700' : 'italic text-slate-400')}>{wg.workItemName}</span>
|
<span className={cn('flex-1 truncate text-[12px]', wg.workItemId ? 'font-medium text-slate-700' : 'italic text-slate-400')}>{wg.workItemName}</span>
|
||||||
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{wg.items.length}</span>
|
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{wg.items.length}</span>
|
||||||
</summary>
|
</summary>
|
||||||
<ul className="ml-3 divide-y divide-slate-100 border-l border-slate-200">
|
{/* [S162] 4 folder giai đoạn dưới GÓI THẦU (owner chốt AskUser 30-07).
|
||||||
|
Khối <li> 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". */}
|
||||||
|
<PipelineStageFolders className="ml-3 border-l border-slate-200" currentStage={1} hideEmpty={filterActive} stages={buildStages(wg.items, { stage1Content: (
|
||||||
|
<ul className="divide-y divide-slate-100">
|
||||||
{wg.items.map(p => (
|
{wg.items.map(p => (
|
||||||
<li key={p.id}>
|
<li key={p.id}>
|
||||||
{/* S79 — dòng phiếu: vùng bấm chính (chọn → inline) + nút
|
{/* S79 — dòng phiếu: vùng bấm chính (chọn → inline) + nút
|
||||||
@ -607,6 +622,7 @@ export function PurchaseEvaluationsListPage() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
) })} />
|
||||||
</details>
|
</details>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user