Files
solution-erp/.claude/workflows/runs/2026-08-05-S174-khkk-style-audit/sub-frontend-designer-L5.md
2026-08-05 16:27:07 +07:00

243 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)`**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)**