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