wal: flush 20260805T1627

This commit is contained in:
pqhuy1987
2026-08-05 16:27:07 +07:00
parent df21a33912
commit c97b5795c6
6 changed files with 862 additions and 80 deletions

View File

@ -0,0 +1,98 @@
# SPEC — Đồng bộ style KHKK theo Duyệt NCC + 8 owner-item (05-08-2026)
> Nguồn: `/fable-clone frontend-designer` ensemble **5 lane / 5 xong / 0 chết** (`wf_016b1bed-b85`, 755K tok, 147 tool-use, ~17).
> Engine **propose-only**; file này do **LEAD ghi sau khi verify** (H21 §Spec-file). Lane-MD gốc: `sub-frontend-designer-L1..L5.md`.
> 🔴 **O7/O8 tới SAU khi lane đã phóng** ⇒ lane KHÔNG thấy; lead tự phủ. "Lane không nhắc" ≠ "không có việc".
---
## ① TÍNH-NĂNG / ĐỀ-BÀI
Module **KHKK (GĐ2)** phải nhìn và cư xử **cùng một hệ** với **Duyệt NCC (GĐ1 — golive, owner coi là CHUẨN)**.
Owner: *"cho style nó giống với duyệt NCC nhé, style hiển thị 2 bên khác nhau ko đc"* · *"cho nó quét lại hết luôn, design lại"*.
Phạm vi = 8 owner-item (O1O8) + **3 phát hiện lane** owner chưa nêu nhưng nặng hơn phần lớn danh sách:
| id | Phát hiện thêm | Vì sao vào spec |
|---|---|---|
| **X1** | `KhkkWorkflowPanel:501` tải file bằng `<a href>` trần ⟂ PE `:331` `api.get(responseType:'blob')` | JWT chỉ gắn qua axios interceptor ⇒ **401, nút không chạy**. Đây là LỖI, không phải style. LEAD ĐÃ VERIFY |
| **X2** | `KhkkWorkflowPanel:247` bọc `card-accent` (viền + rail 3px + bóng + hover) ⟂ PE `:345` panel TRẦN | Lệch **đập vào mắt trước mọi mục owner liệt kê**: PE phẳng, KHKK là thẻ-trong-thẻ |
| **X3** | Màn Thao tác `<lg`: `<main>``hidden … lg:block`**mobile không tạo được phiếu** | 🔴 **Do chính lead gây ra khi dựng host 2-panel trong phiên này.** Regression, phải vá |
🔴 **Hai giả-định của LEAD bị lane BÁC — đã tự verify, xác nhận lane đúng:**
- **O3 KHÔNG cần migration.** `ContractSigningPlanLineConfiguration.cs:37-38` unique index **3 cột** `(PlanId, SupplierId, CatalogEntryId)` ⇒ 1 NCC **đã được phép** N dòng × N hạng mục. Máy rộng sẵn từ Mig 71; chỉ UI hẹp. Lead từng báo owner "nặng nhất, cần migration" — **SAI, đã đính chính**.
- **O4 FE-only, 0 BE.** Rail `dossierItemId` đã land đủ 6 tầng từ Mig 69.
---
## ② CÁCH IMPLEMENT — 4 đợt, xếp theo thứ tự land an toàn
### ĐỢT A — SỬA LỖI (không phải style; land trước, không chờ ai)
| # | File | Đổi | Vì sao |
|---|---|---|---|
| A1 | `components/khkk/KhkkWorkflowPanel.tsx:500-505` | `<a href>``api.get(url,{responseType:'blob'})` + `URL.createObjectURL`, mirror `PeWorkflowPanel.tsx:327-342` | **X1** — nút tải hiện trả 401 |
| A2 | `pages/khkk/KhkkCreatePage.tsx` `<main className="hidden … lg:block">` | Bỏ ngõ cụt `<lg`: cho `<main>` hiện ở mọi breakpoint, panel-1 chuyển `hidden lg:flex` (hoặc accordion) | **X3** — regression lead vừa gây |
| A3 | `pages/khkk/KhkkDetailPage.tsx` | Bọc `KhkkWorkflowPanel` trong `<div className="lg:hidden">` | Nợ đã khai với owner: mobile mất nút Gửi duyệt/Xóa |
### ĐỢT B — BÊ THẲNG TỪ PE (FE-only, 0 quyết định, rủi ro thấp)
**B1 — panel Quy trình duyệt (O1)** · `KhkkWorkflowPanel.tsx`
- **X2** gỡ `card-accent` + `p-5` → panel trần `space-y-4` (PE `:345`). 🔴 **GỠ CLASS, KHÔNG đè `border-0`**`.card-accent` (`index.css:112`) ngoài `@layer` nên thắng utility Tailwind v4 (**gotcha #66, đã cắn thật phiên này**).
- Tiêu đề → `<h3 text-sm font-semibold text-slate-900>` trần, bỏ `icon-chip` (PE `:346-354`).
- Số hiệu WF `v${n}``padStart(2,'0')``v01` (PE `:350`).
- Banner kết-thúc: violet → **emerald** + câu chữ PE (`:360-377`). Dữ-liệu ĐỦ (`khkk.ts:385-390` `endedByLevelFinalize` 3-ngả) ⇒ bê thẳng. *Violet đang bị trộn 2 nghĩa — PE dành violet riêng cho "Duyệt thay CEO".*
- Chip PHÒNG: `border-slate-300 bg-white``bg-emerald-50 text-emerald-700` (PE `:406-410`).
- Nút hành động: lucide `<Check>/<Undo2>/<X>` → glyph chuỗi `'✓ Duyệt'/'← Trả lại'` (PE `:540`); nút **Xóa** xám → đỏ `border-red-300 text-red-700` (PE `:574`).
- "File đính kèm khi duyệt": thêm `border-t border-slate-200 pt-4` + `<h3 text-sm>` + **nút `<Eye>` xem trước** — route BE **đã có** `ContractSigningPlansController.cs:231-232`; cần thêm prop `basePath` cho `AttachmentPreviewDialog` (đang hardcode `evaluationId`).
- "Lịch sử duyệt (n)": bỏ pill đếm → `(n)` trong ngoặc; thẻ `bg-slate-50/60 text-[11px]``rounded border bg-white p-3 text-sm`; mốc-giờ **đẩy phải** bằng `justify-between`; badge **2-ngả → 3-ngả** (Từ chối rose / Trả lại amber / Duyệt emerald) — `KhkkApprovalDto.toPhase` đã có (`khkk.ts:307`).
- "Lịch sử thay đổi": bỏ gập, render ngay (PE `PeDetailTabs.tsx:787-794`).
- Micro-copy: "Huỷ"→"Hủy", "Ý kiến (tuỳ chọn)"→"Ghi chú (tùy chọn)".
**❌ KHÔNG bê:** chip **"NS PRO"/"NS CCM"** (L1 H3). Hai cờ `canEditProBudget/canEditCcmBudget` mở quyền sửa **ngân sách per-gói-thầu** (`PeWorkItemBudgets`, Mig 50) — **khái niệm KHKK không có**. Bê sang = vẽ nhãn quyền không tồn tại.
**✅ GIỮ chỗ KHKK TỐT HƠN PE:** mốc chữ-ký `✓ dd/mm/yyyy hh:mm` + in đậm người đã ký (`:338-355`).
**B2 — nội dung phiếu (L5: 6 P0)** · `KhkkDetailContent.tsx`
`table-fixed` + width cột + `min-w-[880px]` trong `overflow-x-auto` · ô rỗng `—``text-slate-300` (đang cùng màu chữ thật ⇒ đọc nhầm là dữ liệu) · empty-block `text-muted-foreground``text-sm italic text-slate-400` · bỏ emoji `🔒``<Lock>` lucide · thống nhất `text-xs` cả hàng bảng · `hover:bg-slate-50/60` cho `<tr>`.
**B3 — panel danh sách (L2)** · `KhkkListPanel.tsx` + `KhkkWorkListPanel.tsx`
Chế-độ cây thiếu tiêu-đề/số đếm ⇒ cột cụt đầu · **gộp 2 ô tìm chồng nhau** · thang chữ 4 nấc (13/11/10/**9**px) → về 3 nấc chuẩn · 🔴 **`slate-400` = 2.56:1 cho 6 dòng chữ mang thông tin** — dưới ngưỡng WCAG, phải nâng · nút phân trang ký tự trần → icon · chip nhóm lệch baseline.
**B4 — host (L3)** · `KhkkCreatePage.tsx`
Grid `300px`**`260px`** khớp PE (`lg:grid-cols-[260px_1fr] xl:grid-cols-[320px_1fr]`) · sửa comment sai sự thật (L3 H-5: `grep -c Button` = 0, đối chứng dương `PeListPanel` = 8).
**CHỐT KHÔNG SỬA (L3):** `h-calc` toàn hệ · violet 2-hệ toàn hệ · nút tạo ở header List · `truncate` (KHKK tốt hơn chuẩn — **đừng hạ xuống**).
### ĐỢT C — CẦN QUYẾT ĐỊNH TRƯỚC KHI CODE
| # | Việc | Lane khuyến nghị | Cần owner |
|---|---|---|---|
| C1 | **O5** flatMode đổi Cây→Phẳng khi lọc | **GIỮ MÁY R-11, SỬA HÌNH**: bỏ `term` khỏi `flatMode` + gộp ô tìm + **công tắc Cây/Danh-sách** + chip lọc | Gật/bác |
| C2 | **O2** badge "SẮP" → 0 | Lane đo: **không GĐ nào còn đúng nghĩa "sắp"** (cả 4 đều fetch API thật); `SoonChip` **không export**, 8 hit nội bộ, **0 host phụ thuộc** ⇒ bỏ sạch, an toàn | Xác nhận "bỏ hết" = mọi GĐ |
| C3 | **P2-1** `Field` tô brand cho MỌI value ⟂ PE giữ slate | label giữ `text-brand-600`, **value → `text-slate-900`**, chừa màu cho winner/cảnh báo | Nhìn 1 lần |
| C4 | Nút **"Từ chối"**: PE có code nhưng **thực tế không hiện** (BE gỡ khỏi `nextPhases`, UAT S60) ⟂ KHKK VẪN hiện | Đừng tự cắt | Quyết |
### ĐỢT D — VIỆC LỚN
- **O6** (`usePipelineStages.ts:219``/purchase-evaluations?id=…&expand=1`): thêm callback **`onOpenPe`** mirror khuôn `stage2Options.onOpenKhkk` (ternary `:233`). **Không truyền ⇒ giữ navigate** ⇒ 5 host kia không đổi hành vi.
- **O3** hạng mục N-dòng + SUM tổng — **Model-A N-ROW, 0 migration**. Tổng giá duyệt ghi phía trên. 🔴 Người land phải mở đủ `Features.cs:1310-1360` + `CreateContractFromSigningPlanFeatures.cs:100-240` (lane chỉ grep, chưa soi hết) và soi `KhkkDetailPage.tsx:312`**tách N dòng sẽ in N lần cho 1 NCC**, có thể phải gộp.
- **O4** shopdrawing đính kèm — **FE-only**, rail `dossierItemId` đã đủ 6 tầng từ Mig 69.
- **O7** nút edit trên card KHKK — mirror `PeListPanel` `onEditClick`.
- **O8** tầng trên ra cây thư mục **Dự án Hạng mục** 2 cấp (hiện `byProject` mới 1 cấp).
---
## ③ CHECKLIST — acceptance ĐO ĐƯỢC
| # | Deliverable | Acceptance (đo được, không phải "trông ổn") |
|---|---|---|
| 1 | Đợt A land | `grep -c 'href={`/api/contract-signing-plans' KhkkWorkflowPanel.tsx` = **0**; control-dương `grep -c "responseType: 'blob'"` ≥ **1** |
| 2 | A2 mobile | DevTools 375px màn Thao tác: **bấm được "Tạo kế hoạch"** (hiện `<main>` bị `hidden`) |
| 3 | B1 gỡ card-accent | `grep -n 'card-accent' KhkkWorkflowPanel.tsx` = **0 hit**. 🔴 KHÔNG chấp nhận `border-0`/`shadow-none` (gotcha #66) |
| 4 | Mirror 2 app | `sha256sum` **từng cặp** file đụng tới — **khớp 100%**. O2+O6 chạm **5 cặp = 10 file**, nghiệm thu **5/5** |
| 5 | Build | `npm run build` **cả 2 app**, 0 `error TS`. 🔴 `cd` đúng thư mục mỗi lần — thời gian bất thường (`~500ms`) = dấu hiệu build nhầm app |
| 6 | Bundle prod | `curl` bundle + `grep` chuỗi mới **có** ∧ chuỗi cũ **vắng**, cả 2 host (gotcha #77). Mọi 0-hit phải kèm **control-dương** |
| 7 | 6 host cây | O2/O6 land xong: mở PE-list · GĐ3 ×2 · GĐ4 ×2 · KHKK-list — **5 host không truyền callback giữ nguyên hành vi navigate** |
| 8 | 🔴 Visual loop | **CẢ 5 LANE ĐỀU KHÔNG CÓ SCREENSHOT** (lane audit + dev-rig chặn: chỉ `:8080` LISTENING, API `:5443` không có, trang sau `ProtectedRoute` — gotcha #3). Mọi kết luận là **đọc mã, không phải đã nhìn**. ⇒ Người land **BẮT BUỘC** soi thật **375 / 1366 / 1440** trên **prod sau deploy** trước khi tuyên PASS |
| 9 | O3 tiền | Trước khi viết BE: **xác nhận 1 dòng với owner** cách đọc "sum tổng" — đây là tiền |
**Thứ tự land:** A → B → (chờ owner) C → D. A độc lập, land ngay. B không phụ thuộc C/D. D2 (`O3`) land cuối vì chạm dữ-liệu tiền.

View File

@ -11,13 +11,19 @@
| # | Việc | Trạng thái | Ghi chú |
|---|---|---|---|
| 0.1 | Ghi khung rỗng | | |
| 0.2 | Đọc PE panel (chuẩn) | | LOC / cấu trúc |
| 0.3 | Đọc KHKK panel (đối tượng) | | LOC / cấu trúc |
| 0.4 | Đọc DTO 2 phía (field gap) | | types/*.ts |
| 0.5 | Kê bảng đối chiếu 8 hạng mục | ⬜ | |
| 0.6 | Phân loại bê-thẳng ⟂ thiếu-data | ⬜ | |
| 0.7 | Viết spec thi-hành-được | | |
| 0.1 | Ghi khung rỗng | | lượt 1, TRƯỚC mọi Read |
| 0.2 | Đọc PE panel (chuẩn) | | 1062 dòng, đọc HẾT (2 trang) |
| 0.3 | Đọc KHKK panel (đối tượng) | | 726 dòng, đọc HẾT |
| 0.4 | Đọc DTO 2 phía (field gap) | | `types/khkk.ts` (527 dòng, đọc hết) + grep `types/purchaseEvaluation.ts` |
| 0.4b | Đọc phần PE ủy-quyền sang file khác | ✅ | `PeDetailTabs.tsx:778-794` (2 section) + `:3056-3063` (badge) + `:3109-3288` (2 tab) |
| 0.4c | Đọc vỏ host 2 bên (loại nhiễu) | ✅ | `PurchaseEvaluationsListPage.tsx:668``KhkkListPage.tsx:186`**giống hệt** |
| 0.4d | Đọc CSS `.card-accent`/`.icon-chip` | | `fe-admin/src/index.css:112-137` |
| 0.4e | Đo xác thực đường tải file | ✅ | `ContractSigningPlansController.cs:222-232` + `lib/api.ts:18` → §6.1 |
| 0.4f | Đo bất-biến mirror 2 app | ✅ | sha256 khớp `fb563b25…` |
| 0.5 | Kê bảng đối chiếu 8 hạng mục | ✅ | §2 (H0-H8, H0 là mục tao thêm) |
| 0.6 | Phân loại bê-thẳng ⟂ thiếu-data | ✅ | §4.A (15) / §4.B (1) / §4.C (8) |
| 0.7 | Viết spec thi-hành-được | ✅ | §5 (7 đợt + 7 bất-biến + nghiệm thu) |
| 0.8 | Soi-bằng-mắt FD2 | ❌ | **KHÔNG chạy được** — không dev server/API; khai thẳng §6.4, KHÔNG bịa ảnh |
---
@ -96,7 +102,7 @@
- PE `PeWorkflowPanel.tsx:360-377` — gate `evaluation.endsBeforeCeo`, khung **emerald** `border-emerald-300 bg-emerald-50 text-emerald-800`, 2 nhánh theo phase: `DaDuyet` → "✅ Phiếu đã kết thúc tại **{finalizeStepName}** · {finalizeLevelName} — không qua CEO." / đang chạy → "⚑ Quy trình rút gọn: duyệt đến **{…}** là KẾT THÚC, không trình CEO. Các cấp trước vẫn duyệt như thường."
- KHKK `KhkkWorkflowPanel.tsx:269-281` — gate `plan.endedByLevelFinalize`, khung **violet** `border-violet-300 bg-violet-50 text-violet-800`, 2 nhánh theo `isApproved`.
-**màu lệch emerald ⟂ violet** (KHKK dùng violet = màu PE dành riêng cho badge "Duyệt thay CEO" `:441-448` ⇒ KHKK trộn 2 nghĩa vào 1 màu).
- ∅/⊘ **nhánh "báo trước"**: xem §4.B — phụ thuộc ngữ nghĩa cờ BE (đo ở dưới).
- **ĐÃ ĐO — bác giả-thuyết ban đầu của chính tao**: tao định kê "KHKK thiếu nhánh báo-trước vì cờ chỉ bật sau khi duyệt". SAI. `khkk.ts:385-390` khai rõ `endedByLevelFinalize`**3-NGẢ ở BE**: phiếu `DaDuyet` → cờ runtime thật (Mig 71); phiếu **chưa xong → suy từ cấu hình, POINTER-AWARE** (chỉ tính cấp `allowApproverFinalize` ở-hoặc-SAU con-trỏ); chưa pin quy trình → false. Và KHKK **đã** rẽ nhánh sẵn (`:276-278`). ⇒ H1 **chỉ lệch MÀU + CÂU CHỮ**, dữ-liệu đủ, **bê thẳng được** (§4.A-4).
### H2. Bước/Cấp + icon TRÒN check xanh — ≡ (gần như khớp tuyệt đối)
- PE `:381-478` (`stepIcon` `:384`, dot `:397-404`, cấp `:423-434`) ⟂ KHKK `:284-366` (`STATUS_DOT/BOX/ICON` `:64-78`, dot `:293-295`, cấp `:318-320`).
@ -191,25 +197,111 @@
## 4. Phân loại hành động
### 4.A BÊ THẲNG được (data đã có 2 phía)
_(chờ)_
### 4.A BÊ THẲNG được data đã có đủ 2 phía (13 + 2 mục)
> Mọi mục 1-13 chỉ đụng **class / JSX / chuỗi**, KHÔNG đụng query, mutation, guard quyền.
### 4.B THIẾU DỮ-LIỆU (nêu field DTO thiếu + nguồn BE)
_(chờ)_
| # | Mục | Sửa gì | Bằng chứng đủ-data |
|---|---|---|---|
| 1 | **Vỏ panel** | bỏ `<section className="card-accent" style={--accent}>``<div className="space-y-4">`; bỏ `<header border-b px-5 py-3>` + `.icon-chip`; bỏ `p-5` (aside đã `p-4`) | thuần trình bày |
| 2 | **Tiêu đề panel** | `<h3 className="text-sm font-semibold text-slate-900">Quy trình duyệt</h3>` (bỏ `text-emerald-700`), meta xuống dòng riêng `<p className="mt-0.5 font-mono text-[11px] text-slate-500">` — khuôn PE `:347-352` | — |
| 3 | **Số hiệu quy trình** | `v${String(plan.workflowVersion ?? 0).padStart(2,'0')}` → hiện `v01` như PE `:350` | `workflowVersion` có (`khkk.ts:384`) |
| 4 | **Banner kết-thúc-sớm** | violet → **emerald** `border-emerald-300 bg-emerald-50 text-emerald-800`; nhánh đã-duyệt mở bằng `✅`, nhánh đang-chạy mở bằng `⚑ Quy trình rút gọn:` + câu "Các cấp trước vẫn duyệt như thường." | `endedByLevelFinalize` BE **3-ngả, pointer-aware, CÓ cả khi chưa duyệt** (`khkk.ts:385-390`) ⇒ nhánh báo-trước có dữ-liệu thật |
| 5 | **Chip phòng của Bước** | `border border-slate-300 bg-white text-slate-600``bg-emerald-50 text-emerald-700`, bỏ viền | `departmentName` có (`khkk.ts:289`) |
| 6 | **3 nút hành động** | thay `<Check/><Undo2/><X/>` bằng glyph trong chuỗi `'✓ Duyệt'` · `'← Trả lại'` · `'✗ Từ chối'`; bỏ `inline-flex items-center gap-1` để khớp `rounded border px-2 py-1 text-[11px] font-bold` (PE `:553`) | thuần trình bày |
| 7 | **Nút Xóa phiếu** | `border-slate-300 text-slate-600``border-red-300 bg-white text-red-700 hover:bg-red-50`; giữ `<Trash2>` (PE cũng dùng icon ở đúng nút này `:576`) | — |
| 8 | **3 khối cuối** | bọc mỗi khối `<div className="border-t border-slate-200 pt-4">` (PE `:929`,`:970`,`:974`) — đây là thứ tạo "nhịp" dọc của panel PE | — |
| 9 | **Tiêu đề 3 khối cuối** | `<h3 className="text-sm font-semibold text-slate-900">` (+`mb-2` cho 2 khối lịch sử); **bỏ icon `<Paperclip>/<Check>/<History>` + bỏ pill đếm**, viết `(n)` ngay trong tiêu đề: `Lịch sử duyệt ({approvals.length})` — khuôn `PeDetailTabs.tsx:781`,`:790` + `PeWorkflowPanel.tsx:930` | — |
| 10 | **Thẻ lịch sử duyệt** | `bg-slate-50/60 px-2.5 py-1.5 text-[11px]``rounded border border-slate-200 bg-white p-3 text-sm`; dòng 1 bọc `flex items-center justify-between` để **đẩy mốc-giờ sang phải** (`text-xs text-slate-500 shrink-0`); dòng 2 `mt-1 text-xs text-slate-500` | — |
| 11 | **Badge quyết định 3-ngả** | bỏ nhãn gộp *"Trả lại / Từ chối"* (`:83`), theo `decisionBadge` (`PeDetailTabs.tsx:3056-3063`): `toPhase===99`**"Từ chối"** `bg-rose-100 text-rose-700 border border-rose-200`; reject khác → **"Trả lại"** `bg-amber-100 text-amber-700 border border-amber-200`; còn lại → **"Duyệt"** `bg-emerald-100 text-emerald-700 border border-emerald-200`. Cỡ `text-[11px] font-medium` (không phải `text-[9px] font-semibold`) | `KhkkApprovalDto.toPhase` **có** (`khkk.ts:307`) ⇒ tách được Từ-chối ⟂ Trả-lại |
| 12 | **Lịch sử thay đổi mở sẵn** | bỏ nút gập + bỏ `enabled: showChangelog` (`:186`) → render thẳng như PE `:973-975`; giữ `take:200` | xem §6.2 (chi phí 1 request) |
| 13 | **Chi-tiết cấp-chữ** | "Huỷ"→"Hủy"; `variant="outline"``"ghost"`; hộp chọn file dựng theo khuôn brand gạch-đứt + liệt-kê từng file có cỡ + nút `<X>` (PE `:841-866`) | — |
### 4.C KHÔNG áp dụng (domain khác thật)
_(chờ)_
**Bán-bê-thẳng (cần thêm 1 component nhỏ — route BE ĐÃ CÓ SẴN):**
| # | Mục | Ghi chú |
|---|---|---|
| 14 | **Xem trước `<Eye>` + tải xuống có xác thực** | PE `:938-953` + `AttachmentPreviewDialog.tsx:24-32`. KHKK **đủ route**: `GET {id}/attachments/{attId}/view` (`ContractSigningPlansController.cs:231-232`) + `/download` (`:222-223`). Cần (a) đổi `<a href>` `:500-505` sang `api.get(…, {responseType:'blob'})` khuôn PE `:327-342`; (b) thêm prop `basePath` cho `AttachmentPreviewDialog` (đang hardcode `evaluationId`, `:18`) hoặc bản KHKK riêng. 🔴 **Đồng thời là sửa LỖI THẬT — §6.1** |
| 15 | **Mờ nhánh sau cấp KẾT THÚC theo CẤU HÌNH** | PE mờ ngay khi phiếu **đang chạy** (`:165-169`) + badge "Kết thúc tại {X}" ở **Bước** (`:411-418`). KHKK chỉ mờ **sau khi đã duyệt** (`skipped` `:313`). Data đủ (`allowApproverFinalize` `khkk.ts:277` + `finalizeStepName/LevelName` `:392-393`). **Cần owner gật** — đổi *nghĩa hiển thị*, không chỉ màu |
### 4.B THIẾU DỮ-LIỆU — nêu đúng field
| Mục | Field DTO thiếu | Nguồn BE |
|---|---|---|
| **H3 chip "✎ NS PRO" / "✎ NS CCM"** | `KhkkWorkflowLevelDto` (`fe-admin/src/types/khkk.ts:269-278`) chỉ có `{id, order, name, approverUserId, approverFullName, allowApproverFinalize}`**thiếu `canEditProBudget` + `canEditCcmBudget`**, thứ PE có trên `PeCurrentApprovalLevelApprover` (`fe-admin/src/types/purchaseEvaluation.ts:252-253`) | `ContractSigningPlanWorkflowLevelDto` (BE `ContractSigningPlanFeatures.cs:105-110`, theo chú thích `khkk.ts:265-268`) không trả 2 cờ này |
🔴 **Nhưng KHÔNG đề nghị thêm field.** 2 cờ đó của PE suy từ role để mở quyền sửa ô **ngân sách per-gói-thầu** (`PeWorkItemBudgets`, Mig 50) — khái niệm KHKK không có (KHKK chốt giá per-Line tại choke-point BE `ApplyApprovedValuesOnFinalize`, đã khai `KhkkWorkflowPanel.tsx:29`). Bê chip sang = **vẽ nhãn quyền không tồn tại**, tệ hơn thiếu ⇒ xếp §4.C.
### 4.C KHÔNG áp dụng (khác domain thật — ĐỪNG bê)
`✎ NS PRO / NS CCM` (H3) · "Tiến trình duyệt 2-cấp phòng ban" (`:983-1052`, Mig 16) · bộ chọn giá chốt (`:794-820`) · 4 mode Trả lại (`:635-713`) · "Duyệt thẳng Cấp cuối" (`:725-748`) · "Duyệt done miễn CEO" (`:751-768`) · banner phiếu-trước-chưa-duyệt (`:512-516`) · fallback phiếu V1 (`:481-485`, đã khai `KhkkWorkflowPanel.tsx:27`).
---
## 5. SPEC thi-hành-được cho Opus 5 MAX
_(chờ)_
### 5.0 Phạm vi đụng file (KHÔNG mở rộng)
```
fe-admin/src/components/khkk/KhkkWorkflowPanel.tsx ← sửa
fe-user/src/components/khkk/KhkkWorkflowPanel.tsx ← MIRROR y hệt
```
🔴 **2 file đang SHA256 identical** — đo lúc audit: cả hai = `fb563b25cac09af9ccf00fe6145972244d25012a22bd8ffdd9faac2e04dd6913` (`wc -l` = 726 mỗi bên). Sửa 1 bên mà quên bên kia = vỡ bất-biến file tự khai ở `:4`. Cách an toàn: sửa fe-admin xong **copy nguyên file** sang fe-user, rồi `sha256sum` lại 2 bên.
🔴 **TUYỆT ĐỐI KHÔNG đụng** `PeWorkflowPanel.tsx` / `PeDetailTabs.tsx` — PE là **CHUẨN đang golive**, kéo PE về phía KHKK là làm hỏng thứ owner đang lấy làm mẫu.
### 5.1 Thứ tự thi công (theo mức đập-vào-mắt giảm dần)
| Đợt | Việc | Mục §4.A |
|---|---|---|
| **Đ1 — vỏ** | bỏ card-accent + header icon-chip + `p-5`; dựng lại tiêu đề + meta kiểu PE | 1, 2, 3 |
| **Đ2 — 3 khối cuối** | thêm `border-t pt-4`, đổi tiêu đề `<h3 slate-900>` + `(n)`, bỏ pill/icon, mở sẵn changelog | 8, 9, 12 |
| **Đ3 — lịch sử duyệt** | thẻ trắng `p-3 text-sm`, mốc-giờ canh phải, badge 3-ngả có viền | 10, 11 |
| **Đ4 — nút + chip** | glyph thay lucide ở 3 nút, nút Xóa đỏ, chip phòng emerald | 5, 6, 7 |
| **Đ5 — banner + chữ** | banner finalize violet→emerald + đúng câu chữ PE; "Hủy"/ghost; hộp chọn file brand | 4, 13 |
| **Đ6 — file đính kèm** | `<Eye>` preview + tải-xuống-qua-axios (**kèm sửa lỗi 401**) | 14 |
| **Đ7 — CHỜ OWNER** | mờ nhánh sau cấp KẾT THÚC theo cấu hình | 15 |
### 5.2 Bất-biến phải giữ (đụng vào = hỏng nghiệp vụ, KHÔNG phải style)
1. `stepStatus` / `levelStatus` / `currentLevels` / `signedByLevelId` (`:122-155`) — KHKK **tự suy** trạng thái vì BE trả cây THÔ (`khkk.ts:265-268`). PE có `status` BE precompute. **Đừng "đơn giản hoá" theo PE.**
2. Gom Cấp cùng `order` (`:289`, `:307`) = OR-of-N khớp `ContractSigningPlanFeatures.cs:259-260`.
3. `actingLevel` + `approverFinalizeEligible` (`:165-167`) — mirror `ResolveActingLevel` BE; soi cờ của level khác = ô-tích nói dối.
4. `applyLevelFinalize`**opt-IN, reset `false` mỗi lần mở dialog** (`:102`, `:431-432`) — F-1 S166 đã RATIFIED @S167.
5. `canSubmit` **cố ý không ẩn theo vai** (`:169-172`, bug-class gotcha #44).
6. 5 `invalidateQueries` sau transition (`:216-219`) + 2 sau xóa (`:230-231`) — có `pipeline-khkk-index` nuôi cây 4-folder S162.
7. `commentRequired` cho Return/Reject (`:242-244`).
### 5.3 Nghiệm thu (acceptance)
- `cd fe-admin && npm run build` **0 lỗi TS**; `cd fe-user && npm run build` **0 lỗi TS**.
- `sha256sum` 2 file KHKK panel → **bằng nhau** sau khi sửa.
- `git diff --stat` chỉ hiện **đúng 2 file** đó.
- Soi mắt cạnh nhau: mở `/purchase-evaluations` chọn 1 phiếu ⟂ mở `/contract-signing-plans` chọn 1 phiếu → panel phải **cùng một hệ**: không thẻ-trong-thẻ, cùng nhịp `border-t`, cùng cỡ chữ khối lịch sử, cùng cách canh mốc-giờ.
- Bấm "Tải xuống" ở "File đính kèm khi duyệt" → **file tải được thật** (nay đang 401, §6.1).
---
## 6. Rủi ro / caveat / cái tao KHÔNG verify được
_(chờ)_
### 6.1 🔴 LỖI THẬT bắt được khi soi style (không chỉ style)
`KhkkWorkflowPanel.tsx:500-505` tải file bằng **thẻ `<a href="/api/contract-signing-plans/{id}/attachments/{attId}/download">` trần**.
- JWT chỉ được gắn bằng **interceptor axios** (`fe-admin/src/lib/api.ts:18` `config.headers.Authorization = 'Bearer …'`); trình duyệt điều-hướng thẻ `<a>` **không đi qua axios** ⇒ không có header.
- Endpoint đích **bắt buộc xác thực**: `ContractSigningPlansController.cs:222-223` `[Authorize(Policy = "KeHoachKyKet.Read")]`, cộng dồn với `[Authorize]` cấp class `:29`.
- ⇒ Suy luận: bấm "Tải xuống" **ăn 401**. PE không dính vì đi `api.get(..., {responseType:'blob'})` (`PeWorkflowPanel.tsx:327-342`).
- ⚠️ **Tao CHƯA chạy thử** (spawn này không có API + dev server) — đây là suy luận từ 2 mã nguồn, **cần bấm thật 1 lần** để chốt. Nếu đúng thì §4.A-14 đổi hạng từ "style" thành **vá lỗi**.
### 6.2 Bỏ gập "Lịch sử thay đổi" = thêm 1 request mỗi lần mở phiếu
KHKK đang `enabled: showChangelog` (`:186`) ⇒ 0 request khi chưa bấm. PE fetch sẵn nhưng **dùng chung `queryKey ['pe-changelog', id]` giữa `ApprovalsTab` (`:3115`) và `HistoryTab` (`:3224`)** nên vẫn chỉ 1 request. KHKK không có nhu cầu dùng-chung đó ⇒ bỏ gập = **+1 request `take:200` / phiếu**. Chấp nhận được (giống PE), nhưng khai thẳng chứ không giấu.
### 6.3 Nút "Từ chối" — cần owner quyết, ĐỪNG tự bỏ
PE **vẫn còn code** nút Từ chối nhưng thực tế không hiện: BE gỡ `TuChoi` khỏi `nextPhases` + FE filter thêm (`PeWorkflowPanel.tsx:314-317`, UAT S60 "bỏ luôn nút Từ chối — Duyệt hoặc Trả về thôi"). KHKK **vẫn hiện** (`:456-467`). Muốn "giống PE" tuyệt đối thì phải bỏ — nhưng đó là **bỏ chức năng**, không phải đổi style, và KHKK có `KhkkPhase.TuChoi` + banner riêng (`:397-401`) + `commentRequired`. ⇒ **Hỏi owner**, đừng tự cắt.
### 6.4 KHÔNG có ảnh chụp màn hình lượt này (khai thẳng, khuôn NO-SCREENSHOT S159)
Vòng soi-bằng-mắt FD2 **không chạy được** trong spawn này: không có dev server, không có API/SQL, và panel duyệt nằm sau `ProtectedRoute` + cần phiếu KHKK có thật ở trạng thái `ChoDuyet` (bẫy rig #3 trong ký-ức: proxy Vite HTTP ⟂ API HTTPS :5443). Thay bằng **đọc mã nguồn đối chiếu theo cặp file:line** — mọi khẳng định trên đều truy được về dòng cụ thể. **Không bịa screenshot.** Khi lane thi công xong, chốt bằng cách mở prod cạnh nhau (ký-ức: authed pages nghiệm thu ở prod, đừng vật lộn dev-rig).
### 6.5 Ngoài lăng-kính này (nêu để lane khác không sót)
- Thứ tự upload file khác nhau: PE upload **TRƯỚC** transition (`:234-244`, "file lỗi = không duyệt"), KHKK upload **SAU** (`:198-207`, "transition hỏng thì không đẻ file mồ côi"). Hai lựa chọn đều có lý do viết trong mã ⇒ **không đụng ở đợt style**.
- `AttachmentPreviewDialog` xuất sẵn `isPreviewable` (`:11-14`) nhưng `PeWorkflowPanel.tsx:326` **tự viết lại regex** — trùng lặp sẵn có bên PE, không phải việc của đợt này.
- Panel 2 KHKK (`KhkkDetailContent.tsx`) có "Ý kiến cấp duyệt" + có `signedByUserId`/`approverUserId` (`khkk.ts:256-262`) ⇒ badge "Admin duyệt thay" (khuôn `PeDetailTabs.tsx:756-760`) **dựng được** — nhưng thuộc lăng-kính panel 2, không phải lane này.
---
END sub-frontend-designer-L1 — VERDICT=<chờ>
## 7. Kết luận 1 dòng cho owner
Sơ đồ Bước/Cấp của 2 bên **đã giống nhau gần như tuyệt đối** (H2, H4) — cái làm owner thấy "khác nhau" nằm ở **vỏ panel (thẻ-trong-thẻ có viền xanh + tiêu đề có icon)** và ở **3 khối cuối (mất đường kẻ ngăn, tiêu đề nhỏ hơn, thẻ lịch sử xám và đặc chữ, mốc-giờ không canh phải, lịch sử thay đổi bị gập)**. Toàn bộ đều **bê thẳng được**, trừ chip "NS PRO/NS CCM" (KHKK không có khái niệm ngân sách đó — **đừng bê**).
---
END sub-frontend-designer-L1 — VERDICT=15 lệch có-thể-sửa (13 bê-thẳng + 1 bán-bê kèm vá lỗi 401 + 1 chờ-owner) · 1 KHÔNG-BÊ vì thiếu khái niệm (H3 NS PRO/CCM) · H2+H4 đã khớp · lệch nặng nhất = vỏ card-accent (H0) không nằm trong danh sách owner nêu

View File

@ -103,7 +103,7 @@ Shell `<aside className="flex min-h-0 flex-col overflow-hidden border-r border-s
### 🔴 DELTA-2 — HAI Ô TÌM CHỒNG NHAU (nặng nhất, thấy ngay bằng mắt)
Ở chế-độ cây (`flatMode === false`), panel render:
- ô 1 = host `KhkkListPanel.tsx:260-265``Input` (h-8 **rounded-lg** border-slate-**300** text-**sm**), ph. "Tìm mã kế hoạch / mã phiếu / gói thầu…"
- ô 2 = ruột cây `PipelineTreePanel.tsx:236-243``<input>` thô (h-8 **rounded-md** border-slate-**200** text-**[12px]**), ph. "Lọc dự án / hạng mục / mã phiếu…", cách ô 1 đúng 1 `p-2`+`p-3` ≈ 20px.
- ô 2 = ruột cây `PipelineTreePanel.tsx:235-244` (icon `:236`, `<input>` `:237-243`)`<input>` thô (h-8 **rounded-md** border-slate-**200** text-**[12px]**), ph. "Lọc dự án / hạng mục / mã phiếu…", cách ô 1 đúng 1 `p-2`+`p-3` ≈ 20px.
Hai ô **trái nghĩa nhau**: gõ ô 1 ⇒ `flatMode` bật (`:201`) ⇒ **cây + ô 2 biến mất giữa lúc đang gõ**; gõ ô 2 ⇒ lọc trong cây (`PipelineTreePanel:145-153`), ép mở hết node (`:180`).
**Sửa:** 1 ô DUY NHẤT. Nâng `PipelineTreePanel` nhận `searchTerm`/`onSearchTermChange` (controlled) + ẩn ô ruột khi `hideHeader` (cùng lý-lẽ đã dùng để ẩn header ở `:225-232`). Host giữ 1 `Input`.
@ -178,16 +178,157 @@ Vỏ `<aside>` · lưới host 3-panel `lg:grid-cols-[400px_1fr_360px]` (`KhkkLi
## 4. Câu hỏi owner O5 — `flatMode` (KhkkListPanel:202)
Nghi vấn: filter bật ⇒ panel đổi CÂY → DANH-SÁCH-PHẲNG ⇒ 3 leaf (Đang duyệt / Đã duyệt / Đã xóa) trông khác leaf "Danh sách".
Cần: đánh-đổi 2 đường (giữ cây khi lọc vs giữ R-11 server-filter) + KHUYẾN NGHỊ.
_(pending)_
### 4.1 XÁC NHẬN nghi vấn — 4/4 mắt xích, đo được
| Leaf menu | URL (`Layout.tsx`) | Suy ra | `flatMode` (`KhkkListPanel:201`) | HÌNH thấy |
|---|---|---|---|---|
| Danh sách (`Khkk_List`) | `:41` `/khkk/list?group=1` | phase=null, deleted=false | **false** | 🌳 CÂY |
| Đang duyệt (`Khkk_Pending`) | `:43` `…&filter=ChoDuyet` | `FILTER_TO_PHASE` (`KhkkListPage:46-52`) → phase=2 (`:61`,`:86`) | **true** | ▤ PHẲNG |
| Đã duyệt (`Khkk_Approved`) | `:44` `…&filter=DaDuyet` | phase=3 | **true** | ▤ PHẲNG |
| Đã xóa (`Khkk_Deleted`) | `:45` `…&view=deleted` | `deletedView=true` (`:62`) | **true** | ▤ PHẲNG |
**3/4 leaf KHÔNG BAO GIỜ thấy cây, ngay từ lần vẽ đầu**, không cần người dùng gõ gì. Người dùng đọc thành "menu lọc sai" vì thứ họ so sánh là leaf "Danh sách" — thứ DUY NHẤT còn cây.
**Đo thêm — cái GIỐNG và cái KHÁC giữa 2 hình:**
- **Card GIỐNG HỆT**: cả 2 nhánh cùng render `KhkkPlanCard` (tree qua `stage2Content` `:239-244`, flat qua `:335-340`) ⇒ khác biệt **0% ở thẻ phiếu**.
- **Khác 100% ở phần khung**: tree = [ô tìm host][Select][**ô tìm thứ 2**][📅Năm>📁Dự án>🧱Hạng mục>📁GĐ2] · flat = [ô tìm host][Select][ghi-chú italic 10px][card phẳng][thanh phân trang]. ⇒ "trông khác hẳn" **không phải do phiếu**, mà do **giàn giáo + 1 ô tìm thừa + 1 thanh phân trang**.
- Máy lọc thì ĐÚNG: tree mode `enabled: flatMode` = false (`:217`) ⇒ query `khkk-list` **không chạy**, dữ liệu cây đến từ `pipeline-khkk-index` (`usePipelineStages:90-102`, `pageSize=200`, KHÔNG nhận filter).
### 4.2 Đường 1 — GIỮ CÂY KHI LỌC (sửa `flatMode`)
**Được:** 1 hình duy nhất cho cả 4 leaf; giữ ngữ-cảnh "phiếu này thuộc gói/dự án nào" — đúng lý do cây sinh ra; hết cảnh gõ-phím-làm-đổi-màn. Khuôn có sẵn, golive đã chạy: `PurchaseEvaluationsListPage` cho search+phase vào **chính query nuôi cây** (`:75-102`) rồi dựng `yearGroups` từ kết quả ĐÃ LỌC (`:220-262`), + `hideEmpty={filterActive}` (`:58`, `:524`) để không đổ folder rỗng. Cơ chế `hideEmpty` đã tồn tại ở `PipelineStageFolders:314-325`.
**Mất — 3 cái, cái thứ 3 là chí tử:**
1. **Số đếm nói dối.** Lọc client trên cửa-sổ 200 ⇒ leaf "Đã duyệt" hiện 37 trong khi máy chủ có 120. Golive không dính vì cây golive dựng từ *chính* kết quả server đã lọc; cây KHKK thì không.
2. **Mất phân trang thật** (`total`/`totalPages` `:221-222`, `PAGE_SIZE=20`) — cây không có "trang 2".
3. 🔴 **XƯƠNG CÂY KHÔNG PHẢI CỦA KHKK.** Năm>Dự án>Hạng mục dựng từ `pipeline-pe-index` = **200 phiếu PE** (`PipelineTreePanel:22` `PE_INDEX_PAGE_SIZE = 200`, dùng ở `:124`), rồi card KHKK mới treo vào nhánh PE cha (`usePipelineStages:123-134` `khkkByPeId`). Kế hoạch có PE cha nằm ngoài 200 PE đó ⇒ **không có cành để treo ⇒ vô hình**, dù server trả về. Cho lọc chạy trong cây KHÔNG gỡ được điều này — nó là ràng buộc kiến-trúc, không phải tuỳ chọn hiển thị. Đây đúng thứ R-11 đang che.
- 🔸 Hệ quả phụ đã có thật hôm nay: `group` **không** nằm trong điều-kiện `flatMode` (`:201`) và được lọc client (`:227`, `:251`) ⇒ leaf **nhóm N2-N8 "Danh sách"** đang chịu đúng rủi ro "phiếu cũ vô hình" mà R-11 chống. Có xoa dịu một phần: cờ `truncated` (`usePipelineStages:156`) → in cảnh báo trên folder (`PipelineStageFolders:301`) — cảnh báo, không phải đường đi tới phiếu.
### 4.3 Đường 2 — GIỮ R-11 (lọc đi server, chuyển phẳng)
**Được:** đúng về DỮ LIỆU — mọi phiếu khớp đều tới được, kể cả ngoài 200; `total` thật; phân trang thật; số đếm không nói dối. Chi phí đã trả xong (code đang chạy).
**Mất:** đổi hình **IM LẶNG** do gõ phím; **không có đường quay lại cây** khi đang đứng ở 3 leaf trạng-thái (muốn thấy cây phải tự xoá filter trên URL — người dùng không làm được); mất ngữ-cảnh gói/dự án đúng lúc đang cần đối chiếu; và kéo theo **DELTA-2** (ô tìm thứ 2 biến mất giữa lúc gõ) — lỗi nặng nhất trong bài này lại là *hệ quả* của cơ chế đổi hình, không phải của việc lọc.
### 4.4 PHÁN + KHUYẾN NGHỊ
> **GIỮ MÁY của đường 2 (R-11, lọc đi server). SỬA phần HÌNH.**
> Lý do 1 câu: **mất phiếu là lỗi dữ-liệu, mất cây là lỗi tiện-nghi** — đường 1 đổi một lỗi tiện-nghi lấy một lỗi dữ-liệu, mà ràng buộc "xương cây = 200 PE" thì đường 1 KHÔNG gỡ được.
> Nhưng bug thật của O5 **không phải "cây hay phẳng"** — mà là **đổi hình không ai bấm, không ai báo, không quay lại được**. Đó là chỗ phải vá, và vá rẻ.
4 việc, xếp theo hiệu-quả/chi-phí:
**O5-1 (BẮT BUỘC) — gộp 2 ô tìm về 1.** Thêm prop controlled `searchTerm`/`onSearchTermChange` cho `PipelineTreePanel` (nay `useState` nội bộ `:112`), và khi `hideHeader` thì ẩn luôn ô tìm ruột (`:235-245`) — cùng lý-lẽ đã dùng để ẩn header. Ô host thành ô DUY NHẤT: chế-độ cây ⇒ lọc cây (client, đúng như `term` đang làm); chế-độ phẳng ⇒ đi server. Người dùng gõ 1 chỗ, không mất chỗ đang gõ.
**O5-2 (BẮT BUỘC) — nói ra chế-độ + cho đường quay lại.** Thay 2 dòng chữ mờ 10px (`:277-287`) bằng 1 hàng công-tắc đặt NGAY DƯỚI `Select`:
`[🌳 Cây] [▤ Danh sách]` — segmented, `role="group"` + `aria-pressed`, cao 28px, chữ `text-[11px] font-medium`, active = `bg-brand-50 text-brand-700 ring-1 ring-brand-200`.
Mặc định: leaf không-lọc = Cây · 3 leaf trạng-thái = Danh sách (**giữ nguyên hành vi hôm nay**), nhưng NGƯỜI DÙNG BẤM ĐỔI ĐƯỢC. Khi ở Danh sách vì bị lọc, kèm 1 chip "Đang lọc: Chờ duyệt ✕" — bấm ✕ = bỏ lọc + về cây. (Chip này cũng vá luôn DELTA-3: ở view Đã xóa thì chip đỏ "Đã xóa" thay cho `Select` chết câm.)
🔴 Ràng buộc kỹ-thuật để O5-2 không thành bẫy: khi người dùng CHỦ ĐỘNG chọn "Cây" trong lúc đang lọc, cây chỉ lọc trong 200 ⇒ **phải in đúng câu số**: "Cây đang lọc trong 200 phiếu mới nhất — máy chủ có N phiếu khớp" (N = `list.data.total`, đã có sẵn) + nút "Xem đủ ở dạng danh sách". Không có câu này thì đường 1 lẻn vào bằng cửa sau.
**O5-3 (NÊN) — cầu nối khi cây rỗng.** Cây (đã lọc) rỗng nhưng server có kết quả ⇒ hiện `EmptyState` + nút "Xem N phiếu ở dạng danh sách", **không tự nhảy**. Hôm nay chỗ này chỉ có 1 dòng `italic slate-400` (`:230`).
**O5-4 (NÊN) — kéo 2 hình gần nhau về mặt thị-giác.** Card đã giống hệt rồi ⇒ chỉ cần: (a) chế-độ phẳng thêm **hàng nhãn + pill đếm** như `PeListPanel:96-101` để cột không "cụt đầu" khi mất cây (vá luôn DELTA-1); (b) thanh phân trang dùng icon lucide + `aria-label` (DELTA-12); (c) mọi ghi-chú `slate-400``slate-500`, `text-[10px]``text-[11px]` (DELTA-15).
**Nếu owner bác và muốn 1 hình duy nhất:** thì phải chọn hình **PHẲNG cho cả 4 leaf** (bỏ cây khỏi panel 1, cây về đúng chỗ của nó là trang toàn-trình) — KHÔNG chọn "cây cho cả 4". Chọn cây cho cả 4 = chấp nhận giấu phiếu ngoài cửa-sổ 200 ở đúng 2 leaf mà kế-toán/lãnh-đạo hay tra nhất ("Đã duyệt", "Đã xóa"). Đó là đánh đổi tôi không khuyến nghị, và nếu vẫn chọn thì phải khai bằng chữ ngay trên cây, không được im.
---
## 5. SPEC thi-hành-được (cho Opus 5 MAX)
_(pending)_
**Luật chung cho MỌI gói dưới đây**
- 🔴 **MIRROR ĐÔI**: `fe-admin/src/...``fe-user/src/...` phải giữ SHA256 bằng nhau (đã đo trước khi sửa: `KhkkListPanel` `ad9073db…`, `KhkkWorkListPanel` `fcb97650…`). Sửa xong chạy lại `sha256sum` cặp file → phải khớp.
- Chỉ đụng lớp trình-bày. **KHÔNG** đổi `queryKey`, tham số API, điều-kiện `enabled`, `OPEN_PHASES`, `readOnly`, quyền. Sửa xong `grep -c "api.get"` mỗi file phải KHÔNG đổi.
- Verify: `cd fe-admin && npm run build` **và** `cd fe-user && npm run build` — 0 TS error.
- Tailwind v4: dùng class có sẵn trong scale; accent teal/violet/amber **không có stop -800** (nhớ cũ, silent no-class).
### W-A — vá O5 (ưu tiên 1, chạm 3 file)
**A1. Gộp ô tìm** · `components/pipeline/PipelineTreePanel.tsx`
- Thêm 2 prop `searchTerm?: string` + `onSearchTermChange?: (v: string) => void`; `const search = searchTerm ?? innerSearch` (giữ `useState` nội bộ `:112` làm mặc định cho 6 host cũ ⇒ **0 đổi hành vi** ở PE/GĐ3/GĐ4).
- Ẩn khối ô tìm ruột (`:235-244`) khi `hideHeader === true` — chỉ KHKK truyền `hideHeader` (`KhkkListPanel:294`).
- `KhkkListPanel`: truyền `searchTerm={search}` + `onSearchTermChange={onSearchChange}` vào `PipelineTreePanel` (`:292-297`).
- **Nghiệm thu:** ở leaf "Danh sách", đếm `<input>` trong panel 1 = **1** (nay 2). Gõ 1 ký tự vào ô đó ⇒ cây co lại, **không** nhảy sang phẳng (vì `flatMode` sẽ do W-A2 quyết, không còn do `term`).
**A2. Công tắc chế-độ + chip lọc** · `pages/khkk/KhkkListPage.tsx` + `components/khkk/KhkkListPanel.tsx`
- HOST: thêm state theo ĐÚNG khuôn 3-trạng-thái đang có ở `:68-72`: `const [viewOverride, setViewOverride] = useState<'tree'|'flat'|undefined>(undefined)``undefined` = nghe theo URL. Reset về `undefined` trong khối đổi leaf `:80-85` (cùng chỗ `setChipPhase`).
- PANEL: đổi `:201` thành
`const autoFlat = !canPe || deletedView || phase !== null` (🔴 **bỏ `term.length > 0`** — gõ chữ không còn được quyền đổi hình)
`const flatMode = viewOverride ? viewOverride === 'flat' : autoFlat`
`enabled: flatMode` (`:217`) giữ nguyên ⇒ ở chế-độ cây vẫn không gọi `khkk-list`. **NGOẠI LỆ**: khi `viewOverride === 'tree'``autoFlat` đang true, cần `total` để in câu cảnh báo ⇒ đổi `enabled: flatMode || autoFlat`**chỉ đọc `total`**, không render `items`.
- Công tắc đặt ngay sau `Select` (`:276`), ẩn khi `!canPe` (không có cây thì không có gì để bật):
```
<div role="group" aria-label="Chế độ hiển thị" className="flex gap-1">
<button type="button" aria-pressed={!flatMode} onClick={() => onViewChange('tree')}
className={cn('inline-flex h-7 flex-1 items-center justify-center gap-1 rounded-md border px-2 text-[11px] font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/40',
!flatMode ? 'border-brand-200 bg-brand-50 text-brand-700' : 'border-slate-200 text-slate-500 hover:bg-slate-50')}>
<ListTree className="h-3.5 w-3.5" /> Cây
</button>
…bản sao cho 'flat' với <List className="h-3.5 w-3.5" /> Danh sách…
</div>
```
(icon `ListTree`/`List` từ `lucide-react` — **không dùng emoji**, FD3.)
- Chip lọc đang bật, thay cho 2 dòng chữ mờ `:277-287`: khi `phase !== null` → `<button>` chip `inline-flex items-center gap-1 rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-800 ring-1 ring-amber-200` nội dung `Đang lọc: {KHKK_PHASE_LABELS[phase]}` + `<X className="h-3 w-3" />`, `aria-label="Bỏ lọc trạng thái"`, onClick → `onPhaseChange(null)`.
- Khi `deletedView`: **thay** `Select` bằng banner đỏ khuôn golive (`PurchaseEvaluationsListPage:427-431`): `rounded border border-red-200 bg-red-50 px-2 py-1.5 text-[11px] text-red-700` + "🗑 **Phiếu đã xóa** — chỉ xem. Không khôi phục, không thao tác."
- **Nghiệm thu:** vào `/khkk/list?group=1&filter=DaDuyet` → thấy PHẲNG + chip "Đang lọc: Đã duyệt ✕" + công tắc; bấm `Cây` → ra cây; bấm ✕ → về `Danh sách` không lọc, cây giữ nguyên.
**A3. Câu số khi cây bị lọc + cầu nối khi rỗng** · `KhkkListPanel.tsx`
- `viewOverride === 'tree' && autoFlat` ⇒ trên thân cây in: `Cây lọc trong 200 phiếu mới nhất — máy chủ có {total} phiếu khớp.` + `<button>` "Xem đủ ở dạng danh sách" (khuôn nút "Thử lại" `:310-316`). Class chữ: `px-3 py-1.5 text-[11px] text-slate-500` (**KHÔNG** `slate-400` — xem W-C).
- Cây rỗng sau lọc ⇒ `<EmptyState icon={Inbox} title="Không có kế hoạch khớp bộ lọc" description= action={nút trên} />`.
### W-B — đồng bộ hình với golive (chạm 4 file)
| # | File | Sửa |
|---|---|---|
| B1 | `types/khkk.ts:58-64` ⟂ `types/purchaseEvaluation.ts:91-97` | thống nhất 1 khuôn badge. Khuyến nghị: PE **theo** KHKK (`rounded-md border`, `text-X-800`, `border-X-300`) vì có viền thì badge không chìm trên `bg-brand-50` lúc chọn. Sửa ở **map**, không sửa tại card. |
| B2 | `KhkkWorkListPanel.tsx:272` | `text-[9px]` → `text-[10px]` |
| B3 | `KhkkWorkListPanel.tsx:205,201-203,196` | tiêu-đề `text-[12px] text-slate-800`→`text-[13px] text-slate-900`; phụ `text-[10px]`→`text-[11px]`; `py-2`→`py-2.5` |
| B4 | `KhkkWorkListPanel.tsx:197,260` | ring chọn `amber-300`/`violet-300` → `bg-brand-50 ring-brand-200` (giữ accent ở đầu tầng `:60`) |
| B5 | `KhkkWorkListPanel.tsx:160,230` | chữ "Đang tải…" → 4 skeleton `h-12 animate-pulse rounded-md bg-slate-100 motion-reduce:animate-none` trong `space-y-2 p-3` |
| B6 | `KhkkWorkListPanel.tsx:173-177,243-248` + `KhkkListPanel.tsx:319-330` | dùng `<EmptyState>` (`@/components/EmptyState`), giữ nguyên CHỮ đang có |
| B7 | `KhkkListPanel.tsx:354-369` | ``/`` → `<ChevronLeft/>`/`<ChevronRight/>` `h-3.5 w-3.5` + `aria-label="Trang trước"/"Trang sau"` (thêm import lucide) |
| B8 | `KhkkListPanel.tsx:147-151` + `KhkkWorkListPanel.tsx:265-269` | chip nhóm: `text-[10px] px-1.5 py-0.5 leading-none` + `title={khkkGroupMenuLabel(n)}` |
| B9 | `KhkkListPanel.tsx:157-159` | thêm `title` giải nghĩa màu tiền (`'Giá trị đã duyệt (đã chốt)'` ⟂ `'Giá trị đề xuất (tạm tính)'`) |
| B10 | `KhkkListPanel.tsx:282-287` | khối thiếu-quyền dùng khuôn `Lock` của `KhkkWorkListPanel:143-151` |
| B11 | `pages/khkk/KhkkListPage.tsx:128-130` | thêm pill **số** cạnh pill "Giai đoạn 2" (2 pill riêng vai) |
### W-C — sàn a11y (FD5, chạm 3 file, thuần class)
- `text-slate-400` mang CHỮ → `text-slate-500` tại: `KhkkListPanel:230,278,283` · `KhkkWorkListPanel:174,244,290` · `PipelineTreePanel:278`. (Đo: `#94a3b8`=**2.56:1** trượt AA · `#64748b`=**4.76:1** đạt.) Giữ `slate-400` cho icon.
- `text-[10px]` của 2 ghi-chú `KhkkListPanel:278,283` → `text-[11px]`.
### W-D — NÂNG chuẩn golive (đừng hạ KHKK xuống)
- Thêm nhánh `isError` + nút "Thử lại" cho `PeListPanel.tsx` (sau `:134`) và `PurchaseEvaluationsListPage.tsx` (sau `:463`) — khuôn `KhkkListPanel:307-318`.
- Thêm `motion-reduce:animate-none` vào skeleton `PeListPanel:131` + `PurchaseEvaluationsListPage:461`.
- Thêm `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60` vào card `PeListPanel:154` (khuôn `KhkkListPanel:117`).
- ⚠️ Đây là file **golive** ⇒ tách commit riêng, và **nói trước với owner** (anh đã từng bác việc tự sửa màn Duyệt NCC — ghi ở `KhkkWorkListPanel.tsx:7-12`).
### W-E — chờ owner gật (KHÔNG tự làm)
- **DELTA-4** đồng bộ từ vựng trạng thái (`khkk.ts:49-55` → "Nháp / Đã gửi duyệt / Cần chỉnh sửa lại"). Chữ hiển thị = quyền owner.
- **B1** chọn chiều badge (PE theo KHKK, hay ngược lại).
### Thứ tự thi công đề-xuất
`W-C` (rẻ nhất, 0 rủi ro) → `W-B` (thuần hình) → `W-A` (có logic, cần soi kỹ) → `W-D` (đụng golive, tách commit) → `W-E` (sau khi owner gật).
---
## 6. Rubric verdict + scope check
_(pending)_
### 6.1 FD2 — KHAI THẲNG: **KHÔNG CÓ SCREENSHOT** trong lượt này
Lượt này là **AUDIT read-only** (không giao sửa code) và cả 3 màn đều nằm sau `ProtectedRoute` + cần API HTTPS :5443 chạy — đúng **gotcha #3** trong ký-ức của tôi (vite proxy `http://` ⟂ API `https://` ⇒ login fail, kẹt `/login`). Thay bằng bằng-chứng ĐO ĐƯỢC: chuỗi class đọc thẳng từ đĩa + `sha256sum` mirror + tự tính tương-phản WCAG. **Không bịa "đã soi".** Khi thi công W-A/W-B thì FD2 loop là BẮT BUỘC (2 viewport 375/1440 + 4 leaf menu), và nên chụp trên **prod sau deploy** thay vì vật lộn dev-rig.
### 6.2 Rubric FD4 chấm HIỆN TRẠNG panel KHKK (không phải chấm bản sẽ sửa)
| Tiêu chí | Verdict | Căn cứ |
|---|---|---|
| Hierarchy | 🟡 | card 3 hàng rõ, nhưng chế-độ cây **không có tiêu-đề/số đếm** (DELTA-1) ⇒ cột cụt đầu |
| Spacing/rhythm | ✅ | `space-y-2 p-3`, `px-3 py-2.5`, `divide-y` khớp golive từng lớp |
| Typography | 🔴 | 4 nấc chữ trong 1 cột (13/11/10/**9**px); C nhỏ hơn chuẩn 1 nấc (DELTA-6, B2) |
| Color/contrast | 🔴 | `slate-400` = **2.56:1** cho 6 dòng chữ mang thông tin (DELTA-15) |
| Polish/states | 🟢 | hơn golive: có `isError`+Thử lại, `focus-visible` ring, `motion-reduce` |
| Responsive | 🟡 | `<lg` panel 1 chiếm cả màn (`KhkkListPage:140`) — ổn; nhưng cây `bodyMaxHClass="max-h-[28rem] lg:max-h-none"` (`:295`) ⇒ mobile cây kẹt 448px trong cột đã cuộn, cuộn lồng nhau (đúng bệnh đã vá ở GĐ3 F-1) — **cần soi bằng mắt khi thi công** |
| Detail | 🔴 | 2 ô tìm chồng nhau (DELTA-2); nút phân trang là ký tự trần (DELTA-12); chip nhóm lệch baseline (DELTA-8) |
**KẾT: CHƯA PASS** — 3 trục đỏ. Không trục nào cần đổi máy; toàn bộ nằm ở lớp trình-bày + 1 công-tắc chế-độ.
### 6.3 Scope check
✅ FE-only, **KHÔNG viết/sửa 1 dòng code nào** trong lượt này (audit thuần) · KHÔNG đụng BE/DB/logic · KHÔNG double-touch file của implementer-frontend · file duy nhất tôi ghi = chính sub-MD này.
---
END sub-frontend-designer-L2 — VERDICT=CHƯA-PASS (13 delta + 3 trục rubric đỏ) · O5=GIỮ-MÁY-R11-SỬA-HÌNH (bỏ `term` khỏi `flatMode` + gộp 2 ô tìm + công-tắc Cây/Danh-sách + chip lọc)

View File

@ -7,29 +7,48 @@
## 0. KHUNG RỖNG (ghi @lượt 1, TRƯỚC khi đọc file) — append-as-I-read
### 0.1 File trong lăng-kính (3 chính)
- [ ] A. `fe-admin/src/pages/pe/PurchaseEvaluationWorkspacePage.tsx` — CHUẨN (host 2-panel "Thao tác" GĐ1)
- [ ] B. `fe-admin/src/pages/khkk/KhkkCreatePage.tsx` — màn "Thao tác" GĐ2, VỪA dựng lại 2-panel trong phiên này
- [ ] C. `fe-admin/src/pages/khkk/KhkkListPage.tsx` — màn "Danh sách" GĐ2 (host 3-panel)
- [ ] (đối chiếu nếu CẦN, ≤1 file) `fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx`
- [x] A. `fe-admin/src/pages/pe/PurchaseEvaluationWorkspacePage.tsx` — CHUẨN (host 2-panel "Thao tác" GĐ1) — đọc HẾT 147 dòng
- [x] B. `fe-admin/src/pages/khkk/KhkkCreatePage.tsx` — màn "Thao tác" GĐ2, vừa dựng lại — đọc HẾT 394 dòng
- [x] C. `fe-admin/src/pages/khkk/KhkkListPage.tsx` — màn "Danh sách" GĐ2 (3-panel) — đọc HẾT 206 dòng
- [x] (đối chiếu) `fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx`**KHÔNG mở toàn file** (807 dòng): chỉ grep có mục tiêu + đọc đúng 24 dòng header/grid (:379-402)
- [x] (phụ, grep-only — không mở toàn file) `components/pe/PeListPanel.tsx` · `components/khkk/KhkkWorkListPanel.tsx` · `components/Layout.tsx` · `components/TopBar.tsx` · `src/index.css`
### 0.2 Trục so sánh (6 trục bắt buộc — điền khi đọc)
- [ ] T1. Header trang: icon · cỡ chữ title · badge · dòng hint bên phải
- [ ] T2. Chiều cao host: `h-[calc(100vh-4rem)]` (hay khác) · overflow · min-h-0
- [ ] T3. Grid cột + breakpoint (lg/xl/2xl) · gap · width panel trái
- [ ] T4. EmptyState (khi chưa chọn / chưa có data): icon, chữ, canh giữa, chiều cao
- [ ] T5. Hành vi `<lg` (mobile): panel nào ẩn/hiện, toggle, thứ tự
- [ ] T6. Cách truyền `readOnly` xuống panel/form
### 0.2 Trục so sánh (6 trục bắt buộc)
- [x] T1. Header trang: icon · cỡ chữ title · badge · dòng hint bên phải → §1/§2/§3, lệch: **badge** (H-3)
- [x] T2. Chiều cao host `h-[calc(100vh-4rem)]` **KHỚP 4/4 file**; sai ~3px là lỗi toàn hệ (§3bis-d) — KHÔNG sửa
- [x] T3. Grid cột + breakpoint **LỆCH** 300/340 vs 260/320 (H-1), số học ở §3bis-b
- [x] T4. EmptyState **KHỚP** khuôn (icon+title+description), KHKK còn thêm nhánh not-found mà PE thiếu
- [x] T5. Hành vi `<lg` **LỆCH NẶNG**: KHKK-Create không có gì ⇒ ngõ cụt (H-2), 3 khuôn khác nhau ở §3bis-e
- [x] T6. Cách truyền `readOnly` **LỆCH**: hằng số (Create) vs công thức host (List) (H-4)
### 0.3 Bảng kết (điền dần)
### 0.3 Bảng kết
| # | Trục | PE (chuẩn) | KHKK Create | KHKK List | Verdict | Sửa |
|---|---|---|---|---|---|---|
| _(chưa đọc)_ | | | | | | |
| T1a | shell + header class | `flex h-[calc(100vh-4rem)] flex-col` + header 1 khuôn | y hệt (:168-169) | y hệt (:123-124) | ✅ KHỚP BYTE | — |
| T1b | icon | `ClipboardCheck h-5 w-5 text-slate-500` | `FileCheck` cùng size/màu | `FileCheck` | ✅ (đổi icon = đúng, theo module) | — |
| T1c | title | `text-base font-semibold tracking-tight text-slate-900`, không truncate | + `min-w-0`+`truncate` | + `min-w-0`+`truncate` | ⚠️ KHKK **tốt hơn** | H-7 giữ |
| T1d | badge | Workspace: KHÔNG · List: pill **số** (:386) | **KHÔNG có** | pill "Giai đoạn 2 · nhãn" (:128) | 🔴 lệch nội-bộ GĐ2 | **H-3** |
| T1e | phải header | hint `text-[12px] text-slate-500` · List: TRỐNG | hint ✅ | **Button** "Lập kế hoạch" | ⚪ có lý do | H-9 giữ |
| T2 | chiều cao | `100vh-4rem` | y hệt | y hệt | ✅ | — |
| T3 | grid | `[260px_1fr]` / xl `[320px_1fr]` | `[300px_1fr]` / xl `[340px_1fr]` | `[400px_1fr_360px]` = PE list | 🔴 Create lệch | **H-1** |
| T4 | EmptyState | icon+title+desc (:102-108) | icon+title+desc (:217-221) | + rẽ `deletedView` (:163-171) | ✅ | — |
| T5 | `<lg` | dead-code (:135-144) | **không có gì** ⇒ không submit được | matchMedia → navigate (:102-109) | 🔴 hỏng chức năng | **H-2** |
| T6 | readOnly | không dùng — `mode="workspace"` | `readOnly` hằng số (:210) | công thức host (:112-117) | 🔴 2 luật cho 1 component | **H-4** |
| — | mirror 2 app | — | SHA256 == fe-user | SHA256 == fe-user | ✅ 4/4 SAME | giữ khi sửa |
### 0.4 Spec thi-hành-được (viết ở §4, sau khi đọc xong)
- [ ] Mỗi lệch = 1 mục: file:line · hiện-trạng · đích · patch cụ thể · rủi-ro
### 0.4 Spec thi-hành-được → §4 (H-1…H-9, mỗi mục: file:line · hiện-trạng · đích · vì · rủi-ro)
- [x] Đã viết. Thứ tự thi hành + danh sách **KHÔNG SỬA** ở cuối §4.
### 0.5 Nhật ký đọc (append theo thứ tự tool-call)
- (trống — sẽ append)
### 0.5 Nhật ký đọc (theo thứ tự tool-call)
1. `wc -l` 4 file + Read **B** `KhkkCreatePage` (394) — song song
2. Read **A** `PurchaseEvaluationWorkspacePage` (147) + Read **C** `KhkkListPage` (206) — song song → APPEND §1/§2/§3
3. Grep header/grid `PurchaseEvaluationsListPage` + Grep root-className 3 panel
4. Grep `KhkkWorkListPanel` (sticky/tier) + Grep `PeListPanel` (sticky create)
5. Grep `PurchaseEvaluationsListPage` cơ-chế `<lg` (overlay `expand=1`) + Bash sha256 mirror + `git status`
6. Bash `grep -c Button` (đối chứng) + Grep `Layout.tsx` (main/topbar)
7. Grep `TopBar.tsx` (h-14) + Bash khảo sát toàn bộ `lg:grid-cols-[` → APPEND §3bis + §4
8. Bash `index.css` tokens + probe cổng + Read `PurchaseEvaluationsListPage:379-402`
9. Bash đếm stop `violet-*` toàn repo → **sửa lại H-8** (bác giả-thuyết từ MEMORY) + APPEND §5
---
@ -214,4 +233,30 @@ Sau mỗi nhóm: `cd fe-admin && npm run build` + `cd fe-user && npm run build`
- Grid 3-panel `KhkkListPage:140`**đã khớp PE byte-for-byte**, không đụng.
## 5. TỰ ĐÁNH GIÁ + GIỚI HẠN
_(chưa viết)_
### 5.1 🔴 KHÔNG CÓ SCREENSHOT — khai thẳng, không bịa (khuôn NO-SCREENSHOT S159)
Vòng FD2 (run → shoot → critique) **không chạy được** trong lượt này:
- Probe cổng: `netstat`**chỉ `0.0.0.0:8080` LISTENING** (pid 5912). **`5443`/`5444` (API) KHÔNG có** ⇒ đăng nhập fail.
- `/khkk/create``/khkk/list` đều sau `ProtectedRoute` ⇒ không token thì kẹt `/login` (gotcha #3 đã ghi trong MEMORY: dev-rig chặn authed page).
- Dựng harness render-isolated **cố ý KHÔNG làm**: mandate ISOLATION của run này chỉ cho ghi `sub-frontend-designer-L3.md`; harness = file mới trong `fe-user/`/`fe-admin/` ⇒ vi phạm git-diff audit.
⇒ Mọi kết luận dưới đây là **đo tĩnh trên mã nguồn + số học layout**, KHÔNG phải quan sát bằng mắt. Ba mục cần **nghiệm thu bằng mắt trên prod sau khi sửa**: H-1 (panel-1 260px có wrap dòng PE không), H-2 (`<lg` sau khi mở `<main>`), H-3 (badge + title không tràn ở 1366).
### 5.2 Độ chắc của từng kết luận
| Mục | Loại bằng chứng | Chắc |
|---|---|---|
| H-1 grid | so chuỗi class + khảo sát 6 host + số học px | **CAO** — đọc được từ mã, không cần chạy |
| H-2 `<lg` | `hidden … lg:block` + vị trí nút submit trong DOM | **CAO** về mặt suy luận DOM · **CHƯA nhìn bằng mắt** |
| H-3 badge | so 2 file cùng module | CAO |
| H-4 readOnly | hằng số vs công thức host | CAO (còn phụ thuộc **ý owner**, đã nêu 2 nhánh) |
| H-5 comment sai | `grep -c Button` = 0 (đối chứng dương PeListPanel = 8) | **CAO** |
| H-6 glyph | 3 vị trí literal | CAO |
| H-7 truncate | so 4 file | CAO |
| H-8 violet | đo `index.css` + đếm toàn repo — **bác giả-thuyết ban đầu của chính tao** | CAO |
### 5.3 Ngoài lăng-kính này (lane khác lo)
Nội dung *bên trong* panel (`KhkkWorkListPanel` 2 tầng, `KhkkDetailContent`, `KhkkListPanel` cây toàn-trình), micro-copy, trạng thái loading/empty của từng tầng, a11y focus-order, typography scale — **KHÔNG chấm ở lane HOST/BỐ-CỤC**. Tao chỉ chạm chúng ở mức "root className có khớp hợp-đồng grid của host không".
### 5.4 Đã KHÔNG làm (giữ lane)
Không sửa 1 dòng code nào (lane audit). Không đụng `agent-memory/*`, không đụng MD canonical, không đụng sub-MD lane khác. `git status --porcelain -- fe-admin/src fe-user/src` đo được là **rỗng** trước khi tao bắt đầu và tao không ghi gì vào đó.
END sub-frontend-designer-L3 — VERDICT=KHỚP-MỘT-PHẦN: 4 trục khớp chuẩn (shell/header-class/panel-2/EmptyState), 2 lệch nặng (grid 300→260 · `<lg` ngõ cụt không tạo được phiếu), 3 lệch vừa (badge GĐ2 · readOnly ép cứng · comment sai sự thật), 2 minor (glyph thay icon · truncate — giữ vì tốt hơn chuẩn), 3 mục CHỐT-KHÔNG-SỬA (h-calc toàn hệ · violet 2-hệ toàn hệ · nút tạo ở header List). KHÔNG có screenshot — đã khai lý do đo được.

View File

@ -9,15 +9,24 @@
## 0. Khung rỗng (ghi TRƯỚC khi đọc) — checklist tự-ràng-buộc
- [ ] A. Inventory 3 file: LOC, export, prop-surface, ai gọi ai.
- [ ] B. Host-map: liệt ĐỦ 6 host của `PipelineTreePanel` (file:line + prop truyền).
- [ ] 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.
- [ ] D. (O2) Đề xuất đổi sang "0": đụng gì (state/type/style), host nào đổi hành-vi.
- [ ] E. (O6) Truy `navigate(` + `onOpenKhkk` + mọi điều-hướng trong 3 file → đường đang đi.
- [ ] F. (O6) Đề xuất callback-hoá mirror `stage2Options.onOpenKhkk`, ×6 host.
- [ ] G. Style/UX delta KHKK (GĐ2) vs Duyệt NCC (GĐ1 golive = CHUẨN) trong lăng-kính cây.
- [ ] H. Spec thi-hành-được (diff-level) cho Opus 5 MAX.
- [ ] I. Rủi-ro / cái KHÔNG được đụng.
- [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** |
---
@ -51,13 +60,106 @@
⇒ **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.
## D. (O2) Đề xuất "0"
## C. (O2) Truy chữ "SẮP" — sinh ở ĐÂU, bật khi NÀO ✅
_(chưa đọc)_
**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`; 2 file component **0 hit** `navigate(`. 6 call-site:
`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á? |
|---|---|---|---|---|
@ -72,18 +174,167 @@ _(chưa đọc)_
⚠️ **Đố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á
## F. (O6) Đề xuất callback-hoá — mirror ĐÚNG khuôn `onOpenKhkk` (M-2) ✅
_(chưa đọc)_
### F1. Patch tối thiểu, 3 file + 2 host
## G. Style/UX delta GĐ2 vs GĐ1
**(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`),
```
_(chưa đọc)_
**(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', …)}
```
## H. Spec thi-hành-được
**(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).
_(chưa đọc)_
**(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.
## I. Rủi-ro
**(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**.
_(chưa đọc)_
### 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)

View File

@ -5,27 +5,20 @@
> Chuẩn (golive): `fe-admin/src/components/pe/PeDetailTabs.tsx`
> Trần lượt: 25 (≈4 khung / ≈14 đọc / ≈7 chốt)
## 0. KHUNG RỖNG (ghi @lượt-1, TRƯỚC khi đọc file) — append-as-I-go
## 0. KHUNG RỖNG (ghi @lượt-1, TRƯỚC khi đọc file) — append-as-I-go → **TICK @lượt-19**
- [ ] §1. Bản đồ Section chuẩn (PE) — đánh số / icon-chip / accent / count / actions
- [ ] §2. Bản đồ Section đối tượng (KHKK)
- [ ] §3. Diff khuôn Section (bảng đối chiếu)
- [ ] §4. Diff bảng (table) — header, zebra, align số, sticky, overflow
- [ ] §5. Diff Field / label / value / mono / empty-state
- [ ] §6. Diff khoảng cách (spacing rhythm, gap, padding, divider)
- [ ] §7. O3 — Section 2 "Giá đề xuất theo NCC trúng thầu": hạng mục 1→N + SUM + TỔNG GIÁ DUYỆT phía trên
- [ ] 7.1 Trạng thái hiện tại FE
- [ ] 7.2 Trạng thái hiện tại BE (entity / EF config / handler / DTO / validator)
- [ ] 7.3 GREP consumer đầy đủ (write-site + read-site)
- [ ] 7.4 Phương án cardinality (bảng nối vs cột JSON vs N-row)
- [ ] 7.5 Migration cần gì · rủi ro data cũ · thứ tự land
- [ ] 7.6 Spec UI (chọn nhiều + SUM + tổng phía trên)
- [ ] §8. O4 — Section 3 "Căn cứ hồ sơ (mẫu vật liệu / shopdrawing)" cho đính kèm file
- [ ] 8.1 Section 4 "File đính kèm" đang upload thế nào (endpoint, purpose enum)
- [ ] 8.2 Cách TÁI DỤNG (không dựng đường mới)
- [ ] 8.3 Spec UI inline-attach
- [ ] §9. Backlog style-fix ưu tiên (P0/P1/P2) — thi-hành-được cho Opus 5 MAX
- [ ] §10. Rủi ro / giới hạn / cái KHÔNG kiểm được trong lượt này
- [x] §1+§2+§3. Bản đồ + diff khuôn Section — **PE `Section` chỉ 2 prop, KHKK 8 prop (KHKK GIÀU HƠN chuẩn)**
- [x] §4. Diff bảng — 7 trục, 5 lệch
- [x] §5. Diff Field / empty-state — 4 token rỗng khác nhau giữa 2 màn
- [x] §6. Diff khoảng cách — KHKK mất bậc responsive
- [x] §7. O3 — **KHÔNG CẦN MIGRATION** (UNIQUE 3 cột Mig 71 đã cho N dòng/NCC)
- [x] 7.1 FE · [x] 7.2 BE 13 tầng · [x] 7.3 grep consumer đủ · [x] 7.4 6 rủi ro
- [x] 7.5 thứ tự land · [x] 7.6 spec UI · [x] **7.7 bridge K7 khoá lại chỗ mơ hồ**
- [x] §8. O4 — **FE-ONLY, 0 BE, 0 migration** (`dossierItemId` đã land đủ 6 tầng)
- [x] §9. Backlog 6 P0 + 8 P1 + 3 P2
- [x] §10. Giới hạn — KHÔNG có screenshot (khai thẳng)
> 📌 **Lệch tên file trong đề bài:** khối TASK (🔴 lượt 1-2) chỉ định `sub-frontend-designer-**L5**.md`; khối RUN-TRACE ở cuối lại ghi `sub-frontend-designer-**4**.md`. Tao theo lệnh 🔴 (cụ thể hơn + là lệnh chặn lượt 1) và ghi **đúng 1 file** `…-L5.md` — không tạo file thứ 2 (isolation B6). Em main muốn tên kia thì **đổi tên**, đừng để 2 bản.
---
@ -85,3 +78,165 @@ function Section({ title, children }: { title: string; children: React.ReactNode
- Ô bảng: PE `px-2 py-1.5` (dày đặc, ERP) · KHKK `px-3 py-2` (thoáng hơn) → LỆCH, nhưng KHKK **dễ đọc hơn**; giữ KHKK, ghi rõ là chủ đích.
- 🔴 Bảng KHKK **không có `hover:bg-*` hàng** (PE cũng không ở table, nhưng có ở nút) — thiếu affordance khi bảng có hành động inline (S2 select, S3 sửa/xóa). FD4 "states".
---
## §7 — O3: Section 2 hạng mục 1→N + SUM + TỔNG GIÁ DUYỆT phía trên
### 7.1 Hiện trạng FE
`KhkkDetailContent.tsx:397-430` — 1 `<select>` SINGLE trên mỗi dòng, `value={l.catalogEntryId ?? ''}`, `onChange``assignLine.mutate({lineId, catalogEntryId})` (`:175-180`) → `PUT /contract-signing-plans/{id}/lines/{lineId}` body `{catalogEntryId}`.
**KHÔNG có tổng ở đâu cả** — bảng `:375-438` không `<tfoot>`, section-head `:357` chỉ đếm dòng. Đọc-site khác: `KhkkDetailPage.tsx:205` (đặt tên HĐ) + `:312` (list dòng ở panel khác).
### 7.2 Hiện trạng BE (grep toàn repo, không mở thêm file)
| Tầng | file:line | shape |
|---|---|---|
| Entity | `ContractSigningPlanLine.cs:23,27` | `Guid? CatalogEntryId` + `string? TenHangMuc` (denorm 500) |
| EF cfg | `ContractSigningPlanLineConfiguration.cs:37` | **UNIQUE 3 cột `(PlanId, SupplierId, CatalogEntryId)` filtered `WHERE IsDeleted=0`**; `:35` CỐ Ý **không** loại `NULL` ⇒ 2 dòng cùng (Plan,NCC) đều null = VI PHẠM |
| EF cfg | `…Configuration.cs:41` | `HasIndex(CatalogEntryId)`**không FK vật lý** |
| Auto-gen | `ContractSigningPlanFeatures.cs:523-524` | sinh dòng mồi `CatalogEntryId=null, TenHangMuc=null` (1 dòng / NCC trúng thầu của PE) |
| Command | `Features.cs:620-623` | `UpdateContractSigningPlanLineCommand(PlanId, LineId, **Guid CatalogEntryId**)` — SINGLE, `NotEmpty()` `:632` |
| Handler | `Features.cs:649-669` | `:654` chặn khi `line.ContractId != null` · `:662` chặn entry `!IsActive ‖ ApprovalGroup ≠ plan.ApprovalGroup` · `:666-669` ghi `CatalogEntryId` + denorm `TenHangMuc` · `:678-687` changelog `EntityId = line.Id` |
| DTO | `Features.cs:87-90` (`…LineDto`) + `:840` map | `Guid? CatalogEntryId, string? TenHangMuc` |
| Controller | `ContractSigningPlansController.cs:126` + `:313` | `UpdateSigningPlanLineBody(Guid CatalogEntryId)` |
| Submit-guard | `ContractSigningPlanWorkflowService.cs:176-187` | mọi dòng phải có hạng mục · `:182-185` **đếm-so-khớp** `okCount != catalogIds.Count` (chống hạng mục xoá-mềm/sai nhóm) |
| Bridge K7 | `CreateContractFromSigningPlanFeatures.cs:121,175-181` | `l.TenHangMuc ?? "(chưa gán hạng mục)"` · join tên hạng mục → **đặt TÊN HĐ** |
| Test | `ContractSigningPlanSchemaTests.cs:196-297` | 3 vế: filtered-unique · chặn 2 dòng sống trùng 3 cột · **`:291-297` CHO PHÉP cùng (Plan,NCC) KHÁC CatalogEntryId — "mục đích của Mig 71"** |
| Test | `ContractSigningPlanGroupAndCatalogTests.cs:937-966,1020-1021` | `Lines.Single()` — assert gán + snapshot đổi theo lần gán CUỐI |
| Test | `ContractSigningPlanApprovalTests.cs:240-241,274` | seed line có hạng mục để qua guard |
| FE type | `fe-admin/src/types/khkk.ts:210-225` (mirror fe-user) | `peReferenceAmount: number` · `proposedAmount: number` · `approvedAmount: number \| null` · `contractId: string \| null` |
### 7.3 🟢 KẾT LUẬN CARDINALITY — **MÁY ĐÃ RỘNG SẴN, KHÔNG CẦN MIGRATION**
UNIQUE của Mig 71 là **3 cột** `(PlanId, SupplierId, CatalogEntryId)` và test `SchemaTests.cs:291-297` **chứng minh bằng phép đo** rằng "cùng (Plan, NCC) khác CatalogEntryId" **được phép**. ⇒ *N hạng mục cho 1 NCC = **N DÒNG**, DB chịu ngay hôm nay.* Khớp đúng nguyên tắc owner `feedback_may_rong_ui_hep.md` (máy rộng — UI hẹp).
**Phương án chốt = A (N-ROW), KHÔNG bảng nối, KHÔNG cột mới, KHÔNG migration.**
- ❌ Bác **B (bảng nối `…LineCatalogEntries`)**: giết ý nghĩa UNIQUE Mig 71 (cột `CatalogEntryId` thành cột chết), phải viết lại 3 test schema, đổi submit-guard + DTO + bridge K7 — đắt gấp bội mà **không mua thêm khả năng nào**, vì giá tiền vốn đã nằm ở DÒNG.
- ❌ Bác **C (cột JSON/CSV nhiều id)**: mất index, mất đếm-so-khớp `:182-185`, mất changelog per-hạng-mục.
### 7.4 🔴 6 rủi ro PHẢI xử khi tách 1 dòng → N dòng (đây mới là phần dễ vỡ)
1. **`peReferenceAmount` NHÂN BẢN ⇒ tổng tham chiếu phồng ×N.** Nó là *snapshot tổng báo giá của NCC trên phiếu PE* (`KhkkDetailContent.tsx:442` nói rõ) — thuộc về **NCC**, không thuộc **hạng mục**. Tách 3 dòng mà chép cùng số ⇒ mọi `SUM(peReferenceAmount)` sai 300%. → **Luật:** dòng thứ 2+ của cùng NCC ghi `PeReferenceAmount = 0`; FE hiển thị merge (chỉ dòng đầu của nhóm NCC in số, dòng sau in `—`); mọi phép tổng tham chiếu phải `DISTINCT theo supplierId`. *Chưa có SUM nào tồn tại hôm nay (grep `peReferenceAmount` = 4 hit, toàn hiển thị) ⇒ vá TRƯỚC khi ai đó viết SUM.*
2. **Dòng mồi `CatalogEntryId=null` phải bị TIÊU THỤ, không bỏ lại.** Bulk-assign phải **dùng lại** dòng null làm dòng đầu; để lại ⇒ `WorkflowService.cs:176` chặn trình duyệt VĨNH VIỄN (đúng lớp lỗi K8-blocker `feedback_dryrun_finds_promised_but_unlanded.md`).
3. **UNIQUE cấm 2 dòng null cùng NCC** (`Configuration.cs:29-35`) ⇒ **không được** tạo trước N dòng rỗng rồi gán sau. Thứ tự bắt buộc: *insert kèm CatalogEntryId ngay*.
4. **`ContractId` per-dòng (bridge K7).** Khi 1 NCC có 3 dòng vào **cùng 1 HĐ**, cả 3 phải được đóng dấu cùng `ContractId`, nếu không dòng sót sẽ trông như "chưa lên HĐ" và `:654` sẽ cho sửa hạng mục của HĐ đã ký. → hàm bridge phải stamp **theo nhóm NCC**, không theo dòng lẻ. **Phải đọc lại `CreateContractFromSigningPlanFeatures.cs` khi land** (ngoài hạn-mức file lượt này — khai thẳng, không đoán).
5. **Xoá hạng mục khỏi multi-select = soft-delete dòng.** Chặn nếu `ContractId != null` (đối xứng `:654`). Soft-delete + filtered-unique cho phép chọn lại đúng hạng mục đó sau (`SchemaTests.cs:196-233` đã chứng).
6. **Test `.Single()`** (`GroupAndCatalogTests.cs:937,966,1020`) vẫn xanh (ca 1-dòng), nhưng **phải thêm ca N-dòng** — nếu không, cardinality mới không có chứng nhân nào.
### 7.5 Thứ tự LAND (BE trước, FE sau — không đảo)
1. **BE-1** `SetContractSigningPlanSupplierCatalogsCommand(PlanId, SupplierId, IReadOnlyList<Guid> CatalogEntryIds)`**diff-sync 1 phát, nguyên tử**: giữ dòng trùng · insert dòng thiếu (`PeReferenceAmount=0` nếu không phải dòng đầu) · soft-delete dòng thừa (chặn `ContractId != null`) · tái dụng dòng `CatalogEntryId=null` làm dòng đầu · validate từng entry `IsActive && ApprovalGroup == plan.ApprovalGroup` (bê nguyên `:662`) · 1 changelog `Summary = "Gán N hạng mục cho NCC X: a, b, c"`. **GIỮ NGUYÊN** `UpdateContractSigningPlanLineCommand` cũ (dòng đơn) — không xoá, tránh vỡ K8/route đang chạy.
2. **BE-2** route `PUT /contract-signing-plans/{id}/suppliers/{supplierId}/catalog-entries` body `{ catalogEntryIds: Guid[] }`.
3. **BE-3** test-before cho ca N (repro: 1 NCC 3 hạng mục → 3 dòng · bỏ 1 → 2 dòng sống · dòng có ContractId → 409) + ca `PeReferenceAmount` không nhân bản.
4. **FE-1** `types/khkk.ts` **không đổi shape** (`KhkkLineDto` giữ nguyên) — chỉ thêm helper `groupLinesBySupplier(lines)`. Mirror 2 app.
5. **FE-2** UI §7.6. 6. **FE-3** `npm run build` ×2 app + SHA256 pair `fe-admin`/`fe-user`.
> ⚠️ **Ambiguity phải hỏi owner TRƯỚC BE-1, không đoán:** "giá duyệt là tổng" = *(i)* mỗi hạng mục có `approvedAmount` riêng, tổng = SUM các dòng **hay** *(ii)* 1 giá cho cả NCC, hạng mục chỉ là nhãn? Model A phục vụ (i) tự nhiên; nếu owner ý (ii) thì dòng 2+ phải `approvedAmount=null` và tổng lấy theo NCC. **Câu chữ owner nghiêng về (i)** ("sum tổng") nhưng đây là tiền — không suy diễn.
### 7.6 Spec UI (đúng khuôn PE golive)
**(a) TỔNG GIÁ DUYỆT — khối nổi bật ĐẶT TRÊN bảng**, bê thẳng khuôn PE `PeDetailTabs.tsx:1995-2019`:
```
<div className="overflow-hidden rounded-lg border border-slate-300">
<div className="bg-[#1F7DC1] px-3 py-2 text-[12px] font-bold uppercase tracking-wide text-white">
Tổng giá duyệt
</div>
<div className="p-3">
<div className="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2.5">
→ số lớn: text-[18px] font-semibold text-emerald-900 tabular-nums (SUM approvedAmount ≠ null)
→ dòng phụ text-[12px] text-slate-500: "N/M dòng đã có giá duyệt · Đề xuất: <SUM proposedAmount> · Tham chiếu: <SUM DISTINCT theo NCC>"
→ khi tất cả null: <span className="text-slate-400">— (chưa có giá duyệt)</span> ← khuôn PE :2015
→ khi CHỈ MỘT PHẦN có giá: thêm <div className="text-[11px] italic text-amber-700">…</div> ← khuôn PE :1336
</div>
</div>
</div>
```
Đặt **ngay dưới `<Section title="2. …">` header, trên `overflow-x-auto`** (`KhkkDetailContent.tsx:371`). Badge `budgetFrozen` (`:364-368`) chuyển vào khối này thay vì `actions` — 🔒 thuộc về CON SỐ, không thuộc về tiêu đề. **Bỏ emoji 🔒**`<Lock className="h-3 w-3"/>` lucide (FD3 cấm emoji thay icon; PE cũng đang phạm ở `:388,412` nhưng đó là nợ PE, không nhân bản sang KHKK).
**(b) `<tfoot>` echo** trong bảng: `<tr className="border-t-2 border-slate-300 bg-slate-50 font-semibold">` — nhãn "TỔNG" colSpan tới cột Giá đề xuất, rồi 2 ô `tabular-nums`. Số phía trên = focal point, tfoot = xác nhận tại chỗ (không mâu thuẫn FD4 vì khác cấp nhấn).
**(c) Gộp dòng theo NCC:** hàng đầu của mỗi NCC in tên + `rowSpan` cột NCC/Giá tham chiếu; hàng sau để trống → mắt thấy ngay "1 NCC nhiều hạng mục". Kèm 1 hàng nhóm-tổng nhỏ `bg-slate-50/60 text-[11px]` "Cộng NCC X: …" khi nhóm ≥2 dòng.
**(d) Multi-select hạng mục:** KHÔNG dùng `<select multiple>` (xấu, khó dùng trên cảm ứng, không hiện nhãn dài). Dùng **popover checkbox-list + chip**: nút `<Button variant="outline" size="sm">` in "N hạng mục" → popover danh sách checkbox `catalogOptions.data` (search khi >12 mục) → đóng popover mới `mutate` 1 lần (nguyên tử, khớp BE-1). Chip hiển thị dạng `<span className="inline-flex items-center rounded-md border border-teal-300 bg-teal-100 px-2 py-0.5 text-[11px] font-medium text-teal-800">` + `×` khi sửa được. Chip của dòng đã lên HĐ → không có `×`, `title="Dòng đã vào hợp đồng — hạng mục bất biến"` (khớp rào BE `:654`).
**(e) Giữ nguyên** 2 nhánh lỗi/rỗng đang tốt (`:417-427`): `catalogOptions.isError` → thử lại; nhóm N chưa có hạng mục → cảnh báo amber. **Cả hai phải được port sang popover**, đừng rơi mất khi đổi control.
**(f) a11y:** popover = `role="dialog"` + `aria-label="Chọn hạng mục cho NCC {tên}"`, đóng bằng `Esc`, focus-trap, nút mở `aria-expanded`; checkbox có `<label>` thật (FD5). Tap target ≥44px cho hàng checkbox.
### 7.7 🔴 ĐO THÊM — bridge K7 GIẢI QUYẾT LUÔN chỗ mơ hồ ở 7.5 (đo bằng grep-có-ngữ-cảnh, không mở file)
`CreateContractFromSigningPlanFeatures.cs`:
- `:238` `foreach (var l in lines) l.ContractId = contract.Id;` — hạt là **DÒNG**, tập `LineIds` do **client gửi lên** (`:20-22` khai rõ "1 call = 1 HĐ cho ĐÚNG tập LineIds").
- `:125-127` **mọi dòng gửi lên phải có `ApprovedAmount != null`**, không thì `ConflictException`.
- `:128-129` dòng đã có `ContractId``ConflictException`.
**Hệ quả bắt buộc, không tranh cãi được:**
1.**Cách đọc (ii) ("1 giá cho cả NCC, dòng 2+ để null") LÀM VỠ BRIDGE K7 NGAY** — dòng null giá không bao giờ vào HĐ được. ⇒ **Model A phải đi với cách đọc (i): MỖI hạng mục có `approvedAmount` riêng, tổng = SUM.** Đây khớp luôn câu owner "*sum tổng*". → Câu hỏi ở 7.5 **hạ từ "chặn" xuống "xác nhận 1 dòng với owner"**, không còn là rủi ro mở.
2. ⇒ Rủi ro #4 (§7.4) **cụ thể hoá**: dialog tạo HĐ phải chọn **theo NHÓM NCC** (tick 1 NCC = gửi TẤT CẢ `LineIds` của NCC đó). Nếu để user tick từng dòng, họ sẽ tạo HĐ cho 1 trong 3 hạng mục rồi 2 hạng mục kia mồ côi — **và `:128` khiến không thể gộp lại sau**. Đây là bẫy dữ-liệu-không-hoàn-tác ⇒ **P0 phải làm CÙNG lượt tách N-dòng, không để wave sau.**
3. `PeReferenceAmount = 0` cho dòng 2+ (§7.4 #1) **an toàn với bridge** — bridge không đọc field này (không xuất hiện trong vùng grep `:120-240`).
---
## §8 — O4: Section 3 "Căn cứ hồ sơ" cho ĐÍNH KÈM FILE
### 8.1 Section 4 đang upload thế nào
`KhkkDetailContent.tsx:206-217``FormData{ file, purpose }``POST /contract-signing-plans/{id}/attachments`, header `multipart/form-data`. `purpose` cứng `KhkkAttachmentPurpose.DossierScan = 1` (`:210` + `types/khkk.ts:103-110`). Tải xuống `:225-239` (`/attachments/{attId}/download`, blob + `createObjectURL`). Xoá `:219-223`. Lọc hiển thị `:256` — loại `ApprovalAttachment(3)` vì file đó thuộc panel 3.
### 8.2 🟢 ĐƯỜNG ĐÃ CÓ SẴN — **O4 = FE-ONLY, 0 migration, 0 dòng BE**
Chuỗi `dossierItemId` **land đủ 6 tầng** (grep toàn repo, không phải comment suông — đã tự kiểm theo bài học K8):
| Tầng | file:line | bằng chứng |
|---|---|---|
| Entity | `ContractSigningPlanAttachment.cs:28` | `public Guid? ContractSigningPlanDossierItemId` |
| Mig **69 (đã chạy)** | `20260729122015_AddContractSigningPlans.cs:85` + `:252-254` | cột + index `IX_…_ContractSigningPlanDossierItemId` |
| EF cfg | `ContractSigningPlanAttachmentConfiguration.cs:11,31` | index, **cố ý không FK** (né multiple-cascade) |
| Command | `Features.cs:1279` `Guid? DossierItemId` · `:1334-1337` | **tự kiểm căn cứ thuộc ĐÚNG phiếu** (vì không có FK) · `:1354` gán |
| Controller | `ContractSigningPlansController.cs:181,191` | `[FromForm] Guid? dossierItemId = null` truyền thẳng |
| DTO ra | `Features.cs:106, 854, 1379` → FE `types/khkk.ts:241` | `dossierItemId: string \| null` |
**FE chỉ đơn giản là chưa bao giờ GỬI và chưa bao giờ ĐỌC field này.** Không dựng đường mới.
### 8.3 Spec UI (tái dụng 100%)
1. **Thêm cột "File" vào bảng S3** (`:469-477`), đặt trước cột Thao tác. Nội dung/ô = danh sách file của căn cứ đó: `plan.attachments.filter(a => a.dossierItemId === d.id)` → mỗi file 1 chip `<button>` mở tải xuống (dùng lại hàm `download` `:225`, **không viết hàm mới**) + `×` xoá (dùng lại `deleteAttachment` `:219`). Rỗng → `<span className="text-slate-300">—</span>`.
2. **Nút đính kèm per-dòng:** `<label>` ẩn `<input type="file" multiple>` giống `:538-542` (thu nhỏ về icon `<Paperclip className="h-3.5 w-3.5"/>` + `sr-only` text), gọi mutation **mới nhưng cùng endpoint**:
`fd.append('file', f); fd.append('purpose', String(KhkkAttachmentPurpose.DossierScan)); fd.append('dossierItemId', d.id)`.
→ gộp thành `uploadTo(dossierItemId: string | null)`**cho Section 4 dùng lại với `null`** — 1 mutation, 2 chỗ gọi (chống trôi 2 nhánh).
3. **Chống đếm 2 lần:** đổi bộ lọc `:256` thành
`plan.attachments.filter(a => a.purpose !== ApprovalAttachment && a.dossierItemId == null)`
⇒ file của căn cứ **chỉ hiện ở mục 3**. Thêm chú thích dưới S4: `"N file scan căn cứ đang hiển thị ở mục 3."` khi có. **Nếu bỏ qua bước này, số đếm `count` của S4 sẽ nói dối.**
4. **Đính kèm ngay trong Dialog thêm/sửa căn cứ** (`:576-625`): chỉ bật khi `itemForm.id` đã tồn tại (upload cần id) — với căn cứ MỚI hiện dòng chữ `text-[11px] text-slate-500` "Lưu căn cứ trước rồi đính kèm file." Không tự chế cơ chế upload-trước-lưu.
5. **Trạng thái:** `upload.isPending` → nút hiện "Đang tải…" + `disabled` (khuôn `:540`); lỗi đã có `getErrorMessage` toast. Không thêm spinner mới.
6. **Ràng quyền — ĐÃ ĐO, không đoán:** S4 dùng `readOnly`, S3 dùng `isDraft`. Grep `EnsureDraftEditable` trong `ContractSigningPlanFeatures.cs` chỉ ra **3 hit: `:283` (định nghĩa) · `:587` (sửa nháp) · `:647` (gán hạng mục)****KHÔNG có ở handler upload (`:1310-1360`)**. ⇒ BE **cho phép** đính kèm sau khi phiếu đã trình. Vậy **đính kèm căn cứ theo `readOnly` (giống S4), KHÔNG theo `isDraft`** là hợp lệ và đúng nghiệp vụ (TVGS trả kết quả muộn). *Caveat: đây là suy từ vắng-mặt hit — người land phải mở đúng vùng `:1310-1360` xác nhận 1 lần trước khi mở nút (bài học "vắng-mặt trông giống ổn").*
7. **KHÔNG đổi `KhkkAttachmentPurpose`.** Cám dỗ là thêm `Shopdrawing`/`MauVatLieu` vào enum — sai: loại đã nằm ở `DossierItemKind` (`types/khkk.ts:67-78`) của chính căn cứ mà file treo vào. Thêm enum = 2 nguồn sự thật cho cùng 1 sự việc.
---
## §9 — Backlog thi-hành-được (P0 → P2)
**Điều kiện chung mọi item FE:** file `KhkkDetailContent.tsx` + `types/khkk.ts`**cặp MIRROR SHA256** — đo lúc audit: `fa3035496cda0662…` (2 app khớp) · `aa4103de6362e67e…` (2 app khớp). ⇒ mọi sửa phải land **cả 2 app** rồi `sha256sum` lại thành cặp; build `npm run build` ×2.
### P0 — owner-item, đụng dữ-liệu (làm cùng lượt, không tách)
| # | Việc | Chạm | Đo xong = |
|---|---|---|---|
| P0-1 | BE `SetContractSigningPlanSupplierCatalogsCommand` diff-sync N hạng mục/NCC (§7.5-1) + route (§7.5-2) | `ContractSigningPlanFeatures.cs`, `ContractSigningPlansController.cs` | 3 test-before ca N xanh (§7.5-3) |
| P0-2 | `PeReferenceAmount = 0` cho dòng 2+ cùng NCC (§7.4 #1) | cùng handler | test: 1 NCC 3 hạng mục ⇒ `SUM(PeReferenceAmount)` = số PE gốc, không ×3 |
| P0-3 | Dialog tạo HĐ chọn **theo nhóm NCC** (§7.7 #2) — chống hạng mục mồ côi không hoàn tác được | FE dialog K7 | tick 1 NCC ⇒ payload chứa ĐỦ `LineIds` của NCC đó |
| P0-4 | FE multi-select popover + chip (§7.6-d,e,f) | `KhkkDetailContent.tsx:397-430` ×2 app | 2 nhánh lỗi/rỗng `:417-427` còn nguyên trong popover |
| P0-5 | Khối "TỔNG GIÁ DUYỆT" trên bảng (§7.6-a) + `<tfoot>` (§7.6-b) + gộp dòng theo NCC (§7.6-c) | `:355-445` ×2 app | có ca all-null + ca một-phần-null hiển thị đúng |
| P0-6 | O4: đính kèm file per-căn-cứ (§8.3-1,2,4,5,6) + **sửa bộ lọc `:256` thêm `&& a.dossierItemId == null`** (§8.3-3) | `:206-217, :256, :464-525, :576-625` ×2 app | count S4 không đếm 2 lần |
### P1 — style lệch có hại (rẻ, làm ngay sau P0)
| # | Việc | file:line | Vì sao không phải "cho đẹp" |
|---|---|---|---|
| P1-1 | Section padding `px-5 py-4`**`px-3 py-3 sm:px-5 sm:py-4`** | `KhkkDetailContent.tsx:74` | Mất bậc responsive PE cố ý thêm cho laptop nhỏ (`PeDetailTabs.tsx:515-516`). Panel giữa của layout 3-panel còn hẹp hơn PE ⇒ hại gấp đôi |
| P1-2 | Thống nhất **1 kiểu count**: bỏ `(${plan.lines.length} dòng)` khỏi title `:357`, dùng prop `count` như S3/S4 | `:357` | Cùng 1 màn 2 quy ước đếm = người đọc phải học 2 lần |
| P1-3 | Bảng: thêm `table-fixed` + width từng cột + `min-w-[880px]` trong `overflow-x-auto` | `:374-384`, `:467-477` | Không có width ⇒ tên NCC dài đẩy cột tiền co lại; số tiền nhảy cột giữa 2 phiếu = mất niềm tin vào con số |
| P1-4 | Ô rỗng trong bảng: `'—'` trần → `<span className="text-slate-300">—</span>` | `:434`, `:490`, `:493` | Em-dash đang **cùng màu chữ thật** ⇒ đọc nhầm là dữ liệu (PE làm đúng ở `:1415,2761,2769`) |
| P1-5 | Empty-block: `text-muted-foreground``text-sm italic text-slate-400` (khớp PE `:3380,3533`) | `:372`, `:465`, `:547` | token thứ 4 không tồn tại trong PE ⇒ 2 màn cạnh nhau khác sắc độ |
| P1-6 | Bỏ emoji `🔒``<Lock className="h-3 w-3"/>` lucide | `:366` | FD3 cấm emoji thay icon; nền amber + emoji = chỗ duy nhất trên phiếu dùng emoji |
| P1-7 | Font-size trong 1 hàng bảng: chọn **`text-xs` cho cả hàng** (ERP dense, khớp PE `:2719`) thay vì trộn `text-sm`/`text-xs` | `:389-434`, `:482-493` | baseline gãy giữa các ô cùng hàng |
| P1-8 | Thêm `hover:bg-slate-50/60` cho `<tr>` ở 2 bảng có thao tác inline | `:388`, `:481` | FD4 "states" — bảng bấm được mà không phản hồi hover |
### P2 — nợ khung, cần quyết định (KHÔNG tự làm)
- **P2-1 `Field` tô brand-600/brand-800 cho MỌI value** (`:110-111`) → PE giữ slate, chỉ tô chỗ có nghĩa. Sửa = đổi diện mạo cả phiếu ⇒ cần owner nhìn 1 lần. Đề xuất: label giữ `text-brand-600`, **value → `text-slate-900`**, chừa màu cho winner/cảnh báo.
- **P2-2 `label-eyebrow` (brand-600) trong `<th>` trên nền `bg-slate-50/70`** — tương phản header/thân yếu. Hoặc đổi nền `bg-slate-100` (khuôn PE `:1404`), hoặc đổi th về slate-600.
- **P2-3 Hướng đồng bộ khung:** KHKK **giàu hơn** PE ở icon-chip/accent/actions/card-accent. Đừng hạ KHKK; nếu muốn 1 khuôn thì **nâng PE lên** — việc lớn (PE 3613 dòng, 5 section, đang golive) ⇒ phải là run riêng có screenshot đối chứng.
---
## §10 — Giới hạn của lượt này (khai thẳng, không giấu)
1. 🔴 **KHÔNG có screenshot / FD2 visual-loop.** Đây là lane AUDIT (isolation: chỉ được ghi 1 file sub-MD, không giao code-file) và môi trường spawn không có dev-server + API chạy; trang KHKK lại nằm sau `ProtectedRoute` (gotcha authed-rig #3 trong MEMORY). ⇒ Mọi kết luận ở đây là **đọc mã + đo grep**, KHÔNG phải "đã nhìn". Người land P0/P1 **phải** chạy vòng soi thật (mobile 375 + laptop 1366 + desktop 1440) trước khi tuyên PASS.
2. **Hạn mức 1 file BE đã dùng cho `ContractSigningPlanFeatures.cs`** (đọc `:608-687`). `ContractSigningPlanWorkflowService.cs`, `CreateContractFromSigningPlanFeatures.cs`, `ContractSigningPlanLineConfiguration.cs`, entity, controller, test — **chỉ đọc qua grep-có-ngữ-cảnh**, không mở toàn file. Số dòng trích là thật; **ngữ cảnh quanh nó thì chưa soi hết** — đặc biệt vùng `Features.cs:1310-1360` (handler upload) và `CreateContractFromSigningPlanFeatures.cs:100-240` (bridge) phải được người land mở đủ.
3. **Chưa đo** `KhkkListPanel/KhkkWorkListPanel/KhkkWorkflowPanel` (3 file, 1406 dòng) — lăng-kính này chốt phạm vi ở NỘI DUNG PHIẾU. Lệch style ở panel 1/3 do lane khác phủ.
4. **Chưa đo** `fe-user/src/pages/khkk/KhkkDetailPage.tsx:205,312` sâu — chỉ biết qua grep là 2 read-site của `tenHangMuc`. Khi tách N dòng, **`:312` sẽ in N dòng cho 1 NCC** — cần soi lại, có thể phải gộp.
5. Cách đọc "sum tổng" đã được §7.7 khoá lại bằng ràng buộc bridge, **nhưng vẫn nên xác nhận 1 dòng với owner** trước khi viết BE — vì đây là tiền.
---
**END sub-frontend-designer-L5 — VERDICT=AUDIT-DONE · O3=Model-A-N-ROW-0-migration (máy đã rộng sẵn từ Mig 71) · O4=FE-ONLY-0-BE (rail `dossierItemId` đã land đủ 6 tầng từ Mig 69) · style: 6 P0 + 8 P1 + 3 P2 · KHÔNG có screenshot (lane audit, khai thẳng)**