Files
solution-erp/.claude/workflows/runs/2026-07-29-S159-tong-quan-pipeline-menu/sub-reviewer-diff-dot4.md
2026-07-29 13:38:52 +07:00

103 lines
14 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-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``: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``: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ị 4130 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``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ị xy 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.