# sub-frontend-designer-L4 — LĂNG-KÍNH: CÂY TOÀN TRÌNH (Pipeline Tree)

> Run: `2026-08-05-S174-khkk-style-audit` · Lane L4 (ensemble `/fable-clone frontend-designer`, 5 lane cùng vai).
> Scope 3 file: `fe-admin/src/components/pipeline/PipelineTreePanel.tsx` · `fe-admin/src/components/pipeline/PipelineStageFolders.tsx` · `fe-admin/src/hooks/usePipelineStages.ts`.
> Owner-item BẮT BUỘC: **(O2)** badge chữ "SẮP" → "Bỏ hết đi đổi thành 0" · **(O6)** card phiếu PE trong cây văng sang `/purchase-evaluations/...` → giữ tại-chỗ chỉ-xem.
> Ghi APPEND-as-I-read (không gom cuối). Frame lượt 1.

---

## 0. Khung rỗng (ghi TRƯỚC khi đọc) — checklist tự-ràng-buộc

- [x] A. Inventory 3 file: LOC, export, prop-surface, ai gọi ai.
- [x] B. Host-map: liệt ĐỦ 6 host của `PipelineTreePanel` (file:line + prop truyền).
- [x] C. (O2) Truy chữ "SẮP": sinh ở file:line nào, điều kiện bật, GĐ nào đúng-nghĩa / GĐ nào stale.
- [x] D. (O2) Đề xuất đổi sang "0": đụng gì (state/type/style), host nào đổi hành-vi.
- [x] E. (O6) Truy `navigate(` + `onOpenKhkk` + mọi điều-hướng trong 3 file → đường đang đi.
- [x] F. (O6) Đề xuất callback-hoá mirror `stage2Options.onOpenKhkk`, ×6 host.
- [x] G. Style/UX delta KHKK (GĐ2) vs Duyệt NCC (GĐ1 golive = CHUẨN) trong lăng-kính cây.
- [x] H. Spec thi-hành-được (diff-level) cho Opus 5 MAX.
- [x] I. Rủi-ro / cái KHÔNG được đụng.

### Số đo nền (chạy 2026-08-05, trước mọi patch)
| Phép đo | Kết quả |
|---|---|
| `sha256sum` 3 cặp file mirror | **3/3 KHỚP** — `PipelineTreePanel` `3f5ffc6b…27cba2` · `PipelineStageFolders` `bed52f39…6de936` · `usePipelineStages` `55770e39…2f6f4` ⇒ luật MIRROR ×2 app đang được giữ, patch PHẢI giữ tiếp |
| `grep -rn "SoonChip"` (2 app) | **8 hit** (4/app: `:99` def + `:195`/`:233`/`:273` gọi) |
| `grep -c "navigate(\`\|navigate('"` `usePipelineStages.ts` | **7 dòng** = 6 lời gọi + 1 comment (`:182`) — cả 2 app y hệt |
| `grep -c navigate` `PipelineStageFolders.tsx` | **0** (kể cả comment) |
| `grep -c navigate` `PipelineTreePanel.tsx` | **1** — và là COMMENT `:97`, **0 lời gọi** |

---

## A. Inventory 3 file  ✅

| File (fe-admin) | LOC | Vai | Router? |
|---|---|---|---|
| `components/pipeline/PipelineTreePanel.tsx` | 352 | Panel cây trái: query `pipeline-pe-index` (200 phiếu) → `buildTree` Năm>Dự án>Hạng mục → mỗi hạng mục render `<PipelineStageFolders>` (`:330-335`) | **KHÔNG** (0 `useNavigate`) |
| `components/pipeline/PipelineStageFolders.tsx` | 334 | THUẦN TRÌNH BÀY: vẽ 4 folder GĐ + ngăn nhóm + leaf. Không fetch, không route | **KHÔNG** |
| `hooks/usePipelineStages.ts` | 404 | Nguồn dữ liệu + **TOÀN BỘ điều-hướng** (`useNavigate` khai `:200`) | **CÓ — nhà duy nhất của `navigate(`** |

- fe-user MIRROR: LOC 352/334/404 y hệt; 3 header đều khai *"File MIRROR SHA256 identical giữa 2 app — sửa 1 bên PHẢI copy sang bên kia"* (`PipelineStageFolders.tsx:10`, `usePipelineStages.ts:3`, `PipelineTreePanel.tsx:6`) ⇒ **mọi patch = ×2 app**, nghiệm thu bằng SHA256 từng cặp.
- Kiến trúc đã sẵn sàng cho O6: `navigate` KHÔNG rải trong 2 file trình-bày ⇒ callback-hoá chỉ đụng 1 file logic + prop-surface.

## B. Host-map — ĐỦ 6 host của `PipelineTreePanel`  ✅

| # | Host (file:line render) | `currentStage` | Prop đang truyền | Ghi chú |
|---|---|---|---|---|
| H1 | `fe-admin/src/components/khkk/KhkkListPanel.tsx:292` | **2** | `stage2Options` (content+count+`onOpenKhkk`), `hideHeader`, `bodyMaxHClass` | Màn KHKK 3-panel — **host của cả O2 lẫn O6** |
| H2 | `fe-user/src/components/khkk/KhkkListPanel.tsx:292` | **2** | (mirror H1) | mirror |
| H3 | `fe-admin/src/pages/contracts/ContractsListPage.tsx:249` | 3 | `bodyMaxHClass="max-h-[28rem] lg:max-h-none"`, render CÓ ĐIỀU KIỆN `{!selectedId && …}` | HĐ (admin) |
| H4 | `fe-user/src/pages/contracts/MyContractsPage.tsx:225` | 3 | như H3 | HĐ (user) |
| H5 | `fe-admin/src/pages/contracts/HardCopiesPage.tsx:117` | 4 | chỉ `currentStage` | Bản cứng (admin) |
| H6 | `fe-user/src/pages/contracts/HardCopiesPage.tsx:117` | 4 | như H5 | Bản cứng (user) |

**+2 call-site KHÔNG qua Panel** (đếm cho O2 vì badge sống trong `PipelineStageFolders`):
- H7 `fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx:524` — dùng THẲNG `<PipelineStageFolders currentStage={1} … stages={buildStages(wg.items, { stage1Content: … })}>` (cây riêng trang Duyệt NCC; `PipelineTreePanel.tsx:8-10` khai rõ KHÔNG đụng cây đó).
- H8 `fe-user/src/pages/pe/PurchaseEvaluationsListPage.tsx:524` — mirror.

**Host đã GỠ (đừng đếm nhầm):** `fe-user/src/pages/InboxPage.tsx:341` — `[S174 owner] GỠ PipelineTreePanel khỏi Hộp thư`. Không còn render.

⇒ **O2 chạm 8 điểm render (H1-H8)**; **O6 chạm 8 điểm nhưng chỉ H1/H2 (+H7/H8 nếu nhận nhánh đối-xứng) ĐỔI HÀNH VI** — chi tiết §D/§F.

## C. (O2) Truy chữ "SẮP" — sinh ở ĐÂU, bật khi NÀO  ✅

**Nguồn DUY NHẤT:** `fe-admin/src/components/pipeline/PipelineStageFolders.tsx:99-107` — hàm `SoonChip()`:
```tsx
<span className="shrink-0 rounded bg-slate-100 px-1 text-[9px] font-semibold uppercase tracking-wide text-slate-400">
  Sắp
</span>
```
Chuỗi trong code là **`Sắp`**; CSS `uppercase` biến nó thành **"SẮP"** trên màn (comment `:100-101` khai đúng vậy — token bê verbatim từ `components/pe/PePipelineStrip.tsx:50-52`).

**3 chỗ gọi — 3 NGỮ NGHĨA KHÁC NHAU bị gộp vào cùng 1 chip (gốc của lỗi owner thấy)** — vị trí ĐO bằng grep, không ước:

| # | Call-site | Điều kiện bật | Nghĩa THẬT | "SẮP" đúng không? |
|---|---|---|---|---|
| S-a | **`:273`** (`StageFolder` summary, nhánh cuối của ternary `:263-274`) | `!stage.failed && count === 0`; `count` = `stageCount()` (`:95-97`) | **Folder GĐ này rỗng CHO GÓI THẦU NÀY** | ❌ SAI — module đã live, chỉ là gói chưa có bản ghi |
| S-b | **`:195`** (`GroupFolder` summary) | `group.leaves.length === 0` | **Ngăn nhóm duyệt N1-N8 rỗng** | ❌ SAI — thân ngăn ngay dưới (`:200`) đã ghi "Chưa có phiếu ở nhóm này." ⇒ chip nói ngược nội dung |
| S-c | **`:233`** (nhánh `stage.locked`) | `stage.locked` = `!linkage.canX` (`usePipelineStages.ts:298/386/394`) | **Không có QUYỀN xem GĐ này** (và KHÔNG fetch) | ❌ SAI NẶNG NHẤT — "sắp có" ≠ "anh không được xem"; icon `Lock` `:228` + `title` `:226` đã nói đúng rồi |

**GĐ nào còn đúng nghĩa "sắp" (chưa build)? → KHÔNG CÒN GĐ NÀO.**
- GĐ1 Duyệt NCC: live (PE feature-complete).
- GĐ2 Kế hoạch Hợp đồng: **live từ Mig 69 `AddContractSigningPlans` (S161)** — `usePipelineStages.ts:96` gọi thật `/contract-signing-plans`.
- GĐ3 Duyệt Hợp đồng: live — `:112` gọi `/contracts`.
- GĐ4 Hợp đồng cứng: live — `:369` đọc `c.hasSealedCopy`, `:378` mở `/hard-copies?type=`.
⇒ **cả 4 folder đều fetch API THẬT**. Chip "Sắp" là **di-tích ngữ-nghĩa** bê từ `PePipelineStrip` (thời dải strip còn GĐ chưa dựng) — nay **100% ca hiển thị của nó đều là thông tin sai**. Lệnh owner *"Bỏ hết đi đổi thành 0"* là **sửa đúng nghiệp vụ**, không phải chuyện thẩm mỹ.

**Có host nào ĐANG DỰA vào chữ "SẮP"? → KHÔNG** (đo ở D5).

## D. (O2) Đề xuất "0" — đổi gì, đụng gì  ✅

### D1. Patch (áp cho `PipelineStageFolders.tsx`, MIRROR ×2 app)

**(1) XOÁ `SoonChip` (`:99-107`) — thay bằng 2 chip có ngữ-nghĩa tách bạch:**
```tsx
/** Số phiếu thật. `tabular-nums`: 4 folder xếp chồng, 1→2 chữ số không được giật cột. */
function CountChip({ n, style, muted }: { n: number; style: StageStyle; muted?: boolean }) {
  return (
    <span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium tabular-nums',
      muted ? 'bg-slate-100 text-slate-600' : style.count)}>{n}</span>
  )
}
/** Số KHÔNG XÁC ĐỊNH (lỗi tải / không có quyền) — KHÔNG được hiển thị là 0. */
function UnknownChip({ title }: { title: string }) {
  return <span title={title} className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-600">—</span>
}
```

**(2) 3 call-site đổi như sau:**

| Call-site | Trước | Sau | Lý do |
|---|---|---|---|
| `:270-274` StageFolder | `count>0 ? chip : <SoonChip/>` | `<CountChip n={count} style={style} muted={count===0}/>` | **ĐÚNG lệnh owner**: rỗng ⇒ **0** |
| `:192-196` GroupFolder | `count>0 ? chip : <SoonChip/>` | `<CountChip n={count} style={style} muted={count===0}/>` | ngăn N1-N8 rỗng ⇒ 0 (thân ngăn `:200` đã ghi "Chưa có phiếu ở nhóm này." ⇒ hết mâu thuẫn) |
| `:233` nhánh `locked` | `<SoonChip/>` | `<UnknownChip title="Bạn chưa có quyền xem giai đoạn này"/>` | 🔴 **CỐ Ý KHÔNG theo chữ "0"** — xem D2 |
| `:263-269` nhánh `failed` | chip "—" `text-slate-400` | `<UnknownChip title="Không tải được dữ liệu giai đoạn này"/>` | gộp về 1 chip + **vá contrast** (D3) |

### D2. 🔴 ĐIỂM PHẢI HỎI OWNER 1 DÒNG (đừng tự quyết im lặng)
Ca `locked` (`:222-236`) **không fetch** (`usePipelineStages.ts:102/118` `enabled:` theo quyền) ⇒ `leaves` rỗng **vì không được xem**, KHÔNG phải vì không có phiếu. Ghi **"0"** ở đây = **nói dối dữ liệu**: người dùng đọc "gói này chưa có Hợp đồng" trong khi thật ra có mà họ không được thấy. Đề xuất giữ **"—"** (đúng một nghĩa với `failed` = *số không xác định*), icon `Lock` + `title` đã sẵn ở `:226-228`.
→ Câu hỏi cho owner: *"'Bỏ hết SẮP đổi thành 0' — riêng folder BỊ KHOÁ QUYỀN em để dấu '—' (không biết số) thay vì 0, được không anh?"*. Nếu owner vẫn muốn 0 tuyệt đối ⇒ đổi 1 dòng, nhưng phải khai đây là con số không có thật.

### D3. Quality ride-along ĐO ĐƯỢC (FD5 contrast floor)
| Chip | Màu | Tỉ lệ tương phản (tính tay sRGB/WCAG 2.1) | Verdict |
|---|---|---|---|
| "SẮP" hiện tại | `text-slate-400 #94A3B8` / `bg-slate-100 #F1F5F9` | **2.38:1** | ❌ FAIL AA (sàn 4.5:1 cho text nhỏ) |
| "—" failed hiện tại (`:265`) | slate-400 / slate-100 | **2.38:1** | ❌ FAIL AA |
| Đề xuất `0` rỗng | `text-slate-600 #475569` / slate-100 | **6.91:1** | ✅ PASS AA (dư cả AAA text thường) |
| (loại) `text-slate-500 #64748B` / slate-100 | — | **4.34:1** | ❌ vẫn thiếu 0.16 — **đừng dùng slate-500** |
⇒ Đổi "SẮP"→"0" là dịp **hợp lệ** để vá luôn 2 chip FAIL AA đang có, KHÔNG phải scope-creep: cùng đúng 1 vùng code, cùng 1 lần soi.

### D4. Đụng gì thêm — comment/lý-lẽ trỏ vào thứ sắp biến mất (6 chỗ, PHẢI sửa cùng lượt)
`PipelineStageFolders.tsx:77-79` · `:82` · `:219` · `:259` · `usePipelineStages.ts:8` · `:254-256`.
⚠️ Riêng `:77-79` + `usePipelineStages.ts:254-256` dùng chip "Sắp" làm **tiền lệ biện minh** cho quyết định *"hiện đủ 8 ngăn kể cả rỗng"*. **Giữ quyết định, đổi lý-lẽ**: khung 8 nhóm hiện đủ vì người dùng cần thấy chỗ nào trống để bấm vào lập phiếu — không liên quan chữ trên chip.

### D5. Có host nào dựa vào chữ "SẮP"? → **KHÔNG** (đo, không đoán)
- `SoonChip` **không `export`** (`:99`) ⇒ 0 import ngoài. `grep -rn "SoonChip" fe-admin/src fe-user/src` ⇒ chỉ 2 file mirror này.
- Không prop/host nào điều khiển nó; nó suy nội bộ từ `count`/`locked`.
- Không route/test/e2e nào bắt chuỗi.
- ⚠️ **False-positive KHÔNG ĐƯỢC ĐỤNG:** `fe-admin/src/pages/DashboardPage.tsx:113` `"Sắp quá hạn (24h)"` — nghĩa hoàn toàn khác (deadline), không thuộc lớp lỗi này.

### D6. 🔴 SIBLING-INSTANCE cùng lớp lỗi — NGOÀI 3-file lens, escalate em main
`fe-admin|fe-user/src/components/pe/PePipelineStrip.tsx` (dải strip trên đầu detail phiếu PE):
- `:13-18` `STAGES` hardcode `soon: true` cho **GĐ2 "Kế hoạch ký kết HĐ"** và **GĐ4 "Hợp đồng cứng"**;
- `:37` `title='Sắp triển khai'`; `:49-52` chip `Sắp` (cùng token slate-400/slate-100 ⇒ cũng 2.38:1);
- `:7-9` comment khẳng định *"GĐ2/GĐ4 chưa có module"* + *"mọi stage không-active đều KHÔNG click được — hiển thị lộ trình, không giả link"*.
**Nay SAI cả 3:** GĐ2 live từ Mig 69 (S161), GĐ4 live (`hasSealedCopy` + `/hard-copies`), và cầu PE→KHKK→HĐ đã nối (`usePipelineStages.ts:322-328`). Owner nói *"bỏ HẾT"* — nếu chỉ vá cây mà để strip nguyên, người dùng mở phiếu PE vẫn thấy "SẮP" ⇒ **vá nửa vời trông y hệt chưa vá**. Đề nghị em main giao lane khác (chủ file `components/pe/`) xử cùng đợt.

### D7. Ảnh hưởng lên **6 host** (+2 call-site trực tiếp) — O2
| Host | Đổi hành vi? | Cụ thể |
|---|---|---|
| H1 KHKK admin `KhkkListPanel.tsx:292` | **CÓ (thị giác)** | GĐ2 dùng `content` ⇒ **không có ngăn**; folder GĐ1/3/4 rỗng: SẮP→**0**. Không đổi luồng bấm |
| H2 KHKK user | **CÓ (thị giác)** | như H1 |
| H3 HĐ admin `ContractsListPage.tsx:249` | **CÓ (thị giác)** | GĐ2 hiện 8 ngăn ⇒ ngăn rỗng SẮP→**0**; folder rỗng SẮP→**0** |
| H4 HĐ user `MyContractsPage.tsx:225` | **CÓ (thị giác)** | như H3 |
| H5 Bản cứng admin `HardCopiesPage.tsx:117` | **CÓ (thị giác)** | như H3 |
| H6 Bản cứng user `HardCopiesPage.tsx:117` | **CÓ (thị giác)** | như H3 |
| H7/H8 trang Duyệt NCC `…ListPage.tsx:524` | **CÓ (thị giác)** | GĐ1 dùng `stage1Content` (không ảnh hưởng), GĐ2-4 rỗng: SẮP→**0** |
**0 host đổi API/prop/luồng** — patch nằm trọn trong component trình-bày, prop-surface KHÔNG đổi ⇒ 0 rủi ro hồi-quy hành vi. Chỉ pixel đổi.

## E. (O6) Truy điều-hướng — ĐÚNG đường đang đi  ✅

`useNavigate` chỉ khai ở `usePipelineStages.ts:200`. `PipelineStageFolders.tsx` = **0 hit** chuỗi `navigate` (kể cả comment); `PipelineTreePanel.tsx` = **1 hit DUY NHẤT và là COMMENT** (`:97` "…8 ngăn nhóm + navigate…"), **0 lời gọi**. Toàn bộ 6 lời gọi thật nằm ở hook:

| # | file:line | Leaf/nút | Đích hiện tại | Callback-hoá? |
|---|---|---|---|---|
| **N1** | `usePipelineStages.ts:219` | **CARD PHIẾU PE (GĐ1)** | **`navigate(\`/purchase-evaluations?id=${p.id}&expand=1\`)`** | ❌ **CHƯA — ĐÚNG lỗi O6 owner báo** |
| N2 | `:233` | Card/leaf KHKK (GĐ2) | `opts.onOpenKhkk ? onOpenKhkk(k.id) : navigate(\`/khkk/${k.id}\`)` | ✅ ĐÃ (M-2 S171) — **khuôn mẫu để nhân bản** |
| N3 | `:263` | Tên ngăn nhóm N1-N8 | `navigate(\`/khkk/list?group=${n}\`)` | ❌ (rời trang CÓ CHỦ ĐÍCH: "mở danh sách đã lọc") |
| N4 | `:272` | Tên ngăn "(chưa phân nhóm)" | `navigate('/khkk/list')` | ❌ (như N3) |
| N5 | `:366` | Leaf HĐ (GĐ3) | `navigate(\`/contracts/${contractId}\`)` | ❌ |
| N6 | `:378` | Leaf bản cứng (GĐ4) | `navigate(\`/hard-copies?type=${c.type}\`)` | ❌ |

**Chuỗi sự-kiện lỗi owner gặp (H1/H2, màn KHKK):** `KhkkListPanel` **KHÔNG** truyền `stage1Content` ⇒ `usePipelineStages.ts:204-206` rơi xuống nhánh `else` `:207-222` ⇒ mỗi phiếu PE thành leaf `onOpen` = N1 ⇒ `PipelineStageFolders.tsx:119` `onClick={leaf.onOpen}` ⇒ **rời màn KHKK sang `/purchase-evaluations?id=…&expand=1`**. Khớp nguyên văn owner.

⚠️ **Đối xứng bị bỏ quên** — owner nói *"tương tự với duyệt NCC"*: ở H7/H8 (trang Duyệt NCC) GĐ1 dùng `stage1Content` nên PE ở-tại-chỗ ✔, **nhưng leaf GĐ2 chạy N2 nhánh-mặc-định ⇒ `navigate('/khkk/:id')` ⇒ VĂNG khỏi màn Duyệt NCC**. Lỗi TỒN TẠI ĐỐI XỨNG 2 chiều: KHKK→PE (N1) và PE→KHKK (N2 khi host không truyền callback).

## F. (O6) Đề xuất callback-hoá — mirror ĐÚNG khuôn `onOpenKhkk` (M-2)  ✅

### F1. Patch tối thiểu, 3 file + 2 host

**(a) `usePipelineStages.ts` — thêm 1 option, sửa 1 dòng:**
```ts
// trong BuildStagesOptions (cạnh onOpenKhkk :185)
/** [O6] Bấm leaf phiếu PE (GĐ1) ⇒ HOST xử lý tại chỗ thay vì rời trang.
 *  KHÔNG truyền ⇒ giữ NGUYÊN navigate — 0 hồi-quy cho mọi call-site cũ. */
onOpenPe?: (id: string) => void
```
```ts
// :219 — ĐỔI ĐÚNG 1 dòng, sao y khuôn N2 (:233)
onOpen: opts.onOpenPe ? () => opts.onOpenPe!(p.id) : () => navigate(`/purchase-evaluations?id=${p.id}&expand=1`),
```

**(b) `PipelineStageFolders.tsx` — thêm trạng-thái ĐANG CHỌN cho leaf** (bắt buộc, xem F3):
```ts
// PipelineLeaf (:46-56)
selected?: boolean
```
```tsx
// LeafList (:117-125): thêm aria-current + nền active
aria-current={leaf.selected ? 'true' : undefined}
className={cn('block w-full px-3 py-2 …', leaf.selected && 'bg-brand-50 ring-1 ring-inset ring-brand-200', …)}
```

**(c) `PipelineTreePanel.tsx` — prop mới, KHÔNG nhét vào `stage2Options`:**
```ts
// cạnh :98
/** [O6] Tuỳ chọn GĐ1. Tách khỏi `stage2Options` để tên prop không nói dối. */
stage1Options?: Pick<BuildStagesOptions, 'onOpenPe'> & { selectedPeId?: string | null }
```
```tsx
// :334
stages={buildStages(wg.items, { ...stage2Options, ...stage1Options })}
```
🔴 **KHÔNG** nhồi `onOpenPe` vào `stage2Options` (dễ nhưng sai tên ⇒ người sau đọc `stage2Options.onOpenPe` sẽ tưởng là callback GĐ2).

**(d) Host H1/H2 `KhkkListPanel.tsx:292-297`:**
```tsx
<PipelineTreePanel
  currentStage={2}
  hideHeader
  bodyMaxHClass="max-h-[28rem] lg:max-h-none"
  stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect }}
  stage1Options={{ onOpenPe: onSelectPe, selectedPeId }}   // ← MỚI
/>
```
`onSelectPe` + `selectedPeId` là prop mới của `KhkkListPanel`, do `KhkkListPage` cấp.

**(e) `KhkkListPage.tsx` — nơi phiếu PE hiện TẠI CHỖ (chỉ-xem):**
- state qua URL cho deep-link: `?pe=<id>` (song song `?id=` của KHKK). Chọn PE ⇒ **clear `id`**, chọn KHKK ⇒ **clear `pe`** (panel 2 chỉ một nội dung).
- `<lg` **PHẢI** giữ nhánh rời trang, y hệt `selectPlan` (`:102-109`), nếu không mobile bấm PE = **không có gì xảy ra**:
```ts
function selectPe(id: string) {
  if (window.matchMedia('(min-width: 1024px)').matches) { setPe(id); setId(null) }
  else navigate(`/purchase-evaluations?id=${id}&expand=1`)
}
```
- Panel 2 (`:162-179`) thêm nhánh — **tái dùng component sẵn có, KHÔNG dựng viewer mới**:
```tsx
{selectedPeId && peDetail.data && (
  <PeDetailTabs evaluation={peDetail.data} onBack={() => setPe(null)} onDelete={() => {}} readOnly />
)}
```
  ✅ Đã VERIFY chữ ký: `PeDetailTabs.tsx:103-119` — `evaluation: PeDetailBundle` · `onBack: () => void` · `onDelete: () => void` (**cả 2 BẮT BUỘC, không `?`**) · `readOnly?: boolean = false`. Nút Xoá bị chặn bởi `:257 (… && !readOnly)` ⇒ với `readOnly` thì `onDelete` **không có đường chạm** ⇒ `() => {}` an toàn — **ghi comment ngay tại chỗ** kẻo người sau tưởng quên nối. Tiền lệ: trang PE cũng truyền `readOnly={true}` ở `PurchaseEvaluationsListPage.tsx:657-663`.
  - query: `useQuery({ queryKey: ['khkk-pe-detail', selectedPeId], queryFn: … GET /purchase-evaluations/${id}, enabled: !!selectedPeId })` — **key RIÊNG**, không mượn key của trang PE (bài S159: đổi/đụng cache dùng chung làm vỡ trang khác).
- Panel 3 (`:186-202`) khi đang xem PE: **KHÔNG** render `PeWorkflowPanel` (nó mang nút duyệt/xoá phiếu PE — cấp thao tác duyệt PE từ màn KHKK là sai lane quyền). Thay bằng hộp dashed + **1 lối thoát tường minh**: nút *"Mở phiếu Duyệt NCC gốc ↗"* → `navigate('/purchase-evaluations?id=…&expand=1')`. Owner nói *"vẫn cho hiển thị ra"*, không cấm mở trang gốc — chỉ cấm **tự động văng**.

### F2. 🔴 "tương tự với duyệt NCC" — 2 cách hiểu, cả 2 đều dẫn tới cùng một máy
- (i) *Trên màn Duyệt NCC cũng phải ở-tại-chỗ*: hiện leaf GĐ2 trên cây trang PE chạy **N2 nhánh mặc định ⇒ `navigate('/khkk/:id')` ⇒ VĂNG khỏi màn Duyệt NCC** — **lỗi đối xứng, ĐANG TỒN TẠI**.
- (ii) *Làm y như khuôn PE đang làm*: chính là `stage1Content` + `onOpenKhkk`.
→ Đề nghị **ship 2 nấc**: **N1 = màn KHKK (F1, nhỏ, đúng nguyên văn owner)**; **N2 = màn Duyệt NCC truyền `onOpenKhkk`** → panel 2 render `<KhkkDetailContent plan readOnly/>` (component đã nhận `readOnly`, chứng cứ `KhkkListPage.tsx:177`) + query `['pe-khkk-detail', id]`. Nấc 2 to hơn (thêm 1 query + 1 nhánh panel ở trang PE) ⇒ **khai với owner là 2 lượt**, đừng gộp im lặng rồi vỡ trang PE đang golive.

### F3. Vì sao BẮT BUỘC có `selected` (không phải trang trí)
`KhkkPlanCard` có `selected={selectedId === k.id}` (`KhkkListPanel.tsx:241`) nên bấm card KHKK là cây **sáng lên**. Leaf PE **không có** khái niệm đó (`PipelineLeaf` `:46-56`). Nếu chỉ chuyển `navigate` → callback mà không thêm `selected`: người dùng bấm phiếu PE, panel 2 đổi ở **cột giữa** trong khi **cột trái im như không nhận lệnh** ⇒ họ bấm lại lần 2, lần 3. Đây là **hồi-quy trải-nghiệm do chính bản vá gây ra** — FD4 "Polish/states" fail. `selected` + `aria-current` là phần không thể cắt.

### F4. Ảnh hưởng lên **6 host** — O6
| Host | Đổi hành vi? | Cụ thể |
|---|---|---|
| **H1** KHKK admin `KhkkListPanel.tsx:292` | 🔴 **CÓ — đây là mục tiêu** | bấm card PE: `navigate('/purchase-evaluations?id=…&expand=1')` → **ở lại màn KHKK**, panel 2 = `PeDetailTabs readOnly`, leaf sáng `selected`. `<lg` vẫn rời trang (không có panel 2) |
| **H2** KHKK user | 🔴 **CÓ** | mirror H1 |
| H3 HĐ admin `ContractsListPage.tsx:249` | ✅ **KHÔNG** | không truyền `stage1Options` ⇒ `opts.onOpenPe === undefined` ⇒ nhánh `navigate` cũ nguyên vẹn |
| H4 HĐ user `MyContractsPage.tsx:225` | ✅ **KHÔNG** | như H3 |
| H5 Bản cứng admin | ✅ **KHÔNG** | như H3 |
| H6 Bản cứng user | ✅ **KHÔNG** | như H3 |
| H7/H8 trang Duyệt NCC `:524` | ✅ **KHÔNG ở nấc 1** | GĐ1 vẫn dùng `stage1Content` (`onOpenPe` không đụng tới nhánh `content`). **Nấc 2** mới đổi (leaf GĐ2) |
Cơ chế bảo đảm "không đổi": **cùng khuôn ternary đã chứng minh ở M-2** (`:233`) — vắng callback ⇒ rơi về `navigate`. Đây là lý do chọn callback **tuỳ chọn** thay vì đổi thẳng đích `navigate`.

## G. Style/UX delta cây: GĐ2 (KHKK) vs GĐ1 (Duyệt NCC = CHUẨN)  ✅

| # | Delta | Bằng chứng | Verdict |
|---|---|---|---|
| G1 | KHKK truyền `hideHeader` ⇒ mất thanh "Cây toàn trình · N gói" **và mất nút "Hiện/Ẩn cây"**; `:233-234` phải ép `flex` để `<lg` còn thấy cây | `PipelineTreePanel.tsx:104-109`, `:234` | ⚠️ **MINOR THẬT**: trên mobile màn KHKK, cây **luôn mở, không thu gọn được**, đẩy danh sách phiếu xuống dưới màn hình. Trang PE có đường thu gọn. Đề xuất: khi `hideHeader`, `<lg` mặc định **đóng** + 1 nút text nhỏ "Hiện cây" phía trên thân |
| G2 | KHKK bỏ vỏ `card-accent` + `lg:sticky` | `:203` | ✅ ĐÚNG — owner S174 chốt đồng bộ viền; cây nhúng trong panel không cần vỏ thứ hai |
| G3 | GĐ2 trên màn KHKK **không có 8 ngăn N1-N8** (vì dùng `content`), còn trên màn PE/HĐ/Bản-cứng thì CÓ | `usePipelineStages.ts:168-171`, `:292-297` | 🔴 **LÝ-LẼ ĐÃ MẤT CHÂN ĐẾ**: comment `:170` biện minh *"lối vào nhóm giữ ở `Select` nhóm của khối filter panel 1"* — **nhưng `Select` nhóm ĐÃ BỊ GỠ** (`KhkkListPanel.tsx:184-186`: *"`Select` nhóm đã gỡ khỏi UI. Nhóm nay đến DUY NHẤT từ URL"*; khối filter `:257-288` chỉ còn ô tìm + `Select` trạng thái). ⇒ sửa comment cho khỏi nói dối, và cân nhắc trả 8 ngăn về khi `group === null` |
| G4 | Leaf GĐ1 (mã mono + badge, compact) vs card GĐ2 (`KhkkPlanCard`, giàu) | `usePipelineStages.ts:209-221` vs `KhkkListPanel.tsx:239-244` | ✅ NHẤT QUÁN có nguyên tắc: *GĐ đang đứng = card, GĐ khác = leaf*. GIỮ |
| G5 | **Emoji thay icon** ở 3 tầng trên: `📅` `:294` · `📁` `:310` · `🧱` `:326` — trong khi tầng 4 (folder GĐ) dùng **lucide** (`STAGE_ICON` `PipelineStageFolders.tsx:29-34`) | `PipelineTreePanel.tsx:294/310/326` | ❌ **ANTI-SLOP CATCH (FD3)**: 2 bộ icon lệch nhau **trong cùng một cây**. Emoji còn đổi hình theo OS và không nhận màu token. Đề xuất lucide `CalendarDays` / `FolderClosed` / `Layers` + `icon-chip` như folder GĐ |
| G6 | Chevron 3 tầng trên = `<svg>` inline thủ công ×3 (lặp), tầng 4 = `<ChevronRight>` lucide | `:293/309/325` vs `PipelineStageFolders.tsx:165/245` | ❌ cùng lớp G5 — thay bằng lucide, bớt 3 khối SVG lặp |
| G7 | Chevron 3 tầng trên **thiếu** `motion-reduce:transition-none` (folder GĐ có) | `:293/309/325` vs `PipelineStageFolders.tsx:167/247` | ❌ **FD5**: `prefers-reduced-motion` chỉ được tôn trọng nửa cây |
| G8 | `<summary>` 3 tầng trên **thiếu hoàn toàn** `focus-visible:ring…` (folder GĐ + ngăn nhóm đều có) | `:292/308/324` vs `PipelineStageFolders.tsx:164/244` | ❌ **FD5 FAIL THẬT**: đi bàn phím qua Năm/Dự án/Hạng mục **mất dấu focus**, tới folder GĐ mới thấy lại. Vá: dán đúng chuỗi `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60` |
| G9 | Chip đếm 3 tầng trên: `bg-slate-200 text-slate-700` (`:296`) và `bg-slate-100 text-slate-600` (`:312/328`) — tầng 4 lại `style.count` theo màu GĐ | — | ✅ chấp nhận: xám = tầng cấu-trúc, màu = tầng nghiệp-vụ. Nhưng nên thêm `tabular-nums` cho cả 3 (cùng lý do D1) |

## H. Spec thi-hành-được cho Opus 5 MAX (thứ tự áp)  ✅

**S0 — tiền đề:** 3 file MIRROR ×2 app. Mọi bước dưới **áp 2 lần**, chốt bằng `sha256sum` 3 cặp (kỳ vọng: **3 cặp khớp**). Không có bước nào chạm BE/DB.

| Bước | Việc | File | Rủi ro |
|---|---|---|---|
| **P1** | O2: thay `SoonChip` → `CountChip`/`UnknownChip` (D1) + vá 6 comment (D4) | `PipelineStageFolders.tsx` (+`usePipelineStages.ts` comment) | Thấp — 0 đổi prop-surface |
| **P2** | O2-hỏi: chốt ca `locked` = "—" hay "0" (D2) | — | Cần 1 câu owner |
| **P3** | O6: `onOpenPe` + 1 dòng `:219` (F1a) | `usePipelineStages.ts` | Thấp — ternary vắng-callback giữ nguyên |
| **P4** | O6: `selected` + `aria-current` + nền active (F1b) | `PipelineStageFolders.tsx` | Thấp |
| **P5** | O6: prop `stage1Options` + spread (F1c) | `PipelineTreePanel.tsx` | Thấp — optional |
| **P6** | O6: `?pe=` + panel 2 `PeDetailTabs readOnly` + panel 3 hộp dashed + nhánh `<lg` (F1d/e) | `KhkkListPage.tsx` · `KhkkListPanel.tsx` | **Trung bình** — đây là chỗ dễ vỡ nhất (loại trừ `id`⟂`pe`, `<lg`) |
| **P7** | (tuỳ chọn, khuyến nghị) FD5/FD3: G8 focus-ring → G7 motion-reduce → G5/G6 lucide | `PipelineTreePanel.tsx` | Thấp, thuần class |
| **P8** | Escalate D6 (`PePipelineStrip`) + G3 (comment `Select` nhóm đã gỡ) | ngoài lens | — |

**Nghiệm thu (đo được, không cảm tính):**
1. **Có control-dương, KHÔNG dùng phép trích rỗng.** ⚠️ ĐỪNG grep `">Sắp<"`: JSX để chữ trên DÒNG RIÊNG (`:103-105`) nên mẫu đó trả 0 hit **ngay cả lúc chưa vá** — 0-hit giả đọc y hệt đã-sạch.
   - Control-dương ĐO TRƯỚC PATCH (2026-08-05): `grep -rn "SoonChip" fe-admin/src fe-user/src` ⇒ **8 hit** (mỗi app 4: định nghĩa `:99` + gọi `:195`/`:233`/`:273`).
   - Sau patch: **0 hit**. Chênh 8→0 mới là bằng chứng.
   - `grep -rn "Sắp" fe-admin/src fe-user/src` sau patch ⇒ chỉ còn `DashboardPage.tsx:113` ("Sắp quá hạn (24h)" — nghĩa khác, KHÔNG đụng) và `PePipelineStrip.tsx` nếu P8 chưa làm. **Phải liệt kê thẳng phần còn lại khi báo cáo**, đừng khai "sạch".
2. `npm run build` PASS 0 TS error ×2 app.
3. `sha256sum` 3 cặp file mirror ⇒ khớp 3/3.
4. `grep -c "navigate(\`\|navigate('" fe-admin/src/hooks/usePipelineStages.ts` ⇒ **vẫn 7 DÒNG** (đo trước-patch 2026-08-05: 7 = **6 lời gọi** :219/:233/:263/:272/:366/:378 **+ 1 comment** :182 — đừng đọc 7 thành "7 lời gọi"). Callback là nhánh THÊM, KHÔNG xoá nhánh `navigate` nào ⇒ đây là bằng chứng máy-đo cho "5 host kia không đổi hành vi".
5. Soi mắt: KHKK screen — bấm card PE **không đổi URL path** (chỉ thêm `?pe=`), panel 2 hiện phiếu PE chỉ-xem, leaf trái sáng.

## I. Rủi-ro / KHÔNG được đụng  ✅

1. **KHÔNG** đổi `PIPELINE_STAGE_LABELS` (`:22-27`) — nhãn VERBATIM lời owner, `:6-8` khai rõ GĐ2 CỐ Ý khác nhãn strip.
2. **KHÔNG** đổi quyết định *"hiện đủ 8 ngăn kể cả rỗng"* — chỉ đổi chữ trên chip + lý-lẽ trong comment.
3. **KHÔNG** đổi gate quyền (`KHKK_MENU_KEYS = ['KeHoachKyKet']` `:57`) — F-1/S162, gotcha #85.
4. **KHÔNG** sửa `PePipelineStrip` trong lane này (ngoài 3-file lens) — escalate.
5. **KHÔNG** xoá nhánh `navigate` cũ khi thêm callback — 5-6 host sống nhờ nó.
6. `stage2Content` **luôn đi kèm** `stage2Count` (`PipelineStageFolders.tsx:95-97`); nếu ai đó thêm `stage1Content` cho KHKK thì luật y hệt.
7. `PeDetailTabs` mặc định `readOnly=false` ⇒ **quên `readOnly` là mở nút thao tác phiếu PE ngay trong màn KHKK**. Bắt buộc truyền.
8. Cửa sổ index 200 (`INDEX_PAGE_SIZE :47`, `PE_INDEX_PAGE_SIZE PipelineTreePanel.tsx:22`) không đổi ⇒ "0" trên folder vẫn có thể là "0 trong 200 bản ghi mới nhất"; dòng khai `truncated` (`PipelineStageFolders.tsx:301-305`) **giữ nguyên, đừng bỏ** — nó chính là thứ giữ cho số 0 không nói dối.

## J. FD2 visual-loop — KHAI THẲNG  ✅
Lượt này **KHÔNG có screenshot** và **không bịa**: (a) lane là **audit/spec, 0 dòng code UI đổi** ⇒ không có gì mới để chụp; (b) màn KHKK nằm sau `ProtectedRoute` + cần API+SQL, dev-rig authed đã ghi nhận **chặn** (MEMORY gotcha #3: vite proxy `http://localhost:5443` ⟂ API bind HTTPS). Thay thế: **static-analysis + số đo tương phản tính tay theo WCAG 2.1** (D3) + đối chiếu class token trực tiếp trên đĩa. Khi P1-P7 land, FD2 loop chạy trên **prod sau deploy** (đường tin cậy nhất theo MEMORY).

## K. Bổ sung sau khi đo — file HOST cũng đang MIRROR  ✅
`sha256sum` (2026-08-05): `components/khkk/KhkkListPanel.tsx` **ad9073db…c314d** khớp 2 app · `pages/khkk/KhkkListPage.tsx` **ee0dc8af…b69f4** khớp 2 app.
⇒ Bước **P6** (chỗ sửa nhiều nhất của O6) **cũng phải áp ×2 app và giữ SHA khớp**. Tổng cộng O2+O6 chạm **5 cặp file** = 10 file, nghiệm thu **5/5 cặp SHA khớp**. Bỏ sót 1 bên ⇒ admin và user lệch hành vi im lặng (đúng lớp lỗi mà 3 header file cảnh báo).

---

**Bảng phủ owner-item**

| Item | Đòi hỏi | Phủ ở | Trạng thái |
|---|---|---|---|
| **O2** | chữ SẮP sinh ở đâu (file:line) | §C bảng S-a/S-b/S-c — `:99-107` def, gọi `:195`/`:233`/`:273` | ✅ |
| O2 | điều kiện bật từng ca | §C cột 3 | ✅ |
| O2 | GĐ nào còn đúng "sắp" / GĐ nào đã live | §C — **không GĐ nào còn đúng**, cả 4 đều fetch API thật | ✅ |
| O2 | đổi sang 0 thì đụng gì | §D1 (patch) + §D4 (6 comment) + §D3 (contrast) | ✅ |
| O2 | có host nào dựa vào chữ SẮP không | §D5 — **KHÔNG** (`SoonChip` không export, 8-hit nội bộ) | ✅ |
| **O6** | truy `onOpenKhkk` + mọi `navigate(` trong 3 file | §E bảng N1-N6 + §số-đo-nền | ✅ |
| O6 | chỉ ra ĐÚNG đường đang điều hướng | §E — `usePipelineStages.ts:219` → `/purchase-evaluations?id=…&expand=1` | ✅ |
| O6 | đề xuất callback-hoá mirror `stage2Options.onOpenKhkk` | §F1 (a-e) — `onOpenPe`, cùng khuôn ternary `:233` | ✅ |
| **BẮT BUỘC** | 6 host bị ảnh hưởng thế nào — cho CẢ HAI đề xuất | §D7 (O2, 6 host + H7/H8) · §F4 (O6, 6 host + H7/H8) | ✅ |

END sub-frontend-designer-L4 — VERDICT=SHIP-SPEC-READY (2 đề xuất thi-hành-được; 1 câu hỏi owner treo ở §D2; 1 escalate ngoài lens ở §D6)
