# sub-reviewer-1 — T2 review đối-kháng cây 4-folder GĐ (S162, 2026-07-30)

Ghi-đĩa-trong-lúc-làm (chống #53). Append theo từng mục soi. Verdict cuối ở cuối file.

## Phạm vi đo (từ đĩa, `git status --porcelain` + `git diff --stat`)
- 6 file tracked M: `fe-{user,admin}/src/pages/pe/PurchaseEvaluationsListPage.tsx` · `.../khkk/KhkkListPage.tsx` · `.../contracts/HardCopiesPage.tsx` — tổng +168/−16.
- 6 file MỚI untracked: `fe-{user,admin}/src/components/pipeline/{PipelineStageFolders,PipelineTreePanel}.tsx` + `fe-{user,admin}/src/hooks/usePipelineStages.ts`.
- Ngoài src: memory frontend-designer (MEMORY.md + archive/2026-06.md + archive/_INDEX.md), `.session-counter.json`, run-folder artifacts (2 png + 2 harness .txt + sub-frontend-designer-0.md).

## MỤC 1 — Claim "khối `<li>` phiếu giữ nguyên từng byte" — ĐẠT

`git diff -- .../pe/PurchaseEvaluationsListPage.tsx` (cả 2 app, diff IDENTICAL):
- Hunk A `@@ -29,6 +29,8` — +2 import. 0 xoá.
- Hunk B `@@ -47,6 +49,14` — +8 dòng (6 comment + `usePipelineStages()` + `filterActive`). 0 xoá.
- Hunk C `@@ -507,7 +517,12` — xoá ĐÚNG 1 dòng `<ul className="ml-3 divide-y divide-slate-100 border-l border-slate-200">`.
- Hunk D `@@ -607,6 +622,7` — +1 dòng `) })} />`. 0 xoá.
⇒ Tổng xoá = 1 dòng. Khối `<li>` KHÔNG có dòng `-` nào ⇒ CLAIM ĐỨNG. Class cũ không mất: `ml-3 border-l border-slate-200` → prop `className` wrapper; `divide-y divide-slate-100` giữ ở `<ul>` trong.

## MỤC 6 — Biên giới — ĐẠT
- `git diff --name-only` (tracked): 0 file `src/Backend`, 0 `tests/`, 0 seeder/menu (#84), 0 `PePipelineStrip.tsx`. Chỉ 6 file FE + memory/run-artifact ngoài src.
- `grep -rn "export default"` trong 6 file mới ⇒ NONE (named export only, đúng §3.9).
- Nhãn VERBATIM `PipelineStageFolders.tsx:22-27`: `1:'Duyệt NCC'` · `2:'Kế hoạch Hợp đồng'` · `3:'Duyệt Hợp đồng'` · `4:'Hợp đồng cứng'` — khớp lời owner từng chữ.
- Mirror SHA256 6/6 PAIR (tự đo lại, không tin bảng designer):
  PipelineStageFolders `05c93c3619a9cfec` · PipelineTreePanel `76e5dafb239e402a` · usePipelineStages `e762d5eb9c3a057a` · PE page `1d8d8f75c7d2cc14` · Khkk page `40a650bf6964ab4b` · HardCopies page `d30a911e7483f96a`.
- `types/contracts.ts` lệch 2 app CHỈ ở giá trị `ContractPhaseColor` (cùng bộ key 1-10,99) ⇒ hook SHA-chung import an toàn, `badgeClass` undefined vẫn có fallback `PipelineStageFolders.tsx:189`.

## MỤC 3 — Permission-gate 2 tầng — 1 LỖ THẬT (latent) + 1 điểm sai luận-cứ

Key FE (`usePipelineStages.ts:41-44`) vs BE:
- `Khkk_List`/`Khkk_G1`/`KeHoachKyKet` — cả 3 tồn tại thật (`DbInitializer.cs:2184-2188` KhkkKeys + `MenuKeys.cs:41`).
- `Hdc_<7 code>` + `HopDongCung` — tồn tại thật (`DbInitializer.cs:1868/2191`).
- `Ct_<code>_List` + `Contracts` — tồn tại thật.
- `can(k)` = `node.canRead` (`usePermission.ts:20`), findNode không thấy ⇒ false ⇒ OR-nhiều-key hợp lệ về mặt đọc cây.

🔴 **F: OR-nhiều-key ĐÁ NHAU với policy API cho GĐ2.** `ContractSigningPlansController.cs:36` = `[Authorize(Policy="KeHoachKyKet.Read")]`, và `MenuPermissionHandler.cs:40` so khớp **CHÍNH XÁC** `p.MenuKey == req.MenuKey` (KHÔNG kế thừa, KHÔNG OR con). ⇒ role có `Khkk_List=1` mà `KeHoachKyKet=0` sẽ: FE cho `canKhkk=true` → gọi API → **403** → `khkkFailed` → GĐ2 hiện "—" dưới MỌI gói thầu, và claim "0 rác 403" vỡ. Đo Dev DB: 13/13 role đều có `Contracts=1` ∧ `KeHoachKyKet=1` ⇒ HIỆN CHƯA vỡ; prod chưa đo được (chỉ localhost+secret). Là lỗ THIẾT KẾ chờ 1 lần chỉnh ma trận quyền.
🟡 luận-cứ trong comment `usePipelineStages.ts:10-13` ("gate phải OR nhiều key vì root có thể canRead=false") đúng cho TẦNG HIỂN THỊ nhưng sai đích cho tầng API: query nào cũng nên gate bằng ĐÚNG key policy của endpoint (`KeHoachKyKet` cho `/contract-signing-plans`).
🟢 `/contracts` KHÔNG có policy action-level (`ContractsController.cs:16-17`, chỉ `[Authorize]` trần class) ⇒ nhánh `enabled: canContract || canHardCopy` KHÔNG sinh 403. Đổi lại: đây là bare-`[Authorize]` sẵn có của hệ (#82), diff này không làm tệ thêm nhưng có nới hiển-thị: user chỉ được cấp `Hdc_ThauPhu` vẫn thấy leaf GĐ4 của MỌI loại HĐ (`usePipelineStages.ts:211`) vì gate là OR-toàn-nhóm chứ không theo `c.type`.
🟢 Không quyền ⇒ `enabled:false` (`:77`, `:91`, `PipelineTreePanel.tsx:108`) ⇒ KHÔNG gọi API; folder khoá render nhánh riêng KHÔNG `<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.
