[CLAUDE] Contract: KHKK UAT vong-4 — 3 menu loc hien cay Nam>Du an>Hang muc>phieu (FE-only, 0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m39s

Owner: "o cac menu dang duyet/ da duyet/ xoa -> cho hien thi nam -> du an
-> hang muc -> phieu KHKK nhe". Day chinh la O5 "can phan" ton tu S174 —
nghi van `KhkkListPanel flatMode` doi CAY->PHANG khi co filter. Owner phan:
giu CAY.

PipelineTreePanel — them `leafMode` OPT-IN:
- Cay dung o tang Hang muc roi ve THANG phieu: bo tang folder giai-doan VA
  bo tang 8 ngan nhom => dung 4 tang owner go.
- Panel KHONG import kieu KHKK: host cap ca phep DEM lan phep VE, nen cay
  dung lai duoc cho giai-doan khac sau nay.
- `buildTree(rows, weightOf)` — badge dem PHIEU thay vi GOI o ca 3 tang.
  Giu `+1` thi 1 goi mang 2 phieu van keu 1 => badge noi doi (dung luat
  vong-3 "loc TRUOC khi dung cay").
- Tia nhanh 0-phieu: khong de nguoi dung mo Nam>Du an>Hang muc ba lan chi
  de thay cho trong.
- `emptyText` OPT-IN: cau mac dinh "Chua co phieu nao de dung cay" noi ve
  DUNG CAY, o che-do la se doc thanh he-thong hong thay vi "khong khop loc".
- 4 host con lai truyen 0 => di nhanh PipelineStageFolders y nhu cu.

KhkkListPanel:
- `flatMode` bo 2 ve `deletedView`/`phase`. GIU `!canPe` (cay dung tren
  xuong song PE, ep cay se lam ho mat trang phieu cua chinh minh) va GIU
  `term` (tim kiem di server-side co phan trang that = duong thoat cho
  phieu ngoai cua-so 200).
- Thung rac tren cay phai noi NGUON RIENG `/contract-signing-plans/deleted`
  pageSize 200 — linkage cua cay chi goi endpoint thuong nen KHONG he co
  phieu da xoa (grep 'deleted' trong hook = 0 hit).
- Phieu da xoa tren cay nay cu-xu y het o phang: lam mo + KHONG cho mo.
  Thieu ve nay thi cung mot phieu mo duoc o cay ma khong mo duoc o phang.

🔴 CAM them ve `group !== null` vao dieu-kien leafMode: CA BON leaf menu KHKK
deu mang `?group=1` (Layout.tsx:41-45), ke ca "Danh sach" khong loc — them
ve do la keo luon man TOAN CANH vao che-do la, tuc doi mot man owner khong
he yeu cau. Nhom la chieu CHIA NGAN, khong phai chieu CHON HINH CAY.

🔸 KHAI GIOI-HAN: cay an cua-so 200 phieu moi nhat => 3 menu nay thu hep tam
voi so voi phang-phan-trang-that. Dong khai truncation cua cay van hien;
duong thoat = go tim kiem. Volume KHKK hien nho nen chua can.

mirror sha256 khop 2/2 cap file; build xanh x2 app; tsc sach x2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-06 09:06:27 +07:00
parent 8cb1ec78b6
commit 097e41a085
5 changed files with 312 additions and 82 deletions

View File

@ -1,34 +1,34 @@
# WAL — auto-generated, không sửa tay # WAL — auto-generated, không sửa tay
updated: 2026-08-06 | session: S176 (phiên-LOGIC L11, window 4) | branch: main updated: 2026-08-06 | session: S176 (phiên-LOGIC L11, window 4) | branch: main
goal: UAT UI module KHKK. Vòng-2 (12 defect) + vòng-3 (4 defect) ĐÃ ship + deploy VERIFY. Dừng @PAUSE-4 chờ anh soi prod rồi gửi vòng-4. goal: UAT UI module KHKK. Vòng-2 (12) + vòng-3 (4) ĐÃ ship+deploy VERIFY; anh soi prod = "tạm ổn". Vòng-4 = 3 menu lọc đổi sang cây 4 tầng (O5 anh phán) — code xong, chờ ship+soi.
chain: chain:
- [x] nối mạch /tiep @S176 (window 4): Sàn-3 ⑤=3 + ① kêu **2** orphan + ORPHAN-L(p=4,t=2), WAL sống ⇒ đúng ca /tiep · 7/7 `verify:` KHỚP · tick 49→50 · `_tiep-3.md` cân sổ - [x] nối mạch /tiep @S176 (window 4): Sàn-3 ⑤=3 + ① kêu **2** orphan + ORPHAN-L(p=4,t=2), WAL sống ⇒ đúng ca /tiep · 7/7 `verify:` KHỚP · tick 49→50 · `_tiep-3.md` cân sổ
- [x] 7 commit sản-phẩm + 4 đợt deploy VERIFY 2-vế ×2 app: `43e1bd7` `a0ccac3` `b811e11` `32cc8d5` `95f72dc` `99cabd8` `0db1b99` - [x] 7 commit sản-phẩm + 4 đợt deploy VERIFY 2-vế ×2 app: `43e1bd7` `a0ccac3` `b811e11` `32cc8d5` `95f72dc` `99cabd8` `0db1b99` — gồm đợt B (bê style PE) + đợt D (O6·O4·O7·O8) + C3 + O3 vế-2, mirror sha256 khớp
- [x] đợt B (bê style PE) + đợt D (O6·O4·O7·O8) + C3 + O3 vế-2 — mirror sha256 khớp, build xanh ×2
- [x] O3 vế-1 thêm N hạng mục + giá + ghi chú (0-mig): +`AddLine`/`DeleteLine` command + 3 route; `PeReferenceAmount=0` chống đếm-đôi + là DẤU phân biệt dòng-gốc/thêm-tay - [x] O3 vế-1 thêm N hạng mục + giá + ghi chú (0-mig): +`AddLine`/`DeleteLine` command + 3 route; `PeReferenceAmount=0` chống đếm-đôi + là DẤU phân biệt dòng-gốc/thêm-tay
- [x] UAT vòng-2: anh review TRỰC TIẾP prod, khoanh đỏ **12 defect → land 12/12** (gộp mục 3+4 · gom nút về thanh đáy · mục 2 rút về Hạng mục+Giá→Tổng · hạng mục CỦA DỰ ÁN +`PeWorkItemName` BE · lọc theo nhóm · gỡ dải accent · cây gập/mở) - [x] UAT vòng-2: anh review TRỰC TIẾP prod, khoanh đỏ **12 defect → land 12/12** (gộp mục 3+4 · gom nút về thanh đáy · mục 2 rút về Hạng mục+Giá→Tổng · hạng mục CỦA DỰ ÁN +`PeWorkItemName` BE · lọc theo nhóm · gỡ dải accent · cây gập/mở)
- [x] cây màn Thao tác: **dùng LẠI `PipelineTreePanel`** (NămDự ánHạng mục4GĐ) thay vì dựng bản 2 — `+56/300` dòng - [x] cây màn Thao tác: **dùng LẠI `PipelineTreePanel`** (NămDự ánHạng mục4GĐ) thay vì dựng bản 2 — `+56/300` dòng
- [x] `/fable-real investigator-codebase` `wf_a574cff1-6b9` — spec 3-mục ghi; **BÁC 2 giả-thuyết lead** (cột DỰ ÁN = CHỖ-CHỪA-SẴN không phải di-chứng · 11 ô chứ không phải ≥3) + phát hiện `Project.BudgetTotal` rỗng 61/69 - [x] `/fable-real investigator-codebase` `wf_a574cff1-6b9` — spec 3-mục ghi; **BÁC 2 giả-thuyết lead** (cột DỰ ÁN = CHỖ-CHỪA-SẴN không phải di-chứng · 11 ô chứ không phải ≥3) + phát hiện `Project.BudgetTotal` rỗng 61/69
- [x] cổng C2 @PAUSE-3 KÊU (A\B={investigator-codebase, ctx-verifier}) → seed 2 entry → A\B=∅ - [x] cổng C2 @PAUSE-3 KÊU (A\B={investigator-codebase, ctx-verifier}) → seed 2 entry → A\B=∅
- [x] `ctx-curator` @PAUSE-3 = `SUA — 6 điểm`, áp đủ 6 rồi mới chèn MIND-4 (bắt được lead ĐẾM SAI commit của chính mình: viết 7 liệt 6) - [x] `ctx-curator` @PAUSE-3 = `SUA — 6 điểm`, áp đủ 6 rồi mới chèn MIND-4 (bắt được lead ĐẾM SAI commit của chính mình: viết 7 liệt 6)
- [!] 🔴 anh SOI MẮT prod 375/1366/1440 — mọi kết luận giao diện S175 là ĐỌC MÃ (5 lane NO-SCREENSHOT, gotcha #3); chưa soi thì CHƯA được tuyên PASS - [x] anh soi prod → **vòng-3 "tạm ổn"**; câu (2) cột DỰ ÁN 11 ô + (3) `BudgetTotal` 61/69 = anh **PARK** ("chưa quan tâm lúc này") — đừng hỏi lại, đừng tự làm
- [!] 🔴 **vòng-4 anh giao: 3 menu Đang duyệt / Đã duyệt / Đã xóa → cây NămDự ánHạng mụcphiếu KHKK** (= chính O5, anh phán GIỮ CÂY). CODE XONG build xanh ×2, **CHƯA anh soi prod** — kết luận giao diện vẫn là ĐỌC MÃ (gotcha #3)
- [x] **UAT vòng-3 ship `79936ad` + deploy VERIFIED 2-vế ×2 app** (admin `--p-YD6EX` · eoffice `DNq8Gz8s`; khung thẻ `border-[#e9eef4]`=1/1 · 2 chuỗi cũ=0/0 · control-dương `Hạng mục (dự án)`+`Lưu & Gửi duyệt`=1/1) — 4 defect: ① TRẢ LẠI khung thẻ (lead lượt trước gỡ CẢ `.card-accent` ⇒ mất luôn khung; anh chỉ muốn bỏ dải xanh — dựng lại khung bằng utility giữ đúng `#e9eef4`/`rounded-xl`) · ② cây Thao tác `approvedPeOnly` + `stagesOnly={[1]}` (lọc phase TRƯỚC khi dựng cây để badge đếm không nói dối; 4 host HĐ = 0/0 không đụng) · ③ ô Hạng mục (dự án) sang cột phải, nằm dưới Dự án · ④ bỏ 2 dòng chữ thừa - [x] **UAT vòng-3 ship `79936ad` + deploy VERIFIED 2-vế ×2 app** (admin `--p-YD6EX` · eoffice `DNq8Gz8s`; khung thẻ `border-[#e9eef4]`=1/1 · 2 chuỗi cũ=0/0 · control-dương `Hạng mục (dự án)`+`Lưu & Gửi duyệt`=1/1) — 4 defect: ① TRẢ LẠI khung thẻ (lead lượt trước gỡ CẢ `.card-accent` ⇒ mất luôn khung; anh chỉ muốn bỏ dải xanh — dựng lại khung bằng utility giữ đúng `#e9eef4`/`rounded-xl`) · ② cây Thao tác `approvedPeOnly` + `stagesOnly={[1]}` (lọc phase TRƯỚC khi dựng cây để badge đếm không nói dối; 4 host HĐ = 0/0 không đụng) · ③ ô Hạng mục (dự án) sang cột phải, nằm dưới Dự án · ④ bỏ 2 dòng chữ thừa
- [x] `ctx-curator` @PAUSE-4 = `SUA — 9 điểm`, áp đủ 9 rồi mới chèn MIND-5 — vai sửa CÁCH LEAD TRÍCH gotcha #66 ("đè là vô hiệu" là OVERCLAIM; `gotchas.md:1165` ghi rõ fix ĐIỂM bằng `!important` THẮNG) + bắt lead đếm sai commit **lần thứ HAI liên tiếp** - [x] `ctx-curator` @PAUSE-4 = `SUA — 9 điểm`, áp đủ 9 rồi mới chèn MIND-5 — vai sửa CÁCH LEAD TRÍCH gotcha #66 ("đè là vô hiệu" là OVERCLAIM; `gotchas.md:1165` ghi rõ fix ĐIỂM bằng `!important` THẮNG) + bắt lead đếm sai commit **lần thứ HAI liên tiếp**
- [ ] 🔴 2 NỢ MỚI (dư-lượng sau khi gỡ `.card-accent`): ① khung thẻ có **2 nguồn sự-thật** (`index.css:112` ⟂ utility hardcode `#e9eef4`/`rounded-xl` ×2 app) — đổi CSS không kéo theo KHKK · ② comment đã ship overclaim gotcha #66 — 🔴 **ĐO ĐÍCH @S176 = 4 site / 2 CHUỖI KHÁC NHAU** (sổ cũ ghi mờ "≥3 chỗ"): `KhkkWorkflowPanel.tsx:278` ×2 app = *"đè là vô hiệu"* (SAI thẳng) ⟂ `PipelineTreePanel.tsx:226` ×2 app = *"vô hiệu IM LẶNG… Phải GỠ CLASS, không đè"* (chẩn ĐÚNG cho `border-0` trần, chỉ câu kết quá rộng — `border-0!` vẫn thắng). **Grep 1 chuỗi = vá 2/4 rồi tưởng xong** - [ ] 🔴 2 NỢ MỚI (dư-lượng sau khi gỡ `.card-accent`): ① khung thẻ có **2 nguồn sự-thật** (`index.css:112` ⟂ utility hardcode `#e9eef4`/`rounded-xl` ×2 app) — đổi CSS không kéo theo KHKK · ② comment đã ship overclaim gotcha #66 — 🔴 **ĐO ĐÍCH @S176 = 4 site / 2 CHUỖI KHÁC NHAU** (sổ cũ ghi mờ "≥3 chỗ"): `KhkkWorkflowPanel.tsx:278` ×2 app = *"đè là vô hiệu"* (SAI thẳng) ⟂ `PipelineTreePanel.tsx:226` ×2 app = *"vô hiệu IM LẶNG… Phải GỠ CLASS, không đè"* (chẩn ĐÚNG cho `border-0` trần, chỉ câu kết quá rộng — `border-0!` vẫn thắng). **Grep 1 chuỗi = vá 2/4 rồi tưởng xong**
- [x] `ctx-verifier` @/tiep cửa-4 = `NAP-LAI-SAN-SANG — 10 cờ`; lead tự đo lại 3 cờ nặng, **CẢ 3 CONFIRMED** → đã flush bảng Owner-items `runs/…S174…/run.md` **6 dòng + dòng CÒN NỢ** (O1 `43e1bd7` · O3 hai vế `a0ccac3`+`b811e11` · O4/O6/O7/O8 `a0ccac3`) - [x] `ctx-verifier` @/tiep cửa-4 = `NAP-LAI-SAN-SANG — 10 cờ`; lead tự đo lại 3 cờ nặng, **CẢ 3 CONFIRMED** → đã flush bảng Owner-items `runs/…S174…/run.md` **6 dòng + dòng CÒN NỢ** (O1 `43e1bd7` · O3 hai vế `a0ccac3`+`b811e11` · O4/O6/O7/O8 `a0ccac3`)
- [ ] 🔴 **O5 "cần phán" MỒ-CÔI** (`KhkkListPanel:202 flatMode` đổi CÂY→PHẲNG khi có filter) — 0 hit ở WAL/`_mind`/`next:`, chỉ sống trong `runs/…S174…/run.md:26` - [x] **O5 ĐÓNG — anh phán GIỮ CÂY.** `leafMode` OPT-IN ở `PipelineTreePanel` (4 tầng, bỏ tầng GĐ + 8 ngăn nhóm) + `buildTree(rows, weightOf)` đếm PHIẾU thay vì GÓI + tỉa nhánh 0-phiếu. `flatMode` bỏ 2 vế `deletedView`/`phase`, **GIỮ** `!canPe` (cây cần xương sống PE) + `term` (đường thoát server-side ngoài cửa-sổ 200). Thùng rác nối nguồn riêng `/deleted` pageSize 200 vì linkage cây KHÔNG có phiếu đã xoá
- [ ] 🔴 KHAI GIỚI-HẠN vòng-4 (chưa xử, chờ anh quyết): cây ăn **cửa-sổ 200** phiếu mới nhất ⇒ 3 menu này **thu hẹp tầm với** so với phẳng-phân-trang-thật. Dòng khai truncation của cây vẫn hiện; đường thoát = gõ tìm kiếm (rơi về phẳng server-side). Volume KHKK hiện nhỏ nên chưa cắn
- [ ] 🔴 đính-chính `_context-s-11.md:152` — câu *"Lead đã flush (a)"* SAI: flush **1/7** (đúng dòng O2 vai gọi tên, bỏ 6 dòng cùng bảng). `_context` append-only ⇒ đính chính bằng entry PAUSE-5, KHÔNG sửa tại chỗ - [ ] 🔴 đính-chính `_context-s-11.md:152` — câu *"Lead đã flush (a)"* SAI: flush **1/7** (đúng dòng O2 vai gọi tên, bỏ 6 dòng cùng bảng). `_context` append-only ⇒ đính chính bằng entry PAUSE-5, KHÔNG sửa tại chỗ
- [ ] cột DỰ ÁN 11 ô: anh chốt "ko cần so sánh PRO/CCM" ⇒ phương-án engine KHÔNG land — hỏi anh coi ĐÓNG hẳn hay còn mở - 🅿️ **PARK (anh @S176 "chưa quan tâm lúc này") — KHÔNG hỏi lại, KHÔNG tự làm:** cột DỰ ÁN 11 ô (phương-án engine không land) · `Project.BudgetTotal` rỗng 61/69
- [ ] `Project.BudgetTotal` rỗng **61/69** dự án — có phải "mục dự án chưa điền vào" anh nói không
- [ ] thăng luật E4 vế ➊ lên khối auto-inject `harness-eval/MEMORY.md ## Luật cứng` - [ ] thăng luật E4 vế ➊ lên khối auto-inject `harness-eval/MEMORY.md ## Luật cứng`
- [ ] 🔴 ghi synthesis cho **2** run-folder ORPHAN (0 flat `*-synthesis.md`) — sổ cũ chỉ ghi 1, ground-truth @S176 đếm 2: ① `2026-08-05-S173-bookend-open` · ② `2026-08-05-S175-duan-budget-column` (run `/fable-real` `wf_a574cff1-6b9`, có `run.md`+`spec-*.md` nhưng chưa harvest) - [ ] 🔴 ghi synthesis cho **2** run-folder ORPHAN (0 flat `*-synthesis.md`) — sổ cũ chỉ ghi 1, ground-truth @S176 đếm 2: ① `2026-08-05-S173-bookend-open` · ② `2026-08-05-S175-duan-budget-column` (run `/fable-real` `wf_a574cff1-6b9`, có `run.md`+`spec-*.md` nhưng chưa harvest)
- [ ] 5 điểm governance CHƯA ĐỘNG: `STATUS:473` 86→87 · gạch 5 slot HANDOFF · `ROTATE vrapp` · re-stamp Carry 14 slug · slot (70) anchor - [ ] 5 điểm governance CHƯA ĐỘNG: `STATUS:473` 86→87 · gạch 5 slot HANDOFF · `ROTATE vrapp` · re-stamp Carry 14 slug · slot (70) anchor
- [ ] `ring2-audit/MEMORY.md` 26.440 B > cap 25.600 (A1 treo từ PAUSE-1) · monthly drift-audit quá hạn · dọn 9 dir rỗng `runs/.../agent-memory/*` @session-end - [ ] `ring2-audit/MEMORY.md` 26.440 B > cap 25.600 (A1 treo từ PAUSE-1) · monthly drift-audit quá hạn · dọn 9 dir rỗng `runs/.../agent-memory/*` @session-end
- [ ] kéo 2 thư `loi-do-luong` (ĐÃ CÓ ở `AI_INFRA/outbox/all`, SE chưa kéo) + 4× `upgrade-pack-phased` thiếu adap-report — đo @S176: `se=0` (23/23 đã vào `_index`), nợ đúng bằng sổ - [ ] kéo 2 thư `loi-do-luong` (ĐÃ CÓ ở `AI_INFRA/outbox/all`, SE chưa kéo) + 4× `upgrade-pack-phased` thiếu adap-report — đo @S176: `se=0` (23/23 đã vào `_index`), nợ đúng bằng sổ
next: đã /tiep @S176 → CHỜ anh soi prod 375/1366/1440 rồi gửi defect vòng-4 → [đuôi, đừng rơi] E4 ➊ · synthesis **2** run-folder orphan (S173 + S175-duan-budget) · 5 điểm governance · kéo thư + adap-report · A1 ring2 next: vòng-4 (cây 4 tầng cho 3 menu lọc) ĐÃ code+build xanh ×2 → ship+deploy → CHỜ anh soi prod 375/1366/1440 → [đuôi, đừng rơi] E4 ➊ · synthesis **2** run-folder orphan (S173 + S175-duan-budget) · 5 điểm governance · kéo thư + adap-report · A1 ring2
verify: verify:
- `curl -s https://admin.solutions.com.vn/ | grep -oE 'index-[A-Za-z0-9_-]+\.js'` → khác `index--p-YD6EX.js` = đợt SAU đã tới (mốc @PAUSE-4 = bundle `79936ad`) - `curl -s https://admin.solutions.com.vn/ | grep -oE 'index-[A-Za-z0-9_-]+\.js'` → khác `index--p-YD6EX.js` = đợt SAU đã tới (mốc @PAUSE-4 = bundle `79936ad`)

View File

@ -22,12 +22,13 @@
// ⇒ ô tìm + `Select` trạng-thái đi SERVER-SIDE thẳng `GET /contract-signing-plans` // ⇒ ô tìm + `Select` trạng-thái đi SERVER-SIDE thẳng `GET /contract-signing-plans`
// (bật chế-độ danh-sách phẳng), luôn có đường tới phiếu cũ; và dòng cảnh-báo // (bật chế-độ danh-sách phẳng), luôn có đường tới phiếu cũ; và dòng cảnh-báo
// "Cây tải 200 phiếu mới nhất…" của cây KHÔNG bị nuốt. // "Cây tải 200 phiếu mới nhất…" của cây KHÔNG bị nuốt.
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { ChevronLeft, ChevronRight, FileCheck, Inbox, Search } from 'lucide-react' import { ChevronLeft, ChevronRight, FileCheck, Inbox, Search } from 'lucide-react'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel' import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
import { usePipelineStages } from '@/hooks/usePipelineStages' import { usePipelineLinkage, usePipelineStages } from '@/hooks/usePipelineStages'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import { import {
@ -40,6 +41,10 @@ import {
} from '@/types/khkk' } from '@/types/khkk'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
// [S176] Cửa-sổ nguồn cho CÂY ở chế-độ thùng rác. Bằng đúng `INDEX_PAGE_SIZE`/
// `PE_INDEX_PAGE_SIZE` của 2 index cây (200) để cả cây kể CÙNG một câu chuyện về giới-hạn
// — lấy 20/trang như chế-độ phẳng thì cây sẽ cụt mà không có dòng nào khai.
const TREE_SOURCE_PAGE_SIZE = 200
function formatVnd(n: number | null | undefined): string { function formatVnd(n: number | null | undefined): string {
if (n === null || n === undefined) return '—' if (n === null || n === undefined) return '—'
@ -198,17 +203,29 @@ export function KhkkListPanel({
onOpenPe?: (id: string) => void onOpenPe?: (id: string) => void
}) { }) {
const { canPe } = usePipelineStages() const { canPe } = usePipelineStages()
const linkage = usePipelineLinkage()
const term = search.trim() const term = search.trim()
// [R-11] Lọc đi SERVER-SIDE, KHÔNG lọc trên cây: có từ khoá / có trạng-thái / xem thùng // [S176 owner] "ở các menu đang duyệt/ đã duyệt/ xóa → cho hiển thị năm → dự án → hạng
// rác ⇒ chuyển sang danh-sách phẳng có phân-trang thật (`total` từ server) nên phiếu nằm // mục → phiếu KHKK nhé" ⇒ 3 menu đó nay đi CÂY (chế-độ lá 4 tầng), không còn phẳng.
// ngoài 200 phiếu mới nhất VẪN tới được. //
// [M-1] `!canPe` ⇒ cũng dùng đúng danh-sách phẳng này — cây khoá không được phép làm // 🧊 Trước S176: `flatMode` còn gồm `deletedView || phase !== null`. Bỏ 2 vế đó.
// người dùng mất trắng phiếu của chính họ. // [R-11] GIỮ vế từ-khoá: tìm kiếm đi SERVER-SIDE có phân-trang thật (`total` từ server)
const flatMode = !canPe || deletedView || term.length > 0 || phase !== null // nên phiếu nằm ngoài cửa-sổ 200 VẪN tới được — đó là đường thoát khi cây không
// với tới, và là lý do KHÔNG bỏ nốt vế này.
// [M-1] GIỮ vế `!canPe`: cây dựng trên xương sống phiếu Duyệt NCC (`enabled: canPe`),
// ai không có quyền đó thì cây KHÔNG dựng được — ép cây sẽ làm họ mất trắng phiếu
// của chính mình. Danh-sách phẳng là lưới an-toàn, không phải tuỳ chọn.
const flatMode = !canPe || term.length > 0
// Xem thùng rác ở chế-độ CÂY: phiếu đã xoá KHÔNG nằm trong index của cây
// (`usePipelineLinkage` chỉ gọi `/contract-signing-plans`), nên phải tự lấy từ endpoint
// riêng rồi tự gắn vào PE. Lấy đúng cửa-sổ 200 như 2 index kia để cùng một câu chuyện
// truncation, thay vì 20/trang của chế-độ phẳng.
const treeDeleted = deletedView && !flatMode
const list = useQuery({ const list = useQuery({
queryKey: ['khkk-list', { group, phase, search: term, page, deletedView }], queryKey: ['khkk-list', { group, phase, search: term, page, deletedView, tree: treeDeleted }],
queryFn: async () => { queryFn: async () => {
const url = deletedView ? '/contract-signing-plans/deleted' : '/contract-signing-plans' const url = deletedView ? '/contract-signing-plans/deleted' : '/contract-signing-plans'
return (await api.get<PagedResult<KhkkListItemDto>>(url, { return (await api.get<PagedResult<KhkkListItemDto>>(url, {
@ -216,14 +233,39 @@ export function KhkkListPanel({
phase: deletedView ? undefined : (phase ?? undefined), phase: deletedView ? undefined : (phase ?? undefined),
group: group ?? undefined, group: group ?? undefined,
search: term || undefined, search: term || undefined,
page, page: treeDeleted ? 1 : page,
pageSize: PAGE_SIZE, pageSize: treeDeleted ? TREE_SOURCE_PAGE_SIZE : PAGE_SIZE,
}, },
})).data })).data
}, },
enabled: flatMode, enabled: flatMode || treeDeleted,
}) })
// Phiếu đã xoá gom theo PE — chỉ dựng khi đang xem thùng rác bằng cây.
const deletedByPeId = useMemo(() => {
const map = new Map<string, KhkkListItemDto[]>()
if (!treeDeleted) return map
for (const k of list.data?.items ?? []) {
const bucket = map.get(k.purchaseEvaluationId)
if (bucket) bucket.push(k)
else map.set(k.purchaseEvaluationId, [k])
}
return map
}, [treeDeleted, list.data])
// Phiếu treo dưới một gói thầu, SAU khi áp đủ bộ lọc đang bật (trạng-thái + nhóm).
// 🔴 Lọc ở ĐÂY = lọc TRƯỚC khi cây dựng ⇒ badge đếm ở cả 3 tầng tự khớp tập đang hiện.
// Đây đúng là luật vòng-3 ("lọc phase TRƯỚC khi dựng cây, lọc sau thì badge nói dối").
const plansForPe = useMemo(() => {
return (peId: string): KhkkListItemDto[] => {
const src = treeDeleted ? (deletedByPeId.get(peId) ?? []) : (linkage.khkkByPeId.get(peId) ?? [])
const byGroup = group === null ? src : src.filter(k => k.approvalGroup === group)
// Thùng rác KHÔNG lọc theo trạng-thái: phiếu đã xoá giữ nguyên phase lúc bị xoá, lọc
// thêm chỉ tổ giấu mất phiếu người dùng đang đi tìm.
return treeDeleted || phase === null ? byGroup : byGroup.filter(k => k.phase === phase)
}
}, [treeDeleted, deletedByPeId, linkage.khkkByPeId, group, phase])
const items = list.data?.items ?? [] const items = list.data?.items ?? []
const total = list.data?.total ?? 0 const total = list.data?.total ?? 0
const totalPages = list.data?.totalPages ?? 1 const totalPages = list.data?.totalPages ?? 1
@ -242,11 +284,14 @@ export function KhkkListPanel({
return ( return (
<ul className="divide-y divide-slate-100"> <ul className="divide-y divide-slate-100">
{shown.map(k => ( {shown.map(k => (
<li key={k.id}> // [S176] Thùng rác trên CÂY phải cư-xử y hệt thùng rác ở danh-sách phẳng:
// làm mờ + KHÔNG cho mở. Thiếu vế này thì cùng một phiếu đã xoá lại mở được ở
// cây mà không mở được ở phẳng — cùng dữ-liệu, hai luật.
<li key={k.id} className={cn(deletedView && 'opacity-70')}>
<KhkkPlanCard <KhkkPlanCard
plan={k} plan={k}
selected={selectedId === k.id} selected={selectedId === k.id}
onSelect={onSelect} onSelect={deletedView ? () => { /* phiếu đã xóa: chỉ liệt kê, không mở */ } : onSelect}
finalize={selectedId === k.id ? selectedFinalize : undefined} finalize={selectedId === k.id ? selectedFinalize : undefined}
/> />
</li> </li>
@ -306,6 +351,24 @@ export function KhkkListPanel({
hideSearch hideSearch
bodyMaxHClass="max-h-[28rem] lg:max-h-none" bodyMaxHClass="max-h-[28rem] lg:max-h-none"
stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect, onOpenPe }} stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect, onOpenPe }}
// [S176 owner] ĐÚNG 3 menu anh gọi tên — `filter=ChoDuyet` (Đang duyệt) ·
// `filter=DaDuyet` (Đã duyệt) · `view=deleted` (Đã xóa) ⇒ cây rút về 4 tầng
// Năm Dự án Hạng mục phiếu KHKK.
// 🔴 CẤM thêm vế `group !== null` vào đây: CẢ BỐN leaf menu KHKK đều mang
// `?group=1` (`Layout.tsx:41-45`), kể cả "Danh sách" không lọc — thêm vế đó là
// kéo luôn menu TOÀN CẢNH vào chế-độ lá, tức đổi một màn owner không hề yêu cầu.
// Nhóm là chiều CHIA NGĂN, không phải chiều CHỌN HÌNH CÂY.
leafMode={
phase !== null || deletedView
? {
countForPe: pe => plansForPe(pe.id).length,
render: peItems => stage2Content(peItems.flatMap(p => plansForPe(p.id))),
emptyText: deletedView
? 'Không có phiếu đã xóa.'
: 'Chưa có kế hoạch ký kết nào khớp bộ lọc.',
}
: undefined
}
/> />
)} )}

View File

@ -8,7 +8,7 @@
// 🔴 Cây trang Duyệt NCC (`pages/pe/PurchaseEvaluationsListPage.tsx:210-252`) KHÔNG // 🔴 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 // 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. // 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 { useMemo, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { ListTree, Search } from 'lucide-react' import { ListTree, Search } from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
@ -31,7 +31,13 @@ type ProjectNode = {
} }
type YearNode = { year: number; projects: ProjectNode[]; totalCount: number } type YearNode = { year: number; projects: ProjectNode[]; totalCount: number }
function buildTree(rows: PeListItem[]): YearNode[] { // [S176 owner] `weightOf` = mỗi PE đóng góp BAO NHIÊU vào badge đếm. Mặc định 1 (đếm
// gói thầu) ⇒ 6 host cũ không đổi một số nào. Chế-độ lá (`leafMode`) truyền số PHIẾU
// khớp bộ lọc, vì ở 3 menu Đang duyệt / Đã duyệt / Đã xóa thứ người dùng đang đếm là
// PHIẾU chứ không phải gói — và 1 gói có thể mang nhiều phiếu.
// 🔴 Cùng luật vòng-3: đếm phải khớp ĐÚNG tập đang hiện. Giữ `+1` ở chế-độ lá thì badge
// nói dối theo chiều ngược lại (gói có 2 phiếu vẫn kêu 1).
function buildTree(rows: PeListItem[], weightOf: (p: PeListItem) => number = () => 1): YearNode[] {
const yearMap = new Map<number, YearNode>() const yearMap = new Map<number, YearNode>()
for (const p of rows) { for (const p of rows) {
const year = new Date(p.createdAt).getFullYear() const year = new Date(p.createdAt).getFullYear()
@ -63,8 +69,9 @@ function buildTree(rows: PeListItem[]): YearNode[] {
pg.workItems.push(wg) pg.workItems.push(wg)
} }
wg.items.push(p) wg.items.push(p)
pg.totalCount++ const w = weightOf(p)
yg.totalCount++ pg.totalCount += w
yg.totalCount += w
} }
const arr = Array.from(yearMap.values()) const arr = Array.from(yearMap.values())
arr.sort((a, b) => b.year - a.year) arr.sort((a, b) => b.year - a.year)
@ -92,6 +99,7 @@ export function PipelineTreePanel({
hideSearch, hideSearch,
approvedPeOnly, approvedPeOnly,
stagesOnly, stagesOnly,
leafMode,
}: { }: {
currentStage: PipelineStageNo currentStage: PipelineStageNo
className?: string className?: string
@ -126,6 +134,24 @@ export function PipelineTreePanel({
// [S175] Chỉ vẽ các folder giai-đoạn trong danh sách này (vd `[1]` = chỉ Duyệt NCC). // [S175] Chỉ vẽ các folder giai-đoạn trong danh sách này (vd `[1]` = chỉ Duyệt NCC).
// Không truyền ⇒ vẽ đủ 4 giai-đoạn như cũ. // Không truyền ⇒ vẽ đủ 4 giai-đoạn như cũ.
stagesOnly?: PipelineStageNo[] stagesOnly?: PipelineStageNo[]
// [S176 owner — "ở các menu đang duyệt/ đã duyệt/ xóa → cho hiển thị năm → dự án →
// hạng mục → phiếu KHKK nhé"] CHẾ-ĐỘ LÁ: cây dừng ở tầng Hạng mục rồi vẽ THẲNG phiếu,
// bỏ tầng folder giai-đoạn VÀ tầng 8 ngăn nhóm. Đúng 4 tầng anh gõ.
// 🔴 Panel này KHÔNG biết gì về kiểu KHKK — host cấp cả phép ĐẾM lẫn phép VẼ, nên cây
// dùng lại được cho giai-đoạn khác sau này mà không kéo theo import chéo.
// 🔴 OPT-IN: 6 host còn lại không truyền ⇒ đi nhánh `PipelineStageFolders` y như cũ.
leafMode?: {
/** Số phiếu của PE này SAU bộ lọc. Trả 0 ⇒ nhánh bị TỈA khỏi cây (không hiện folder rỗng). */
countForPe: (pe: PeListItem) => number
/** Thân tầng Hạng mục = phiếu của đúng các PE trong hạng mục đó. */
render: (peItems: PeListItem[]) => ReactNode
/**
* Câu hiển thị khi bộ lọc tỉa sạch cây. Mặc định "Chưa có phiếu nào để dựng cây" nói
* về DỰNG CÂY — ở chế-độ lá nó đánh lừa: người dùng lọc "Đã duyệt" mà rỗng sẽ tưởng
* hệ-thống hỏng chứ không hiểu là không phiếu nào khớp bộ lọc.
*/
emptyText?: string
}
}) { }) {
const { canPe, buildStages } = usePipelineStages() const { canPe, buildStages } = usePipelineStages()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
@ -170,14 +196,23 @@ export function PipelineTreePanel({
const all = approvedPeOnly const all = approvedPeOnly
? base.filter(p => p.phase === PurchaseEvaluationPhase.DaDuyet) ? base.filter(p => p.phase === PurchaseEvaluationPhase.DaDuyet)
: base : base
if (!term) return all const searched = !term
return all.filter(p => ? all
[p.projectCode, p.projectName, p.workItemName, p.maPhieu, p.tenGoiThau] : all.filter(p =>
.some(v => (v ?? '').toLowerCase().includes(term)), [p.projectCode, p.projectName, p.workItemName, p.maPhieu, p.tenGoiThau]
) .some(v => (v ?? '').toLowerCase().includes(term)),
}, [peIndex.data, term, approvedPeOnly]) )
// [S176] TỈA nhánh rỗng ở chế-độ lá: gói thầu không còn phiếu nào khớp bộ lọc thì
// KHÔNG dựng nhánh cho nó. Cùng lý-do "lọc TRƯỚC khi dựng" ở trên — để lại nhánh rỗng
// thì người dùng mở Năm→Dự án→Hạng mục ba lần chỉ để thấy chỗ trống.
return leafMode ? searched.filter(p => leafMode.countForPe(p) > 0) : searched
}, [peIndex.data, term, approvedPeOnly, leafMode])
const years = useMemo(() => buildTree(rows), [rows]) // [S176] Chế-độ lá đếm PHIẾU (`countForPe`); mặc định đếm GÓI (mỗi PE = 1) như cũ.
const years = useMemo(
() => buildTree(rows, leafMode ? p => leafMode.countForPe(p) : undefined),
[rows, leafMode],
)
const packageCount = years.reduce((n, y) => n + y.projects.reduce((m, p) => m + p.workItems.length, 0), 0) 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). // Đ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 const hideEmpty = term.length > 0
@ -296,7 +331,9 @@ export function PipelineTreePanel({
)} )}
{canPe && !peIndex.isLoading && !peIndex.isError && years.length === 0 && ( {canPe && !peIndex.isLoading && !peIndex.isError && years.length === 0 && (
<p className="px-3 py-4 text-[12px] text-slate-500"> <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.'} {term
? 'Không có gói thầu nào khớp từ khoá.'
: (leafMode?.emptyText ?? 'Chưa có phiếu nào để dựng cây.')}
</p> </p>
)} )}
@ -351,19 +388,34 @@ export function PipelineTreePanel({
<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> <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="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={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> {/* [S176] Chế-độ lá đếm PHIẾU, mặc định đếm GÓI — khớp đúng
thứ đang hiện ngay dưới nó. */}
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">
{leafMode
? wg.items.reduce((n, p) => n + leafMode.countForPe(p), 0)
: wg.items.length}
</span>
</summary> </summary>
<PipelineStageFolders {leafMode ? (
className="ml-3 border-l border-slate-200" // [S176 owner] 4 tầng: Năm Dự án Hạng mục PHIẾU.
currentStage={currentStage} // Không đi qua `PipelineStageFolders` ⇒ không có tầng GĐ,
hideEmpty={hideEmpty} // không có 8 ngăn nhóm.
stages={(() => { <div className="ml-3 border-l border-slate-200">
const built = buildStages(wg.items, stage2Options ?? {}) {leafMode.render(wg.items)}
// [S175] `stagesOnly` = lọc HIỂN THỊ, KHÔNG đụng bộ dựng — </div>
// 5 host kia không truyền thì `built` đi thẳng như cũ. ) : (
return stagesOnly ? built.filter(st => stagesOnly.includes(st.n)) : built <PipelineStageFolders
})()} className="ml-3 border-l border-slate-200"
/> currentStage={currentStage}
hideEmpty={hideEmpty}
stages={(() => {
const built = buildStages(wg.items, stage2Options ?? {})
// [S175] `stagesOnly` = lọc HIỂN THỊ, KHÔNG đụng bộ dựng —
// 5 host kia không truyền thì `built` đi thẳng như cũ.
return stagesOnly ? built.filter(st => stagesOnly.includes(st.n)) : built
})()}
/>
)}
</details> </details>
) )
})} })}

View File

@ -22,12 +22,13 @@
// ⇒ ô tìm + `Select` trạng-thái đi SERVER-SIDE thẳng `GET /contract-signing-plans` // ⇒ ô tìm + `Select` trạng-thái đi SERVER-SIDE thẳng `GET /contract-signing-plans`
// (bật chế-độ danh-sách phẳng), luôn có đường tới phiếu cũ; và dòng cảnh-báo // (bật chế-độ danh-sách phẳng), luôn có đường tới phiếu cũ; và dòng cảnh-báo
// "Cây tải 200 phiếu mới nhất…" của cây KHÔNG bị nuốt. // "Cây tải 200 phiếu mới nhất…" của cây KHÔNG bị nuốt.
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { ChevronLeft, ChevronRight, FileCheck, Inbox, Search } from 'lucide-react' import { ChevronLeft, ChevronRight, FileCheck, Inbox, Search } from 'lucide-react'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel' import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
import { usePipelineStages } from '@/hooks/usePipelineStages' import { usePipelineLinkage, usePipelineStages } from '@/hooks/usePipelineStages'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import { import {
@ -40,6 +41,10 @@ import {
} from '@/types/khkk' } from '@/types/khkk'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
// [S176] Cửa-sổ nguồn cho CÂY ở chế-độ thùng rác. Bằng đúng `INDEX_PAGE_SIZE`/
// `PE_INDEX_PAGE_SIZE` của 2 index cây (200) để cả cây kể CÙNG một câu chuyện về giới-hạn
// — lấy 20/trang như chế-độ phẳng thì cây sẽ cụt mà không có dòng nào khai.
const TREE_SOURCE_PAGE_SIZE = 200
function formatVnd(n: number | null | undefined): string { function formatVnd(n: number | null | undefined): string {
if (n === null || n === undefined) return '—' if (n === null || n === undefined) return '—'
@ -198,17 +203,29 @@ export function KhkkListPanel({
onOpenPe?: (id: string) => void onOpenPe?: (id: string) => void
}) { }) {
const { canPe } = usePipelineStages() const { canPe } = usePipelineStages()
const linkage = usePipelineLinkage()
const term = search.trim() const term = search.trim()
// [R-11] Lọc đi SERVER-SIDE, KHÔNG lọc trên cây: có từ khoá / có trạng-thái / xem thùng // [S176 owner] "ở các menu đang duyệt/ đã duyệt/ xóa → cho hiển thị năm → dự án → hạng
// rác ⇒ chuyển sang danh-sách phẳng có phân-trang thật (`total` từ server) nên phiếu nằm // mục → phiếu KHKK nhé" ⇒ 3 menu đó nay đi CÂY (chế-độ lá 4 tầng), không còn phẳng.
// ngoài 200 phiếu mới nhất VẪN tới được. //
// [M-1] `!canPe` ⇒ cũng dùng đúng danh-sách phẳng này — cây khoá không được phép làm // 🧊 Trước S176: `flatMode` còn gồm `deletedView || phase !== null`. Bỏ 2 vế đó.
// người dùng mất trắng phiếu của chính họ. // [R-11] GIỮ vế từ-khoá: tìm kiếm đi SERVER-SIDE có phân-trang thật (`total` từ server)
const flatMode = !canPe || deletedView || term.length > 0 || phase !== null // nên phiếu nằm ngoài cửa-sổ 200 VẪN tới được — đó là đường thoát khi cây không
// với tới, và là lý do KHÔNG bỏ nốt vế này.
// [M-1] GIỮ vế `!canPe`: cây dựng trên xương sống phiếu Duyệt NCC (`enabled: canPe`),
// ai không có quyền đó thì cây KHÔNG dựng được — ép cây sẽ làm họ mất trắng phiếu
// của chính mình. Danh-sách phẳng là lưới an-toàn, không phải tuỳ chọn.
const flatMode = !canPe || term.length > 0
// Xem thùng rác ở chế-độ CÂY: phiếu đã xoá KHÔNG nằm trong index của cây
// (`usePipelineLinkage` chỉ gọi `/contract-signing-plans`), nên phải tự lấy từ endpoint
// riêng rồi tự gắn vào PE. Lấy đúng cửa-sổ 200 như 2 index kia để cùng một câu chuyện
// truncation, thay vì 20/trang của chế-độ phẳng.
const treeDeleted = deletedView && !flatMode
const list = useQuery({ const list = useQuery({
queryKey: ['khkk-list', { group, phase, search: term, page, deletedView }], queryKey: ['khkk-list', { group, phase, search: term, page, deletedView, tree: treeDeleted }],
queryFn: async () => { queryFn: async () => {
const url = deletedView ? '/contract-signing-plans/deleted' : '/contract-signing-plans' const url = deletedView ? '/contract-signing-plans/deleted' : '/contract-signing-plans'
return (await api.get<PagedResult<KhkkListItemDto>>(url, { return (await api.get<PagedResult<KhkkListItemDto>>(url, {
@ -216,14 +233,39 @@ export function KhkkListPanel({
phase: deletedView ? undefined : (phase ?? undefined), phase: deletedView ? undefined : (phase ?? undefined),
group: group ?? undefined, group: group ?? undefined,
search: term || undefined, search: term || undefined,
page, page: treeDeleted ? 1 : page,
pageSize: PAGE_SIZE, pageSize: treeDeleted ? TREE_SOURCE_PAGE_SIZE : PAGE_SIZE,
}, },
})).data })).data
}, },
enabled: flatMode, enabled: flatMode || treeDeleted,
}) })
// Phiếu đã xoá gom theo PE — chỉ dựng khi đang xem thùng rác bằng cây.
const deletedByPeId = useMemo(() => {
const map = new Map<string, KhkkListItemDto[]>()
if (!treeDeleted) return map
for (const k of list.data?.items ?? []) {
const bucket = map.get(k.purchaseEvaluationId)
if (bucket) bucket.push(k)
else map.set(k.purchaseEvaluationId, [k])
}
return map
}, [treeDeleted, list.data])
// Phiếu treo dưới một gói thầu, SAU khi áp đủ bộ lọc đang bật (trạng-thái + nhóm).
// 🔴 Lọc ở ĐÂY = lọc TRƯỚC khi cây dựng ⇒ badge đếm ở cả 3 tầng tự khớp tập đang hiện.
// Đây đúng là luật vòng-3 ("lọc phase TRƯỚC khi dựng cây, lọc sau thì badge nói dối").
const plansForPe = useMemo(() => {
return (peId: string): KhkkListItemDto[] => {
const src = treeDeleted ? (deletedByPeId.get(peId) ?? []) : (linkage.khkkByPeId.get(peId) ?? [])
const byGroup = group === null ? src : src.filter(k => k.approvalGroup === group)
// Thùng rác KHÔNG lọc theo trạng-thái: phiếu đã xoá giữ nguyên phase lúc bị xoá, lọc
// thêm chỉ tổ giấu mất phiếu người dùng đang đi tìm.
return treeDeleted || phase === null ? byGroup : byGroup.filter(k => k.phase === phase)
}
}, [treeDeleted, deletedByPeId, linkage.khkkByPeId, group, phase])
const items = list.data?.items ?? [] const items = list.data?.items ?? []
const total = list.data?.total ?? 0 const total = list.data?.total ?? 0
const totalPages = list.data?.totalPages ?? 1 const totalPages = list.data?.totalPages ?? 1
@ -242,11 +284,14 @@ export function KhkkListPanel({
return ( return (
<ul className="divide-y divide-slate-100"> <ul className="divide-y divide-slate-100">
{shown.map(k => ( {shown.map(k => (
<li key={k.id}> // [S176] Thùng rác trên CÂY phải cư-xử y hệt thùng rác ở danh-sách phẳng:
// làm mờ + KHÔNG cho mở. Thiếu vế này thì cùng một phiếu đã xoá lại mở được ở
// cây mà không mở được ở phẳng — cùng dữ-liệu, hai luật.
<li key={k.id} className={cn(deletedView && 'opacity-70')}>
<KhkkPlanCard <KhkkPlanCard
plan={k} plan={k}
selected={selectedId === k.id} selected={selectedId === k.id}
onSelect={onSelect} onSelect={deletedView ? () => { /* phiếu đã xóa: chỉ liệt kê, không mở */ } : onSelect}
finalize={selectedId === k.id ? selectedFinalize : undefined} finalize={selectedId === k.id ? selectedFinalize : undefined}
/> />
</li> </li>
@ -306,6 +351,24 @@ export function KhkkListPanel({
hideSearch hideSearch
bodyMaxHClass="max-h-[28rem] lg:max-h-none" bodyMaxHClass="max-h-[28rem] lg:max-h-none"
stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect, onOpenPe }} stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect, onOpenPe }}
// [S176 owner] ĐÚNG 3 menu anh gọi tên — `filter=ChoDuyet` (Đang duyệt) ·
// `filter=DaDuyet` (Đã duyệt) · `view=deleted` (Đã xóa) ⇒ cây rút về 4 tầng
// Năm Dự án Hạng mục phiếu KHKK.
// 🔴 CẤM thêm vế `group !== null` vào đây: CẢ BỐN leaf menu KHKK đều mang
// `?group=1` (`Layout.tsx:41-45`), kể cả "Danh sách" không lọc — thêm vế đó là
// kéo luôn menu TOÀN CẢNH vào chế-độ lá, tức đổi một màn owner không hề yêu cầu.
// Nhóm là chiều CHIA NGĂN, không phải chiều CHỌN HÌNH CÂY.
leafMode={
phase !== null || deletedView
? {
countForPe: pe => plansForPe(pe.id).length,
render: peItems => stage2Content(peItems.flatMap(p => plansForPe(p.id))),
emptyText: deletedView
? 'Không có phiếu đã xóa.'
: 'Chưa có kế hoạch ký kết nào khớp bộ lọc.',
}
: undefined
}
/> />
)} )}

View File

@ -8,7 +8,7 @@
// 🔴 Cây trang Duyệt NCC (`pages/pe/PurchaseEvaluationsListPage.tsx:210-252`) KHÔNG // 🔴 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 // 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. // 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 { useMemo, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { ListTree, Search } from 'lucide-react' import { ListTree, Search } from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
@ -31,7 +31,13 @@ type ProjectNode = {
} }
type YearNode = { year: number; projects: ProjectNode[]; totalCount: number } type YearNode = { year: number; projects: ProjectNode[]; totalCount: number }
function buildTree(rows: PeListItem[]): YearNode[] { // [S176 owner] `weightOf` = mỗi PE đóng góp BAO NHIÊU vào badge đếm. Mặc định 1 (đếm
// gói thầu) ⇒ 6 host cũ không đổi một số nào. Chế-độ lá (`leafMode`) truyền số PHIẾU
// khớp bộ lọc, vì ở 3 menu Đang duyệt / Đã duyệt / Đã xóa thứ người dùng đang đếm là
// PHIẾU chứ không phải gói — và 1 gói có thể mang nhiều phiếu.
// 🔴 Cùng luật vòng-3: đếm phải khớp ĐÚNG tập đang hiện. Giữ `+1` ở chế-độ lá thì badge
// nói dối theo chiều ngược lại (gói có 2 phiếu vẫn kêu 1).
function buildTree(rows: PeListItem[], weightOf: (p: PeListItem) => number = () => 1): YearNode[] {
const yearMap = new Map<number, YearNode>() const yearMap = new Map<number, YearNode>()
for (const p of rows) { for (const p of rows) {
const year = new Date(p.createdAt).getFullYear() const year = new Date(p.createdAt).getFullYear()
@ -63,8 +69,9 @@ function buildTree(rows: PeListItem[]): YearNode[] {
pg.workItems.push(wg) pg.workItems.push(wg)
} }
wg.items.push(p) wg.items.push(p)
pg.totalCount++ const w = weightOf(p)
yg.totalCount++ pg.totalCount += w
yg.totalCount += w
} }
const arr = Array.from(yearMap.values()) const arr = Array.from(yearMap.values())
arr.sort((a, b) => b.year - a.year) arr.sort((a, b) => b.year - a.year)
@ -92,6 +99,7 @@ export function PipelineTreePanel({
hideSearch, hideSearch,
approvedPeOnly, approvedPeOnly,
stagesOnly, stagesOnly,
leafMode,
}: { }: {
currentStage: PipelineStageNo currentStage: PipelineStageNo
className?: string className?: string
@ -126,6 +134,24 @@ export function PipelineTreePanel({
// [S175] Chỉ vẽ các folder giai-đoạn trong danh sách này (vd `[1]` = chỉ Duyệt NCC). // [S175] Chỉ vẽ các folder giai-đoạn trong danh sách này (vd `[1]` = chỉ Duyệt NCC).
// Không truyền ⇒ vẽ đủ 4 giai-đoạn như cũ. // Không truyền ⇒ vẽ đủ 4 giai-đoạn như cũ.
stagesOnly?: PipelineStageNo[] stagesOnly?: PipelineStageNo[]
// [S176 owner — "ở các menu đang duyệt/ đã duyệt/ xóa → cho hiển thị năm → dự án →
// hạng mục → phiếu KHKK nhé"] CHẾ-ĐỘ LÁ: cây dừng ở tầng Hạng mục rồi vẽ THẲNG phiếu,
// bỏ tầng folder giai-đoạn VÀ tầng 8 ngăn nhóm. Đúng 4 tầng anh gõ.
// 🔴 Panel này KHÔNG biết gì về kiểu KHKK — host cấp cả phép ĐẾM lẫn phép VẼ, nên cây
// dùng lại được cho giai-đoạn khác sau này mà không kéo theo import chéo.
// 🔴 OPT-IN: 6 host còn lại không truyền ⇒ đi nhánh `PipelineStageFolders` y như cũ.
leafMode?: {
/** Số phiếu của PE này SAU bộ lọc. Trả 0 ⇒ nhánh bị TỈA khỏi cây (không hiện folder rỗng). */
countForPe: (pe: PeListItem) => number
/** Thân tầng Hạng mục = phiếu của đúng các PE trong hạng mục đó. */
render: (peItems: PeListItem[]) => ReactNode
/**
* Câu hiển thị khi bộ lọc tỉa sạch cây. Mặc định "Chưa có phiếu nào để dựng cây" nói
* về DỰNG CÂY — ở chế-độ lá nó đánh lừa: người dùng lọc "Đã duyệt" mà rỗng sẽ tưởng
* hệ-thống hỏng chứ không hiểu là không phiếu nào khớp bộ lọc.
*/
emptyText?: string
}
}) { }) {
const { canPe, buildStages } = usePipelineStages() const { canPe, buildStages } = usePipelineStages()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
@ -170,14 +196,23 @@ export function PipelineTreePanel({
const all = approvedPeOnly const all = approvedPeOnly
? base.filter(p => p.phase === PurchaseEvaluationPhase.DaDuyet) ? base.filter(p => p.phase === PurchaseEvaluationPhase.DaDuyet)
: base : base
if (!term) return all const searched = !term
return all.filter(p => ? all
[p.projectCode, p.projectName, p.workItemName, p.maPhieu, p.tenGoiThau] : all.filter(p =>
.some(v => (v ?? '').toLowerCase().includes(term)), [p.projectCode, p.projectName, p.workItemName, p.maPhieu, p.tenGoiThau]
) .some(v => (v ?? '').toLowerCase().includes(term)),
}, [peIndex.data, term, approvedPeOnly]) )
// [S176] TỈA nhánh rỗng ở chế-độ lá: gói thầu không còn phiếu nào khớp bộ lọc thì
// KHÔNG dựng nhánh cho nó. Cùng lý-do "lọc TRƯỚC khi dựng" ở trên — để lại nhánh rỗng
// thì người dùng mở Năm→Dự án→Hạng mục ba lần chỉ để thấy chỗ trống.
return leafMode ? searched.filter(p => leafMode.countForPe(p) > 0) : searched
}, [peIndex.data, term, approvedPeOnly, leafMode])
const years = useMemo(() => buildTree(rows), [rows]) // [S176] Chế-độ lá đếm PHIẾU (`countForPe`); mặc định đếm GÓI (mỗi PE = 1) như cũ.
const years = useMemo(
() => buildTree(rows, leafMode ? p => leafMode.countForPe(p) : undefined),
[rows, leafMode],
)
const packageCount = years.reduce((n, y) => n + y.projects.reduce((m, p) => m + p.workItems.length, 0), 0) 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). // Đ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 const hideEmpty = term.length > 0
@ -296,7 +331,9 @@ export function PipelineTreePanel({
)} )}
{canPe && !peIndex.isLoading && !peIndex.isError && years.length === 0 && ( {canPe && !peIndex.isLoading && !peIndex.isError && years.length === 0 && (
<p className="px-3 py-4 text-[12px] text-slate-500"> <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.'} {term
? 'Không có gói thầu nào khớp từ khoá.'
: (leafMode?.emptyText ?? 'Chưa có phiếu nào để dựng cây.')}
</p> </p>
)} )}
@ -351,19 +388,34 @@ export function PipelineTreePanel({
<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> <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="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={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> {/* [S176] Chế-độ lá đếm PHIẾU, mặc định đếm GÓI — khớp đúng
thứ đang hiện ngay dưới nó. */}
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">
{leafMode
? wg.items.reduce((n, p) => n + leafMode.countForPe(p), 0)
: wg.items.length}
</span>
</summary> </summary>
<PipelineStageFolders {leafMode ? (
className="ml-3 border-l border-slate-200" // [S176 owner] 4 tầng: Năm Dự án Hạng mục PHIẾU.
currentStage={currentStage} // Không đi qua `PipelineStageFolders` ⇒ không có tầng GĐ,
hideEmpty={hideEmpty} // không có 8 ngăn nhóm.
stages={(() => { <div className="ml-3 border-l border-slate-200">
const built = buildStages(wg.items, stage2Options ?? {}) {leafMode.render(wg.items)}
// [S175] `stagesOnly` = lọc HIỂN THỊ, KHÔNG đụng bộ dựng — </div>
// 5 host kia không truyền thì `built` đi thẳng như cũ. ) : (
return stagesOnly ? built.filter(st => stagesOnly.includes(st.n)) : built <PipelineStageFolders
})()} className="ml-3 border-l border-slate-200"
/> currentStage={currentStage}
hideEmpty={hideEmpty}
stages={(() => {
const built = buildStages(wg.items, stage2Options ?? {})
// [S175] `stagesOnly` = lọc HIỂN THỊ, KHÔNG đụng bộ dựng —
// 5 host kia không truyền thì `built` đi thẳng như cũ.
return stagesOnly ? built.filter(st => stagesOnly.includes(st.n)) : built
})()}
/>
)}
</details> </details>
) )
})} })}