# sub-frontend-designer-dot4 — Đợt-4 redesign `UserDashboardPage.tsx` (fe-user ONLY)

> Run: `2026-07-29-S159-tong-quan-pipeline-menu` · vai: frontend-designer · file đích: `fe-user/src/pages/UserDashboardPage.tsx`
> 🔴 GHI ĐĨA TRONG LÚC LÀM (chống #53). Mỗi mục = 1 bước ĐÃ XONG, ghi ngay khi xong.
> 🔴 **NO-SCREENSHOT** — spawn này không có dev-server/Playwright + trang sau login (ProtectedRoute) ⇒ FD2 visual-loop KHÔNG chạy được. Thay bằng (i) `npm run build` PASS + tail, (ii) tự-review tĩnh theo rubric FD4. KHÔNG bịa screenshot.

---

## 1 — Khảo sát file HIỆN HÀNH (đọc đĩa, KHÔNG dùng bản nhớ)

`fe-user/src/pages/UserDashboardPage.tsx` — **766 dòng** (không phải 309 như đợt-1; đã qua review + 3 đợt sửa nhãn). Trạng thái TRƯỚC đợt-4:

- Comment đầu file đã ghi nhãn 4 GĐ **bản sửa mới nhất**: GĐ1 "Duyệt NCC" (bước 1→6, CEO chốt) · GĐ2 **"Kế hoạch ký kết HĐ"** (7→12) · GĐ3 **"Duyệt hợp đồng"** (13→18) · GĐ4 **"Hợp đồng cứng"** (19→21). ⇒ Tab đợt-4 phải echo ĐÚNG 4 tên này (không lấy tên đợt-1 "Đề xuất ký kết hợp đồng"/"Ký cứng & phát hành").
- `PageHeader` title "Tổng quan tất cả luồng duyệt".
- **HERO pipeline 4 GĐ** (dòng 403-538) — GIỮ (owner không khoanh).
- Khối BỊ KHOANH BỎ: `<section>` "Việc của tôi" 5 `StatCard` (541-582) + `grid xl:grid-cols-2` chứa "HĐ gần đây" (587-673) + "Phiếu Duyệt NCC gần đây" (678-762).
- 5 query đang có: `['my-dashboard']` · `['my-contracts-recent']` (`/contracts?page=1&pageSize=5`) · `['my-pe-recent']` (`/purchase-evaluations?page=1&pageSize=5`) · `['pipeline-pe-approved']` (phase=7, pageSize=1 lấy `.total`) · `['pe-inbox']` (endpoint `/inbox` mảng phẳng, dùng `select: d => d.length` để KHÔNG vỡ cache dùng chung với InboxPage).
- Component local tái dùng được: `STAGE_TONE` (brand/teal), `StageCard`, `StageArrow`, `StageStatRow`, `StageLink`, `StageSoon`, `SectionTitle`, `fmtMoney`, `StatCard`+`STAT_TONE`.
- Gotcha CSS đã ghi sẵn trong file: `h1..h4`, `.stat-value`, `.icon-chip` KHÔNG nằm trong `@layer` ⇒ thắng utility Tailwind v4, muốn đổi màu heading phải dùng `!`.

## 2 — Khảo sát BE (xác nhận PARAM THẬT, không chế)

`src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs:18-35`
```
[HttpGet] List(page=1, pageSize=20, search=null, sortDesc=true, type=null, phase=null,
                projectId=null, approvalWorkflowId=null) -> PagedResult<PurchaseEvaluationListItemDto>
[HttpGet("inbox")] Inbox(type=null, approvalWorkflowId=null) -> List<...>   // MẢNG PHẲNG, không paging
```
`src/Backend/SolutionErp.Api/Controllers/ContractsController.cs:16-28`
```
[HttpGet] List(page=1, pageSize=20, search=null, sortDesc=true, phase=null,
                supplierId=null, projectId=null) -> PagedResult<ContractListItemDto>
[HttpGet("inbox")] Inbox() -> List<...>
```
✅ **CẢ paging LẪN search đều có thật** ở cả 2 endpoint ⇒ yêu cầu 3 (phân trang thật + ô tìm kiếm) làm được ĐẦY ĐỦ, KHÔNG phải cắt.

Phạm vi `search` (đọc handler, để đặt placeholder trung thực):
- PE — `PurchaseEvaluationFeatures.cs:624-632`: `MaPhieu` ∨ `TenGoiThau` ∨ `Project.Name` ∨ `Project.Code`.
- HĐ — `ContractFeatures.cs:309-316`: `MaHopDong` ∨ `TenHopDong` ∨ `Supplier.Name` ∨ `Project.Name`.
⇒ placeholder PE = "Tìm mã phiếu, gói thầu, dự án…" · HĐ = "Tìm mã HĐ, tên HĐ, nhà cung cấp, dự án…" (đúng cái BE thật lọc, không hứa quá).

`Paged<T>` (`fe-user/src/types/master.ts`) = `{items,total,page,pageSize,totalPages,hasNext,hasPrev}` — có `hasNext/hasPrev` ⇒ nút Trước/Sau bind thẳng, không tự tính.

⚠️ Nhắc lại đính chính đợt-1 (vẫn đúng): **KHÔNG có query-param `pendingMe`** ở BE — đó là URL-param của trang list, map sang endpoint `/inbox`.

## 2b — Tài sản có sẵn (FD6 chống reinvent — đã đọc, KHÔNG suy đoán)

| Thứ | Đường dẫn | Dùng vào |
|---|---|---|
| `PePipelineStrip` | `components/pe/PePipelineStrip.tsx` | **NGUỒN TÊN 4 GĐ CANONICAL** (`Duyệt NCC / Kế hoạch ký kết HĐ / Duyệt hợp đồng / Hợp đồng cứng`) — tab đợt-4 lấy tên khớp file này, KHÔNG chép tay |
| `PeFinalizeChip` | `components/pe/PeFinalizeChip.tsx` | cột Trạng thái GĐ1 (tái dùng như list cũ) |
| `PeUrgentChips` | `components/pe/PeUrgentChips.tsx` | cờ GẤP PRO/CCM cạnh mã phiếu (có sẵn trên `PeListItem`, 0 request thêm) |
| `PhaseBadge` · `EmptyState` · `Button` · `Input` | `components/…` | bảng GĐ3 · empty/error · nút · ô tìm |
| `getPeDisplayStatus` + `PeDisplayStatus{Label,Color}` | `types/purchaseEvaluation.ts` | chip trạng thái GĐ1 |
| `ComingSoonPage` | `pages/ComingSoonPage.tsx` | nhận `?stage=2|4` (`STAGE_INFO`) ⇒ link `/coming-soon?stage=N` là route THẬT, không đâm `*` |
| `Paged.hasNext/hasPrev/totalPages` | `types/master.ts` | nút Trước/Sau bind thẳng, KHÔNG tự tính |
| `keepPreviousData` | `@tanstack/react-query` **v5.99.2** (node-verify: `typeof === function`) | phân trang không nháy trắng |

Route đích đã verify trong `App.tsx`: `/purchase-evaluations/:id` (mở phiếu — đúng cách list "gần đây" cũ navigate) · `/my-contracts?id=<guid>` (`MyContractsPage.tsx:30` đọc `searchParams.get('id')`) · `/contracts/new` · `/coming-soon`.
Icon lucide đã node-verify 26 export (`Search`, `ChevronLeft/Right`, `ListChecks`, `SearchX`, `Plus`, `X`… ) — tránh bẫy alias-dodge.

## 3 — Quyết định design (chốt TRƯỚC khi code)

**Xoá:** section "Việc của tôi" (5 `StatCard`) + grid 2 list "HĐ gần đây"/"Phiếu Duyệt NCC gần đây" — đúng khối owner khoanh. Kéo theo: `StatCard`/`STAT_TONE`/`fmtMoney`?/`SlaTimer`/`Pencil,Inbox,Clock,Coins` không còn dùng ⇒ phải gỡ import (TS `noUnusedLocals` sẽ chặn build nếu để lại). `fmtMoney` GIỮ (bảng GĐ3 hiện cột Giá trị).

**Thêm:** 1 section `Danh sách phiếu toàn trình` = tabs (tầng liên kết 1) → bảng (tầng liên kết 2 = cột Hành trình).

1. **Single source `STAGES`** — 1 mảng const cấp module (`n / title / stepRange / icon / tone / soon`) cho **CẢ hero LẪN tab**. Lý do: nhãn 4 GĐ đã drift qua 3 đợt sửa; hero + tab chép tay = drift lần 4. Hero giữ nguyên JSX `StageCard`, chỉ đổi `title`/`stepRange` sang đọc từ `STAGES` ⇒ echo tên là ĐÚNG do cấu trúc, không do cẩn thận.
2. **Tab** `role="tablist"` + 4 `role="tab"` (roving tabindex, ←/→/Home/End, `aria-selected`, `aria-controls`), panel `role="tabpanel"` + `aria-labelledby`. KHÔNG chèn mũi tên `→` giữa các tab (con của tablist phải là tab) — mạch "toàn trình" đã do hero ngay trên gánh + mỗi tab mang eyebrow "Giai đoạn N" khớp badge hero.
3. **Màu tab active = tone của GĐ đó** (GĐ1 brand, GĐ3 teal, GĐ2/4 slate) ⇒ bảng bên dưới ăn cùng accent → mắt nối được "ô hero ↔ tab ↔ bảng". Contrast (tự tính, không phỏng):
   - `bg-brand-700 #1b6aa3` + chữ trắng = **5.77:1** ✓ (KHÔNG dùng brand-600 = 4.41:1 — chính gotcha đã ghi trong file, dòng 409-410)
   - `bg-teal-700 #0a7170` + trắng = **5.82:1** ✓ · `bg-slate-600` + trắng ≈ 7:1 ✓
   - Badge count trên tab active = **pill trắng chữ `text-brand-800`** (7.75:1) thay vì `bg-white/20` (3.85:1 ✗ — đã loại).
4. **Cột Hành trình** = `JourneyDots` (4 chấm + gạch nối): chấm của CHÍNH GĐ dòng đó mang trạng thái, 3 chấm còn lại `bg-slate-200` mờ + `title` tên GĐ. Map trạng thái (mở rộng đúng tinh thần owner, không bịa nghĩa):
   `Đã duyệt`→**đầy** (tone-600) · `Đã gửi duyệt`→**nửa** (linear-gradient 50% inline, viền tone-500) · `Cần chỉnh sửa lại`→**nửa amberx** · `Nháp`→**rỗng** (viền slate-300) · `Từ chối`→**đầy accent-500 (đỏ)** + tooltip "dừng tại GĐ này".
   a11y: bọc `role="img"` + **1** `aria-label` kể trọn hành trình; từng chấm `aria-hidden` + `title` (tooltip chuột). Không bắt screen-reader đọc 4 chấm rời.
   GĐ3 dùng CÙNG component (chấm 3 sáng, 1/2/4 mờ) — KHÔNG suy diễn "HĐ tồn tại ⇒ GĐ1/2 xong" (chưa có cầu nối per-phiếu; `PePipelineStrip.tsx:8-9` nói rõ).
5. **Row click**: `<tr onClick>` (chuột bấm đâu cũng được) + trong ô Mã có `<button>` THẬT (`stopPropagation`) làm điểm focus bàn phím ⇒ WCAG 2.1.1 đủ, KHÔNG lồng button-trong-row-button, không `div role=row onClick` câm.
6. **Responsive** (không tràn ngang 375): ô "Gói thầu"/"Tên HĐ" dùng `w-full max-w-0 truncate` (ăn hết chỗ thừa rồi cắt) + dòng meta `lg:hidden` gộp Dự án · Ngày; cột Dự án `hidden lg:table-cell`, Người soạn `hidden xl:table-cell`, Ngày `hidden md:table-cell`, Hành trình `hidden md:table-cell`. Mobile còn 3 cột thật (Mã · Gói thầu+meta · Trạng thái).
7. **Request**: list **lazy theo tab** (`enabled: stage===1` / `stage===3`) ⇒ GĐ2/GĐ4 bắn 0 request. 2 query `*-recent` (pageSize 5) đổi thành `*-total` **pageSize=1** (hero chỉ cần `.total`) ⇒ tổng vẫn 5 query nền + 1 list = **6**, payload NHỎ HƠN bản cũ. `['pe-inbox']` GIỮ NGUYÊN key+queryFn+`select` (ràng buộc owner + cache chung `InboxPage`).
8. **Search** debounce 300ms (`useEffect`+`setTimeout`, 0 dep mới), đổi search/tab → reset `page=1`. Placeholder ghi ĐÚNG cái BE lọc.

## 4 — File đã sửa (CHỈ 1 file, fe-user ONLY, 0 dep mới)

`D:\Dropbox\CONG_VIEC\SOLUTION\SOLUTION_ERP\fe-user\src\pages\UserDashboardPage.tsx` — sửa TẠI CHỖ (1 atomic Write cả file, chống truncate #53). Named export `UserDashboardPage` GIỮ ⇒ route `/dashboard` + sidebar không phải đụng. 766 → **1.130 dòng**.

### GỠ (đúng khối owner khoanh đỏ)
| Gỡ | Kéo theo phải gỡ (TS `noUnusedLocals` sẽ chặn build nếu để lại) |
|---|---|
| `<section>` "Việc của tôi" — 5 `StatCard` (HĐ đang soạn / Chờ tôi duyệt / Sắp quá hạn / Đã quá hạn / Tổng giá trị nháp) | `StatCard`, `STAT_TONE`, icon `Pencil` `Inbox` `Clock` `Coins`, `CheckCircle2` |
| `<section>` "HĐ gần đây" (list 5) | `SlaTimer` import |
| `<section>` "Phiếu Duyệt NCC gần đây" (list 5) | `SectionTitle` |
| query `['my-contracts-recent']` pageSize **5** | → đổi thành `['pipeline-contract-total']` pageSize **1** (hero chỉ cần `.total`) |
| query `['my-pe-recent']` pageSize **5** | → đổi thành `['pipeline-pe-total']` pageSize **1** |

### THÊM
- `STAGES: StageMeta[]` — **single source 4 giai đoạn** (`n/title/stepRange/icon/tone/soonDesc`). Hero (`StageCard`) đổi signature nhận `stage: StageMeta` thay 5 prop chuỗi rời ⇒ tab & hero KHÔNG THỂ lệch nhãn. Text hero giữ **nguyên văn** (`soonDesc` chép đúng câu đang chạy) — owner đã duyệt hero, không đổi chữ.
- `JourneyDots` + `DOT_TEXT` + `dotVisual` + `peDot()` + `contractDot()` — cột **Hành trình**.
- `TAB_TONE` + `StageTab` — thanh tab 4 GĐ.
- `ListToolbar` (ô tìm + nút "Mở màn …") · `TableSkeleton` (colSpan, không lệch cột ẩn) · `Pager` (Trước/Sau + "Hiển thị x–y trong N", `aria-live=polite`).
- `PeTable` (7 cột: Mã phiếu · Gói thầu · Dự án · Người soạn · Ngày · Trạng thái · Hành trình) · `ContractTable` (7 cột: Mã HĐ · Tên HĐ · Loại · Giá trị · Ngày · Trạng thái · Hành trình) · `SoonPanel` (GĐ2/GĐ4).
- 2 query list LAZY: `['dash-stage-pe',{page,pageSize,search}]` (`enabled: stage===1`) · `['dash-stage-contracts',{…}]` (`enabled: stage===3`), cả 2 `placeholderData: keepPreviousData` (đổi trang không nháy trắng) + `aria-busy` + mờ 60% khi `isFetching`.
- State: `stage` · `page` · `searchInput`/`search` (debounce 300ms) · `tabRefs` (roving focus).

### GIỮ NGUYÊN 100% (không đụng)
`PageHeader` title/description · toàn bộ **HERO** (4 `StageCard` + `StageArrow` + `StageStatRow` + `StageLink` + `StageSoon` + `STAGE_TONE` + banner lỗi + `retryFailed`) · query `['my-dashboard']` · `['pipeline-pe-approved']` · **`['pe-inbox']` (key + queryFn + `select: d => d.length` y nguyên — cache dùng chung `InboxPage`)** · `fmtMoney` · mọi target `navigate()` của hero.

### Route đích của bảng (đều là route THẬT trong `App.tsx`)
`onOpen` GĐ1 → `/purchase-evaluations/:id` (đúng cách list "gần đây" cũ navigate) · `onOpen` GĐ3 → `/my-contracts?id=<guid>` (đúng cách cũ) · `onCreate` → `/purchase-evaluations/new` · `/contracts/new` (CTA "Tạo HĐ mới" giữ ở empty-state GĐ3 như owner dặn) · `SoonPanel` → `/coming-soon?stage=2|4`.

## 5 — [LEAD GHI ON-BEHALF @13:2x — vai chết #53 đúng lúc verify CSS, đĩa giữ trọn §1-§4]

- `npm run build` fe-user: **PASS 419ms** (lead chạy độc lập SAU khi vai chết — luật #68).
- 🔴 **Nỗi lo CSS-token của vai (Tailwind v4 drop class không quét được): VERIFY SẠCH** — grep pattern class-ghép-động (`` `...-${...}` `` trong className) = **0 hit**; mọi biến thể màu đi qua lookup-map TĨNH (`STAGE_TONE:158` · `DOT_TEXT:337` · `TAB_TONE:422`) = literal đầy đủ trong source ⇒ Tailwind quét được hết. `divide-slate` src=2/css=1 (present).
- Việc vai CHƯA kịp: tự-review rubric FD4 → chuyển cho `reviewer` adversarial (spawn kế tiếp) — không bịa hộ.

<!-- END sub-frontend-designer-dot4 · TOTAL=5 muc (muc 5 = lead on-behalf) -->
