# sub-reviewer-diff-dot4 — soi đối kháng diff đợt-4 "Danh sách phiếu toàn trình" (S159 L8)

> Run: `2026-07-29-S159-tong-quan-pipeline-menu` · vai: reviewer (adversarial, READ-only)
> Vật soi: `git diff -- fe-user/src/pages/UserDashboardPage.tsx` — 1321 dòng (984+/337−), 1 file.
> Tác giả diff: frontend-designer (sub-file `sub-frontend-designer-dot4.md`). **Vai đó CHƯA tự-review ⇒ đây là lớp review DUY NHẤT của đợt-4.**
> Tiền lệ: đợt-1 đã soi cùng trang (`sub-reviewer-diff-tongquan.md`, 7 FLAG 0H/2M/5L) — mục §A của đợt-1 CÒN GIÁ TRỊ, không lặp lại; đợt này thêm trục REGRESSION so với đợt-1.
> 🔴 Ghi đĩa TỪNG FLAG ngay khi tìm ra (chống #53). KHÔNG batch.
> 🔴 Giới hạn khai trước: **KHÔNG live-verify** (chưa deploy, `/dashboard` cần login) ⇒ toàn bộ là soi TĨNH trên diff + đọc mã nguồn BE/FE/route. Mọi phát biểu về runtime là suy luận từ mã, đánh dấu rõ chỗ chưa chứng được.

---

## 0 — Tiến trình (append-only, ghi trong lúc làm)

- [t0] Đã chạm đĩa: `git diff --stat` → đúng 1 file `fe-user/src/pages/UserDashboardPage.tsx` 984+/337−. Các file M khác (`docs/STATUS.md`, `docs/HANDOFF.md`, `scripts/*`, `.claude/commands/*`, `.claude/governance/*`) KHÔNG thuộc vật soi (của lead). 0 file `fe-admin/**`. ✅ ràng buộc "fe-user ONLY" THOẢ.

- [t1] Trục 1 (BỎ) đo xong · Trục 7 (route) đo xong · FLAG-1 chốt (xem dưới).

---

## FLAG — ghi từng cái, ngay khi chốt

### FLAG-1 [M] — bấm dòng hợp đồng trên ĐIỆN THOẠI dẫn tới ngõ cụt (mở ra danh sách, không mở ra hợp đồng)

`fe-user/src/pages/UserDashboardPage.tsx:1395` — `onOpen={id => navigate(`/my-contracts?id=${id}`)}`.

Trang đích `fe-user/src/pages/contracts/MyContractsPage.tsx` chỉ render nội dung hợp đồng ở Panel 2/3, mà **cả hai panel đều `hidden … lg:block`** (`MyContractsPage.tsx:172` và `:189`). Dưới 1024px người dùng chỉ thấy Panel 1 là danh sách — hợp đồng vừa bấm KHÔNG mở ra. Chính trang đó đã tự biết điều này: `selectContract()` (`MyContractsPage.tsx:59-68`) có nhánh `window.matchMedia('(min-width: 1024px)')`, hễ hẹp hơn thì `navigate('/contracts/:id')` fullpage. Bảng mới trên trang chủ **không** có nhánh đó, nên nó phá đúng quy ước mà trang đích đặt ra.

Mức phơi nhiễm tăng thật chứ không chỉ trên lý thuyết: bảng GĐ3 giữ 3 cột (Mã HĐ · Tên hợp đồng · Trạng thái) ở mọi bề rộng (`:860-866`, các cột còn lại mới `hidden md/lg/xl`), tức dòng bấm được trên điện thoại; và nó thay cho danh sách "HĐ gần đây" 5 dòng cũ bằng danh sách phân trang đầy đủ.

**Khai trung thực về mức lỗi:** đây là **tật kế thừa, KHÔNG phải hồi quy** — bản cũ cũng `navigate(`/my-contracts?id=${c.id}`)` (dòng bị xoá trong diff). Tôi vẫn để mức M vì đợt-4 nhân diện tích chạm của nó lên nhiều lần và biến nó thành đường mở hợp đồng CHÍNH của trang chủ.

**Fix đề nghị:** dùng lại đúng nhánh của trang đích — nếu `window.matchMedia('(min-width: 1024px)').matches` thì `/my-contracts?id=<id>`, ngược lại `/contracts/<id>` (route có thật, `App.tsx:60`). Hoặc đơn giản hơn: luôn `/contracts/<id>`.

### FLAG-2 [M] — cột "Hành trình" nói CHẮC một điều nó không biết: "các giai đoạn còn lại **chưa tới**"

`fe-user/src/pages/UserDashboardPage.tsx:337-344` khai `DOT_TEXT.none = 'chưa tới'`, rồi `:378` ghép thành nhãn đọc-được của cả cụm: *"Hành trình toàn trình: giai đoạn N … ; **các giai đoạn còn lại chưa tới**."*, và `:389` gắn tooltip từng chấm *"Giai đoạn 1 · Duyệt NCC — chưa tới"*.

Hai chỗ câu đó SAI:

1. **Dòng hợp đồng (bảng GĐ3).** Một hợp đồng đang ở giai đoạn 3 thì giai đoạn 1 và 2 là các giai đoạn **phía trước** nó, không thể "chưa tới". Người dùng rê chuột lên chấm đầu sẽ đọc "Giai đoạn 1 · Duyệt NCC — chưa tới" cho một hợp đồng đã đi qua chặng đó (hoặc ít nhất không ai biết là chưa).
2. **Dòng phiếu đã sinh hợp đồng (bảng GĐ1).** `PeListItem.contractId` (`fe-user/src/types/purchaseEvaluation.ts:126`) **có thật trong payload đang tải** — backend chiếu thẳng `x.e.ContractId` vào DTO (`src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs`, dòng projection `x.e.SelectedSupplierId, …, x.e.ContractId, …`). Phiếu nào `contractId != null` là phiếu đã sinh hợp đồng, tức GĐ3 **đã tới**; vậy mà chấm GĐ3 vẫn báo "chưa tới". Không tốn thêm một request nào để biết điều này.

Tác giả có ghi lý do trong mã (`:331-332`): không suy diễn "có HĐ ⇒ GĐ1/GĐ2 xong" vì chưa có cầu nối per-phiếu. Lập luận đó ĐÚNG cho chiều "đã xong", nhưng câu chữ được chọn lại khẳng định chiều ngược lại — "chưa tới" cũng là một khẳng định, và là khẳng định sai. Không biết thì phải nói là không biết.

Đây đúng lớp lỗi đợt-1 đã ghi (FLAG-3 "nói quá điều dữ liệu chứng minh được"), tái phát ở chỗ mới.

**Fix đề nghị:** (a) đổi `DOT_TEXT.none` thành câu trung tính, ví dụ "chưa có thông tin ở giai đoạn này", và bỏ mệnh đề "các giai đoạn còn lại chưa tới" khỏi `aria-label`; (b) nếu muốn đúng hơn nữa mà vẫn 0 request: phiếu có `contractId != null` thì cho chấm GĐ3 mang trạng thái riêng (ví dụ "đã sinh hợp đồng").

**FLAG-2 · bổ sung (ca thứ ba, cùng một lớp lỗi):** hợp đồng ở phase `DangDongDau(8)` hoặc `DaPhatHanh(9)` — tức đã đóng dấu / đã phát hành, đúng phần việc mà chính trang này gọi là **giai đoạn 4 "Hợp đồng cứng" (bước 19 → 21)** — vẫn được `contractDot` (`:410-416`) chấm vào **giai đoạn 3**, còn chấm giai đoạn 4 báo "chưa tới". Bản hợp đồng cứng đã phát hành rồi mà hành trình bảo giai đoạn hợp đồng cứng chưa tới.

### FLAG-3 [L] — nút phân trang tính từ DỮ LIỆU TRẢ VỀ chứ không từ trang đang chọn ⇒ bấm nhanh 2 lần chỉ đi được 1 trang

`fe-user/src/pages/UserDashboardPage.tsx:611` và `:618` — `onClick={() => onPage(data.page - 1)}` / `onPage(data.page + 1)`, trong đó `data` là dữ liệu **đã tải xong**, không phải state `page`. Hai bảng đều bật `placeholderData: keepPreviousData` (`:1151`, `:1163`), nghĩa là trong lúc trang mới đang bay thì `data` vẫn là trang cũ.

Hệ quả đo được từ mã: đang ở trang 1, bấm "Sau" → `setPage(2)`, request bay, `data.page` vẫn = 1. Bấm "Sau" lần nữa trước khi request về → tính ra `1 + 1 = 2` → `setPage(2)` trên state đã là 2 ⇒ **không có gì xảy ra**. Người dùng bấm hai lần nhưng chỉ tiến một trang, và nút "Sau"/"Trước" trong khoảng đó bật/tắt theo `hasNext`/`hasPrev` của trang cũ.

Kèm theo, cùng chỗ này có một ca hiển thị vô nghĩa: nếu tổng số bản ghi giảm trong lúc đang xem trang cuối (người khác xoá phiếu, rồi query refetch), `from = (page-1)*pageSize+1` có thể lớn hơn `data.total`, dòng `:607-608` in ra dạng "Hiển thị 41–30 trong 30 phiếu" và bảng rơi vào empty-state "Chưa có phiếu Duyệt NCC nào" trong khi thực tế vẫn còn 30 phiếu.

**Fix đề nghị:** tính từ state — truyền `page` xuống `Pager` và dùng `onPage(page + 1)` / `onPage(page - 1)`; thêm chốt `if (data.page > data.totalPages && data.totalPages > 0) setPage(1)` (hoặc kẹp `from = Math.min(from, data.total)`).

---

## TRỤC 2 — param BE có thật không, và tab không chọn có bắn request không

Đã đọc thẳng controller, không tin lời khai:

| Điều được khai | Đo ở đâu | Kết quả |
|---|---|---|
| `/purchase-evaluations` nhận `page`·`pageSize`·`search`·`sortDesc`·`type`·`phase`·`projectId`·`approvalWorkflowId` | `src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs:18-28` | ĐÚNG cả 8 |
| `/contracts` nhận `page`·`pageSize`·`search`·`sortDesc`·`phase`·`supplierId`·`projectId` | `ContractsController.cs:16-24` | ĐÚNG cả 7 |
| `search` PE lọc `MaPhieu ∨ TenGoiThau ∨ Project.Name ∨ Project.Code` | `PurchaseEvaluationFeatures.cs` (khối `if (!string.IsNullOrWhiteSpace(request.Search))`) | ĐÚNG — placeholder `:1352` "Tìm mã phiếu, gói thầu, dự án…" mô tả đúng chừng đó, không hứa quá |
| `search` HĐ lọc `MaHopDong ∨ TenHopDong ∨ Supplier.Name ∨ Project.Name` | `ContractFeatures.cs:309-316` | ĐÚNG — placeholder `:1380` khớp |
| `pendingMe` KHÔNG phải param BE | `PurchaseEvaluationsController.cs:30-35` — `/inbox` là endpoint riêng, trả `List<>` phẳng | ĐÚNG, và mã ghi rõ điều này ở đầu file (`:33-35`) |
| `hasNext`/`hasPrev`/`totalPages` do BE tính | `src/Backend/SolutionErp.Application/Common/Models/PagedResult.cs:9-11` | CÓ THẬT (`HasNext => Page*PageSize < Total`, `HasPrev => Page > 1`) ⇒ nút biên bind thẳng là đúng |

**Lazy per-tab:** `enabled: stage === 1` (`:1150`) và `enabled: stage === 3` (`:1162`). Đứng ở tab GĐ2 hoặc GĐ4 ⇒ **0 request danh sách**. Đứng ở GĐ1 ⇒ chỉ `dash-stage-pe` bắn, `dash-stage-contracts` im, và ngược lại. ✅ đúng như khai.

Tổng request lúc mở trang (tab GĐ1): 5 query nền (`my-dashboard`, `pipeline-pe-total`, `pipeline-contract-total`, `pipeline-pe-approved`, `pe-inbox`) + 1 query danh sách = **6**. Bản HEAD là 5. Tức +1 request, nhưng 2 query `*-recent` cũ `pageSize=5` đã rút xuống `pageSize=1` nên tải về NHẸ hơn. Không phải bão request: `main.tsx:7-14` đặt `refetchOnWindowFocus:false` + `retry:1`.

## TRỤC 5 — hồi quy so với vòng-1 (đo HEAD ⟂ cây làm việc, không đo bằng trí nhớ)

Nền so sánh: `HEAD` = 766 dòng và ĐÃ chứa hero + các bản vá vòng-1 (đợt 1-3 đã commit) ⇒ diff 984+/337− này đúng là đợt-4 đứng một mình.

| Thứ phải còn sống | Phép đo | HEAD | Cây làm việc | Kết luận |
|---|---|---|---|---|
| Bản vá FLAG-1 vòng-1 (aria-label mang tên giai đoạn) | đếm `group=` và ``aria-label={`${group}`` | 6 / 1 | 6 / 1 | CÒN NGUYÊN |
| Nhãn 4 GĐ chốt | so từng chuỗi với `components/pe/PePipelineStrip.tsx:15-19` (nguồn chuẩn) | — | `Duyệt NCC` · `Kế hoạch ký kết HĐ` · `Duyệt hợp đồng` · `Hợp đồng cứng` | KHỚP TỪNG CHỮ; nay còn gom về một mảng `STAGES` (`:131-148`) nên hero và tab không thể lệch nhau |
| `['pe-inbox']` dùng chung + `select` đếm | so key ⟂ hàm tải với `pages/InboxPage.tsx:76-79` | 1 | 1 | CÒN NGUYÊN — cùng key, cùng hàm tải trả MẢNG, chỉ `select: d => d.length` ở phía quan sát (`:1070-1074`) nên không phá cache của Hộp thư |
| Bản vá FLAG-2 vòng-1 (chữ "Pipeline" / "Upload") | đếm "Toàn trình 4 giai đoạn" và "Tải lên bản hợp đồng" | 1 / 1 | 1 / 1 | CÒN NGUYÊN |
| Tiếng Việt 100% ở phần MỚI | rút mọi chuỗi hiển thị mới rồi soi từng chuỗi | — | — | KHÔNG lọt chữ Anh nào. (`Sắp`, `Sắp triển khai`, `Trước`, `Sau`, `Trang`, `Hiển thị`, `Xoá ô tìm kiếm`, `Mở màn Duyệt NCC`, `Hành trình`, `(chưa cấp mã)`, `(chưa đặt tên)`… đều thuần Việt) |
| 0 dependency mới | `git diff --stat -- fe-user/package.json fe-admin/package.json` | — | **rỗng** | 0 thay đổi |
| Named export | `grep "export function UserDashboardPage"` | 1 | 1 | CÒN NGUYÊN |
| Không đụng `fe-admin` | `git diff --name-only \| grep fe-admin` | — | **0 file** | THOẢ |
| Đích điều hướng của hero | liệt kê mọi `navigate(...)` ở HEAD ⟂ cây làm việc | 8 dạng | 10 dạng | 8 dạng cũ giữ y nguyên, 2 dạng mới (`/purchase-evaluations/new`, `/coming-soon?stage=N`) đều là route CÓ THẬT (`App.tsx:52,65`) |

**Ghi chú về một con số thoạt nhìn giống hồi quy:** chuỗi "Duyệt hợp đồng" giảm 5 → 2. Truy ra: HEAD có 1 lần ở chú thích + 1 lần `title=` + 3 lần `group=` (truyền tay cho từng dòng số liệu); nay còn 1 chú thích + 1 lần khai trong `STAGES`, còn `group` đọc gián tiếp qua `STAGES[2].title`. Không mất nhãn nào.

### FLAG-4 [L] — người dùng trình đọc màn hình không được báo khi kết quả tìm kiếm đổi

`fe-user/src/pages/UserDashboardPage.tsx:606` đặt `aria-live="polite"` lên dòng "Hiển thị x–y trong N", nhưng chính khối chứa nó bị gỡ khỏi cây khi không có kết quả (`:601` — `if (!data || data.total === 0) return null`). Nghĩa là đúng lúc cần thông báo nhất — gõ một từ khoá không khớp gì — vùng thông báo biến mất, và khối trạng thái rỗng thay thế nó (`:704-731`) thì không nằm trong bất kỳ vùng `aria-live` nào. Người dùng bàn phím/trình đọc gõ xong sẽ không nghe gì cả và không biết bảng vừa đổi.

**Fix đề nghị:** giữ vùng `aria-live="polite"` luôn tồn tại (khi rỗng thì thông báo "Không tìm thấy phiếu nào khớp"), hoặc gắn `role="status"` cho khối trạng thái rỗng.

### FLAG-5 [L] — tính năng chủ đạo của đợt-4 biến mất trên điện thoại

Cột "Hành trình" — thứ mà chính đầu bài gọi là tầng liên kết thứ hai, và là câu trả lời cho yêu cầu "nhìn vào vẫn thấy được tổng quan liên kết" — được đặt `hidden … md:table-cell` ở cả hai bảng (`fe-user/src/pages/UserDashboardPage.tsx:672` và `:866`, kèm ô dữ liệu `:812` và `:977`). Dưới 768px cột này không tồn tại, nên trên điện thoại người dùng chỉ còn dải hero ở trên, không còn thông tin "từng phiếu đang đứng đâu".

Chọn ẩn cột trên màn hẹp là quyết định hợp lý về mặt bố cục — tôi không đòi hiện nguyên bảng 7 cột trên điện thoại. Điều đáng ghi là **phần bị hy sinh lại đúng là phần được giao làm**, và dòng phụ gộp cho mobile (`:765-767`) chỉ gộp Dự án + Ngày, không gộp hành trình.

**Fix đề nghị:** đưa cụm 4 chấm vào dòng phụ trên mobile (cùng chỗ với Dự án · Ngày), hoặc đổi ngưỡng ẩn của cột Người soạn/Ngày để nhường chỗ cho Hành trình.

### FLAG-6 [L] — hai con số đếm cùng một thứ, hiện cạnh nhau, không khớp nhau khi đang tìm kiếm

Số trên thẻ tab lấy từ `peTotalQuery` / `contractTotalQuery` (`:1333`) — đây là tổng **không kèm từ khoá**. Bảng ngay bên dưới lại lọc theo `search` (`:1147`, `:1159`), và dòng phân trang in tổng **có kèm từ khoá** (`:608`). Gõ một từ khoá xong, cùng một màn hình sẽ có thẻ tab ghi "Giai đoạn 1 · 57" trong khi dòng dưới ghi "Hiển thị 1–3 trong 3 phiếu".

Không sai dữ liệu — hai số đo hai thứ khác nhau — nhưng đặt cạnh nhau mà không chú thích thì người đọc phải tự đoán.

**Fix đề nghị:** khi `search` khác rỗng thì cho thẻ tab đang chọn lấy số từ chính kết quả danh sách (`peList.data.total`), hoặc thêm một chữ nhỏ "trên tổng 57" cạnh dòng phân trang.

### FLAG-7 [L] — hai nút cùng nghĩa "xoá tìm kiếm" nhưng phản hồi khác nhau

Nút chữ X trong ô tìm (`:556-558`) chỉ gọi `onChange('')`, tức chỉ đổi `searchInput`, rồi phải chờ hết 300ms gỡ nhịp mới thực sự bỏ lọc. Nút "Xoá tìm kiếm" ở khối trạng thái rỗng (`:717`, `:911`) gọi `clearSearch()` (`:1136-1140`) đặt lại cả `searchInput`, `search` và `page` **ngay lập tức**. Cùng một hành động, một đường có độ trễ, một đường không.

**Fix đề nghị:** cho nút X gọi thẳng `clearSearch()`.

---

## TRỤC 6 — bảng có tử tế với bàn phím / trình đọc không, và mỗi tab có đủ 3 trạng thái không

Đo trên mã, từng mục:

| Mục | Có / Không | Chỗ đo |
|---|---|---|
| `<caption>` cho bảng | CÓ, cả 2 bảng, `sr-only`, câu nói rõ cột Hành trình dùng để làm gì | `:660-663`, `:854-857` |
| `scope="col"` mọi ô tiêu đề | CÓ, 7/7 mỗi bảng | `:666-672`, `:860-866` |
| `scope="row"` cho ô đầu dòng | CÓ (ô Mã phiếu / Mã HĐ là `<th scope="row">`) | `:740`, `:934` |
| Bấm dòng bằng bàn phím | CÓ — `<tr onClick>` chỉ là tiện cho chuột; điểm dừng bàn phím THẬT là `<button>` trong ô Mã, có `stopPropagation` nên không kích hoạt hai lần | `:735-751`, `:929-945` |
| Không lồng nút trong nút | ĐÚNG — `PeUrgentChips` và `PeFinalizeChip` chỉ dựng `<span>` + `title`, không có phần tử bấm được | `components/pe/PeUrgentChips.tsx`, `components/pe/PeFinalizeChip.tsx` |
| Trạng thái đang tải, theo từng tab | CÓ — `TableSkeleton` dùng một ô `colSpan` nên không lệch khi cột bị ẩn theo bề rộng | `:574-590`, dùng ở `:677`, `:871` |
| Trạng thái lỗi, theo từng tab | CÓ — khối lỗi riêng + nút "Thử lại" gọi đúng `refetch()` của query đó, KHÔNG gộp vào băng lỗi của hero | `:686-702`, `:880-896`, nối ở `:1366`, `:1394` |
| Trạng thái rỗng, theo từng tab | CÓ, và tách hai ca: rỗng vì chưa có dữ liệu (nút tạo mới) ⟂ rỗng vì tìm không ra (nút xoá tìm kiếm) | `:704-731`, `:898-925` |
| Đang tải lại thì báo gì | `aria-busy` trên `<tbody>` + làm mờ 60%, có tôn trọng `motion-reduce` | `:680-684`, `:874-878` |
| Thanh tab đúng khuôn WAI-ARIA | CÓ — `role="tablist"` + 4 `role="tab"` mang `id`/`aria-selected`/`aria-controls`, tabIndex luân phiên, panel `role="tabpanel"` + `aria-labelledby`; phím ←/→ cuộn vòng, Home/End nhảy biên | `:1323-1327`, `:466-480`, `:1346`, `:1123-1134` |
| Vùng bấm đủ lớn | CÓ — nút X 24×24, dòng số liệu hero cao ~32px, đều có chú thích lý do trong mã | `:560`, `:266` |
| Tương phản chữ nền | ĐẠT — tôi **tự tính lại** từ mã màu trong `index.css`, không lấy số của người làm: `brand-700 #1b6aa3` + trắng = **5,77:1**; `teal-700 #0a7170` + trắng = **5,82:1**; `brand-800 #175685` trên trắng = **7,76:1**; chữ `text-white/90` trên nền `brand-700` = **5,01:1**. Cả bốn đều qua ngưỡng AA 4,5:1, và ba số đầu khớp đúng con số người làm khai. |
| Màu dùng có thật trong bảng màu | CÓ — mọi `var(--color-…)` gọi trong `dotVisual`/`STAGE_TONE` (`brand-500/600`, `teal-500/600`, `amberx-500`, `accent-500`) đều được khai trong `@theme` (`fe-user/src/index.css:5-52`), và file **không** có dòng `--color-*: initial` nào nên các nấc mặc định của Tailwind (`slate`, `red`, `emerald`, `violet-800`…) vẫn còn. Không có màu ma. |

Một khoản nhỏ không tính FLAG: khung cuộn ngang của bảng (`:658`, `:852` — `overflow-x-auto`) không nhận được tiêu điểm bàn phím, nên người chỉ dùng bàn phím không cuộn ngang được bằng phím mũi tên. Vì mọi cột tràn đều đã bị ẩn theo bề rộng nên gần như không xảy ra; ghi lại cho đủ.

---

## TRỤC 1 — khối bị owner khoanh đã biến hẳn chưa

Quét cây làm việc bằng các chuỗi nhận dạng của khối cũ (`Việc của tôi`, `gần đây`, `StatCard`, `STAT_TONE`, `SlaTimer`, `SectionTitle`, `Tổng giá trị nháp`, `đang soạn`, `Sắp quá hạn`, `my-contracts-recent`, `my-pe-recent`): chỉ còn **hai dòng chú thích** kể lại việc đã gỡ (`:12`, `:1034`) và ba trường của kiểu `MyDashboard` (`:94`, `:96`, `:98`) mô tả hình dạng dữ liệu backend trả về. **Không còn một dòng giao diện nào.** Đây là phân biệt dùng ⟂ nhắc-đến, không phải bỏ nửa vời.

Bằng chứng phụ mạnh hơn cả grep: `fe-user/tsconfig.app.json` bật `noUnusedLocals` và `noUnusedParameters`, mà `npx tsc --noEmit -p tsconfig.app.json` chạy sạch ⇒ **không còn import hay biến mồ côi nào** của khối đã gỡ (`StatCard`, `SlaTimer`, `SectionTitle`, các icon `Pencil`/`Inbox`/`Clock`/`Coins`). Nếu sót, trình biên dịch đã chặn.

**Hero còn nguyên:** 4 thẻ giai đoạn, mũi tên nối, 6 dòng số liệu, băng lỗi + nút "Thử lại", và toàn bộ 8 đích điều hướng cũ — đối chiếu HEAD ⟂ cây làm việc đều khớp. Thay đổi duy nhất là nhãn/biểu tượng nay đọc từ mảng `STAGES` thay vì viết tay trong JSX.

## TRỤC 4 — bản đồ trạng thái sang chấm có ca nào rơi không

Liệt kê vét cạn, không lấy mẫu:

- **Phiếu (`peDot`, `:401-408`)** đi qua `getPeDisplayStatus` (`types/purchaseEvaluation.ts:104-110`). Toàn bộ 10 giá trị phase khai trong `PurchaseEvaluationPhase` (`:21-32`) đều có chỗ đáp: `1` → nháp; `7` → xong; `98` → bị trả lại; `99` → dừng; `2,3,4,5,6,10` → đang chạy. **Không ca nào rơi**, nhánh mặc định là "đang chạy" — an toàn, vì mọi phase còn lại đều thật sự là phase trung gian.
- **Hợp đồng (`contractDot`, `:410-416`)** so thẳng với `ContractPhase` (`types/contracts.ts:1-14`): `9` → xong; `99` → dừng; `98` → bị trả lại; `1,2` → nháp; `3,4,5,6,7,8,10` → đang chạy. **Không ca nào rơi.**

Trục này ĐẠT về mặt "không rơi ca". Phần sai nằm ở chỗ khác — xem FLAG-2 (chấm của các giai đoạn KHÔNG phải giai đoạn của dòng đó bị gán nghĩa "chưa tới").

## Quan sát KHÔNG tính FLAG (ghi để khỏi bị đào lại)

- **Không có dấu vết giả lập.** Rà toàn bộ dòng thêm mới: 0 lần `// Mock`, 0 `alert(`, 0 `TODO/FIXME`, 0 `console.`. Bảy lần khớp chữ `placeholder` đều là thuộc tính ô tìm kiếm (dùng thật), không phải đánh dấu chỗ làm dối.
- **An toàn.** Không `dangerouslySetInnerHTML`, không `eval`, không địa chỉ máy chủ hay khoá cứng trong mã. Dữ liệu hai bảng lấy từ hai endpoint đã lọc theo người dùng ở tầng backend (`ContractFeatures.cs:297-303` và khối IDOR trong `PurchaseEvaluationFeatures.cs`) ⇒ bảng mới **không** mở rộng phạm vi dữ liệu người dùng được thấy so với trước, chỉ hiển thị nhiều dòng hơn của cùng phạm vi đó.
- **Tật cũ từ vòng-1 chưa xử (không tính là mới):** hai ô của giai đoạn 3 vẫn lấy số từ `/reports/my-dashboard` nhưng dẫn sang `/inbox` — hai nơi đếm theo hai luật khác nhau (vòng-1 FLAG-4). Owner đã biết, để lại là lựa chọn của owner.
- **Ba trường `draftsInProgress` / `dueSoon` / `draftsTotalValue`** trong kiểu `MyDashboard` không còn nơi nào đọc. Vô hại (nó tả hình dạng dữ liệu backend trả về, không phải mã chết chạy được). Nhưng đáng nói một câu: "Sắp quá hạn" nay không còn xuất hiện ở đâu trên trang chủ — đúng ý owner đã yêu cầu bỏ, chỉ ghi lại để owner biết mình đã mất cảnh báo đó.

## Chỗ chịu được soi (ghi lại vì có giá trị đối chứng lần sau)

- **Gom nhãn 4 giai đoạn về một mảng `STAGES`** khiến hero và thanh tab không thể lệch tên nhau nữa — đây là cách chữa đúng gốc cho một thứ đã trôi qua ba đợt sửa, không phải chép tay cẩn thận hơn.
- **Cách dùng lại `['pe-inbox']`**: giữ nguyên khoá và hàm tải, chỉ đếm bằng `select` ở phía quan sát. Đúng chỗ đúng cách — nếu đổi hàm tải thành trả `.length` dưới cùng khoá thì đã phá cache của trang Hộp thư. Trong mã có ghi rõ lý do cấm làm vậy (`:1066-1069`).
- **Bốn con số tương phản và toàn bộ mã màu**: tôi tự tính lại và tự tra `@theme`, cả hai đều đứng.
- **Khung xương khi đang tải dùng một ô `colSpan`** thay vì dựng đủ 7 ô — tránh được lỗi lệch cột khi cột bị ẩn theo bề rộng màn hình.

## Sai lệch giữa lời khai và đĩa (không phải lỗi mã, nhưng phải nói)

`sub-frontend-designer-dot4.md` §4 khai *"766 → **1.130 dòng**"*. Đo lại trên đĩa: **1.413 dòng** (`wc -l`), và phép cộng từ diff cũng ra đúng số đó: 766 − 337 + 984 = 1.413. Con số 1.130 lệch 283 dòng. Không ảnh hưởng gì tới mã, nhưng ai đọc sổ sau này mà trích lại con số đó thì trích phải số sai.

## Giới hạn của lượt soi này — khai thẳng

1. **Không kiểm chứng trực tiếp trên máy chủ.** Chưa triển khai, `/dashboard` lại nằm sau đăng nhập. Mọi kết luận về bố cục, tương phản thực tế, hành vi khi bấm đều là **suy luận từ mã**. Chưa có một khung hình nào được nhìn bằng mắt.
2. **Bản chạy `tsc` sạch của tôi yếu hơn vẻ ngoài của nó.** `fe-user/tsconfig.app.json` **không** bật `strict` cũng không bật `strictNullChecks` (tôi đã tra cả `tsconfig.json` gốc — chỉ có `references`, không có cờ nào). Nên "biên dịch sạch" ở đây chứng minh được: không import thừa, không biến mồ côi, tên thuộc tính có thật. Nó **không** chứng minh được an toàn null. Đây là cấu hình sẵn có của dự án, không phải chuyện của diff này.
3. **Hai điểm tôi không bác được bằng cách soi tĩnh:** mẹo `w-full max-w-0` để cắt chữ trong ô bảng có ăn đúng ở mọi bề rộng không, và dải 4 chấm có đọc ra "hành trình" bằng mắt thường không. Cần người nhìn màn hình thật.

---

## KẾT LUẬN

**Verdict: PASS_WITH_FLAGS — 7 FLAG (0 Nặng / 2 Vừa / 5 Nhẹ).**

Nói rõ vì sao không có mục nào xếp mức Nặng, để không ai hiểu là tôi hạ chuẩn cho hợp ý: tôi đã tìm đúng bốn lớp lỗi thuộc mức đó và không thấy lớp nào — dữ liệu hiển thị sai (đã đối chiếu từng tham số với controller và từng trường với DTO), lỗ hổng phân quyền (không có endpoint mới; hai endpoint đang dùng đều lọc theo người dùng ở backend), vỡ biên dịch (tự chạy `tsc`, sạch), và hồi quy so với HEAD (đã đo từng bản vá của vòng-1, còn nguyên cả sáu). Hai mục mức Vừa đều là chuyện đúng-sai về hành vi và câu chữ, sửa được trong vài dòng, và một trong hai là tật kế thừa chứ không phải do đợt này gây ra.

**Thứ tự nên sửa:** FLAG-1 (ngõ cụt trên điện thoại) → FLAG-2 (bỏ khẳng định "chưa tới") → FLAG-3 (phân trang tính từ state) → còn lại tuỳ owner.

**Bốn yêu cầu của đầu bài, đối chiếu từng cái:** khối bị khoanh đã bỏ hết (không sót dòng giao diện nào) · hero giữ nguyên (8 đích điều hướng khớp HEAD) · bảng chi tiết có đủ 7 cột cho giai đoạn 1 gồm cột Hành trình, có bảng hợp đồng cho giai đoạn 3 kèm nút "Tạo HĐ mới" ở trạng thái rỗng, giai đoạn 2 và 4 dẫn sang `/coming-soon?stage=N` (route có thật, và `STAGE_INFO` có đủ cả khoá `'2'` lẫn `'4'` nên không rơi về nhầm giai đoạn) · phân trang và tìm kiếm đều dựa trên tham số backend CÓ THẬT, không phải làm cho có.

<!-- END sub-reviewer-diff-dot4 · TOTAL=7 FLAG (0H/2M/5L) · verdict=PASS_WITH_FLAGS -->
