# sub-frontend-designer-L5 — LĂNG-KÍNH 5: NỘI DUNG PHIẾU

> Run: `2026-08-05-S174-khkk-style-audit` · vai: frontend-designer (ensemble /fable-clone lane L5)
> Đối tượng: `fe-admin/src/components/khkk/KhkkDetailContent.tsx`
> 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  → **TICK @lượt-19**

- [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.

---

## Nhật ký đọc (append tới đâu ghi tới đó)

### §1+§2+§3 — Diff KHUNG Section (PE chuẩn ⟂ KHKK)  [đọc @lượt 2-5]

**PE `Section` (`fe-admin/src/components/pe/PeDetailTabs.tsx:514-523`) — TRẦN, 2 prop:**
```
function Section({ title, children }: { title: string; children: React.ReactNode })
  <section className="px-3 py-3 sm:px-5 sm:py-4">
    <h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-500">{title}</h3>
```
**KHKK `Section` (`fe-admin/src/components/khkk/KhkkDetailContent.tsx:60-96`) — GIÀU, 8 prop:**
`title · icon · accent · head · chipBg · chipFg · count? · actions?` →
`<section className="px-5 py-4">` + `icon-chip` 1.75rem + `h3 text-sm font-semibold tracking-tight {head}` + count-pill + `actions` ml-auto.

| Trục | PE (chuẩn golive) | KHKK | Verdict |
|---|---|---|---|
| Đánh số trong title | ✅ "1./2./3./5." nhúng chuỗi (`PeDetailTabs.tsx:402,405,418,426`) | ✅ y hệt (`:287,357,449,529`) | KHỚP |
| Count trong title | ✅ nhúng CHUỖI `(${details.length} hạng mục · ${suppliers.length} NCC)` (`:405`) | ⚠️ **HAI KIỂU LẪN**: S2 nhúng chuỗi `(${plan.lines.length} dòng)` (`:357`) · S3/S4 dùng `count` pill (`:455,535`) | **LỆCH NỘI-BỘ** |
| icon-chip | ❌ PE **không có icon** ở section-head | ✅ có | KHKK GIÀU HƠN chuẩn |
| accent/head color | ❌ đồng loạt `text-slate-500` | ✅ brand/teal/violet per-section | KHKK GIÀU HƠN chuẩn |
| Kiểu chữ head | `text-xs uppercase tracking-wide` (eyebrow) | `text-sm tracking-tight` (title thường) | **LỆCH** |
| actions slot | ❌ không (PE nhét nút vào thân, vd `ItemsTab`) | ✅ `ml-auto` | KHKK GIÀU HƠN |
| Padding | `px-3 py-3 sm:px-5 sm:py-4` — **có bậc responsive**, comment `:515-516` ghi rõ "laptop màn nhỏ tiết kiệm ~16px" | `px-5 py-4` **CỨNG** | 🔴 **KHKK REGRESS** — mất bài học laptop 1366/mobile 375 |
| divide-y giữa section | `divide-y divide-slate-200` (`:397`) | y hệt (`:284`) | KHỚP |
| Vỏ ngoài | KHÔNG `card-accent` (bọc bởi host) | `card-accent` + `--accent: brand-500` (`:264`) | KHKK GIÀU HƠN |
| Khối "nổi bật" | ✅ PE có: section 4 **KHÔNG bọc `Section`** mà là `<section>` trần chứa khối viền + **thanh header `bg-[#1F7DC1] text-white uppercase`** (`:423-425` + `:1995-1998`) | ❌ KHKK không có khối nổi bật nào | 🔴 **THIẾU** — đây chính là khuôn cho O3 (xem §7.6) |

> ⚖️ **Kết luận khung (quan trọng, không đảo chiều mù quáng):** KHKK **không phải "xấu hơn"** — nó giàu hơn PE ở 4 trục (icon/accent/actions/card). Việc cần làm KHÔNG phải hạ KHKK xuống mức PE, mà là (a) **vá đúng 2 chỗ KHKK regress thật** (padding responsive + count 2-kiểu), (b) **port ngược** khuôn "khối nổi bật header xanh" của PE sang KHKK cho O3. Nếu muốn đồng bộ 1 chiều thì hướng đúng là **nâng PE lên khuôn Section giàu của KHKK** (việc lớn, ngoài lượt này) — ghi vào backlog §9-P2.

### §4 — Diff BẢNG (table)

| Trục | PE | KHKK | Verdict |
|---|---|---|---|
| Layout | `table-fixed` + **width từng cột** (`w-[24%] w-[9%]…`, `:2719-2728`) + `min-w-[860px]` | `w-full text-sm` **không table-fixed, không set width cột** (`:375,468`) | 🔴 LỆCH — cột KHKK co giãn theo nội dung ⇒ nhảy layout khi tên NCC dài |
| Viền | lưới ĐẦY ĐỦ kiểu Excel: `border border-slate-300` mọi ô (`:1402-1407`) hoặc `border border-slate-200` + `border-r` (`:2719-2727`) | chỉ `border-b` hàng (`:388,481`) | LỆCH (chấp nhận được — nhưng phải chọn 1) |
| thead | `bg-slate-50 text-slate-600` (`:2720`) **hoặc** `bg-slate-100 text-[11px] font-semibold uppercase tracking-wide text-slate-600` (`:1404,1509`) — **PE không dùng `.label-eyebrow` lần nào trong file 3613 dòng** | `bg-slate-50/70` + `th class="label-eyebrow"` (`:376-383,469-476`) | LỆCH: `.label-eyebrow` = `11px/700/ls .07em/**brand-600**` (`fe-admin/src/index.css:89-95`) ⇒ th KHKK **màu brand**, th PE **slate**. Head-xanh trên nền `bg-slate-50/70` = nhạt, kém tương phản header/thân |
| Body font-size | `text-xs` / `text-[13px]` | `text-sm` bảng + `text-xs` từng ô lẫn lộn (`:397,431-434`) | LỆCH nội-bộ: cột NCC `text-sm`, cột Hạng mục/Ghi chú `text-xs` cùng hàng ⇒ baseline gãy |
| Số | `text-right` + `tabular-nums` | y hệt (`:431-433`) | KHỚP ✅ |
| overflow | `overflow-x-auto` + min-w | `overflow-x-auto` **không min-w** (`:374,467`) | LỆCH nhẹ |
| Hàng rỗng | `<td …text-slate-300>—</td>` | dùng `'—'` chuỗi trần trong `text-slate-600` (`:434,493`) | LỆCH — em-dash KHKK **cùng màu chữ thật** ⇒ đọc nhầm là dữ liệu |

### §5 — Diff FIELD / label / value / rỗng

- KHKK `Field` (`KhkkDetailContent.tsx:98-116`): label `text-[11px] font-semibold uppercase tracking-wide **text-brand-600**` · value `font-medium **text-brand-800**` · rỗng `text-slate-300` + `—`.
- PE tương đương `FormRow` (`PeDetailTabs.tsx:2166`) + label rời `text-[11px] font-semibold uppercase tracking-wide **text-slate-500**` (`:1774`), value mặc định slate.
- 🔴 **Lệch nặng nhất về màu:** KHKK tô **brand-600/brand-800 cho MỌI value** (`:110-111`) ⇒ toàn phiếu xanh, mất hierarchy (FD4 "1 focal point"). PE giữ value slate, chỉ tô màu chỗ CÓ NGHĨA (winner emerald `:2008`, cảnh báo amber `:1336`).
- Rỗng: PE có 3 biến thể — `text-sm text-slate-500` (`:2572,3194,3285`), `italic text-slate-400` (`:603,736,3380,3533`), `text-slate-300 —` (in-cell). KHKK dùng `text-sm text-muted-foreground` (`:372,465,547`) — **token thứ 4, không có ở PE**. → chọn 1: `text-sm italic text-slate-400` cho empty-block, `text-slate-300 —` cho in-cell.

### §6 — Diff KHOẢNG CÁCH

- Section padding: xem bảng §3 (KHKK mất bậc `px-3 py-3 sm:`).
- Header→body: PE `mb-3` (`:519`) · KHKK `mb-3` (`:75`) → KHỚP ✅.
- Grid field: KHKK `gap-x-6 gap-y-4 md:grid-cols-2` (`:301,344`) — trên scale ✅.
- Ô 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)` và **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` là **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)**

