# 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Ỏ: `
` "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 [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 [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` (`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=` (`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**: `` (chuột bấm đâu cũng được) + trong ô Mã có `