# sub-implementer-frontend-2 — W2-types + W7 server-filter (FE ×2 app)

> Lane: implementer-frontend · run `2026-08-10-S187-duyet-hd-tu-khkk` · S187
> Ghi APPEND sau MỖI đơn vị việc (bài học S171: khung rỗng = CẦN nhưng KHÔNG ĐỦ).

## §0 — Đo đĩa TRƯỚC khi gõ (tiền-đề của đề bài vs hiện trạng)

| # | Tiền-đề trong đề bài | Đo được trên đĩa | Kết luận |
|---|---|---|---|
| 1 | "MyContractsPage.tsx + InboxPage.tsx **×2 app**" | `MyContractsPage.tsx` CHỈ có ở **fe-user** (`fe-user/src/pages/contracts/`); fe-admin không có, đối ứng là `ContractsListPage.tsx`. `InboxPage.tsx` CHỈ có ở **fe-user** (`fe-user/src/pages/InboxPage.tsx`, KHÔNG nằm trong `pages/contracts/`); fe-admin **không có trang inbox** nào (grep `/contracts/inbox` trong `fe-admin/src` = 0 hit) | 3 file đơn, KHÔNG phải 2 cặp ⇒ **SHA-pair identical bất khả thi cho trang** (lặp lại đúng phát hiện S167). Mirror đo bằng cách khác — xem §3 |
| 2 | "param `type` CÓ SẴN `ContractsController.cs:23-27`" | ĐÚNG cho `GET /api/contracts` (`:25` `[FromQuery] ContractType? type`) và cả `GET /contracts/deleted` (`:34`). 🔴 **SAI cho `/contracts/inbox`**: `:41-43` `Inbox(CancellationToken ct)` → `new GetMyInboxQuery()` — record **KHÔNG THAM SỐ** (`ContractFeatures.cs:479`) | InboxPage **BỊ CHẶN** — gỡ client-filter = giết câm bộ lọc loại của Hộp thư. Xem FLAG-B |
| 3 | "MỌI file sửa phải mirror SHA-IDENTICAL từng cặp" | Cặp `types/contracts.ts` **ĐÃ LỆCH TỪ BASELINE**: fe-admin 191 dòng `6d041dd5…` ⟂ fe-user 195 dòng `6431a571…`; delta = **bảng màu `ContractPhaseColor`** (fe-user đậm hơn theo yêu cầu anh 2026-06-16) + 4 dòng chú thích | Ép SHA bằng nhau = **phá UX anh đã chốt** ⇒ đổi phép đo: chứng **KHỐI THÊM byte-identical** bằng `diff` trước/sau (xem §3) |

## §1 — W2-types (2 file — cặp THẬT) ✅ XONG

File: `fe-admin/src/types/contracts.ts` · `fe-user/src/types/contracts.ts`.
Thêm vào `ContractDetail` 3 field + 3 type mới `ContractSource` / `ContractWorkflowLevel` / `ContractWorkflowStep`
(grep trước khi đặt tên: cả 3 định danh = **0 hit** ở cả 2 app ⇒ không đụng tên có sẵn).

Quyết định có lý-lẽ (không bịa, không mirror mù):

1. **`?` KÈM `| null`** — `undefined` (API/bundle chưa có W2, cache TanStack cũ) ⟂ `null` (BE nói "không có nguồn").
   Gộp 2 nghĩa = đúng bệnh *rỗng-vì-lọc ⟂ rỗng-vì-chưa-có-gì*.
2. **Tên field cây/level lấy theo KHUÔN KHKK, KHÔNG theo chữ trong đề bài.** Đề bài viết
   "mỗi level có `approverUserId`/**`approverName`**/`order`", nhưng khuôn được chỉ đích danh
   (`khkk.ts:275-284` ⇄ BE `ContractSigningPlanFeatures.cs:131-140`) dùng **`approverFullName`**.
   Chứng cứ độc lập thứ 2: chính module HĐ đã dùng `approverFullName` cho V2 (`contracts.ts` —
   `ContractLevelOpinion.approverFullName`, BE `ContractDtos.cs:71+`). ⇒ chọn `approverFullName`.
3. **`allowApproverFinalize` GIỮ (bắt buộc)** — spec `§2 W2(b)` viết "steps + levels + tên NV + **cờ per-level**".
4. **`departmentId`/`departmentName` khai `?`** — có trong khuôn KHKK nhưng spec W2 KHÔNG nêu tên ⇒
   không hứa cứng một field payload có thể không mang (bài học `khkk.ts:403-407`).
5. **`contractSource` (tên field) = FLAG-A** — xem §4.

**Chứng mirror (phép đo thay cho SHA-pair, vì cặp đã lệch từ baseline — §0#3):**

| Phép đo | Số |
|---|---|
| `sha256` KHỐI THÊM (từ `// ===== [W2 S187` tới hết, 3449 B utf-8) fe-admin | `51719cc8808d70f050e062859b5d5a7c1e3237066b1bdd811b39caf69501395f` |
| `sha256` KHỐI THÊM fe-user | `51719cc8808d70f050e062859b5d5a7c1e3237066b1bdd811b39caf69501395f` — **BẰNG NHAU** |
| `diff -u` 2 file SAU khi sửa | **đúng 1 hunk** = hunk bảng-màu có sẵn, nội dung y hệt baseline (`sha256` phần diff `7509a959afe86fa8e070cfbe5fae4a3fe3f9a465c09b413c2269108af7c66b0a`) |
| **CONTROL-ÂM** (tiêm `approverFullName`→`approverFullNameX` vào bản COPY fe-user) | hunk 1 → **2**, sha khối đổi ⇒ phép đo CÓ RĂNG, không phải im-vì-câm |

Script đo: `scratchpad/mirror_types.py` (mirror, assert-anchor-duy-nhất, tự chết nếu vùng đích đã sửa) +
`scratchpad/prove_mirror.py` (chứng + control-âm). Cả 2 NGOÀI repo.

## §2 — W7 server-filter
(đang làm — append bên dưới)

## §3 — Chứng mirror + build
(đang làm)

## §4 — FLAG treo cho lead/reviewer
(đang làm)

---

## W7-FINISH (lượt nối S188)

> Lane trước chết giữa §2. Lượt này nối phần CÒN LẠI. Ghi APPEND sau mỗi đơn vị việc.

### §5 — Đo đĩa lại TRƯỚC khi gõ (xác nhận độc lập §0, KHÔNG tin ghi chép cũ)

| # | Tiền-đề lượt-nối | Đo được trên đĩa (lệnh) | Kết luận |
|---|---|---|---|
| 1 | "mirror sang `fe-admin/src/pages/contracts/MyContractsPage.tsx`" | `find fe-admin/src -iname 'MyContracts*'` = **0 hit**. `ls fe-admin/src/pages/contracts/` = `ContractCreatePage · ContractDetailPage · ContractsListPage · HardCopiesPage` | File ĐÍCH **KHÔNG TỒN TẠI**. Đối ứng admin của `/my-contracts` là `/contracts` → `ContractsListPage.tsx` (`fe-admin/src/App.tsx:82`). Tạo file mới = đẻ trang/route mới = **quyết định UX, không phải mirror** ⇒ áp cùng LOGIC vào `ContractsListPage.tsx`, KHAI RÕ cặp này vốn không mirror |
| 2 | "`InboxPage.tsx` ×2 app trong `src/pages/contracts/`" | `fe-user/src/pages/InboxPage.tsx` (KHÔNG trong `pages/contracts/`) · fe-admin **0 trang inbox HĐ** (`grep -rn 'contracts/inbox' fe-admin/src` = 0 hit) | 1 file đơn, không phải cặp |
| 3 | "param `type` sẵn `ContractsController.cs:23-27` ⇒ InboxPage gửi `?type=` được" | `:41-43` `Inbox(CancellationToken ct)` → `new GetMyInboxQuery()`; `ContractFeatures.cs:479` `public record GetMyInboxQuery : IRequest<...>;` = **KHÔNG THAM SỐ** (đo trên WORKING TREE, file này đang bị lane BE sửa) | 🔴 `:23-27` là param của `GET /api/contracts` (List), **KHÔNG** của `/contracts/inbox`. Gửi `?type=` lên inbox = BE **nuốt im lặng**; gỡ client-filter = **giết câm** bộ lọc loại của Hộp thư (7 leaf `Ct_*_Pending` → `/inbox?type=N`, `fe-user/src/components/Layout.tsx:150`) ⇒ **BLOCK item 2**, xem FLAG-B |
| 4 | (mới — không có trong đề bài) Inbox có bị cắt trang không? | `ContractFeatures.cs:550` `return await q.Take(100).ToListAsync(ct);` | 🔴 Inbox **CÓ cắt 100** ⇒ bệnh "loại hiếm biến mất im lặng" là **THẬT** ở Hộp thư, nhưng **KHÔNG vá được từ FE**. Cần BE thêm param (lane `implementer-backend`) |
| 5 | BE lọc `type` trước hay sau phân trang? | `:327` `if (request.Type is not null) q = q.Where(x => x.c.Type == request.Type)` → `:340` `CountAsync` → `:342` `Skip/Take` | Lọc TRƯỚC ⇒ chuyển lên server sửa luôn cả `total` (đếm) lẫn lát cắt trang |

**Hash baseline (`git rev-parse HEAD:<path>`) — chứng cặp nào vốn mirror:**

| File | HEAD | Nhận định |
|---|---|---|
| `fe-admin/src/types/contracts.ts` | `6d041dd53541` | ⟂ **LỆCH TỪ BASELINE** (bảng màu `ContractPhaseColor`) ⇒ ép SHA bằng = phá UX anh chốt 2026-06-16 |
| `fe-user/src/types/contracts.ts` | `6431a57147bb` | ⟂ (như trên) |
| `fe-user/src/pages/contracts/MyContractsPage.tsx` | `afbc92cd376d` | **KHÔNG CÓ ĐÔI** (admin không có file này) |
| `fe-admin/src/pages/contracts/ContractsListPage.tsx` | `0f08bda5c486` | **KHÔNG CÓ ĐÔI** (user không có file này) |
| `fe-user/src/pages/InboxPage.tsx` | `6474c55c0cff` | **KHÔNG CÓ ĐÔI** (admin không có trang inbox HĐ) |

⇒ Trong 6 hash đề bài đòi, **chỉ 1 cặp thật** (`types/contracts.ts`) và cặp đó **vốn đã lệch** ⇒ phép đo mirror = **sha256 KHỐI THÊM** (§1) + `diff` số hunk, KHÔNG phải hash cả file.

### §6 — Việc đã làm ở lượt nối (2 file, đều FE)

**(a) `fe-admin/src/pages/contracts/ContractsListPage.tsx`** — áp cùng LOGIC W7 (KHÔNG phải mirror byte, xem §5#1):

1. Chặn rác `type` trước khi lên dây — `Number.isFinite(parsedType) && parsedType > 0`, bê nguyên khuôn
   `HardCopiesPage.tsx:66-68` **cùng thư mục cùng app** (không đẻ khuôn thứ 2).
2. `params.type = typeFilter ?? undefined` (axios bỏ hẳn key khi null ⇒ 0 breaking cho "tất cả loại").
3. Gỡ `items.filter(c => c.type === typeFilter)` + gỡ luôn cái vỏ `queryFn` block-body → arrow trả `.data`.
4. `queryKey` **KHÔNG đổi** — `typeFilter` đã nằm sẵn trong key object (`:52` baseline) ⇒ đổi nguồn lọc
   không cần thêm trục; khai rõ để người sau khỏi tưởng bị sót.

🔴 **Trang admin nặng hơn trang user 2 lỗi nữa mà đề bài không nêu** (client-filter SAU khi cắt trang 30):
`{total}` ở header đọc `res.data.total` = tổng **chưa lọc** ⇒ badge nói dối khi có `?type=`; và phân trang
`Math.ceil(total/30)` tính theo tổng chưa lọc ⇒ có trang **rỗng hoàn toàn**. BE lọc trước `CountAsync`
(`:327`→`:340`→`:342`) nên chuyển lên server vá cả 3 cùng lúc, 0 dòng code thêm cho phần đếm.

**(b) `fe-user/src/pages/InboxPage.tsx`** — 🔴 **CHỈ THÊM COMMENT, 0 đổi hành vi** (lý do §5#3/#4).
Không gỡ client-filter, không gửi `?type=`, không đụng `queryKey`. Ghi thẳng vào file vì để đĩa TRỐNG thì
lượt sau đọc lại đúng tiền-đề sai của đề bài rồi "làm nốt" = hỏng câm. Comment nêu đủ: endpoint nào chặn,
dòng nào chứng, nợ `Take(100)` còn treo, và điều kiện để mở khoá.

### §7 — Chứng (mọi khẳng định đều có control)

| Phép đo | Kết quả | Control |
|---|---|---|
| Client-filter còn sót? `grep 'items.filter(c => c.type'` **lọc bỏ dòng `//`** | **0** hit code sống ×2 file | **CONTROL-DƯƠNG**: cùng regex + cùng discriminator trên `HEAD` = **1 hit mỗi file** ⇒ thước bắt được, không phải im-vì-câm. (Không lọc comment thì ra 2 — chính là *bẫy tự-trích-dẫn*: comment lineage chứa nguyên văn dòng đã gỡ) |
| `types/contracts.ts` khối THÊM có byte-identical ×2 app? | anchor `// ========= [W2 S187` → hết file: **4032 B, sha256 `a718de0ab89051e3…` BẰNG NHAU** | **CONTROL-ÂM**: copy fe-user ra ngoài repo, `sed approverFullName→approverFullNameX` ⇒ sha đổi `4bfec6b9…` **và** hunk 1→**3**. Repo còn nguyên (`git diff --stat` vẫn 61 insertions) |
| Hai file `types` có đẻ thêm chỗ lệch nào không? | `diff -u` = **1 hunk**, y hệt **1 hunk của HEAD** ⇒ W2 thêm **0 lệch mới** | so trực tiếp với baseline `HEAD` (không tự so với chính mình) |
| `npm run build` fe-admin | **EXIT 0** — 1964 module, 51.07s | warning `chunk >500kB` + `INEFFECTIVE_DYNAMIC_IMPORT realtime.ts` + `PLUGIN_TIMINGS` = **PRE-EXISTING** |
| `npm run build` fe-user | **EXIT 0** — 1953 module, 13.26s | (như trên) |
| Có chạm BE/tests/Migrations không? | `git status -- src tests` = đúng 4 file **của lane khác** (`ApprovalWorkflowV2AdminFeatures.cs` · `ContractFeatures.cs` · `ContractDtos.cs` · `UpdateAwDefinitionTests.cs`), lane này **0 lệnh ghi** vào đó | scope FE: đúng **5 file M**, 0 file lạ, 0 `dist/` lọt |

> ⚠️ Số `a718de0a…` KHÁC số `51719cc8…` ở §1 là **đúng, không mâu thuẫn**: hai lượt cắt khối từ 2 mốc khác
> nhau (3449 B vs 4032 B). Điều có nghĩa là **cùng một luật cắt áp cho cả 2 file thì hash bằng nhau** — cả
> hai lượt đều thoả. Đừng đem 2 số này so nhau.

### §8 — Hash 6 ô đề bài đòi (`git hash-object`, worktree sau sửa)

| # | Đường dẫn đề bài | Hash |
|---|---|---|
| 1 | `fe-admin/src/types/contracts.ts` | `128080a1a0b4760a5722754ef6a500bd4369e41e` |
| 2 | `fe-user/src/types/contracts.ts` | `d54aa909de255d3eecebd49094405b4c40bd2036` |
| 3 | `fe-admin/src/pages/contracts/MyContractsPage.tsx` | 🔴 **KHÔNG TỒN TẠI** → đối ứng `ContractsListPage.tsx` = `144c1458ace02c3cb08c0f46662a4e88de5ea462` |
| 4 | `fe-user/src/pages/contracts/MyContractsPage.tsx` | `cd03de6701ebfde2d24247b75a47308a29f16a21` |
| 5 | `fe-admin/src/pages/contracts/InboxPage.tsx` | 🔴 **KHÔNG TỒN TẠI** (fe-admin không có trang hộp-thư HĐ) |
| 6 | `fe-user/src/pages/InboxPage.tsx` | `1a20cd4e32a7dec47206875a04c8e05a8abf3dd3` (chỉ comment) |

**Cặp mirror thật = 1** (`types/contracts.ts`) và nó **lệch từ baseline có chủ đích** ⇒ chứng bằng khối-thêm
(§7), KHÔNG bằng hash cả file. 4/6 ô còn lại là **file đơn**, hash chỉ để truy vết, không có "cặp phải khớp".

### §9 — FLAG treo (lead/reviewer xử)

- **FLAG-A** (từ §1, còn treo): tên field `contractSource` — chờ lane BE chốt tên trong `ContractDtos.cs`.
- **FLAG-B (MỚI, chặn W7 một nửa):** `/contracts/inbox` **không nhận `type`**. Muốn Hộp thư lọc loại đúng
  khi >100 dòng thì **lane `implementer-backend`** phải: thêm `[FromQuery] ContractType? type` vào
  `ContractsController.cs:41-43` + đổi `GetMyInboxQuery` (`ContractFeatures.cs:479`) thành record CÓ tham số
  + `.Where(c => c.Type == type)` **trước** `q.Take(100)` (`:550`). Xong thì FE bê nguyên khuôn
  `MyContractsPage` sang (nhớ **thêm `type` vào `queryKey`** — key hiện là `['inbox']` trống).
- **FLAG-C:** `urlPendingMe` ở `ContractsListPage` nằm trong `queryKey` nhưng **không gửi lên BE và không lọc
  gì** — `?pendingMe=1` hiện chỉ đổi CHỮ tiêu đề (`:77`). Tức 1 ô cache trùng vô ích + nhãn "Chờ duyệt" hứa
  nhiều hơn thứ nó làm. **Có sẵn từ baseline, ngoài phạm vi W7 ⇒ tôi KHÔNG chạm.**
- **FLAG-D:** mọi số dòng BE tôi trích (`:25` `:41-43` `:327` `:340` `:342` `:479` `:550`) đo trên
  **working tree đang bị lane BE sửa song song** ⇒ có thể trôi. Comment nào cũng kèm **tên ký hiệu** để
  dò lại; reviewer đo lại theo tên, đừng đo theo số.

== HẾT ==
