# sub-reviewer-diff-tongquan — soi đối kháng diff `fe-user/src/pages/UserDashboardPage.tsx` (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` — 728 dòng (585+/143−), 1 file.
> 🔴 Ghi đĩa TRONG LÚC LÀM (chống #53). Mỗi FLAG ghi ngay khi tìm ra, không đợi cuối.
> 🔴 Giới hạn đã khai: **KHÔNG live-verify** (route `/dashboard` cần login, FE chưa deploy) ⇒ toàn bộ kết luận là **soi TĨNH** trên diff + đọc file nguồn BE/FE + đọc route/type. Mọi phát biểu về hành vi runtime là **suy luận từ mã**, đã đánh dấu rõ chỗ nào chưa chứng được.

---

## 0 — Xác nhận phạm vi (đã chạm đĩa, không tin lời khai)

- `git diff --stat` → **đúng 1 file** `fe-user/src/pages/UserDashboardPage.tsx`, 585+/143−. ✅ khớp lệnh.
- `git status --porcelain` → các file M khác (`WAL.md`, `docs/STATUS.md`, `docs/HANDOFF.md`, `scripts/*`, `.claude/commands/*`) **không thuộc vật soi**, đúng như FD khai là của lead. **0 file `fe-admin/**`** bị chạm. ✅ ràng buộc "fe-user ONLY" THOẢ.
- Named export `UserDashboardPage` giữ nguyên (dòng context không đổi trong hunk `@@ -113,20 +351,180 @@` phía trên). ✅
- 0 dependency mới (chỉ thêm icon `lucide-react` đã có sẵn). ✅

---

## A — Những claim của FD tôi đã ĐỘC LẬP kiểm và XÁC NHẬN ĐÚNG (không phải tin lời)

| Claim | Kiểm bằng | Kết quả |
|---|---|---|
| `Paged<T>` có `total`, **không** có `totalCount` | `fe-user/src/types/master.ts:1-9` | ✅ ĐÚNG — lead brief ghi `totalCount` là **SAI**, FD đính chính đúng |
| `phase` là query-param BE thật | `PurchaseEvaluationsController.cs:23` `[FromQuery] PurchaseEvaluationPhase? phase` | ✅ |
| `PurchaseEvaluationPhase.DaDuyet === 7` | `fe-user/src/types/purchaseEvaluation.ts:28` | ✅ |
| `pendingMe` KHÔNG phải query-param BE; `/inbox` trả **mảng phẳng** | `PurchaseEvaluationsController.cs:30-35` trả `List<...>` (không `PagedResult`) | ✅ — dùng `.length` là đúng |
| `/reports/my-dashboard` chỉ đếm bảng **Contracts** ⇒ gán GĐ3 đúng nghĩa | `GetMyDashboardQuery.cs` — mọi query đi từ `db.Contracts` | ✅ |
| `.icon-chip` có size nội tại 2.25rem ⇒ bỏ `h-9 w-9` là no-op | `index.css:128-137` | ✅ |
| `h1..h4` + `.stat-value` là rule **unlayered** ⇒ phải dùng `!` để đè màu | `index.css:79-83` + `:140-146` — cả 2 set `color`, **KHÔNG set `font-size`** | ✅ và FD áp `!` ĐÚNG CHỖ: `text-brand-800!` / `text-brand-700!` / `text-accent-600!` / `text-slate-600!`. `text-base` / `text-[15px]` **không** cần `!` vì 2 rule đó không set `font-size` — tôi đã đọc để bác giả thuyết "chữ phình to". |
| Mọi stop màu dùng đều CÓ THẬT trong `@theme` | `index.css:7-51` — brand 50-900 · teal 50/100/500/600/700 · violet · amberx · accent 500/600 | ✅ 0 stop ma (không có `-800` của teal/violet/amberx nào bị dùng) |
| `.label-eyebrow` = brand-600 chỉ 4.42:1 | tự tính lại luminance từ `#1f7dc1` | ✅ 4.41 — số FD khai **đo được và đúng**, né sang `text-brand-700` là hợp lý |
| `slate-500` = 4.76:1 (FD ghi 4.8) | tự tính từ `#64748b` | ✅ |
| `Button` có `size="sm"` + `gap-1.5` sẵn ⇒ bỏ `mr-1` là đúng | `fe-user/src/components/ui/button.tsx` | ✅ |
| 1 file, 0 file `fe-admin/**`, 0 dep mới, named export giữ | `git diff --stat` + đọc file | ✅ |

**Giả thuyết tôi TỰ ĐẶT RA rồi TỰ BÁC (ghi lại để khỏi ai đào lại):**
- *"Số GĐ1 là toàn hệ thống còn GĐ3 là của riêng tôi ⇒ lệch phạm vi, user đọc sai tỷ lệ rơi rụng"* → **BÁC**: cả 2 endpoint đều IDOR-scoped per-user (`PurchaseEvaluationFeatures.cs:596-614` + `ContractFeatures.cs:297-303`). Cùng phạm vi ⇒ so sánh được.
- *"`Chờ tôi duyệt` GĐ1 = `inbox.length` sẽ LỚN HƠN số ở màn đích vì màn đích lọc thêm client-side `DaGuiDuyet`"* (`PurchaseEvaluationsListPage.tsx:211-212`) → **BÁC**: BE inbox chỉ trả phase ∈ {ChoPurchasing, ChoDuAn, ChoCCM, ChoCEODuyetPA, ChoCEODuyetNCC, ChoDuyet} (`PurchaseEvaluationFeatures.cs:731-742,762`), tất cả đều map về `DaGuiDuyet` (`purchaseEvaluation.ts:105-110`) ⇒ bộ lọc client là no-op, **2 số khớp nhau**.
- *"`?phase=7` / `?pendingMe=1` là URL bịa, màn đích không đọc"* → **BÁC**: `PurchaseEvaluationsListPage.tsx:37,45,95` đọc cả 2. Route `/purchase-evaluations`, `/my-contracts`, `/inbox` đều có thật trong `App.tsx:50,58,59`.
- *"queryKey mới đè cache trang list"* → **BÁC**: trang list dùng `['pe-list', {...}]` / `['pe-detail', id]`; key mới là `['pipeline-pe-approved']` / `['pipeline-pe-inbox']` — không giao.
- *"bắn request bão"* → **BÁC**: `main.tsx:7-14` `refetchOnWindowFocus:false`, `retry:1`; +2 request đúng trần ≤3.
- *"chia 0 / NaN khi rỗng"* → **BÁC**: diff không có phép chia nào; `value ?? 0` chặn undefined.
- *"skeleton che cả pipeline"* → **BÁC**: loading là per-row (`StageStatRow`), khung 4 GĐ luôn render.

---

## B — FLAG

### FLAG-1 [M] — a11y AA: hai nút khác đích nhưng TRÙNG tên đọc được

`fe-user/src/pages/UserDashboardPage.tsx:449` (GĐ1 → `/purchase-evaluations?pendingMe=1`) và `:495` (GĐ3 → `/inbox`) đều là `<button>` có tên khả truy cập **y hệt nhau**: "Chờ tôi duyệt". Thêm nút thứ ba cùng nhãn ở KPI (`:538`). Ngữ cảnh phân biệt (tiêu đề "Duyệt NCC" / "Duyệt Hợp đồng") chỉ tồn tại **bằng thị giác** — `<h3>` của thẻ giai đoạn (`:153`) không được liên kết chương trình với nhóm nút, nên người dùng trình đọc màn hình duyệt theo danh sách nút sẽ thấy 2-3 mục trùng tên dẫn tới 3 nơi khác nhau. Spec yêu cầu a11y AA nên đây là khoản nợ thật, không phải chuyện thẩm mỹ.
**Fix đề nghị:** thêm `aria-label` đầy đủ cho `StageStatRow` (ví dụ `aria-label={`${title} — ${label}`}`), hoặc bọc cụm số liệu bằng `role="group"` + `aria-labelledby` trỏ vào `id` của `<h3>` giai đoạn.

### FLAG-2 [M] — lọt chữ tiếng Anh ra giao diện (ràng buộc "100% tiếng Việt")

Tôi rút toàn bộ chuỗi hiển thị MỚI từ diff (`git diff | grep '^+' | grep -oE '>...<|label="..."|title="..."|description="..."'`) rồi soi từng chuỗi. Có đúng **2 chuỗi lọt tiếng Anh**:
- `:405` — `<h2>Pipeline 4 giai đoạn · 21 bước</h2>`: **"Pipeline"**. Nó lại nằm ngay dưới dòng eyebrow "Quy trình mua sắm → ký kết hợp đồng" (`:402-404`) nên vừa là tiếng Anh vừa lặp ý.
- `:520` — `description="Upload bản hợp đồng đã ký cứng."`: **"Upload"**.

Mọi chuỗi còn lại (14 nhãn/tiêu đề) đều thuần Việt, kể cả "Giai đoạn {n}", "Sắp triển khai", các thông báo lỗi. Lưu ý bối cảnh: "upload các file đã ký cứng" là chữ owner dùng trong lời thoại gốc (`run.md:10`), nên đây là câu hỏi biên tập cho owner chứ không phải implementer làm sai ý; còn "Pipeline" thì owner chỉ nói trong lời trao đổi nội bộ (`run.md:14` — "Pipeline 4 GĐ đầy đủ"), không phải chốt nhãn hiển thị.
**Fix đề nghị:** `:405` → "Quy trình 4 giai đoạn · 21 bước" (bỏ luôn trùng ý với eyebrow); `:520` → "Tải lên bản hợp đồng đã ký cứng." — hoặc owner xác nhận giữ nguyên chữ "Upload".

### FLAG-3 [L] — câu "sẵn sàng nối tiếp" ở GĐ2 nói QUÁ điều dữ liệu chứng minh được

`:467-471` — dòng gợi ý của GĐ2 in `${peApproved} phiếu Duyệt NCC đã duyệt sẵn sàng nối tiếp`, trong đó `peApproved` là **tổng mọi phiếu `phase=DaDuyet`**. Nhưng phiếu đã duyệt có thể ĐÃ sinh hợp đồng rồi: entity có sẵn cột `PurchaseEvaluation.ContractId` (`src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluation.cs:31` — *"FK Contracts — set khi user gen HĐ từ phiếu"*), và endpoint `POST /purchase-evaluations/{id}/contracts-from-evaluation` (`PurchaseEvaluationsController.cs:340`) chính là đường sinh ra nó. Vậy con số đang gộp cả phiếu "đã nối tiếp xong" vào nhóm "sẵn sàng nối tiếp" — càng dùng lâu số càng phồng và càng sai nghĩa.
Không thể lọc bằng endpoint hiện có (spec cấm thêm endpoint), nên cách rẻ nhất là sửa câu chữ.
**Fix đề nghị:** đổi thành "{n} phiếu Duyệt NCC đã duyệt" (bỏ mệnh đề "sẵn sàng nối tiếp"), hoặc chờ GĐ2 có thật rồi thêm tham số lọc `hasContract=false` ở BE.

### FLAG-4 [L] — bấm số ở GĐ3 sẽ ra màn hình có số KHÁC (lệch có sẵn từ trước, nay bị nhân đôi độ nổi bật)

Hai ô của GĐ3 lấy số từ `/reports/my-dashboard` nhưng lại dẫn sang `/inbox` — hai nguồn đếm theo hai luật khác nhau:
- `:495-501` "Chờ tôi duyệt" = `pendingMyApproval`, handler **loại trừ hợp đồng do chính tôi soạn** (`GetMyDashboardQuery.cs` — `eligiblePhases.Contains(c.Phase) && c.DrafterUserId != userId`). Còn `/contracts/inbox` **không** loại trừ (`ContractFeatures.cs:391-401` chỉ lọc `eligiblePhases.Contains(c.Phase)`), lại còn `.Take(100)`.
- `:503-510` "Đã quá hạn" = `overdue` tính trên hợp nhất *hợp đồng tôi soạn* ∪ *hợp đồng chờ tôi duyệt*; phần "tôi soạn" **không bao giờ xuất hiện** trong hộp thư đến.

Nghĩa là số 3 trên trang chủ có thể mở ra hộp thư 7 dòng, hoặc 0 dòng quá hạn. Đây là lệch **có sẵn trước diff** (thẻ KPI cũ `:542`, `:557` cũng trỏ `/inbox`), nên tôi không tính là hồi quy — nhưng hero vừa nâng nó lên vị trí nổi nhất trang và lặp lại đúng con số đó hai lần trên cùng một màn.
**Fix đề nghị:** hoặc đổi đích sang `/my-contracts` cho ô "Đã quá hạn", hoặc để hộp thư nhận tham số lọc (`/inbox?overdue=1`) — nếu chưa làm ngay thì ghi vào backlog, đừng để trôi.

### FLAG-5 [L] — cùng một request nhưng hai khoá cache, thay vì dùng lại khoá đã có

`:367-370` tạo `queryKey: ['pipeline-pe-inbox']` cho `GET /purchase-evaluations/inbox`. Nhưng `fe-user/src/pages/InboxPage.tsx:77-78` **đã** có `queryKey: ['pe-inbox']` gọi đúng endpoint đó, đúng bộ tham số đó (không tham số). Hai khoá khác nhau ⇒ hai bản cache độc lập cho cùng một tài nguyên: mỗi lần đi Trang chủ ↔ Hộp thư đều bắn thêm một request, và hai màn có thể hiển thị hai ảnh chụp khác thời điểm.
Yêu cầu "queryKey mới không đè cache trang list" vẫn **thoả** (`['pe-list', …]` không bị đụng) — đây là chuyện tiết kiệm và nhất quán, không phải vi phạm spec.
**Fix đề nghị:** dùng lại `['pe-inbox']` cho ô "Chờ tôi duyệt" của GĐ1 ⇒ số request thêm rơi từ +2 xuống +1 và hai màn luôn khớp nhau.

### FLAG-6 [L] — báo lỗi hiện hai lần cho cùng một sự cố

`:409-420` băng cảnh báo `hasError` gộp cả 5 query, trong đó có `recent` và `recentPe`. Khi đúng một danh sách hỏng, người dùng thấy đồng thời băng vàng ở hero ("Không tải được một vài số liệu") **và** khối lỗi riêng của danh sách đó (`:559-573` cho hợp đồng, phần tương ứng cho phiếu), mỗi chỗ một nút "Thử lại". Không sai chức năng — `retryFailed` chỉ gọi lại đúng query hỏng — nhưng là nhiễu.
**Tự đính chính trong lúc viết flag này:** tôi định đề nghị "bỏ `recent`/`recentPe` khỏi `hasError`", nhưng đọc lại thì **sai** — `recent.data.total` nuôi ô "Tổng hợp đồng" (`:488`) và `recentPe.data.total` nuôi ô "Tổng phiếu" (`:434`), tức hai query đó thực sự thuộc hero. Băng cảnh báo phủ chúng là ĐÚNG.
**Fix đề nghị (đã sửa theo đúng sự thật):** giữ nguyên `hasError`, chỉ bỏ bớt một trong hai nút "Thử lại" trùng nhau — hoặc chấp nhận trùng vì nó vô hại. Mức L, không chặn commit.

### FLAG-7 [L] — GĐ4 gắn nhãn "Sắp triển khai" trong khi phần khung của nó đã chạy thật (việc của owner quyết, không phải lỗi người làm)

`:519-521` đánh GĐ4 "Ký cứng & phát hành (Bước 19 → 21)" là chưa triển khai. Trên thực tế module Hợp đồng đã có sẵn hai phase cuối `DangDongDau(8) → DaPhatHanh(9)` cùng chức năng đính kèm bản scan đã ký (`POST /contracts/{id}/attachments`). Người dùng đang dùng hai phase này mỗi ngày có thể ngơ ngác khi trang chủ bảo phần đó "sắp triển khai".
Diff **khớp đúng spec** ở điểm này — owner tự chốt GĐ4 nằm ngoài hệ thống (`run.md:10` và bảng `run.md:26`: *"ngoài hệ thống — upload file đã ký (Contract attachments sẵn nền); thiết kế sau"*) — nên tôi ghi nhận là điểm cần owner biết, **không** tính là implementer làm sai.
**Fix đề nghị:** nếu owner muốn tránh hiểu nhầm, đổi mô tả GĐ4 thành "Hiện làm ngoài hệ thống — đính kèm bản đã ký vào hồ sơ hợp đồng" thay vì chỉ badge "Sắp triển khai".

---

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

- **Mũi tên nối `text-slate-400` (~2,6:1)** ở `:165-166`: KHÔNG vi phạm AA vì cả hai icon đều `aria-hidden` và thứ tự giai đoạn đã được mã hoá bằng badge chữ "Giai đoạn 1..4"; đây là đồ hoạ trang trí thuần tuý.
- **Nợ FD tự khai** (chiều cao dải pipeline ở tablet 768–1023px; số đỏ "quá hạn" nằm trong thẻ teal): tôi không phản bác được bằng cách soi tĩnh — cần mắt người trên prod. Ghi nhận là nợ đã khai minh bạch, không phải giấu.
- **Trùng số liệu hero ⟂ KPI**: "Chờ tôi duyệt" và "Đã quá hạn" nay xuất hiện hai lần trên một màn. Owner đã yêu cầu giữ KPI cũ nên đây là hệ quả cố ý, không phải sơ suất.

---

## D — Kết luận

**Verdict: PASS_WITH_FLAGS — 7 FLAG (0 H / 2 M / 5 L). Không có gì chặn commit.**

Bốn trục soi được lệnh giao, kết quả từng trục:

| Trục | Kết quả | Căn cứ |
|---|---|---|
| Đúng-spec (4 nhãn + ranh bước) | **ĐẠT** | GĐ2 đúng nguyên văn "Đề xuất ký kết hợp đồng" (`:464`, khớp `run.md:13`); ranh 1→6 / 7→12 / 13→18 / 19→21 đúng từng chữ; tổng 21 bước cộng lại khớp. GĐ2/GĐ4 dùng badge xám nét đứt + biểu tượng đồng hồ cát, KHÔNG dùng đỏ/vàng ⇒ đọc ra "kế hoạch", không đọc nhầm thành lỗi. |
| Data đúng | **ĐẠT** | `phase=7` đúng là Đã-duyệt; `pendingMe` được xử lý đúng bản chất (endpoint riêng trả mảng, không phải query-param); đọc `total` đúng tên field của `Paged<>`; không có phép chia nào nên không sinh NaN; skeleton chỉ nằm trong từng dòng số, khung 4 giai đoạn luôn hiện. |
| Không hồi quy KPI cũ | **ĐẠT** | Đủ 5 trường `MyDashboardDto` (`:532`, `:539`, `:547`, `:555`, `:562`) + đủ 2 danh sách gần đây, lại còn thêm nhánh báo lỗi mà bản cũ thiếu. |
| Vệ sinh query / TS / React | **ĐẠT** | Khoá cache mới không đụng khoá cũ; +2 request (trần 3); `refetchOnWindowFocus:false` nên không bão request; named export `UserDashboardPage` giữ nguyên; 0 import từ `fe-admin`; 0 import thừa (tôi đối chiếu từng icon với chỗ dùng). |

**Kiểm chứng độc lập tôi tự chạy (không tin lời khai của FD):** `npx tsc --noEmit -p tsconfig.app.json` trong `fe-user` → **exit 0, output 0 byte**. Đây là bản chạy riêng của tôi, không dùng lại kết quả build 506 ms mà lead đã chạy.

**Giới hạn phải khai:** không live-verify được (route `/dashboard` đòi đăng nhập, FE chưa deploy). Mọi kết luận về bố cục, tương phản thực tế và hành vi runtime là **suy luận từ mã nguồn**, chưa có một khung hình nào được nhìn bằng mắt.

<!-- END sub-reviewer-diff-tongquan · TOTAL=7 FLAG -->
