GATE-GD3D: PASS-WITH-FLAGS-10 — Đúng phương án D, khuôn nguyên vẹn (SHA-pair identical), import sạch; 0 blocker, nhưng `max-h` của khuôn vượt chiều cao Panel-3 trên InboxPage (~26px) đẻ 2 thanh cuộn lồng nhau + 2 class khuôn chết âm thầm.

# Sub-reviewer gate GĐ3-D (S168, 2026-08-01)

Scope: 3 file FE uncommitted, phương án D "cây thay chỗ Panel-3 khi chưa chọn HĐ".
Spec nguồn: `.claude/workflows/runs/2026-07-31-S164-4gd-khkk-fanout/sub-implfe-gd3.md` §1 bảng 4 phương án (D).
Vai: reviewer đối-kháng, **propose-only** — không sửa code, không commit.

---

## Đo trước (baseline)

- `git status --porcelain` toàn repo: 5 `M` — `.claude/WAL.md`, `.claude/agent-memory/memory-budget.json`, + 3 file FE.
- `git status --porcelain --untracked-files=all -- fe-admin/src fe-user/src`: **ĐÚNG 3 file**, 0 untracked.
- `git diff --stat`: mỗi file FE 13 dòng đổi (+6/−7), tổng 3 file.

## R1 — Đúng-D (điều kiện render) → **PASS**

Cả 3 file giữ NGUYÊN cặp điều kiện:

```
{!selectedId && <PipelineTreePanel currentStage={3} />}
{selectedId && detail.data && <WorkflowHistoryPanel contract={detail.data} />}
```

- Cây CHỈ render khi `!selectedId` — đúng D.
- Chọn HĐ → `WorkflowHistoryPanel` như cũ; vế điều kiện **không đổi một ký tự**.
- Trạng thái "đã chọn HĐ nhưng `detail` đang tải" render **RỖNG ở CẢ bản cũ lẫn bản mới** (bản cũ: `!selectedId` sai nên ô dashed tắt, `detail.data` chưa có nên panel trống) ⇒ hành vi loading giữa chừng KHÔNG đổi.
- `currentStage={3}` đúng GĐ Hợp đồng cho cả 3 trang (xem F-10 cho lưu ý riêng về InboxPage).

## R2 — 0-đụng-khuôn → **PASS**

`sha256sum` (16 ký tự đầu), fe-admin ⟂ fe-user:

| File | admin | user | Kết quả |
|---|---|---|---|
| `components/pipeline/PipelineTreePanel.tsx` | `d70ac315bae485e6` | `d70ac315bae485e6` | IDENTICAL |
| `components/pipeline/PipelineStageFolders.tsx` | `bed52f39d595c86a` | `bed52f39d595c86a` | IDENTICAL |
| `hooks/usePipelineStages.ts` | `76e1823345d4e78b` | `76e1823345d4e78b` | IDENTICAL |

`git status --porcelain` trên 4 đường dẫn khuôn = **RỖNG** ⇒ khuôn chưa bị sửa. SHA-pair identical vừa chứng mirror 2 app còn khớp, vừa chứng không ai chạm vào khuôn trong đợt này.

## R3 — Unused-import `X` → **PASS**

- Cây làm việc: 0 hit mã ở cả 3 file. Hit duy nhất là chuỗi `type=X` trong comment `ContractsListPage.tsx:6` (không phải icon).
- **Control-âm:** cùng regex chạy trên `git show HEAD:<file>` cho **2 hit/file** (dòng `import … X …` + `<X className="mx-auto mb-2 h-5 w-5" />`) ⇒ phép đo CÓ răng, không phải 0-hit vì regex hỏng.
- Không gỡ nhầm icon khác: `FileText` / `Search` / `Plus` / `Inbox` / `AlertTriangle` / `Clock` / `ClipboardList` đều còn nguyên trong dòng import mới.

---

## FINDINGS

### F-1 (MAJOR) — R4: `max-h` của khuôn hiệu-chỉnh cho host page-flow, vượt chiều cao Panel-3 trên InboxPage ⇒ 2 thanh cuộn lồng nhau

Chiều cao TỐI ĐA của khuôn khi đặt ở lg:

| Thành phần | File:dòng | Lớp CSS | px |
|---|---|---|---|
| Đầu panel | `PipelineTreePanel.tsx:181` | `px-3 py-2.5` (20) + `icon-chip h-7!` (28) + `border-b` (1) | 49 |
| Ô lọc | `PipelineTreePanel.tsx:204` | `p-2` (16) + `input h-8` (32) + `border-b` (1) | 49 |
| Thân cây | `PipelineTreePanel.tsx:215` | `lg:max-h-[calc(100vh-16rem)]` | 100vh − 256 |
| **Tổng** | | | **100vh − 158** |

Chiều cao KHẢ DỤNG của Panel-3 = `100vh − 4rem` (vỏ trang) − đầu trang − dải phụ − `p-4` (32):

| Trang | Vỏ / đầu trang | Đầu trang | Dải phụ | Khả dụng | So với 100vh−158 |
|---|---|---|---|---|---|
| `fe-user/src/pages/contracts/MyContractsPage.tsx` | `:105` / `:107` `py-3` + `Button size=md h-8` + border | 57px | 0 | 100vh − 153 | **thừa 5px** (khít) |
| `fe-admin/src/pages/contracts/ContractsListPage.tsx` | `:106` / `:107` `py-3` + h1 24px + border | 49px | 0 | 100vh − 145 | **thừa 13px** |
| `fe-user/src/pages/InboxPage.tsx` | `:166` / `:168` `py-3` + `StatPill` 34px (`:50` `px-3 py-1.5` + `h-5`) + border | 59px | **29px** — dải "Vai trò bạn đang xử lý" `:183-187` (`py-1.5` + `text-[11px]` + border) | 100vh − 184 | **THIẾU ~26px** |

Hệ quả trên `/inbox`: `<aside className="hidden overflow-y-auto … p-4 lg:block">` mọc **thanh cuộn dọc thứ hai** bao ngoài thanh cuộn của thân cây, tức trong cột nội dung rộng 328px (360 − 2×16) có hai thanh cuộn lồng nhau. Nội dung vẫn với tới được (không vỡ layout, không mất dữ liệu), nhưng người dùng phải cuộn hai tầng, và đầu panel cùng ô lọc trôi khỏi tầm nhìn khi cuộn tầng ngoài.

Dải "Vai trò" chỉ render khi `user?.roles.length` — **đo prod**: `/api/roles` trả 13 vai, mọi tài khoản thật đều thuộc ít nhất một vai ⇒ nhánh này gần như LUÔN bật, không phải ca hiếm.

Hai trang còn lại chỉ thừa 5px và 13px ⇒ **mong manh**: bất kỳ thay đổi nào ở đầu trang (đổi `Button` sang `size=lg` = `h-10`, hoặc thêm một chip trạng thái làm `flex-wrap` xuống dòng ở bề rộng 1024px) đều đẩy chúng sang đúng trạng thái của InboxPage.

**Tiêu chí nghiệm thu đề nghị:** ở 1280×800 và 1366×768, mở `/inbox` khi chưa chọn HĐ — số thanh cuộn dọc trong vùng Panel-3 phải bằng **1**, và cuộn tới đáy cây không cần cuộn `<aside>` ngoài. Lặp lại phép đo cho `/my-contracts` và fe-admin `/contracts`.

### F-2 (MINOR) — R4: hai class của khuôn chết ÂM THẦM trong host mới

`PipelineTreePanel.tsx:177` — `cn('card-accent flex flex-col self-start overflow-hidden lg:sticky lg:top-4', className)`:

- `self-start` là `align-self:flex-start`, chỉ có tác dụng khi cha là flex hoặc grid. Host mới có cha là `<aside … lg:block>` ⇒ `display:block` ⇒ **vô hiệu**.
- `lg:sticky lg:top-4`: tổ tiên cuộn gần nhất **chính là** `<aside overflow-y-auto>`, mà cây lại là con DUY NHẤT của nó ⇒ khối chứa gần bằng chính nó ⇒ quãng "dính" xấp xỉ 0 ⇒ **vô hiệu**.

Ở ba host cũ (`KhkkListPage.tsx:197` và `HardCopiesPage.tsx:116`, đều là `lg:grid-cols-[19rem_minmax(0,1fr)]`) cha là grid và trang cuộn ở `<main className="flex-1 overflow-auto">`, nên **cả hai class đều sống**. Đây không phải lỗi chức năng, nhưng là dấu hiệu khuôn đang chạy ngoài giả định thiết kế của nó — cùng gốc với F-1. Nên ghi một dòng chú thích ở chỗ cắm để người sau không tưởng rằng cây có bám dính ở đây.

### F-3 (MINOR) — R4 / a11y: `<aside>` lồng trong `<aside>`

Host Panel-3 là `<aside>`, và gốc khuôn cũng là `<aside>` (`PipelineTreePanel.tsx:176`) có kèm `aria-label="Cây toàn trình theo gói thầu"` (`:179`). Theo HTML-AAM, một `aside` nằm trong sectioning content chỉ tụt xuống `role=generic` khi KHÔNG có tên khả truy cập; ở đây nó có tên nên **giữ nguyên `role=complementary`** ⇒ sinh ra hai landmark `complementary` lồng nhau. Điều này hợp lệ về HTML nhưng gây nhiễu khi người dùng điều hướng theo landmark bằng trình đọc màn hình. Ba host cũ không dính vì cha của chúng là `<div className="grid …">`.

### F-4 (MINOR) — R5: `!canPe` đổi thông điệp Panel-3 từ chỉ dẫn sang than phiền quyền; đo prod cho thấy hiện chưa vai nào dính

Đường quyền, đọc thẳng từ mã chứ không suy:

- `usePipelineStages.ts:83` — `canPe = PE_MENU_KEYS.some(k => can(k))`, với `PE_MENU_KEYS` ở `:55` là `['PurchaseEvaluations','Pe_DuyetNcc_List','Pe_DuyetNccPhuongAn_List']`.
- Khi `canPe` sai, `PipelineTreePanel.tsx:111` đặt `enabled: canPe` nên truy vấn `pipeline-pe-index` **không chạy** ⇒ **không sinh rác 403**, đúng ý đồ đã khai ở `usePipelineStages.ts:5-8`.
- Nhưng Panel-3 khi đó hiển thị khung cây rỗng, huy hiệu "**0 gói**", và câu ở `:216-220`: "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." Câu này nằm **trên trang Hợp đồng**, thay cho câu cũ "Quy trình duyệt sẽ hiện khi chọn HĐ." ⇒ người dùng mất chỉ dẫn hữu ích và nhận về một lời than phiền lạc đề.

**Đo prod** `/api/permissions/by-role/{id}` trên 13 vai (tất cả trả `http=200`): `PurchaseEvaluations`.canRead bằng True ở **13/13 vai** ⇒ nhánh `!canPe` **hiện không xảy ra với vai nào**.
**Control-âm của cùng phép đo:** `Permissions`.canRead và `Users`.canRead bằng False ở **12/13 vai** (chỉ `Admin` True; số dòng quyền là 169 so với 191) ⇒ phép đo có phân biệt thật, không phải "True tuốt vì đọc sai trường".
Kết luận: F-4 là **rủi ro tương lai** (nếu ai đó gỡ quyền PE của một vai), không phải lỗi đang chảy máu. Không chặn commit.

### F-5 (MINOR) — R5: cây bơm thêm tối đa ba truy vấn index vào lượt tải đầu của ba trang

`PipelineTreePanel` gọi `usePipelineStages`, hàm này gọi `usePipelineLinkage`, sinh ra ba truy vấn — `pipeline-pe-index` (`PipelineTreePanel.tsx:100`), `pipeline-khkk-index` (`usePipelineStages.ts:90`) và `pipeline-contract-index` (`:108`) — mỗi truy vấn lấy `pageSize=200`.

Trên InboxPage, MyContractsPage và ContractsListPage, truy vấn `pipeline-contract-index` gọi `GET /contracts?pageSize=200` **song song** với truy vấn danh sách của chính trang (`/contracts/inbox` ở `InboxPage.tsx:71`, `/contracts` ở MyContractsPage) — cùng một tài nguyên nhưng khác `queryKey`, nên mỗi lần vào trang mà chưa chọn HĐ là hai lần nạp danh sách hợp đồng. Đo prod cho thấy 13/13 vai đều có đủ `canPe`, `canKhkk` và `canContract` ⇒ **cả ba truy vấn đều bắn cho mọi vai**, không vai nào được miễn. Mức này chấp nhận được vì `staleTime` là 60 giây và đây là hệ quả nội tại của phương án D, nhưng nên biết trước để không bất ngờ khi UAT phản ánh trang tải chậm hơn.

### F-6 (MINOR) — R1 phụ: trade-off của D khai thiếu một vế — ô lọc cây mất chữ khi chọn HĐ rồi bỏ chọn

Vì điều kiện là `{!selectedId && <PipelineTreePanel …>}`, việc chọn một HĐ sẽ **gỡ bỏ (unmount)** component. Hệ quả:

- `expandedSet` **sống sót** nhờ `localStorage` (`PipelineTreePanel.tsx:119-126` đọc, `:170` ghi).
- `search` (`:94`) và `mobileOpen` (`:95`) là state thuần nên **mất trắng**: người dùng gõ "FLOCK" để lọc cây, bấm xem một HĐ, quay lại thì ô lọc rỗng và cây bung về nhánh mặc định.

Spec D ở `sub-implfe-gd3.md` §1 chỉ khai trade-off "cây nhảy chỗ khi chọn HĐ", không khai việc mất chữ đang lọc. Đề nghị bổ sung vế này khi báo owner để owner chốt trên thông tin đầy đủ.

### F-7 (MINOR) — R8 mobile: không hồi quy, nhưng lệch với ba host cũ

- Host là `<aside className="hidden overflow-y-auto … lg:block">` nên dưới 1024px Panel-3 **không render**. Bản cũ (ô dashed) nằm trong **đúng cùng** `<aside>` đó, vì vậy **không có hồi quy** trên mobile.
- Nhưng khuôn có sẵn cả một nhánh mobile riêng: nút "Hiện cây / Ẩn cây" gắn `lg:hidden` (`:193-200`), state `mobileOpen` khống chế thân cây (`:203`), và `max-h-[28rem]` dành cho mobile (`:215`). Trong host này **toàn bộ nhánh mobile của khuôn chết**.
- Hệ quả nghiệp vụ: người dùng mobile CÓ cây ở GĐ2 (`KhkkListPage`) và GĐ4 (`HardCopiesPage`, hai trang page-flow nên cây xếp ở trên), nhưng **không có cây ở GĐ3**.

Điều này đúng chủ đích của HOST (bố cục 3-panel vốn chỉ dành cho desktop) nhưng lệch chủ đích của KHUÔN. Owner cần biết để chốt, đây không phải lỗi code.

### F-8 (INFO — phán quyết: lead ĐÚNG) — R6: `WorkflowMatrixViewPage` không áp D là quyết định đúng

`fe-user/src/pages/pe/WorkflowMatrixViewPage.tsx:104` cho thấy gốc trang là `<div className="space-y-4 px-2 py-5">`: đây là trang page-flow, **không có `<aside>`, không có lưới 3-panel, không có Panel-3**. Vì vậy "cây thay chỗ Panel-3" **không có chỗ cắm**, và bỏ qua trang này là đúng theo đúng định nghĩa của phương án D. Đây không phải chỗ lead bỏ sót.

Tuy nhiên có một câu cần owner chốt: trang này được mount ở **ba route** (`fe-user/src/App.tsx:71` là `/purchase-evaluations/workflow-matrix`, `:73` là `/contracts/workflow-matrix`, `:83` là `/khkk/workflow-matrix`), tức nó là trang giai đoạn của cả ba GĐ. Nếu ý owner khi nói "(a) cứ mở rộng" là "mọi trang giai đoạn đều phải có cây", thì đây là trang duy nhất còn thiếu, và cách cắm đúng phải là lưới page-flow theo khuôn `KhkkListPage.tsx:197` chứ không phải D. Việc này nằm ngoài phạm vi wave hiện tại, nêu ra để owner quyết.

### F-9 (INFO — phán quyết: KHÔNG mâu thuẫn K6) — R7: sửa trang đang bị ẩn khỏi sidebar

- `fe-admin/src/components/Layout.tsx:227-240` — tập `ADMIN_HIDDEN_KEYS` có chứa `'Contracts'`; thêm nữa, `:248` trong `isAdminHidden` còn chặn mọi key bắt đầu bằng `Ct_`. Vì vậy admin không nhìn thấy mục Hợp đồng ở sidebar.
- **Route thì vẫn sống**: `fe-admin/src/App.tsx:82` map `/contracts` sang `ContractsListPage`, kèm `:83` cho `/contracts/new` và `:84` cho `/contracts/:id`.
- **Có tiền lệ khớp**: key `'HopDongCung'` cũng nằm trong `ADMIN_HIDDEN_KEYS` (`:231`), vậy mà `HardCopiesPage` bên fe-admin vẫn được S162 cắm cây (`fe-admin/src/pages/contracts/HardCopiesPage.tsx:117`) và route của nó vẫn sống (`App.tsx:87`).

Kết luận: K6 ẩn phần **hiển thị**, chứ không xoá **trang**. Sửa một trang bị ẩn chính là giữ mirror theo `docs/rules.md §3.9`, nên **không có mâu thuẫn**.
Một lưu ý thực dụng: thay đổi bên fe-admin gần như **vô hình với admin** vì phải gõ thẳng URL mới tới được. Giá trị của nó là parity giữa hai app, nên đừng tính nó vào "ba trang người dùng nhìn thấy" khi báo owner, và UAT phải làm ở fe-user chứ không phải fe-admin.

### F-10 (INFO) — R1 phụ: `currentStage={3}` trên InboxPage, trong khi hộp thư trộn GĐ1 và GĐ3

`fe-user/src/pages/InboxPage.tsx:71` gọi `/contracts/inbox` và `:79` gọi `/purchase-evaluations/inbox`, nên trang liệt kê cả hợp đồng lẫn phiếu Duyệt NCC (bấm vào phiếu PE thì `:283` điều hướng sang `/purchase-evaluations/:id`). Giá trị `currentStage={3}` làm cây mở sẵn folder GĐ3.

Cách đặt này vẫn hợp lý, vì Panel-3 là chỗ dành cho HĐ đang chọn (`selectedId` được dùng cho `/contracts/${selectedId}` ở `:84`), tức ngữ cảnh của panel đúng là GĐ3. Nêu ra để tránh hiểu nhầm nếu sau này owner coi Inbox là trang GĐ1. Không tính là lỗi.

---

## Tổng kết theo trục

| Trục | Kết quả | Ghi chú |
|---|---|---|
| R1 Đúng-D + loading không đổi | **PASS** | điều kiện render không đổi một ký tự; kèm F-6 và F-10 dạng thông tin |
| R2 0-đụng-khuôn + SHA-pair | **PASS** | 3/3 cặp IDENTICAL, `git status` trên khuôn rỗng |
| R3 Unused-import `X` | **PASS** | 0 hit mã, control-âm cho 2 hit/file ở HEAD |
| R4 Ngữ cảnh render / CSS | **FLAG** | F-1 (MAJOR), F-2, F-3 |
| R5 Permission-path / 403 rác | **PASS có cờ** | không sinh rác 403; F-4 là rủi ro tương lai (prod 13/13 chưa dính), F-5 là chi phí truy vấn |
| R6 Trang không áp | **PASS** | F-8 — quyết định của lead là đúng |
| R7 fe-admin ẩn sidebar | **PASS** | F-9 — không mâu thuẫn K6, có tiền lệ HardCopiesPage |
| R8 Mobile | **PASS** | F-7 — không hồi quy, nhưng nhánh mobile của khuôn chết trong host này |

**Blocker: 0.** Không finding nào chặn commit. F-1 là thứ đáng xử lý nhất trước khi UAT, hoặc phải khai rõ với owner như một trade-off đã biết.

**Ranh vai:** không sửa file mã nào, không commit, không push. Mọi finding đều ở dạng mô tả vấn đề kèm tiêu chí nghiệm thu.

END sub-reviewer-gate-gd3d — VERDICT=PASS-WITH-FLAGS-10 — TOTAL=10 finding

---

## LEAD DISPOSITION @S168 (từng dòng — không số tổng)

| F | Xử | Chi tiết |
|---|---|---|
| F-1 MAJOR | ✅ **VÁ** | Nới khuôn đúng ngoại-lệ "thiếu prop cho GĐ3" artifact lane khoanh: `PipelineTreePanel` +prop `bodyMaxHClass?` (default GIỮ NGUYÊN — 3 host giấy-cuộn cũ 0 đổi hành vi), 3 host Panel-3 pass `max-h-[28rem] lg:max-h-none` ⇒ **panel cuộn = đúng 1 thanh, bền mọi trang** (không phụ thuộc 5px/13px mong manh). Mirror ×2 app SHA-pair mới `7baf6247af3d`. Lệch tiêu-chí-CHỮ của gate ("cuộn tới đáy cây không cần cuộn aside ngoài") — đạt tiêu-chí-Ý (1 thanh cuộn, không lồng): chọn panel-cuộn thay cây-tự-cuộn vì bền cấu-trúc, đầu cây + ô lọc trôi theo là trade-off khai |
| F-2 MINOR | ✅ VÁ (comment) | Chú thích tại 3 chỗ cắm: sticky/self-start vô hiệu ở host display:block — by-design |
| F-3 MINOR | 🔸 CHẤP NHẬN-khai | aside-lồng-aside 2 landmark complementary: hợp lệ HTML, nhiễu screen-reader — không đổi khuôn lượt này (đổi root khuôn = đụng 4 host); ghi nợ a11y cùng lô a11y-`<a>`-label K5 |
| F-4 MINOR | 🔸 CHẤP NHẬN | `!canPe` than-phiền-quyền thay chỉ-dẫn: prod 13/13 vai có PE.Read (gate đo + control-âm) — rủi ro tương lai, không chảy máu |
| F-5 MINOR | 🔸 CHẤP NHẬN | +tối đa 3 query index lượt tải đầu (staleTime 60s, enabled canPe) — giá của D |
| F-6 MINOR | 🔸 CHẤP NHẬN-khai | chọn HĐ → cây unmount → bỏ chọn → ô lọc mất chữ (state trong component) — trade-off D "cây nhảy chỗ" đã khai với owner |
| F-7 MINOR | 🔸 CHẤP NHẬN | mobile: panel-3 hidden dưới lg = khuôn 3-panel by-design (mobile đi fullpage route) |
| F-8 INFO | ✓ (gate phán lead ĐÚNG) | WorkflowMatrixView không áp D |
| F-9 INFO | ✓ (gate phán KHÔNG mâu-thuẫn K6) | fe-admin /contracts ẩn sidebar — route sống, tiền lệ HardCopiesPage |
| F-10 INFO | 🔸 GIỮ `currentStage={3}` | /inbox trộn GĐ1+GĐ3 nhưng trang chủ-đích là HĐ Pending; PE item navigate sang trang PE riêng |

Vá F-1/F-2 nằm trong commit GĐ3-D (build+test re-verify sau vá). 6 finding chấp-nhận đi vào UAT-NÓI-TRƯỚC.
