wal: flush 20260730T0946

This commit is contained in:
pqhuy1987
2026-07-30 09:46:39 +07:00
parent c59ece09db
commit 8eba1379b2
9 changed files with 301 additions and 8 deletions

View File

@ -28,12 +28,8 @@
- Fallback khi stack chưa chạy: static component preview / screenshot `/login`**KHÔNG bỏ soi** (FD2 cấm ship-unseen). - Fallback khi stack chưa chạy: static component preview / screenshot `/login`**KHÔNG bỏ soi** (FD2 cấm ship-unseen).
## Component inventory (built/verified — chống reinvent) ## Component inventory (built/verified — chống reinvent)
- **3 shared UI cho Văn phòng số / E-Office (S69, 2026-06-17) — PURO-style + HRM visual language. ALL build-PASS 0 TS:** - _(**3 shared UI E-Office (S69): `ui/PageHeader` giàu (eyebrow/icon/accent) + `ui/KpiCard` filter-chip + `ui/WidgetCard`** — accent map, a11y Enter/Space, gotcha-66 `text-white!` trên nền gradient. verbatim → `archive/2026-06.md` §"S69 … 3 shared UI")_
- `fe-user/src/components/ui/PageHeader.tsx`**richer page header** (eyebrow/title/subtitle/icon/accent/actions/breadcrumb). ⚠️ KHÁC `@/components/PageHeader` (constrained {title,description,actions}) — module path `@/components/ui/PageHeader` riêng, KHÔNG collision. icon-chip accent-tinted + title `text-xl font-bold` accent-head. Local `ACCENT` map {chipBg,chipFg,head} (brand=text-brand-800, rest -700). Title trên nền SÁNG → KHÔNG cần `text-white!`. - _(**`pages/office/OfficeDashboardPage.tsx` (S69)** — E-Office landing PURO, compose 3 widget, reuse hook verbatim 0 API mới. verbatim → `archive/2026-06.md` §"S69 … OfficeDashboardPage")_
- `fe-user/src/components/ui/KpiCard.tsx`**clickable stat card = FILTER chip** (PURO: row KpiCards thay tabs). icon-chip + `.stat-value text-2xl` accent + `.label-eyebrow`. active = `bg-{x}-50` + `border-{x}-300` + `ring-{x}-500`. a11y FULL: `onClick``role=button`+`tabIndex=0`+Enter/Space (`e.key===' '`)+`aria-pressed=active`+focus-visible ring; no-onClick = inert div. hover `-translate-y-0.5` + `motion-reduce:transform-none`.
- `fe-user/src/components/ui/WidgetCard.tsx`**dashboard widget container** (PURO HomePage). Wrap `.card-accent` (rail via inline `--accent`). Header: brand=`.app-gradient-brand text-white` · non-brand=tinted `bg-{x}-50` bar. **gotcha 66 APPLIED:** gradient `<h3>` title = **`text-white!`** (bang) — plain text-white thua unlayered h1-h4 rule. Props: title/icon/accent/stats[]/onExpand/onRefresh/children/empty/emptyText. `stats[]` = clickable StatChip row (mỗi chip a11y button khi có onClick). empty → muted icon-chip + emptyText. Header IconButton (RefreshCw/Maximize2) contrast-adapt gradient↔tinted, aria-label.
- **3 đều:** NAMED export · `import type` (verbatimModuleSyntax) · `cn` from `@/lib/cn` · lucide-react · accent palettes stop 50/100/500/600/700 ONLY (no -800) → head/value -700 (brand -800 OK) · icon-chip recolor `['--chip-bg' as string]`/`['--chip-fg' as string]` inline (pattern từ HRM Card). NO new npm dep. Build `tsc -b && vite` PASS 0 TS, 24.87s (warning @import-order + chunk-size = pre-existing). fe-admin NOT mirrored (separate pass nếu cần). FD2 authed-screenshot SKIP (components chưa wired vào page nào — pure library; visual verify khi page tiêu thụ chúng).
- `fe-user/src/pages/office/OfficeDashboardPage.tsx`**E-Office landing dashboard (S69, 2026-06-17) — PURO HomePage, COMPOSES the 3 shared ui widgets. build-PASS 0 TS, 434ms.** Layout: `ui/PageHeader` (eyebrow "Văn phòng số" / title "Bảng điều khiển" / icon LayoutDashboard / accent brand) on top → `grid grid-cols-1 lg:grid-cols-3 gap-5`: LEFT `lg:col-span-2` = stack of 4 `WidgetCard` (Đề xuất brand / Đơn từ teal / Ticket CNTT violet / Phòng họp hôm nay amberx — each body = row of 3 `KpiCard` filter-chips except Phòng họp = 1 KpiCard + next-4 booking peek list) · RIGHT `lg:col-span-1` = "Công việc của tôi" WidgetCard (brand-50 hero count `myTodo` + 3 clickable `MetricRow`) + "Thao tác nhanh" `.card-accent` panel (3 Button primary/secondary/outline). Stacks 1-col <lg. **Hooks REUSED (verbatim queryKey+endpoint = shared TanStack cache, NO new API, NO new BE):** proposals `['proposals',{…}]``GET /proposals` (+ separate `inboxOnly:true` query for "Cần duyệt" = needs-my-action signal) · đơn-từ `['/leave-requests'|'/ot-requests'|'/travel-requests',{page:1}]`those 3 endpoints, merged+`countByStatus` client-side · tickets `['it-tickets']``GET /it-tickets` · meetings `['meeting-bookings',{…}]``GET /meeting-bookings` windowed to today (local-midnight→+1d ISO). **Counts ALL client-side** (`countByStatus` reducer; status enums from `@/types/proposal` + `@/types/workflowApps`). **States graceful per-widget:** isError`WidgetError` (retry btn, accent-500 chip) · isLoading`WidgetSkeleton` (3 pulse bars, `motion-reduce:animate-none`) · emptyWidgetCard `empty` prop. NEVER blocks page. **Routing question:** only `/proposals/new` exists as real create route quick-actions "Tạo đề xuất"→`/proposals/new`, "Tạo đơn"→`/workflow-apps/leave` (đơn-từ landing, NO standalone /new), "Tạo ticket"→`/it-tickets` (ticket landing) every link hits an EXISTING route (no `*` fallback dead-link). onExpand per widget its real route. a11y: KpiCard/MetricRow clickable = role=button+Enter/Space+focus-ring; reduced-motion honored. Routing/menu wiring NOT done (next agent's job page NOT imported in App.tsx yet). fe-admin NOT mirrored. FD2 authed-screenshot SKIP (ProtectedRoute + gotcha #3 rig blocks authed; visual verify via deploy).
- `fe-user/src/pages/LoginPage.tsx` — login (public, no auth). Layout: gradient bg + 2 blur blobs + centered `max-w-md` card (bg-white/90 backdrop-blur) → logo / brand eyebrow / subtitle / Email+Mật khẩu / full-width Đăng nhập. Uses ui/{Button,Input,Label}. Solid baseline; nearly identical in fe-admin (mirror candidate). - `fe-user/src/pages/LoginPage.tsx` — login (public, no auth). Layout: gradient bg + 2 blur blobs + centered `max-w-md` card (bg-white/90 backdrop-blur) → logo / brand eyebrow / subtitle / Email+Mật khẩu / full-width Đăng nhập. Uses ui/{Button,Input,Label}. Solid baseline; nearly identical in fe-admin (mirror candidate).
- `fe-user/src/pages/hrm/EmployeesListPage.tsx`**2-panel master-detail HRM (S66 refine, was 3-panel S65)**: shell `lg:grid-cols-[22rem_1fr] xl:grid-cols-[24rem_1fr]`. **CỘT TRÁI** = `<div flex flex-col gap-4>` chứa Org-tree (TRÊN, `lg:max-h-[44%] lg:shrink-0`, cuộn riêng) + List+filter (DƯỚI, `flex-1`, cuộn riêng). **CỘT PHẢI** = Detail 5-tab (flex-1, rộng). <lg 1-col (treelistdetail) + mobile tree-toggle `treeOpenMobile` (tree `hidden``flex`). Org tree = recursive `TreeNode` consume `GET /api/departments/tree` (DepartmentTreeNode {id,code,name,parentId,directEmployeeCount,totalEmployeeCount,children}); gốc "SOLUTION COMPANY" (`companyOpen`) `pickDept(null)`=all; `CountBadge` (totalEmployeeCount, active=brand-600 fill) `deptId` URL param. Detail = avatar header (`.app-gradient-brand` + initials-in-rounded-2xl) + 5-tab nav (Tổng quan/Thân nhân/Trình độ/Kinh nghiệm/Hợp đồng) count pills + brand underline. **Accent system (S66 việc 2+3):** `type Accent='brand'|'teal'|'violet'|'amberx'|'greenx'` + `ACCENT` map (chipBg/chipFg/head/rail/labelText). `Card` nhận `accent` prop `.icon-chip` tinted (`--chip-bg`/`--chip-fg` inline) + heading `text-{x}-700` + left rail pseudo `before:content-[''] before:w-1 before:bg-{x}-500` (clip qua overflow-hidden). `Field` label = `text-{x}-700` uppercase semibold (was slate-400), value = `font-medium text-slate-900` (was slate-800). OverviewTab: 1 accent/card (Thông tin chung=brand, Sức khoẻ/Lương=greenx, Liên hệ/Ngân hàng=teal, Giấy tờ/Đoàn thể=violet, Công việc=amberx). Tab-body sections: family=violet, đào tạo=teal, kỹ năng=greenx, công tác=amberx, hợp đồng=brand. **accent palettes chỉ có stop 50/100/500/600/700 — KHÔNG -800** head dùng -700 (else Tailwind v4 silent no-class). Reusable: `Avatar`(hash 5 gradients + dim), `CountBadge`, `Card`(+accent), `Field`(+accent/mono/icon/full). **ALL 5 satellite CRUD + 15 satellite api endpoint (+ top-level del + 3 reads) + 3 query keys preserved verbatim** (grep+tsc verified, layout/style-only). fe-admin NOT mirrored (separate pass). - `fe-user/src/pages/hrm/EmployeesListPage.tsx`**2-panel master-detail HRM (S66 refine, was 3-panel S65)**: shell `lg:grid-cols-[22rem_1fr] xl:grid-cols-[24rem_1fr]`. **CỘT TRÁI** = `<div flex flex-col gap-4>` chứa Org-tree (TRÊN, `lg:max-h-[44%] lg:shrink-0`, cuộn riêng) + List+filter (DƯỚI, `flex-1`, cuộn riêng). **CỘT PHẢI** = Detail 5-tab (flex-1, rộng). <lg 1-col (treelistdetail) + mobile tree-toggle `treeOpenMobile` (tree `hidden``flex`). Org tree = recursive `TreeNode` consume `GET /api/departments/tree` (DepartmentTreeNode {id,code,name,parentId,directEmployeeCount,totalEmployeeCount,children}); gốc "SOLUTION COMPANY" (`companyOpen`) `pickDept(null)`=all; `CountBadge` (totalEmployeeCount, active=brand-600 fill) `deptId` URL param. Detail = avatar header (`.app-gradient-brand` + initials-in-rounded-2xl) + 5-tab nav (Tổng quan/Thân nhân/Trình độ/Kinh nghiệm/Hợp đồng) count pills + brand underline. **Accent system (S66 việc 2+3):** `type Accent='brand'|'teal'|'violet'|'amberx'|'greenx'` + `ACCENT` map (chipBg/chipFg/head/rail/labelText). `Card` nhận `accent` prop `.icon-chip` tinted (`--chip-bg`/`--chip-fg` inline) + heading `text-{x}-700` + left rail pseudo `before:content-[''] before:w-1 before:bg-{x}-500` (clip qua overflow-hidden). `Field` label = `text-{x}-700` uppercase semibold (was slate-400), value = `font-medium text-slate-900` (was slate-800). OverviewTab: 1 accent/card (Thông tin chung=brand, Sức khoẻ/Lương=greenx, Liên hệ/Ngân hàng=teal, Giấy tờ/Đoàn thể=violet, Công việc=amberx). Tab-body sections: family=violet, đào tạo=teal, kỹ năng=greenx, công tác=amberx, hợp đồng=brand. **accent palettes chỉ có stop 50/100/500/600/700 — KHÔNG -800** head dùng -700 (else Tailwind v4 silent no-class). Reusable: `Avatar`(hash 5 gradients + dim), `CountBadge`, `Card`(+accent), `Field`(+accent/mono/icon/full). **ALL 5 satellite CRUD + 15 satellite api endpoint (+ top-level del + 3 reads) + 3 query keys preserved verbatim** (grep+tsc verified, layout/style-only). fe-admin NOT mirrored (separate pass).
@ -49,8 +45,9 @@
- **Lucide alias-dodge:** icon từ export-aggregation-line (`SendHorizonal`) / mojibake token (`网络Placeholder` autocomplete) → dùng proven-safe export (`Send`), node-check icons trước build. - **Lucide alias-dodge:** icon từ export-aggregation-line (`SendHorizonal`) / mojibake token (`网络Placeholder` autocomplete) → dùng proven-safe export (`Send`), node-check icons trước build.
## Activity log ## Activity log
- **S162 (2026-07-30) Cây 4 folder GIAI ĐOẠN dưới từng gói thầu — 3 file MỚI + 3 trang, MIRROR đủ 2 app, build PASS ×2.** Owner (screenshot khoanh đỏ dải 4 GĐ → cây trái trang Duyệt NCC) + AskUser chốt: folder đặt DƯỚI TỪNG GÓI THẦU · áp cả trang GĐ khác. Nhãn VERBATIM "Duyệt NCC · Kế hoạch Hợp đồng · Duyệt Hợp đồng · Hợp đồng cứng" (GĐ2 CỐ Ý khác nhãn strip `PePipelineStrip` = lời-mới-nhất-thắng; KHÔNG sửa strip/menu). **3 file mới:** `components/pipeline/PipelineStageFolders.tsx` (thuần trình bày; `<details>` controlled `useState(n===currentStage)`; prop `content` cho GĐ1 giữ leaf phiếu cũ; `locked`/`failed`/`loading`/`hideEmpty`) · `hooks/usePipelineStages.ts` (2 query index key MỚI `pipeline-khkk-index`/`pipeline-contract-index` pageSize 200 — `PagedResult.cs:20` KẸP ≤200 không 400; `buildStages(peItems)` nối GĐ2 qua `khkk.purchaseEvaluationId`, GĐ3 qua `pe.contractId`, GĐ4 = HĐ đó có `hasSealedCopy`) · `components/pipeline/PipelineTreePanel.tsx` (panel cây trái cho trang GĐ2/GĐ4; `buildTree` RIÊNG, KHÔNG đụng memo `yearGroups` trang PE). **Wire:** PE list (`currentStage=1`, khối `<li>` phiếu giữ NGUYÊN TỪNG BYTE kể cả thụt lề ⇒ diff chỉ 2 mẩu bọc = bằng chứng không đụng behavior) · KhkkListPage (=2) · HardCopiesPage (=4), 2 trang sau bọc lưới `lg:grid-cols-[19rem_minmax(0,1fr)]` + `min-w-0` + `<table>` bọc `overflow-x-auto` + KPI `lg:grid-cols-6``xl:`. **Permission 2 tầng (S118/#82):** gate TRƯỚC fetch, `enabled:false` ⇒ 0 rác 403; ⚠️ phải OR NHIỀU KEY vì `GetMyMenuTreeQuery.cs:96` trả root canRead=false khi con có quyền, `Ct_*` kế thừa `Contracts` nhưng `Khkk_*`/`Hdc_*` KHÔNG. Tag [s162, pipeline-stage-folders, 4-folder-gd, permission-or-keys, index-query-key-moi, leaf-byte-verbatim, sha-pair-6-file].
- **S159 (2026-07-29) ARC-2 "Tổng quan quy trình" ×2 lượt — `UserDashboardPage.tsx` fe-user (đợt-1 hero pipeline + đợt-4 redesign list toàn-trình).** *(Entry on-behalf bởi lead @S160 closeout M9 — VERIFY từ 2 sub-file + commit; cả 2 lượt vai ghi-đĩa-trong-lúc-làm nên #53 ×2 first-return mất 0 B.)* **Đợt-1** (`b5799fc`, `sub-frontend-designer-tongquan.md` 15.803 B): file 309 dòng → THÊM hero pipeline 4 GĐ trên cùng, GIỮ trọn 3 query (`my-dashboard`/`my-contracts-recent`/`my-pe-recent`) + 5 StatCard + 2 list (owner: *"đừng vứt"*); đổi PageHeader title "Tổng quan tất cả luồng duyệt". **Đợt-4** (`a2bbcb9`, `sub-frontend-designer-dot4.md` 13.956 B): file lúc đó 766 dòng — đọc đĩa KHÔNG dùng bản nhớ (đã qua 3 đợt sửa nhãn giữa 2 lượt); bỏ khối owner khoanh (5 StatCard + 2 list "gần đây") → **list chi tiết toàn-trình**; GIỮ hero (owner không khoanh — *đợt-5 vai khác mới bỏ hero theo lệnh anh*); nhãn 4 GĐ echo bản MỚI NHẤT từ comment đầu file (GĐ2 "Kế hoạch ký kết HĐ", KHÔNG lấy tên đợt-1 "Đề xuất ký kết hợp đồng" đã superseded); +2 query `['pipeline-pe-approved']` (phase=7 pageSize=1 lấy `.total`) + `['pe-inbox']` **dùng `select: d => d.length` để KHÔNG vỡ cache dùng chung InboxPage**; verify param BE bằng đọc controller thật. 🔴 **Môi trường spawn KHÔNG có dev-server/Playwright ⇒ FD2 visual-loop bất-khả — thay bằng `npm run build` PASS + tự-review tĩnh FD4, KHAI THẲNG không bịa screenshot** (khuôn NO-SCREENSHOT này tái dùng cho mọi spawn thiếu rig). Bài giữ lại: (i) giữa 2 lượt cùng file, dòng-đếm/nhãn đổi ⇒ **khảo sát lại từ đĩa mỗi lượt**; (ii) đổi consumer của endpoint dùng-chung → `select` transform thay đổi shape cache. Tag [s159, tong-quan-quy-trinh, hero-pipeline-4gd, dot4-list-toan-trinh, no-screenshot-khai-that, select-transform-cache-safe, doc-dia-moi-luot, on-behalf-m9]. - **S159 (2026-07-29) ARC-2 "Tổng quan quy trình" ×2 lượt — `UserDashboardPage.tsx` fe-user (đợt-1 hero pipeline + đợt-4 redesign list toàn-trình).** *(Entry on-behalf bởi lead @S160 closeout M9 — VERIFY từ 2 sub-file + commit; cả 2 lượt vai ghi-đĩa-trong-lúc-làm nên #53 ×2 first-return mất 0 B.)* **Đợt-1** (`b5799fc`, `sub-frontend-designer-tongquan.md` 15.803 B): file 309 dòng → THÊM hero pipeline 4 GĐ trên cùng, GIỮ trọn 3 query (`my-dashboard`/`my-contracts-recent`/`my-pe-recent`) + 5 StatCard + 2 list (owner: *"đừng vứt"*); đổi PageHeader title "Tổng quan tất cả luồng duyệt". **Đợt-4** (`a2bbcb9`, `sub-frontend-designer-dot4.md` 13.956 B): file lúc đó 766 dòng — đọc đĩa KHÔNG dùng bản nhớ (đã qua 3 đợt sửa nhãn giữa 2 lượt); bỏ khối owner khoanh (5 StatCard + 2 list "gần đây") → **list chi tiết toàn-trình**; GIỮ hero (owner không khoanh — *đợt-5 vai khác mới bỏ hero theo lệnh anh*); nhãn 4 GĐ echo bản MỚI NHẤT từ comment đầu file (GĐ2 "Kế hoạch ký kết HĐ", KHÔNG lấy tên đợt-1 "Đề xuất ký kết hợp đồng" đã superseded); +2 query `['pipeline-pe-approved']` (phase=7 pageSize=1 lấy `.total`) + `['pe-inbox']` **dùng `select: d => d.length` để KHÔNG vỡ cache dùng chung InboxPage**; verify param BE bằng đọc controller thật. 🔴 **Môi trường spawn KHÔNG có dev-server/Playwright ⇒ FD2 visual-loop bất-khả — thay bằng `npm run build` PASS + tự-review tĩnh FD4, KHAI THẲNG không bịa screenshot** (khuôn NO-SCREENSHOT này tái dùng cho mọi spawn thiếu rig). Bài giữ lại: (i) giữa 2 lượt cùng file, dòng-đếm/nhãn đổi ⇒ **khảo sát lại từ đĩa mỗi lượt**; (ii) đổi consumer của endpoint dùng-chung → `select` transform thay đổi shape cache. Tag [s159, tong-quan-quy-trinh, hero-pipeline-4gd, dot4-list-toan-trinh, no-screenshot-khai-that, select-transform-cache-safe, doc-dia-moi-luot, on-behalf-m9].
- **S79 (2026-06-19) PE list fe-user DECOUPLE chọn/mở-rộng (anh chốt, annotated screenshot):** `pages/pe/PurchaseEvaluationsListPage.tsx`. S78 cũ: bấm dòng = mở overlay full-bleed, 2 panel giữa/phải LÀ placeholder vĩnh viễn. S79 đổi: **bấm dòng = inline 3-panel detail "như cũ" (8e68ed1)** + thêm nút **"Xem mở rộng"** mỗi dòng → overlay. Mechanism = param thứ-2 **`?expand=1`** cạnh `?id`: overlay render CHỈ khi `id && expand`; inline detail (panel giữa `<PeDetailTabs readOnly onBack=closeDetail>` + panel phải `<PeWorkflowPanel readOnly={!pendingMe} onApproved>`) render khi `id && !expand`. Handlers: `selectRow`=set id+clear expand (NHƯNG `<lg`/innerWidth<1024 set expand=1 đi thẳng overlay 3-panel không vừa) · `expandRow`=id+expand=1 · `collapseFocus`(Thu gọn/Esc/backdrop)=`setParam('expand',null)` GIỮ id (về inline, KHÔNG về list) · `closeDetail`(← Đóng/del)=clear id+expand list · `onApproved`=clear cả hai list (phiếu rời inbox). Overlay gate đổi `hasSelection``overlayActive=id&&expand`; Esc gọi collapseFocus (không closeFocus). Nút "Xem mở rộng" = `Maximize2` (lucide, import THÊM) icon-btn 7×7 góc phải-trên mỗi leaf row, `opacity-0 group-hover/row:opacity-100` + `opacity-100` khi selected, `title`+`aria-label`+brand focus-ring; leaf `<button>` bọc trong `<div className="group/row relative">` + `pr-9` chừa chỗ nút. **ALL logic VERBATIM** (grep ✓): 3 queryKey (`approval-workflows-v2-filter`/`pe-list`/`pe-detail`) · 3 endpoint (`/inbox`+paged+detail) · `del` mutation (onSuccess giờ clear id+expand thay `setParam`) · `yearGroups` 4-tầng tree memo · `PeUrgentChips` pill · search/filter/SLA/localStorage-expand. `PurchaseEvaluationDetailPage` route GIỮ. **slide+a11y S78 giữ NGUYÊN** (mount-rAF×2, role=dialog/aria-modal, focus-trap Tab-vòng, body scroll-lock, motion-reduce). `npm run build` (tsc -b strict + vite v8) **PASS 0 TS err, 570ms** (3 warning pre-existing). **FD2: static layout-shell harness** (backend :5443 down authed dev-rig blocked gotcha#3) Playwright 6 shot × 3 state: (a) inline 3-panel desktop1920+laptop1280 = list+PeDetailTabs giữa+PeWorkflowPanel phải, dòng-chọn nút Maximize2; (b) overlay 1920+1280 = full-bleed phiếu+panel cạnh; (c) mobile390 list (1-col, nút mở-rộng) + mobile390 overlay (stack). Read tất 6 PNG đúng hết, no overflow, pill+tree intact. Harness/driver/shots ĐÃ XÓA. Rubric PASS. fe-admin NOT mirrored (implementer-frontend SHA-mirror riêng), no BE, no commit. Tag [s79, pe-decouple-select-expand, expand-param, maximize2-row-btn, inline-restore-8e68ed1, lg-straight-overlay, collapse-keeps-id, logic-verbatim, build-pass, harness-fd2]. - _(**S79 (2026-06-19) PE list fe-user DECOUPLE chọn/mở-rộng: `?expand=1` cạnh `?id`, nút Maximize2 mỗi dòng, ALL logic verbatim, harness-FD2 6 shot.** verbatim → `archive/2026-06.md` §"S79 (2026-06-19)")_
- _(**S69 (2026-06-17) OfficeDashboardPage.tsx fe-user — E-Office landing PURO, COMPOSES 3 shared widgets, reuse hooks verbatim 0 new API, build PASS 0 TS.** verbatim → `archive/2026-06.md` substring:"S69 (2026-06-17) OfficeDashboardPage.tsx")_ - _(**S69 (2026-06-17) OfficeDashboardPage.tsx fe-user — E-Office landing PURO, COMPOSES 3 shared widgets, reuse hooks verbatim 0 new API, build PASS 0 TS.** verbatim → `archive/2026-06.md` substring:"S69 (2026-06-17) OfficeDashboardPage.tsx")_
- _(**S69 (2026-06-17) Văn phòng số / Đơn từ RE-SKIN 2 file (PURO+HRM visual, client-side filter view, logic verbatim, lucide alias-dodge Send).** verbatim → `archive/2026-06.md` substring:"S69 (2026-06-17) Văn phòng số / Đơn từ")_ - _(**S69 (2026-06-17) Văn phòng số / Đơn từ RE-SKIN 2 file (PURO+HRM visual, client-side filter view, logic verbatim, lucide alias-dodge Send).** verbatim → `archive/2026-06.md` substring:"S69 (2026-06-17) Văn phòng số / Đơn từ")_

View File

@ -15,3 +15,16 @@
- **S69 (2026-06-17) OfficeDashboardPage.tsx fe-user E-Office landing, PURO HomePage, COMPOSES 3 shared widgets:** built `pages/office/OfficeDashboardPage.tsx` (~400 LOC) over EXISTING data hooks of 4 modules. Read-first: 3 ui widgets (exact prop sigs) + 4 source pages (ProposalsListPage/WorkflowAppsListPage/ItTicketsPage/MeetingCalendarPage) to harvest queryKey+endpoint + types + App.tsx routes. **Reused hooks verbatim** shared TanStack cache, ZERO new API/BE: `GET /proposals` (+inboxOnly query for needs-my-action) · `/leave|ot|travel-requests` (merged, countByStatus client-side) · `/it-tickets` · `/meeting-bookings` (today window). Layout = PageHeader(brand) + `lg:grid-cols-3` [LEFT col-span-2 = 4 WidgetCards w/ KpiCard filter-chip bodies | RIGHT col-span-1 = "Công việc của tôi" hero+MetricRows + "Thao tác nhanh" 3 buttons], 1-col <lg. **Routing insight (verified App.tsx):** đơn-từ + ticket have NO standalone `/new` route (creation in-page) quick-actions point at landings `/workflow-apps/leave` + `/it-tickets` (only `/proposals/new` is a real create route) so no link hits the `*` "chưa build" fallback. Per-widget graceful states: WidgetError(retry)/WidgetSkeleton(pulse, motion-reduce)/empty never blocks. a11y full (role=button+Enter/Space+focus-ring on clickables, reduced-motion). `npm run build` (tsc -b strict + vite v8) **PASS 0 TS err, 434ms** (only pre-existing @import-order + chunk-size + INEFFECTIVE_DYNAMIC_IMPORT warnings none from new file). Routing/menu NOT wired (next agent; page not yet in App.tsx). fe-admin NOT mirrored. FD2 authed-screenshot SKIP (ProtectedRoute + rig gotcha #3 verify via deploy). Tag [s69, office-dashboard, compose-3-widgets, reuse-hooks-shared-cache, no-new-be, routing-existing-only, build-pass]. - **S69 (2026-06-17) OfficeDashboardPage.tsx fe-user E-Office landing, PURO HomePage, COMPOSES 3 shared widgets:** built `pages/office/OfficeDashboardPage.tsx` (~400 LOC) over EXISTING data hooks of 4 modules. Read-first: 3 ui widgets (exact prop sigs) + 4 source pages (ProposalsListPage/WorkflowAppsListPage/ItTicketsPage/MeetingCalendarPage) to harvest queryKey+endpoint + types + App.tsx routes. **Reused hooks verbatim** shared TanStack cache, ZERO new API/BE: `GET /proposals` (+inboxOnly query for needs-my-action) · `/leave|ot|travel-requests` (merged, countByStatus client-side) · `/it-tickets` · `/meeting-bookings` (today window). Layout = PageHeader(brand) + `lg:grid-cols-3` [LEFT col-span-2 = 4 WidgetCards w/ KpiCard filter-chip bodies | RIGHT col-span-1 = "Công việc của tôi" hero+MetricRows + "Thao tác nhanh" 3 buttons], 1-col <lg. **Routing insight (verified App.tsx):** đơn-từ + ticket have NO standalone `/new` route (creation in-page) quick-actions point at landings `/workflow-apps/leave` + `/it-tickets` (only `/proposals/new` is a real create route) so no link hits the `*` "chưa build" fallback. Per-widget graceful states: WidgetError(retry)/WidgetSkeleton(pulse, motion-reduce)/empty never blocks. a11y full (role=button+Enter/Space+focus-ring on clickables, reduced-motion). `npm run build` (tsc -b strict + vite v8) **PASS 0 TS err, 434ms** (only pre-existing @import-order + chunk-size + INEFFECTIVE_DYNAMIC_IMPORT warnings none from new file). Routing/menu NOT wired (next agent; page not yet in App.tsx). fe-admin NOT mirrored. FD2 authed-screenshot SKIP (ProtectedRoute + rig gotcha #3 verify via deploy). Tag [s69, office-dashboard, compose-3-widgets, reuse-hooks-shared-cache, no-new-be, routing-existing-only, build-pass].
- **S69 (2026-06-17) Văn phòng số / Đơn từ fe-user RE-SKIN (PURO + HRM visual lang) 2 file, CONSUMES shared ui (parallel fan-out, 7 agents same app):** surgical re-skin (KHÔNG rewrite) `pages/office/WorkflowAppsListPage.tsx` + `WorkflowAppDetailPage.tsx` (`:kind`-driven leave/ot/travel/vehicle via KIND_CONFIG). **LIST:** swap `@/components/PageHeader`(constrained)→`@/components/ui/PageHeader`(eyebrow "Văn phòng số · Đơn từ" / title từ KIND_CONFIG / icon per-kind / **accent teal**) + status filter = ROW 4 `ui/KpiCard` (Tất cả teal / Đã gửi duyệt amberx / Trả lại violet / Đã duyệt greenx, `grid-cols-2 sm:grid-cols-4`) + slate table chrome (thead uppercase 11px slate-500, hover teal-50). **Filter là CLIENT-SIDE view** added `useState<StatusFilter>` + 2 `useMemo` (counts + visibleItems) DERIVED over already-fetched `items`; **KHÔNG touch query/endpoint/queryKey/navigation** (page chỉ fetch `page:1` như , không filter-state sẵn nên thêm view-layer = thuần presentation; empty-state phân biệt "chưa data" vs "không đơn trạng thái này"). **DETAIL:** ui/PageHeader teal + 4 section dùng local `Card`(accent-rail pseudo `before:bg-{x}-500` + icon-chip) + `Field`(label uppercase `text-{x}-700`, value `text-brand-800`) copy idiom HRM EmployeesListPage (KHÔNG import HRM, helper local riêng). Accent gán: Thông tin=teal · Số phép=greenx · Quy trình=violet · Ý kiến=brand. Status badge giữ `WORKFLOW_APP_STATUS_BADGE`. Drop raw "⚠" emoji trong over-budget bannertext thuần (anti-slop FD3). **ALL data logic VERBATIM** (grep-verified): 2 query `[endpoint,id]`+`['approval-workflows-v2',applicableType]` (key/endpoint/`enabled` y nguyên) · 3 mutation pinWorkflow(PUT /workflow)/submit(POST /submit)/action(POST /{k}) body+onSuccess+invalidate identical · 3 state + flags isDraft/isInWorkflow/hasWorkflow + mọi onClick/nav target bất biến. **gotcha Tailwind-v4 stop:** dùng CHỈ -50/-500/-700 cho teal/violet/amberx/greenx (no -800) `border-l-greenx-500`/`bg-amberx-50`/`text-amberx-700` đều stop tồn tại (index.css verified). **Self-caught:** `SendHorizonal` (KpiCard "Đã gửi duyệt" icon) export-aggregation-line trong lucide d.ts đổi `Send` (proven-safe export) tránh alias-risk. Self-review: mọi import resolve, 0 unused local (noUnusedLocals strict) `Info`/`Wallet`/`GitBranch`/`MessageSquareText` đều dùng. **KHÔNG run npm build** (em main builds central, 7-agent parallel interference) + KHÔNG modify ui/index.css (other agents edit). FD2 authed-screenshot SKIP (ProtectedRoute + rig gotcha #3 verify via deploy). fe-admin NOT mirrored. Tag [s69, eoffice-donutu-reskin, puro-hrm-visual, consume-ui-pageheader-kpicard, client-side-filter-view, logic-verbatim, no-build-parallel-fanout, lucide-alias-dodge]. - **S69 (2026-06-17) Văn phòng số / Đơn từ fe-user RE-SKIN (PURO + HRM visual lang) 2 file, CONSUMES shared ui (parallel fan-out, 7 agents same app):** surgical re-skin (KHÔNG rewrite) `pages/office/WorkflowAppsListPage.tsx` + `WorkflowAppDetailPage.tsx` (`:kind`-driven leave/ot/travel/vehicle via KIND_CONFIG). **LIST:** swap `@/components/PageHeader`(constrained)→`@/components/ui/PageHeader`(eyebrow "Văn phòng số · Đơn từ" / title từ KIND_CONFIG / icon per-kind / **accent teal**) + status filter = ROW 4 `ui/KpiCard` (Tất cả teal / Đã gửi duyệt amberx / Trả lại violet / Đã duyệt greenx, `grid-cols-2 sm:grid-cols-4`) + slate table chrome (thead uppercase 11px slate-500, hover teal-50). **Filter là CLIENT-SIDE view** added `useState<StatusFilter>` + 2 `useMemo` (counts + visibleItems) DERIVED over already-fetched `items`; **KHÔNG touch query/endpoint/queryKey/navigation** (page chỉ fetch `page:1` như , không filter-state sẵn nên thêm view-layer = thuần presentation; empty-state phân biệt "chưa data" vs "không đơn trạng thái này"). **DETAIL:** ui/PageHeader teal + 4 section dùng local `Card`(accent-rail pseudo `before:bg-{x}-500` + icon-chip) + `Field`(label uppercase `text-{x}-700`, value `text-brand-800`) copy idiom HRM EmployeesListPage (KHÔNG import HRM, helper local riêng). Accent gán: Thông tin=teal · Số phép=greenx · Quy trình=violet · Ý kiến=brand. Status badge giữ `WORKFLOW_APP_STATUS_BADGE`. Drop raw "⚠" emoji trong over-budget bannertext thuần (anti-slop FD3). **ALL data logic VERBATIM** (grep-verified): 2 query `[endpoint,id]`+`['approval-workflows-v2',applicableType]` (key/endpoint/`enabled` y nguyên) · 3 mutation pinWorkflow(PUT /workflow)/submit(POST /submit)/action(POST /{k}) body+onSuccess+invalidate identical · 3 state + flags isDraft/isInWorkflow/hasWorkflow + mọi onClick/nav target bất biến. **gotcha Tailwind-v4 stop:** dùng CHỈ -50/-500/-700 cho teal/violet/amberx/greenx (no -800) `border-l-greenx-500`/`bg-amberx-50`/`text-amberx-700` đều stop tồn tại (index.css verified). **Self-caught:** `SendHorizonal` (KpiCard "Đã gửi duyệt" icon) export-aggregation-line trong lucide d.ts đổi `Send` (proven-safe export) tránh alias-risk. Self-review: mọi import resolve, 0 unused local (noUnusedLocals strict) `Info`/`Wallet`/`GitBranch`/`MessageSquareText` đều dùng. **KHÔNG run npm build** (em main builds central, 7-agent parallel interference) + KHÔNG modify ui/index.css (other agents edit). FD2 authed-screenshot SKIP (ProtectedRoute + rig gotcha #3 verify via deploy). fe-admin NOT mirrored. Tag [s69, eoffice-donutu-reskin, puro-hrm-visual, consume-ui-pageheader-kpicard, client-side-filter-view, logic-verbatim, no-build-parallel-fanout, lucide-alias-dodge].
## S79 (2026-06-19) PE list fe-user — DECOUPLE chọn/mở-rộng
- **S79 (2026-06-19) PE list fe-user DECOUPLE chọn/mở-rộng (anh chốt, annotated screenshot):** `pages/pe/PurchaseEvaluationsListPage.tsx`. S78 cũ: bấm dòng = mở overlay full-bleed, 2 panel giữa/phải placeholder vĩnh viễn. S79 đổi: **bấm dòng = inline 3-panel detail "như cũ" (8e68ed1)** + thêm nút **"Xem mở rộng"** mỗi dòng overlay. Mechanism = param thứ-2 **`?expand=1`** cạnh `?id`: overlay render CHỈ khi `id && expand`; inline detail (panel giữa `<PeDetailTabs readOnly onBack=closeDetail>` + panel phải `<PeWorkflowPanel readOnly={!pendingMe} onApproved>`) render khi `id && !expand`. Handlers: `selectRow`=set id+clear expand (NHƯNG `<lg`/innerWidth<1024 set expand=1 đi thẳng overlay 3-panel không vừa) · `expandRow`=id+expand=1 · `collapseFocus`(Thu gọn/Esc/backdrop)=`setParam('expand',null)` GIỮ id (về inline, KHÔNG về list) · `closeDetail`(← Đóng/del)=clear id+expand list · `onApproved`=clear cả hai list (phiếu rời inbox). Overlay gate đổi `hasSelection``overlayActive=id&&expand`; Esc gọi collapseFocus (không closeFocus). Nút "Xem mở rộng" = `Maximize2` (lucide, import THÊM) icon-btn 7×7 góc phải-trên mỗi leaf row, `opacity-0 group-hover/row:opacity-100` + `opacity-100` khi selected, `title`+`aria-label`+brand focus-ring; leaf `<button>` bọc trong `<div className="group/row relative">` + `pr-9` chừa chỗ nút. **ALL logic VERBATIM** (grep ✓): 3 queryKey (`approval-workflows-v2-filter`/`pe-list`/`pe-detail`) · 3 endpoint (`/inbox`+paged+detail) · `del` mutation (onSuccess giờ clear id+expand thay `setParam`) · `yearGroups` 4-tầng tree memo · `PeUrgentChips` pill · search/filter/SLA/localStorage-expand. `PurchaseEvaluationDetailPage` route GIỮ. **slide+a11y S78 giữ NGUYÊN** (mount-rAF×2, role=dialog/aria-modal, focus-trap Tab-vòng, body scroll-lock, motion-reduce). `npm run build` (tsc -b strict + vite v8) **PASS 0 TS err, 570ms** (3 warning pre-existing). **FD2: static layout-shell harness** (backend :5443 down authed dev-rig blocked gotcha#3) Playwright 6 shot × 3 state: (a) inline 3-panel desktop1920+laptop1280 = list+PeDetailTabs giữa+PeWorkflowPanel phải, dòng-chọn nút Maximize2; (b) overlay 1920+1280 = full-bleed phiếu+panel cạnh; (c) mobile390 list (1-col, nút mở-rộng) + mobile390 overlay (stack). Read tất 6 PNG đúng hết, no overflow, pill+tree intact. Harness/driver/shots ĐÃ XÓA. Rubric PASS. fe-admin NOT mirrored (implementer-frontend SHA-mirror riêng), no BE, no commit. Tag [s79, pe-decouple-select-expand, expand-param, maximize2-row-btn, inline-restore-8e68ed1, lg-straight-overlay, collapse-keeps-id, logic-verbatim, build-pass, harness-fd2].
## S69 (2026-06-17) 3 shared UI cho Văn phòng số / E-Office
- **3 shared UI cho Văn phòng số / E-Office (S69, 2026-06-17) PURO-style + HRM visual language. ALL build-PASS 0 TS:**
- `fe-user/src/components/ui/PageHeader.tsx` **richer page header** (eyebrow/title/subtitle/icon/accent/actions/breadcrumb). KHÁC `@/components/PageHeader` (constrained {title,description,actions}) module path `@/components/ui/PageHeader` riêng, KHÔNG collision. icon-chip accent-tinted + title `text-xl font-bold` accent-head. Local `ACCENT` map {chipBg,chipFg,head} (brand=text-brand-800, rest -700). Title trên nền SÁNG KHÔNG cần `text-white!`.
- `fe-user/src/components/ui/KpiCard.tsx` **clickable stat card = FILTER chip** (PURO: row KpiCards thay tabs). icon-chip + `.stat-value text-2xl` accent + `.label-eyebrow`. active = `bg-{x}-50` + `border-{x}-300` + `ring-{x}-500`. a11y FULL: `onClick``role=button`+`tabIndex=0`+Enter/Space (`e.key===' '`)+`aria-pressed=active`+focus-visible ring; no-onClick = inert div. hover `-translate-y-0.5` + `motion-reduce:transform-none`.
- `fe-user/src/components/ui/WidgetCard.tsx` **dashboard widget container** (PURO HomePage). Wrap `.card-accent` (rail via inline `--accent`). Header: brand=`.app-gradient-brand text-white` · non-brand=tinted `bg-{x}-50` bar. **gotcha 66 APPLIED:** gradient `<h3>` title = **`text-white!`** (bang) plain text-white thua unlayered h1-h4 rule. Props: title/icon/accent/stats[]/onExpand/onRefresh/children/empty/emptyText. `stats[]` = clickable StatChip row (mỗi chip a11y button khi onClick). empty muted icon-chip + emptyText. Header IconButton (RefreshCw/Maximize2) contrast-adapt gradienttinted, aria-label.
- **3 đều:** NAMED export · `import type` (verbatimModuleSyntax) · `cn` from `@/lib/cn` · lucide-react · accent palettes stop 50/100/500/600/700 ONLY (no -800) head/value -700 (brand -800 OK) · icon-chip recolor `['--chip-bg' as string]`/`['--chip-fg' as string]` inline (pattern từ HRM Card). NO new npm dep. Build `tsc -b && vite` PASS 0 TS, 24.87s (warning @import-order + chunk-size = pre-existing). fe-admin NOT mirrored (separate pass nếu cần). FD2 authed-screenshot SKIP (components chưa wired vào page nào pure library; visual verify khi page tiêu thụ chúng).
## S69 (2026-06-17) OfficeDashboardPage.tsx — E-Office landing
- `fe-user/src/pages/office/OfficeDashboardPage.tsx` **E-Office landing dashboard (S69, 2026-06-17) — PURO HomePage, COMPOSES the 3 shared ui widgets. build-PASS 0 TS, 434ms.** Layout: `ui/PageHeader` (eyebrow "Văn phòng số" / title "Bảng điều khiển" / icon LayoutDashboard / accent brand) on top `grid grid-cols-1 lg:grid-cols-3 gap-5`: LEFT `lg:col-span-2` = stack of 4 `WidgetCard` (Đề xuất brand / Đơn từ teal / Ticket CNTT violet / Phòng họp hôm nay amberx each body = row of 3 `KpiCard` filter-chips except Phòng họp = 1 KpiCard + next-4 booking peek list) · RIGHT `lg:col-span-1` = "Công việc của tôi" WidgetCard (brand-50 hero count `myTodo` + 3 clickable `MetricRow`) + "Thao tác nhanh" `.card-accent` panel (3 Button primary/secondary/outline). Stacks 1-col <lg. **Hooks REUSED (verbatim queryKey+endpoint = shared TanStack cache, NO new API, NO new BE):** proposals `['proposals',{…}]``GET /proposals` (+ separate `inboxOnly:true` query for "Cần duyệt" = needs-my-action signal) · đơn-từ `['/leave-requests'|'/ot-requests'|'/travel-requests',{page:1}]`those 3 endpoints, merged+`countByStatus` client-side · tickets `['it-tickets']``GET /it-tickets` · meetings `['meeting-bookings',{…}]``GET /meeting-bookings` windowed to today (local-midnight→+1d ISO). **Counts ALL client-side** (`countByStatus` reducer; status enums from `@/types/proposal` + `@/types/workflowApps`). **States graceful per-widget:** isError`WidgetError` (retry btn, accent-500 chip) · isLoading`WidgetSkeleton` (3 pulse bars, `motion-reduce:animate-none`) · emptyWidgetCard `empty` prop. NEVER blocks page. **Routing question:** only `/proposals/new` exists as real create route quick-actions "Tạo đề xuất"→`/proposals/new`, "Tạo đơn"→`/workflow-apps/leave` (đơn-từ landing, NO standalone /new), "Tạo ticket"→`/it-tickets` (ticket landing) every link hits an EXISTING route (no `*` fallback dead-link). onExpand per widget its real route. a11y: KpiCard/MetricRow clickable = role=button+Enter/Space+focus-ring; reduced-motion honored. Routing/menu wiring NOT done (next agent's job page NOT imported in App.tsx yet). fe-admin NOT mirrored. FD2 authed-screenshot SKIP (ProtectedRoute + gotcha #3 rig blocks authed; visual verify via deploy).

View File

@ -2,7 +2,10 @@
> One line per archived entry. Pointer = substring:"..." (Ctrl-F in `2026-06.md`; chuẩn-hoá @S152 A4 từ dạng `→ find` để lọt mắt A7 :284). NO line-hints (additive appends shift lines). Built @S96 (2026-07-01). Verbatim → `2026-06.md` · 4-field distill → `2026-06.gist.md`. > One line per archived entry. Pointer = substring:"..." (Ctrl-F in `2026-06.md`; chuẩn-hoá @S152 A4 từ dạng `→ find` để lọt mắt A7 :284). NO line-hints (additive appends shift lines). Built @S96 (2026-07-01). Verbatim → `2026-06.md` · 4-field distill → `2026-06.gist.md`.
## 2026-06 (5 entries · S66→S47) ## 2026-06 (8 entries · S79→S47)
- **S79 pe-decouple-select-expand** (2026-06-19) — bấm dòng = inline 3-panel "như cũ", nút Maximize2 = overlay; param thứ-2 `?expand=1`; ALL logic verbatim; harness-FD2 6 shot. Archive @S162 (L1 quá ngân sách). · substring:"S79 (2026-06-19)"
- **S69 e-office-3-shared-ui** (2026-06-17) — `ui/PageHeader` giàu + `ui/KpiCard` filter-chip + `ui/WidgetCard`; accent map + a11y Enter/Space + gotcha-66 `text-white!`. Archive @S162. · substring:"S69 (2026-06-17) 3 shared UI"
- **S69 office-dashboard** (2026-06-17) — OfficeDashboardPage compose 3 widget, reuse hook verbatim 0 API mới. Archive @S162. · substring:"S69 (2026-06-17) OfficeDashboardPage"
- **S66 hrm-2col-refine** (2026-06-16) — HRM Hồ sơ 3-cột→2-cột + accent-detail 5-tone; Tailwind-v4 -800-stop self-caught ×2 + rail `before:content-['']`; atomic-Write anti-#53; 5 CRUD+15 api verbatim. · substring:"S66 (2026-06-16) HRM Hồ sơ" - **S66 hrm-2col-refine** (2026-06-16) — HRM Hồ sơ 3-cột→2-cột + accent-detail 5-tone; Tailwind-v4 -800-stop self-caught ×2 + rail `before:content-['']`; atomic-Write anti-#53; 5 CRUD+15 api verbatim. · substring:"S66 (2026-06-16) HRM Hồ sơ"
- **S65 hrm-3panel** (2026-06-16) — EmployeesListPage → 3-panel master-detail NamGroup-ref; ONE atomic Write anti-#53; mojibake `网络Placeholder` lucide self-caught; org-tree `/departments/tree`. · substring:"S65 (2026-06-16) HRM Hồ sơ" - **S65 hrm-3panel** (2026-06-16) — EmployeesListPage → 3-panel master-detail NamGroup-ref; ONE atomic Write anti-#53; mojibake `网络Placeholder` lucide self-caught; org-tree `/departments/tree`. · substring:"S65 (2026-06-16) HRM Hồ sơ"
- **S58 fe-user-redesign** (2026-06-11) — mirror fe-admin DS → 14 file fe-user KEEP brand; truncated-#53 2nd-consecutive (em-main proxy, ship `e959f72`); LESSON emit-file-list-before-FD2-loop. · substring:"S58 (2026-06-11) fe-user redesign" - **S58 fe-user-redesign** (2026-06-11) — mirror fe-admin DS → 14 file fe-user KEEP brand; truncated-#53 2nd-consecutive (em-main proxy, ship `e959f72`); LESSON emit-file-list-before-FD2-loop. · substring:"S58 (2026-06-11) fe-user redesign"

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>S162 harness — cây 4 folder GĐ</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/harness-s162.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,180 @@
// THROWAWAY — harness FD2 cho S162 (cây 4 folder GĐ). XOÁ SAU KHI CHỤP.
// Render CHÍNH các component thật (không vẽ lại markup) với cache TanStack seed
// sẵn + menu quyền seed vào localStorage ⇒ soi được UI mà không cần API/SQL.
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { MemoryRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import './index.css'
import { AuthProvider } from '@/contexts/AuthContext'
import { PipelineStageFolders } from '@/components/pipeline/PipelineStageFolders'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
const menu = [
{ key: 'PurchaseEvaluations', label: 'Duyệt NCC', parentKey: null, order: 1, icon: null, canRead: true, canCreate: true, canUpdate: true, canDelete: true, isVisible: true, displayLabel: null, children: [] },
{ key: 'KeHoachKyKet', label: 'Kế hoạch ký kết HĐ', parentKey: null, order: 2, icon: null, canRead: true, canCreate: true, canUpdate: true, canDelete: true, isVisible: true, displayLabel: null, children: [] },
{ key: 'Contracts', label: 'Duyệt hợp đồng', parentKey: null, order: 3, icon: null, canRead: true, canCreate: true, canUpdate: true, canDelete: true, isVisible: true, displayLabel: null, children: [] },
{ key: 'HopDongCung', label: 'Hợp đồng cứng', parentKey: null, order: 4, icon: null, canRead: true, canCreate: true, canUpdate: true, canDelete: true, isVisible: true, displayLabel: null, children: [] },
]
localStorage.setItem('solution-erp-user-token', 'harness')
localStorage.setItem('solution-erp-user-user', JSON.stringify({ id: 'u1', fullName: 'Harness', email: 'h@x.vn', roles: [] }))
localStorage.setItem('solution-erp-user-menu', JSON.stringify(menu))
const pe = (i: number, over: Record<string, unknown>) => ({
id: `pe-${i}`, maPhieu: `PDG-2026-${String(i).padStart(3, '0')}`,
tenGoiThau: 'Cung cấp và lắp đặt hệ thống điện nhẹ khối A', type: 1, phase: 7,
projectId: 'prj-1', projectName: 'Khu phức hợp Sunrise Riverside', projectCode: 'SRS',
workItemId: 'wi-1', workItemName: '02 Hệ thống điện nhẹ',
selectedSupplierId: null, selectedSupplierName: null, contractId: null, slaDeadline: null,
createdAt: '2026-05-14T03:00:00Z', updatedAt: null,
drafterUserId: 'u1', drafterName: 'Nguyễn Văn Kiệt', departmentId: 'd1', departmentName: 'Phòng Mua hàng',
budgetPeriodAmount: null, expectedRemainingAmount: null, isUrgentByPro: false, isUrgentByCcm: false,
winnerSupplierNames: [], endsBeforeCeo: false, finalizeStepName: null, finalizeLevelName: null,
...over,
})
const peRows = [
pe(1, { contractId: 'ct-1' }),
pe(2, { phase: 3, tenGoiThau: 'Thi công phần thô tháp B', maPhieu: 'PDG-2026-002' }),
pe(3, { workItemId: 'wi-2', workItemName: '10 Cửa nhôm kính', tenGoiThau: 'Cửa nhôm kính mặt dựng', maPhieu: 'PDG-2026-003', createdAt: '2026-03-02T03:00:00Z' }),
pe(4, { projectId: 'prj-2', projectName: 'Nhà máy Đại An giai đoạn 2', projectCode: 'DAN', workItemId: null, workItemName: null, tenGoiThau: 'San lấp mặt bằng', maPhieu: 'PDG-2026-004', createdAt: '2025-11-20T03:00:00Z' }),
]
const khkk = [{
id: 'kh-1', maKeHoach: 'KHKK-2026-001', phase: 3, purchaseEvaluationId: 'pe-1',
peMaPhieu: 'PDG-2026-001', peTenGoiThau: 'Cung cấp và lắp đặt hệ thống điện nhẹ khối A',
projectId: 'prj-1', projectName: 'Khu phức hợp Sunrise Riverside', departmentId: null, departmentName: null,
drafterUserId: 'u1', drafterFullName: 'Nguyễn Văn Kiệt', approvalWorkflowId: null, workflowCode: null,
currentApprovalLevelOrder: null, totalProposedAmount: 4200000000, createdAt: '2026-06-01T03:00:00Z', updatedAt: null,
}]
const contracts = [{
id: 'ct-1', maHopDong: 'HĐ-TP-2026-014', tenHopDong: 'HĐ thi công hệ thống điện nhẹ khối A',
type: 1, phase: 9, supplierId: 's1', supplierName: 'Công ty TNHH Điện nhẹ Toàn Cầu',
projectId: 'prj-1', projectName: 'Khu phức hợp Sunrise Riverside', giaTri: 4150000000,
slaDeadline: null, createdAt: '2026-06-20T03:00:00Z', hasSealedCopy: true,
}]
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 600_000 } } })
qc.setQueryData(['pipeline-pe-index'], peRows)
qc.setQueryData(['pipeline-khkk-index'], khkk)
qc.setQueryData(['pipeline-contract-index'], contracts)
// Vỏ cây trang Duyệt NCC (sao markup từ PurchaseEvaluationsListPage để soi
// folder trong đúng ngữ cảnh). Leaf phiếu = rút gọn của dòng phiếu thật.
function PeTreeShell() {
return (
<aside className="flex w-[400px] flex-col overflow-hidden border-r border-slate-200 bg-white">
<div className="flex-1 overflow-y-auto">
<div className="divide-y divide-slate-100">
<details open className="group/year">
<summary className="flex cursor-pointer items-center gap-1.5 bg-slate-50 px-3 py-2 [&::-webkit-details-marker]:hidden">
<svg className="h-3 w-3 shrink-0 text-slate-500 transition-transform group-open/year:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
<span className="text-base">📅</span>
<span className="flex-1 truncate text-[13px] font-semibold text-slate-900">Năm 2026</span>
<span className="rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-medium text-slate-700">3</span>
</summary>
<div className="ml-3 border-l border-slate-200">
<details open className="group/proj">
<summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 [&::-webkit-details-marker]:hidden">
<svg className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/proj:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
<span className="text-sm">📁</span>
<span className="flex-1 truncate text-[12px] font-medium text-slate-700">SRS</span>
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">3</span>
</summary>
<div className="ml-3 border-l border-slate-200">
<details open className="group/wi">
<summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 [&::-webkit-details-marker]:hidden">
<svg className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/wi:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
<span className="text-sm">🧱</span>
<span className="flex-1 truncate text-[12px] font-medium text-slate-700">02 Hệ thống điện nhẹ</span>
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">2</span>
</summary>
<PipelineStageFolders
className="ml-3 border-l border-slate-200"
currentStage={1}
stages={[
{ n: 1, leaves: [], count: 2, content: (
<ul className="divide-y divide-slate-100">
{peRows.slice(0, 2).map(p => (
<li key={p.id}>
<div className="px-3 py-2">
<div className="flex items-start justify-between gap-2">
<span className="truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</span>
<span className="shrink-0 rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium text-emerald-800">Đã duyệt</span>
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono">{p.maPhieu}</span>
<span className="text-slate-300">·</span>
<span>14/05/2026 10:00</span>
</div>
</div>
</li>
))}
</ul>
) },
{ n: 2, leaves: [{ id: 'kh-1', primary: 'KHKK-2026-001', secondary: 'Cung cấp và lắp đặt hệ thống điện nhẹ khối A', badgeLabel: 'Đã duyệt', badgeClass: 'bg-emerald-100 text-emerald-800 border-emerald-300', onOpen: () => {} }] },
{ n: 3, leaves: [{ id: 'ct-1', primary: 'HĐ-TP-2026-014', secondary: 'HĐ thi công hệ thống điện nhẹ khối A', badgeLabel: 'Đã phát hành', badgeClass: 'bg-emerald-100 text-emerald-800', onOpen: () => {} }] },
{ n: 4, leaves: [{ id: 'ct-1', primary: 'HĐ-TP-2026-014', secondary: 'HĐ thi công hệ thống điện nhẹ khối A', badgeLabel: 'Đã lưu bản cứng', badgeClass: 'bg-emerald-100 text-emerald-800', onOpen: () => {} }] },
]}
/>
</details>
<details className="group/wi">
<summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 [&::-webkit-details-marker]:hidden">
<svg className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/wi:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
<span className="text-sm">🧱</span>
<span className="flex-1 truncate text-[12px] font-medium text-slate-700">10 Cửa nhôm kính</span>
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">1</span>
</summary>
</details>
</div>
</details>
</div>
</details>
</div>
</div>
</aside>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="space-y-2">
<h2 className="text-[13px] font-bold uppercase tracking-wide text-slate-500">{title}</h2>
{children}
</section>
)
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<MemoryRouter>
<QueryClientProvider client={qc}>
<AuthProvider>
<div className="space-y-8 bg-slate-50 p-5">
<Section title="A · Trong cây trang Duyệt NCC (GĐ1 mở sẵn)">
<PeTreeShell />
</Section>
<Section title="B · Panel cây trái — trang Kế hoạch ký kết (GĐ2 mở sẵn)">
<div className="max-w-[21rem]"><PipelineTreePanel currentStage={2} /></div>
</Section>
<Section title="C · Panel cây trái — trang Hợp đồng cứng (GĐ4 mở sẵn)">
<div className="max-w-[21rem]"><PipelineTreePanel currentStage={4} /></div>
</Section>
<Section title="D · Trạng thái biên: khoá quyền / lỗi tải / rỗng">
<div className="w-[400px] border-l border-slate-200 bg-white">
<PipelineStageFolders
currentStage={1}
stages={[
{ n: 1, leaves: [{ id: 'x', primary: 'PDG-2026-009', secondary: 'Gói thầu mẫu', badgeLabel: 'Đã gửi duyệt', badgeClass: 'bg-amber-100 text-amber-800', onOpen: () => {} }] },
{ n: 2, leaves: [], locked: true },
{ n: 3, leaves: [], failed: true },
{ n: 4, leaves: [] },
]}
/>
</div>
</Section>
</div>
</AuthProvider>
</QueryClientProvider>
</MemoryRouter>
</StrictMode>,
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

@ -76,3 +76,53 @@ T1f build gate:
- fe-user `npm run build`**EXIT 0**, `✓ built in 21.91s`, `dist/assets/index-CaEssrop.js 1,629.67 kB │ gzip 411.70 kB`, css 91.82 kB. - fe-user `npm run build`**EXIT 0**, `✓ built in 21.91s`, `dist/assets/index-CaEssrop.js 1,629.67 kB │ gzip 411.70 kB`, css 91.82 kB.
- fe-admin `npm run build`**EXIT 0**, `✓ built in 10.15s`, `dist/assets/index-BtjKK7qJ.js 1,713.48 kB │ gzip 425.60 kB`, css 86.14 kB. - fe-admin `npm run build`**EXIT 0**, `✓ built in 10.15s`, `dist/assets/index-BtjKK7qJ.js 1,713.48 kB │ gzip 425.60 kB`, css 86.14 kB.
- Warning cả 2 app = PRE-EXISTING (chunk >500 kB · INEFFECTIVE_DYNAMIC_IMPORT `src/lib/realtime.ts`). 0 lỗi TS. - Warning cả 2 app = PRE-EXISTING (chunk >500 kB · INEFFECTIVE_DYNAMIC_IMPORT `src/lib/realtime.ts`). 0 lỗi TS.
## MỐC 3 — FD2 visual-verify (3 vòng soi, KHÔNG ship-unseen)
Rig: authed dev-rig bị chặn (gotcha #3 — API HTTPS :5443 ⟂ vite proxy HTTP + ProtectedRoute) ⇒ dùng **static harness** như S79: mount CHÍNH component thật (không vẽ lại markup) với `queryClient.setQueryData` seed 3 cache index + menu quyền seed `localStorage:solution-erp-user-menu` (AuthProvider giữ menu cache khi `/menus/me` lỗi — `AuthContext.tsx:29-31`) + `MemoryRouter`. 4 khung: A cây trang PE · B panel GĐ2 · C panel GĐ4 · D trạng thái biên (khoá/lỗi/rỗng). Chụp `desktop-1440` + `laptop-1366` + `mobile-375`, `device_scale_factor=2`, full_page. Gotcha Vite đã né: `domcontentloaded` + `wait_for_selector` (KHÔNG networkidle) + warm-up goto 60s.
**Vòng 1 (r1) — 3 lỗi tự bắt được khi NHÌN ảnh:**
1. 🔴 Panel B/C **trông y hệt nhau**, folder giai đoạn nằm sâu 3 lớp collapsed ⇒ vào trang KHKK/HĐ-cứng KHÔNG thấy folder của mình đâu (hỏng đúng ý owner "folder trang hiện tại mở sẵn"). Kèm bug tương tác: mẹo "set rỗng = mặc định mở năm mới nhất" làm node **KHÔNG ĐÓNG ĐƯỢC** (đóng → set rỗng → mặc định bật lại).
2. 🔴 Dòng folder **khoá quyền lệch 26px** so với 3 dòng kia (thiếu khoang icon-chip) — soi rõ ở khung D mobile.
3. 🟡 Leaf: mã `font-mono 11px` bẹt ngang dòng phụ sans 11px ⇒ mất phân cấp.
**Sửa:** (1) `defaultOpen` = mở HẲN 1 NHÁNH (năm mới nhất > dự án đầu > hạng mục đầu) + state `Set|null` (`null` = chưa từng chỉnh) thay mẹo set-rỗng + đang-lọc thì ép mở hết & `toggleExpand` no-op. (2) dòng khoá giữ đúng 3 khoang (ổ khoá thay chevron + icon-chip + nhãn). (3) mã leaf `text-[12px] font-medium`.
**Vòng 2 (r2):** cả 3 PASS — B mở tới folder "Kế hoạch Hợp đồng" hiện leaf `KHKK-2026-001`, C mở "Hợp đồng cứng" hiện `HĐ-TP-2026-014 · Đã lưu bản cứng` ⇒ B/C giờ KHÁC nhau rõ, chứng minh `currentStage` ăn thật. Còn 1 điểm tự bắt: nhãn muted `text-slate-400` (#94a3b8) = **2.8:1 < sàn AA 4.5:1** mà folder rỗng VẪN bấm mở được (chữ đang-hoạt-động) → đổi `text-slate-500` (4.8:1); chip "Sắp"/icon giữ slate-400 (trạng thái trơ + token verbatim dải strip).
**Vòng 3 (r3):** chụp lại sau sửa contrast — PASS, không lệch layout.
**Rubric FD4:** hierarchy ✓ (5 cấp: Năm 13px semibold → Dự án/Hạng mục 12px slate-700 → folder 12px accent → leaf mã 12px mono + phụ 11px slate-500) · spacing ✓ (py-1.5 dòng cây / py-2 leaf, rail `ml-3 border-l` mỗi cấp) · typography ✓ · contrast ✓ (brand-800 13.0:1 · violet-700 7.4:1 · amberx-700 4.9:1 · teal-700 5.6:1 · muted slate-500 4.8:1) · states ✓ (hover/focus-visible ring brand + loading skeleton + empty + error + locked) · responsive ✓ (1440/1366/375; `<lg` panel thu về nút "Hiện cây", bảng không bị đẩy) · detail ✓ (4 dòng folder thẳng hàng tuyệt đối sau fix 2).
Ảnh giữ lại: `harness-shot-r3-desktop-1440.png` + `harness-shot-r3-mobile-375.png` (cùng thư mục run). Harness gốc archive `harness-s162.tsx.txt` + `harness-s162.html.txt`, **đã XÓA khỏi cây src cả 2 app** (verify `ls` = No such file).
## MỐC 4 — RE-MIRROR + BUILD GATE CUỐI
Bảng SHA MỚI (bảng ở MỐC 2 đã thối vì 3 fix — 2 file đầu đổi hash), đo sau khi copy lại đủ 6 file:
| file | sha256[0:16] | verdict |
|---|---|---|
| components/pipeline/PipelineStageFolders.tsx | 05c93c3619a9cfec | SHA-PAIR |
| components/pipeline/PipelineTreePanel.tsx | 76e5dafb239e402a | SHA-PAIR |
| hooks/usePipelineStages.ts | e762d5eb9c3a057a | SHA-PAIR |
| pages/pe/PurchaseEvaluationsListPage.tsx | 1d8d8f75c7d2cc14 | SHA-PAIR |
| pages/khkk/KhkkListPage.tsx | 40a650bf6964ab4b | SHA-PAIR |
| pages/contracts/HardCopiesPage.tsx | d30a911e7483f96a | SHA-PAIR |
Build gate CUỐI (sau khi đã xoá harness khỏi src):
- fe-user: `npx tsc -b --force`**EXIT 0**; `npm run build`**EXIT 0**, `dist/assets/index-JKWooGXp.js 1,630.09 kB │ gzip 411.78 kB` · css 91.82 kB · `✓ built in 472ms`.
- fe-admin: `npx tsc -b --force`**EXIT 0**; `npm run build`**EXIT 0**, `dist/assets/index-DHQO_qL3.js 1,713.91 kB │ gzip 425.69 kB` · css 86.14 kB · `✓ built in 520ms`.
- Warning 2 app = PRE-EXISTING (chunk >500 kB · INEFFECTIVE_DYNAMIC_IMPORT realtime.ts). 0 lỗi TS.
- `git status --porcelain`: đúng 12 file sản phẩm (6 × 2 app) + 4 artifact thư mục run. KHÔNG commit (em main commit).
## 2 lần lead báo "mày chết" — ĐÍNH CHÍNH bằng đĩa
Cả 2 lần đều là **stale IDE diagnostic** (bài `feedback_stale_diagnostic_background_agent`, gotcha #68), KHÔNG phải #53:
- Lần 1 (báo `:625 ')' expected` + 4 symbol unused): đo lại `npx tsc -b --force` ngay lúc đó = **EXIT 0**, vùng `:620-627` trên đĩa khâu đúng.
- Lần 2 (báo KhkkListPage `:114/:138/:141` + HardCopiesPage `:97/:116/:250-264` rách JSX): đọc thẳng 3 vùng đó trên đĩa = nguyên vẹn, `tsc -b --force` = **EXIT 0**.
⇒ Diagnostic chụp GIỮA lúc agent đang Edit nhiều file. Chỉ tin build chạy SAU khi agent dừng tay.
## MỐC 5 — MEMORY + đóng việc
- `.claude/agent-memory/frontend-designer/MEMORY.md`: +1 entry S162 (Activity log). L1 vượt ngân sách (19.6KB > sàn 17.1KB) ⇒ nén theo đúng khuôn archive sẵn có: chuyển VERBATIM 3 mục 06/2026 (S79 pe-decouple · S69 3-shared-UI · S69 OfficeDashboardPage) sang `archive/2026-06.md` + để lại dòng trỏ `substring:"…"`; cập nhật `archive/_INDEX.md` (5→8 entry). Sau nén **14.6KB**, integrity check 7/7 khoá value_protect còn nguyên (S162 · S159 · EmployeesListPage · #53 · Tailwind-v4-accent-stop · authed-rig-#3 · lucide-alias-dodge).
- KHÔNG commit (em main commit, scope `FE-User`/`FE-Admin`). KHÔNG đụng BE/menu/seeder/strip/tests.
### TỰ ĐÁNH GIÁ CÒN HỞ (lead cần biết)
1. **Chưa soi được trên PROD/authed thật** — mọi ảnh là harness tĩnh với dữ liệu giả. Nút cần nghiệm thu khi deploy: cây có ra đúng gói thầu thật không, và `pageSize 200` có đủ cho lượng HĐ/KHKK thật không (>200 thì leaf GĐ3 rơi về dạng tối giản, GĐ4 mất cờ bản cứng).
2. **GĐ3/GĐ4 nối qua `pe.contractId`** — phiếu PE chưa gắn HĐ thì 2 folder đó rỗng dù HĐ có tồn tại (đi đường KHKK→HĐ mà không ghi ngược `contractId` về phiếu là cây đứt). Đây là giới hạn DỮ LIỆU, không phải UI.
3. Trang GĐ3 (cụm HĐ 7 loại) CHƯA gắn panel — đúng spec đợt-1 (để đợt-2 sau khi owner xem prod).

View File

@ -0,0 +1,38 @@
# sub-reviewer-1 — T2 review đối-kháng cây 4-folder GĐ (S162, 2026-07-30)
Ghi-đĩa-trong-lúc-làm (chống #53). Append theo từng mục soi. Verdict cuối ở cuối file.
## Phạm vi đo (từ đĩa, `git status --porcelain` + `git diff --stat`)
- 6 file tracked M: `fe-{user,admin}/src/pages/pe/PurchaseEvaluationsListPage.tsx` · `.../khkk/KhkkListPage.tsx` · `.../contracts/HardCopiesPage.tsx` — tổng +168/16.
- 6 file MỚI untracked: `fe-{user,admin}/src/components/pipeline/{PipelineStageFolders,PipelineTreePanel}.tsx` + `fe-{user,admin}/src/hooks/usePipelineStages.ts`.
- Ngoài src: memory frontend-designer (MEMORY.md + archive/2026-06.md + archive/_INDEX.md), `.session-counter.json`, run-folder artifacts (2 png + 2 harness .txt + sub-frontend-designer-0.md).
## MỤC 1 — Claim "khối `<li>` phiếu giữ nguyên từng byte" — ĐẠT
`git diff -- .../pe/PurchaseEvaluationsListPage.tsx` (cả 2 app, diff IDENTICAL):
- Hunk A `@@ -29,6 +29,8` — +2 import. 0 xoá.
- Hunk B `@@ -47,6 +49,14` — +8 dòng (6 comment + `usePipelineStages()` + `filterActive`). 0 xoá.
- Hunk C `@@ -507,7 +517,12` — xoá ĐÚNG 1 dòng `<ul className="ml-3 divide-y divide-slate-100 border-l border-slate-200">`.
- Hunk D `@@ -607,6 +622,7` — +1 dòng `) })} />`. 0 xoá.
⇒ Tổng xoá = 1 dòng. Khối `<li>` KHÔNG có dòng `-` nào ⇒ CLAIM ĐỨNG. Class cũ không mất: `ml-3 border-l border-slate-200` → prop `className` wrapper; `divide-y divide-slate-100` giữ ở `<ul>` trong.
## MỤC 6 — Biên giới — ĐẠT
- `git diff --name-only` (tracked): 0 file `src/Backend`, 0 `tests/`, 0 seeder/menu (#84), 0 `PePipelineStrip.tsx`. Chỉ 6 file FE + memory/run-artifact ngoài src.
- `grep -rn "export default"` trong 6 file mới ⇒ NONE (named export only, đúng §3.9).
- Nhãn VERBATIM `PipelineStageFolders.tsx:22-27`: `1:'Duyệt NCC'` · `2:'Kế hoạch Hợp đồng'` · `3:'Duyệt Hợp đồng'` · `4:'Hợp đồng cứng'` — khớp lời owner từng chữ.
- Mirror SHA256 6/6 PAIR (tự đo lại, không tin bảng designer):
PipelineStageFolders `05c93c3619a9cfec` · PipelineTreePanel `76e5dafb239e402a` · usePipelineStages `e762d5eb9c3a057a` · PE page `1d8d8f75c7d2cc14` · Khkk page `40a650bf6964ab4b` · HardCopies page `d30a911e7483f96a`.
- `types/contracts.ts` lệch 2 app CHỈ ở giá trị `ContractPhaseColor` (cùng bộ key 1-10,99) ⇒ hook SHA-chung import an toàn, `badgeClass` undefined vẫn có fallback `PipelineStageFolders.tsx:189`.
## MỤC 3 — Permission-gate 2 tầng — 1 LỖ THẬT (latent) + 1 điểm sai luận-cứ
Key FE (`usePipelineStages.ts:41-44`) vs BE:
- `Khkk_List`/`Khkk_G1`/`KeHoachKyKet` — cả 3 tồn tại thật (`DbInitializer.cs:2184-2188` KhkkKeys + `MenuKeys.cs:41`).
- `Hdc_<7 code>` + `HopDongCung` — tồn tại thật (`DbInitializer.cs:1868/2191`).
- `Ct_<code>_List` + `Contracts` — tồn tại thật.
- `can(k)` = `node.canRead` (`usePermission.ts:20`), findNode không thấy ⇒ false ⇒ OR-nhiều-key hợp lệ về mặt đọc cây.
🔴 **F: OR-nhiều-key ĐÁ NHAU với policy API cho GĐ2.** `ContractSigningPlansController.cs:36` = `[Authorize(Policy="KeHoachKyKet.Read")]`, và `MenuPermissionHandler.cs:40` so khớp **CHÍNH XÁC** `p.MenuKey == req.MenuKey` (KHÔNG kế thừa, KHÔNG OR con). ⇒ role có `Khkk_List=1``KeHoachKyKet=0` sẽ: FE cho `canKhkk=true` → gọi API → **403**`khkkFailed` → GĐ2 hiện "—" dưới MỌI gói thầu, và claim "0 rác 403" vỡ. Đo Dev DB: 13/13 role đều có `Contracts=1``KeHoachKyKet=1` ⇒ HIỆN CHƯA vỡ; prod chưa đo được (chỉ localhost+secret). Là lỗ THIẾT KẾ chờ 1 lần chỉnh ma trận quyền.
🟡 luận-cứ trong comment `usePipelineStages.ts:10-13` ("gate phải OR nhiều key vì root có thể canRead=false") đúng cho TẦNG HIỂN THỊ nhưng sai đích cho tầng API: query nào cũng nên gate bằng ĐÚNG key policy của endpoint (`KeHoachKyKet` cho `/contract-signing-plans`).
🟢 `/contracts` KHÔNG có policy action-level (`ContractsController.cs:16-17`, chỉ `[Authorize]` trần class) ⇒ nhánh `enabled: canContract || canHardCopy` KHÔNG sinh 403. Đổi lại: đây là bare-`[Authorize]` sẵn có của hệ (#82), diff này không làm tệ thêm nhưng có nới hiển-thị: user chỉ được cấp `Hdc_ThauPhu` vẫn thấy leaf GĐ4 của MỌI loại HĐ (`usePipelineStages.ts:211`) vì gate là OR-toàn-nhóm chứ không theo `c.type`.
🟢 Không quyền ⇒ `enabled:false` (`:77`, `:91`, `PipelineTreePanel.tsx:108`) ⇒ KHÔNG gọi API; folder khoá render nhánh riêng KHÔNG `<details>` (`PipelineStageFolders.tsx:99-113`). Fetch lỗi ⇒ `failed` ⇒ badge "—" + body "Không tải được…" (`:140-156`) ⇒ cây không vỡ. ĐẠT.