[CLAUDE] FE: Văn phòng số foundation — shared PageHeader/KpiCard/WidgetCard + Dashboard landing (PURO · CSS Hồ sơ NS) + sync fe-admin index.css + menu Off_Dashboard
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m41s

- 3 shared component PageHeader/KpiCard/WidgetCard ×2 app SHA256-identical; tái dùng token Hồ sơ NS (.app-gradient-brand/.card-accent/.icon-chip/.stat-value/.label-eyebrow + accent palette teal/violet/amberx/greenx); gotcha #66 text-white! trên gradient header.
- OfficeDashboardPage 2-cột widget kiểu PURO HomePage: Đề xuất/Đơn từ/Ticket/Phòng họp hôm nay + "Công việc của tôi" + Thao tác nhanh. Reuse query endpoint sẵn có (shared TanStack cache, KHÔNG BE/API mới), đếm client-side, loading/error/empty mỗi widget.
- Sync fe-admin/src/index.css ← fe-user (đóng drift S66-S68: heading 600→700, ink #0f172a→#0b1220, label-eyebrow slate→brand-600 + rule gotcha #66). Nay 2 app đồng bộ.
- Menu key Off_Dashboard (MenuKeys.cs const + All[] + DbInitializer seed Order 0 dưới Off) — no migration, idempotent. GIỮ ẨN non-Admin (RevokeTemporarilyHiddenModules StartsWith Off). Chưa golive.
- Wire 4-place ×2 app: App.tsx route /office/dashboard + menuKeys.ts + Layout staticMap.
- Fix KpiCard activeBorder -300 → -500 (accent palette chỉ 50/100/500/600/700 — chống "vỡ màu im lặng" Tailwind v4: border-teal-300 rơi default Tailwind, border-amberx-300 drop hẳn).
- Build PASS x2 (fe-user index-DrxDysO7 / fe-admin index-TbkadgKd) + dotnet slnx 0/0. reviewer PASS 0 blocker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-17 09:24:17 +07:00
parent 764fe7024b
commit a8bbdaeeea
21 changed files with 1821 additions and 23 deletions

View File

@ -28,6 +28,12 @@
- 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:**
- `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 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).
@ -37,6 +43,7 @@
- Minor noted (NOT fixed, out of bounded scope): 2 `blur-3xl` blobs barely visible at 1440 = render cost ~0 payoff; eyebrow `tracking-[0.2em]` heavy. Candidates if login redesign requested. - Minor noted (NOT fixed, out of bounded scope): 2 `blur-3xl` blobs barely visible at 1440 = render cost ~0 payoff; eyebrow `tracking-[0.2em]` heavy. Candidates if login redesign requested.
## Activity log ## Activity log
- **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].
- **S66 (2026-06-16) HRM Hồ Nhân sự fe-user REFINE từ eoffice LIVE (3 việc) layout 3-cột2-cột + màu detail:** anh góp ý sau khi xem prod. **Việc 1 (layout):** 3-cột-ngang `[tree 244 | list 352 | detail 1fr]` **2-cột** `lg:grid-cols-[22rem_1fr] xl:[24rem_1fr]`: CỘT TRÁI = `<div flex flex-col gap-4>` ôm tree (TRÊN, `lg:max-h-[44%] lg:shrink-0`, overflow-auto) + list+filter (DƯỚI, `flex-1`, overflow-auto) mỗi panel cuộn độc lập; CỘT PHẢI = detail (flex-1, rộng hơn nhiều, đỡ chật). <lg vẫn 1-col treelistdetail + giữ nguyên `treeOpenMobile` toggle. **Việc 2+3 (màu detail):** thêm `ACCENT` map 5 tone (brand/teal/violet/amberx/greenx) `Card` prop `accent` icon-chip nền nhạt (`--chip-bg/--chip-fg`) + heading `text-{x}-700` + rail trái pseudo-element; `Field` label uppercase `text-{x}-700` semibold (was slate-400 đơn điệu) + value `font-medium text-slate-900`; mỗi card/section gán 1 accent màu nhưng tinh tế, brand #1F7DC1 + Be Vietnam Pro + avatar gradient brand GIỮ. **Strategy chống truncation #53 = ONE atomic `Write` cả file** (1556 LOC) emit change-list + build status SỚM. **2 self-caught bug TRƯỚC build:** (1) `text-{teal,violet,greenx}-800` accent palettes KHÔNG stop -800 (chỉ 50/100/500/600/700) Tailwind v4 silent no-class đổi head sang -700 (all AA on white); (2) rail pseudo thiếu `before:content-['']` ::before không render box thêm. `npm run build` (tsc -b strict + vite v8) **PASS 0 TS err, 495ms** (warning @import-order + chunk-size = pre-existing, không phải mình). **5 satellite CRUD + 15 satellite api endpoint + top-level del + 3 reads + 3 query keys (employees-list/employee-detail/departments-tree-hrm) + cây SOLUTION COMPANY + 5 tab + search/filter preserved VERBATIM** (grep: 15 satellite api.post/put/delete + 3 queryKey + 5 form fns; tsc type-checks mọi payload shape = wiring bất biến). FD2 authed-screenshot SKIPPED per task instruction + gotcha #3 (rig chặn authed ProtectedRoute; anh xem qua deploy) structural verify thay thế. fe-admin + BE NOT touched, no commit (em main commits). Tag [s66, hrm-2col-refine, eoffice-ref, accent-system, atomic-write-antitrunc, crud-preserved, build-pass, tailwind-v4-stop-gotcha]. - **S66 (2026-06-16) HRM Hồ Nhân sự fe-user REFINE từ eoffice LIVE (3 việc) layout 3-cột2-cột + màu detail:** anh góp ý sau khi xem prod. **Việc 1 (layout):** 3-cột-ngang `[tree 244 | list 352 | detail 1fr]` **2-cột** `lg:grid-cols-[22rem_1fr] xl:[24rem_1fr]`: CỘT TRÁI = `<div flex flex-col gap-4>` ôm tree (TRÊN, `lg:max-h-[44%] lg:shrink-0`, overflow-auto) + list+filter (DƯỚI, `flex-1`, overflow-auto) mỗi panel cuộn độc lập; CỘT PHẢI = detail (flex-1, rộng hơn nhiều, đỡ chật). <lg vẫn 1-col treelistdetail + giữ nguyên `treeOpenMobile` toggle. **Việc 2+3 (màu detail):** thêm `ACCENT` map 5 tone (brand/teal/violet/amberx/greenx) `Card` prop `accent` icon-chip nền nhạt (`--chip-bg/--chip-fg`) + heading `text-{x}-700` + rail trái pseudo-element; `Field` label uppercase `text-{x}-700` semibold (was slate-400 đơn điệu) + value `font-medium text-slate-900`; mỗi card/section gán 1 accent màu nhưng tinh tế, brand #1F7DC1 + Be Vietnam Pro + avatar gradient brand GIỮ. **Strategy chống truncation #53 = ONE atomic `Write` cả file** (1556 LOC) emit change-list + build status SỚM. **2 self-caught bug TRƯỚC build:** (1) `text-{teal,violet,greenx}-800` accent palettes KHÔNG stop -800 (chỉ 50/100/500/600/700) Tailwind v4 silent no-class đổi head sang -700 (all AA on white); (2) rail pseudo thiếu `before:content-['']` ::before không render box thêm. `npm run build` (tsc -b strict + vite v8) **PASS 0 TS err, 495ms** (warning @import-order + chunk-size = pre-existing, không phải mình). **5 satellite CRUD + 15 satellite api endpoint + top-level del + 3 reads + 3 query keys (employees-list/employee-detail/departments-tree-hrm) + cây SOLUTION COMPANY + 5 tab + search/filter preserved VERBATIM** (grep: 15 satellite api.post/put/delete + 3 queryKey + 5 form fns; tsc type-checks mọi payload shape = wiring bất biến). FD2 authed-screenshot SKIPPED per task instruction + gotcha #3 (rig chặn authed ProtectedRoute; anh xem qua deploy) structural verify thay thế. fe-admin + BE NOT touched, no commit (em main commits). Tag [s66, hrm-2col-refine, eoffice-ref, accent-system, atomic-write-antitrunc, crud-preserved, build-pass, tailwind-v4-stop-gotcha].
- **S65 (2026-06-16) HRM Hồ Nhân sự fe-user 3-panel master-detail NamGroup-ref:** RESTRUCTURE `EmployeesListPage.tsx` (1201→~1140 LOC) 6 `<details>` [Org tree | List | Detail 5-tab]. **Strategy chống truncation #53 = ONE atomic `Write` (cả file)** thay piecemeal Edit (atomic Write either fully-lands or errors, KHÔNG half-break) emit change-list TRƯỚC build DID BOTH Part A (avatar header+5 tab+sectiontab redistribution) + Part B (org tree panel) trong 1 pass, không phải defer B. Org tree consume `/departments/tree` verified BE-side (DepartmentFeatures.cs DepartmentTreeNodeDto, controller `[HttpGet("tree")]`, class-Authorize only). Foundation màu mới DÙNG: `.app-gradient-brand` header / `.icon-chip` / accent palette teal/violet/amberx/greenx (avatar tones) brand #1F7DC1 + Be Vietnam Pro KEPT. **5 satellite CRUD + 16 api endpoint + query keys preserved VERBATIM** (grep-verified: 16 api.post/put/delete identical payload shape, 5 form fns intact). `npm run build` (tsc -b strict + vite) **PASS 0 TS err, 6.13s**. 1 self-caught bug: typo garbage token `网络Placeholder` trong lucide import (mojibake autocomplete) removed, all 21 icons valid (node-checked). FD2 authed-screenshot SKIPPED per explicit task instruction + gotcha #3 (rig blocks authed; anh xem qua deploy) did static structural verify instead (grep endpoint/key preservation). fe-admin NOT touched (mirror = separate pass), no commit. Tag [s65, hrm-3panel, namgroup-ref, atomic-write-antitrunc, crud-preserved, build-pass]. - **S65 (2026-06-16) HRM Hồ Nhân sự fe-user 3-panel master-detail NamGroup-ref:** RESTRUCTURE `EmployeesListPage.tsx` (1201→~1140 LOC) 6 `<details>` [Org tree | List | Detail 5-tab]. **Strategy chống truncation #53 = ONE atomic `Write` (cả file)** thay piecemeal Edit (atomic Write either fully-lands or errors, KHÔNG half-break) emit change-list TRƯỚC build DID BOTH Part A (avatar header+5 tab+sectiontab redistribution) + Part B (org tree panel) trong 1 pass, không phải defer B. Org tree consume `/departments/tree` verified BE-side (DepartmentFeatures.cs DepartmentTreeNodeDto, controller `[HttpGet("tree")]`, class-Authorize only). Foundation màu mới DÙNG: `.app-gradient-brand` header / `.icon-chip` / accent palette teal/violet/amberx/greenx (avatar tones) brand #1F7DC1 + Be Vietnam Pro KEPT. **5 satellite CRUD + 16 api endpoint + query keys preserved VERBATIM** (grep-verified: 16 api.post/put/delete identical payload shape, 5 form fns intact). `npm run build` (tsc -b strict + vite) **PASS 0 TS err, 6.13s**. 1 self-caught bug: typo garbage token `网络Placeholder` trong lucide import (mojibake autocomplete) removed, all 21 icons valid (node-checked). FD2 authed-screenshot SKIPPED per explicit task instruction + gotcha #3 (rig blocks authed; anh xem qua deploy) did static structural verify instead (grep endpoint/key preservation). fe-admin NOT touched (mirror = separate pass), no commit. Tag [s65, hrm-3panel, namgroup-ref, atomic-write-antitrunc, crud-preserved, build-pass].
- **S58 (2026-06-11) fe-user redesign theo UI/UX guide AI_INFRA canonical KEEP brand [em main proxy truncated #53 giữa FD2 screenshot, 2nd consecutive]:** Mirror design-system fe-admin S55 14 file fe-user (index.css heading-ladder+.label-eyebrow / 6 ui primitives Button gần SHA-identical fe-admin chỉ khác comment / 6 shell DataTable+RowActions-additive·Layout-brand-left-rail·TopBar·PageHeader·PhaseBadge-ring·EmptyState / LoginPage polish). Rubric mới = guide 13 mục `D:\Dropbox\CONG_VIEC\AI_INFRA\docs\reference\ui-ux-design-guide.md` (density 14px/h32-34/radius-8/thead-sticky/action-luôn-hiện/no-font-bold). BRAND KEPT: #1F7DC1 + Be Vietnam Pro + slate (guide cho plug hue riêng). Chết NGAY TRƯỚC with_server.py screenshot /login em main recover: build ×2 PASS 0 TS + diff-review key-stability từng file + ship `e959f72`; authed visual qua deploy prod (rig-gotcha #3 standing). LESSON: 2 lần liên tiếp truncate CÙNG điểm (sau khi sửa xong, lúc bắt đầu FD2 rig) lần sau EMIT file-list verdict TRƯỚC khi vào screenshot loop. Tag [s58, fe-user-redesign, guide-aiinfra, keep-brand, truncated-53-proxy]. - **S58 (2026-06-11) fe-user redesign theo UI/UX guide AI_INFRA canonical KEEP brand [em main proxy truncated #53 giữa FD2 screenshot, 2nd consecutive]:** Mirror design-system fe-admin S55 14 file fe-user (index.css heading-ladder+.label-eyebrow / 6 ui primitives Button gần SHA-identical fe-admin chỉ khác comment / 6 shell DataTable+RowActions-additive·Layout-brand-left-rail·TopBar·PageHeader·PhaseBadge-ring·EmptyState / LoginPage polish). Rubric mới = guide 13 mục `D:\Dropbox\CONG_VIEC\AI_INFRA\docs\reference\ui-ux-design-guide.md` (density 14px/h32-34/radius-8/thead-sticky/action-luôn-hiện/no-font-bold). BRAND KEPT: #1F7DC1 + Be Vietnam Pro + slate (guide cho plug hue riêng). Chết NGAY TRƯỚC with_server.py screenshot /login em main recover: build ×2 PASS 0 TS + diff-review key-stability từng file + ship `e959f72`; authed visual qua deploy prod (rig-gotcha #3 standing). LESSON: 2 lần liên tiếp truncate CÙNG điểm (sau khi sửa xong, lúc bắt đầu FD2 rig) lần sau EMIT file-list verdict TRƯỚC khi vào screenshot loop. Tag [s58, fe-user-redesign, guide-aiinfra, keep-brand, truncated-53-proxy].

View File

@ -74,6 +74,7 @@ UI `disabled={!canX}` + BE helper `EnsureCanXAsync(id, userId)` throw 403 (NOT i
## 📅 Recent activity (FIFO — older → archive/git) ## 📅 Recent activity (FIFO — older → archive/git)
- **2026-06-17 (S? Off_Dashboard menu leaf BE — NO migration, 3 edit/2 file, idempotent seed mirror S53/S54-TaskD, em-main spec deterministic 100% → ACCEPT Case 1):** +1 menu key `Off_Dashboard` ("Bảng điều khiển Văn phòng số"), pattern = S53 Off_AttendanceReport EXACT. 3 insert: (1) `MenuKeys.cs` const `OffDashboard = "Off_Dashboard"` ngay sau root `Off:99` · (2) `MenuKeys.cs` All[] line `Off, OffDanhBa``Off, OffDashboard, OffDanhBa` · (3) `DbInitializer.cs` SeedMenuTreeAsync tuple `(OffDashboard, "Bảng điều khiển Văn phòng số", Off, **0**, "LayoutDashboard")` trước OffDanhBa=1 (Order 0 = landing đầu nhóm, KHÔNG renumber children 1-7 hiện có). **KEY recon — Off_* leaves ARE IN All[] (NOT factory-excluded):** task hint "leaf may be excluded+granted-via-factory" KHÔNG áp Off (chỉ Pe_* leaf sinh động). Off_AttendanceReport :160 in All → tôi follow SAME = +All. Admin auto 2-point verified: `SeedAdminPermissionsAsync:2001` + `Program.cs:78` both iterate `MenuKeys.All` → +All = 4 policy {Read/Create/Update/Delete} + Admin Permission row auto, NO manual grant. **Revoke verified KHÔNG sửa:** `RevokeTemporarilyHiddenModulesAsync:2170` `p.MenuKey.StartsWith("Off")` → Off_Dashboard tự nằm trong scope ẩn-non-Admin. `InReviewScope:2070` chỉ match Catalog*/Master-keys/Pe_* → Off_Dashboard KHÔNG re-grant non-admin. Idempotent: upsert loop :1909 `existingItems.TryGetValue(key)` miss→Add / hit→chỉ reconcile Order (prod DB cũ nhận leaf next boot, re-run no-op). Build SolutionErp.slnx (gồm 2 test project, gotcha #65) **0 warn 0 err**. KHÔNG touch FE (menuKeys.ts/Layout=implementer-frontend)/test/mig/commit. Tag `[s?, off-dashboard, menu-leaf, no-mig, admin-perm-via-all, order-0-landing]`.
- **2026-06-16 (S65b PE +HoSoLink BE — Mig 51 `AddHoSoLinkToPurchaseEvaluation` 3-file, 6 file edit + 0 new file, em-main CHỐT spec 100% → ACCEPT Case 1):** Phiếu PE +1 cột `HoSoLink` = 1 hyperlink tới thư mục hồ sơ NAS (anh Kiệt paste link, FE render bấm-mở). KHÔNG entity con/bảng mới — 1 cột nullable. (1) `PurchaseEvaluation.cs` +`string? HoSoLink` sau MoTa. (2) `PurchaseEvaluationConfiguration.cs` +`HasMaxLength(1000)` (KHÔNG index — hyperlink free-text, không filter/join). (3) Mig via `dotnet ef migrations add` → Up=1 `AddColumn<string> nvarchar(1000) maxLength:1000 nullable` NO table NO index, Down=1 DropColumn; snapshot HoSoLink nvarchar(1000) verified. (4a) `CreatePurchaseEvaluationCommand` +trailing `string? HoSoLink = null` (sau WorkItemId — optional-param-after-required rule) + validator `MaximumLength(1000)` MATCH EF (S35 lesson) + handler `HoSoLink = request.HoSoLink`. (4b) `UpdatePurchaseEvaluationDraftCommand` +trailing `string? HoSoLink = null` + validator 1000 + handler **absolute-set** `entity.HoSoLink = request.HoSoLink` (Section-1 text-field family MoTa/DiaDiem pattern → null=clear, KHÔNG null-safe-keep như WorkItemId picker; deliberate: hyperlink user cần clear được). (5) `PurchaseEvaluationDetailBundleDto` +`string? HoSoLink` sau MoTa + projection `e.HoSoLink` positional-insert đúng vị trí. **RANG-CUNG grep verify (bài học CreateDepartmentCommand CS7036):** `Grep CreatePurchaseEvaluationCommand|UpdatePurchaseEvaluationDraftCommand` repo-wide gồm tests → 0 manual `new ...Command(...)` call-site (controller bind `[FromBody]` model-binding, KHÔNG manual ctor; tests dùng NAMED-ARG dừng ở WorkItemId) → trailing-optional-default fully backward-compat, KHÔNG sửa call-site nào. List DTO KHÔNG đụng (spec chỉ Detail/Get). `.slnx` KHÔNG update (chỉ 2 mig-file trong project có sẵn). KHÔNG apply DB/FE/test/commit. Build SolutionErp.slnx (gồm 2 test project) **0 warn 0 err** (DocxRenderer warn cleared). Route: `hoSoLink` camelCase qua POST/PUT body + GET detail. Tag `[s65b, pe-hosolink, mig51, one-column-no-table, trailing-optional-param, named-arg-callsite-safe]`. - **2026-06-16 (S65b PE +HoSoLink BE — Mig 51 `AddHoSoLinkToPurchaseEvaluation` 3-file, 6 file edit + 0 new file, em-main CHỐT spec 100% → ACCEPT Case 1):** Phiếu PE +1 cột `HoSoLink` = 1 hyperlink tới thư mục hồ sơ NAS (anh Kiệt paste link, FE render bấm-mở). KHÔNG entity con/bảng mới — 1 cột nullable. (1) `PurchaseEvaluation.cs` +`string? HoSoLink` sau MoTa. (2) `PurchaseEvaluationConfiguration.cs` +`HasMaxLength(1000)` (KHÔNG index — hyperlink free-text, không filter/join). (3) Mig via `dotnet ef migrations add` → Up=1 `AddColumn<string> nvarchar(1000) maxLength:1000 nullable` NO table NO index, Down=1 DropColumn; snapshot HoSoLink nvarchar(1000) verified. (4a) `CreatePurchaseEvaluationCommand` +trailing `string? HoSoLink = null` (sau WorkItemId — optional-param-after-required rule) + validator `MaximumLength(1000)` MATCH EF (S35 lesson) + handler `HoSoLink = request.HoSoLink`. (4b) `UpdatePurchaseEvaluationDraftCommand` +trailing `string? HoSoLink = null` + validator 1000 + handler **absolute-set** `entity.HoSoLink = request.HoSoLink` (Section-1 text-field family MoTa/DiaDiem pattern → null=clear, KHÔNG null-safe-keep như WorkItemId picker; deliberate: hyperlink user cần clear được). (5) `PurchaseEvaluationDetailBundleDto` +`string? HoSoLink` sau MoTa + projection `e.HoSoLink` positional-insert đúng vị trí. **RANG-CUNG grep verify (bài học CreateDepartmentCommand CS7036):** `Grep CreatePurchaseEvaluationCommand|UpdatePurchaseEvaluationDraftCommand` repo-wide gồm tests → 0 manual `new ...Command(...)` call-site (controller bind `[FromBody]` model-binding, KHÔNG manual ctor; tests dùng NAMED-ARG dừng ở WorkItemId) → trailing-optional-default fully backward-compat, KHÔNG sửa call-site nào. List DTO KHÔNG đụng (spec chỉ Detail/Get). `.slnx` KHÔNG update (chỉ 2 mig-file trong project có sẵn). KHÔNG apply DB/FE/test/commit. Build SolutionErp.slnx (gồm 2 test project) **0 warn 0 err** (DocxRenderer warn cleared). Route: `hoSoLink` camelCase qua POST/PUT body + GET detail. Tag `[s65b, pe-hosolink, mig51, one-column-no-table, trailing-optional-param, named-arg-callsite-safe]`.
- **2026-06-16 (S65 Department hierarchy BE — Mig `AddDepartmentParentId` 3-file, 4 file edit + 0 new file, em-main schema CHỐT → ACCEPT Case 1):** Cây tổ chức nền trang Hồ sơ Nhân sự. (1) `Department.cs` +`Guid? ParentId` loose-Guid KHÔNG physical FK (convention PE.ProjectId/WorkItemId/SelectedSupplierId). (2) `DepartmentConfiguration.cs` +`HasIndex(x=>x.ParentId)` only — **KHÔNG `HasOne` self-FK** (em main chốt loose). (3) `DepartmentFeatures.cs` +`GetDepartmentTreeQuery`+`DepartmentTreeNodeDto`+Handler (append existing file, NO new .cs → `.slnx` KHÔNG cần update; slnx lists projects-not-files). (4) `DepartmentsController.cs` +`[HttpGet("tree")]`. **KEY recon finding (spec asked verify):** `EmployeeProfile` has **NO `DepartmentId`** — links via `UserId`; org-chart dept field nằm trên **`User.DepartmentId`** (Mig 11) → GROUP BY `db.Users.Where(DepartmentId!=null && IsActive).GroupBy(DepartmentId).ToDictionary` = DirectEmployeeCount (recon NOT schema-decision). Tree ráp in-mem: roots=ParentId-null OR orphan-parent (safe-root); TotalEmployeeCount=Direct+Σ(Children.Total) đệ quy rollup via `record with{}`; **cycle-guard HashSet<Guid> visited** (node đã thăm→return null cắt vòng); Children sort `OrderBy(Code, StringComparer.Ordinal)` ổn định. **Authz copy-từ-đâu:** `[HttpGet]` List = CHỈ class-level `[Authorize]` (no per-action attr) → `/tree` cũng vậy (verified read controller). Mig diff CLEAN: AddColumn ParentId nullable + CreateIndex IX_Departments_ParentId, NO new table, Down DropIndex→DropColumn (SQL 5074 order). KHÔNG apply (prod/CI). Build SolutionErp.slnx 0/0. KHÔNG touch FE/test/seed-parent/commit (em main). Route `GET /api/departments/tree``List<DepartmentTreeNodeDto>`. Tag `[s65, dept-hierarchy, loose-guid-no-fk, in-mem-tree-rollup, cycle-guard, user-departmentid-source]`. - **2026-06-16 (S65 Department hierarchy BE — Mig `AddDepartmentParentId` 3-file, 4 file edit + 0 new file, em-main schema CHỐT → ACCEPT Case 1):** Cây tổ chức nền trang Hồ sơ Nhân sự. (1) `Department.cs` +`Guid? ParentId` loose-Guid KHÔNG physical FK (convention PE.ProjectId/WorkItemId/SelectedSupplierId). (2) `DepartmentConfiguration.cs` +`HasIndex(x=>x.ParentId)` only — **KHÔNG `HasOne` self-FK** (em main chốt loose). (3) `DepartmentFeatures.cs` +`GetDepartmentTreeQuery`+`DepartmentTreeNodeDto`+Handler (append existing file, NO new .cs → `.slnx` KHÔNG cần update; slnx lists projects-not-files). (4) `DepartmentsController.cs` +`[HttpGet("tree")]`. **KEY recon finding (spec asked verify):** `EmployeeProfile` has **NO `DepartmentId`** — links via `UserId`; org-chart dept field nằm trên **`User.DepartmentId`** (Mig 11) → GROUP BY `db.Users.Where(DepartmentId!=null && IsActive).GroupBy(DepartmentId).ToDictionary` = DirectEmployeeCount (recon NOT schema-decision). Tree ráp in-mem: roots=ParentId-null OR orphan-parent (safe-root); TotalEmployeeCount=Direct+Σ(Children.Total) đệ quy rollup via `record with{}`; **cycle-guard HashSet<Guid> visited** (node đã thăm→return null cắt vòng); Children sort `OrderBy(Code, StringComparer.Ordinal)` ổn định. **Authz copy-từ-đâu:** `[HttpGet]` List = CHỈ class-level `[Authorize]` (no per-action attr) → `/tree` cũng vậy (verified read controller). Mig diff CLEAN: AddColumn ParentId nullable + CreateIndex IX_Departments_ParentId, NO new table, Down DropIndex→DropColumn (SQL 5074 order). KHÔNG apply (prod/CI). Build SolutionErp.slnx 0/0. KHÔNG touch FE/test/seed-parent/commit (em main). Route `GET /api/departments/tree``List<DepartmentTreeNodeDto>`. Tag `[s65, dept-hierarchy, loose-guid-no-fk, in-mem-tree-rollup, cycle-guard, user-departmentid-source]`.

View File

@ -42,13 +42,13 @@ Dynamic class purged. PALETTE array full literal `as const` cycle `index % lengt
## 📅 Recent activity (last 10 FIFO) ## 📅 Recent activity (last 10 FIFO)
- **2026-06-17 (Văn phòng số "Bảng điều khiển" — mirror fe-user→fe-admin + 4-place wiring ×2 app):** Mechanical mirror+wire step (frontend-designer built in fe-user, em main chốt UX). (A) **index.css SYNC** prereq: fe-admin STALE pre-S66 → `cp fe-user/src/index.css fe-admin/` (no admin-only class to merge — utility sets identical, chỉ S66 gotcha-66 diffs: h1-h4 `#0b1220`/wt700 + .label-eyebrow brand-600 + accent palette comments) → SHA256 `e8631471…` identical. (B) **Mirror 4 file** `cp` user→admin SHA256 IDENTICAL: ui/PageHeader `6ff5303f…`(Văn-phòng-số richer header — eyebrow/icon/accent/breadcrumb, NOT @/components/PageHeader constrained one), ui/KpiCard `f8042ade…`(clickable filter chip a11y role=button), ui/WidgetCard `9221cbed…`(gotcha66 gradient header `text-white!`), pages/office/OfficeDashboardPage `c6d9dc08…`(composes 3 ui over EXISTING hooks, NO new API). All `@/` imports + Button/api/cn + types/{proposal,workflowApps,meeting} đã có ở fe-admin → 0 import fix. (C) **4-place ×2 app:** page(mirror) + App.tsx import+`<Route path="/office/dashboard">` (NEW `/office/` prefix convention — page file ở pages/office/, không xung đột; flat office routes giữ nguyên) + menuKeys `OffDashboard:'Off_Dashboard'` (mirror BE MenuKeys.OffDashboard, đặt sau `Off` trước `OffDanhBa` đúng thứ tự BE) + Layout staticMap `Off_Dashboard:'/office/dashboard'` (4th place gotcha#50, trước Off_DanhBa). Leaf admin auto via BE All[]. Build PASS ×2 (user 1934mod `index-BYj_ew5Q.js`, admin 1945mod `index-Cn1flmn6.js`, 0 TS err). CSS @import-order warning + >500KB chunk = pre-existing. NO ambiguity, full precedent (Pattern 16-bis 4-place + SHA256 mirror). Tag `[office-dashboard, mirror-step, 4-place, sha256-5file, office-route-prefix]`.
- **2026-06-16 (S65 PE mục E "Link hồ sơ" FE ×2 app — em-main PROXY, PE-Workflow FE-stage died-empty #53):** Thêm mục "e. Link hồ sơ" hyperlink NAS dưới mục "d. Bản so sánh" + rename "Dự trù PRO"→"Ngân sách PRO". 4-file ×{user,admin} SHA256-mirror: PeDetailTabs.tsx (`HoSoLinkRow` :1353/1386 — useState(ev.hoSoLink), PUT echo required+hoSoLink, readOnly→`<a target=_blank rel=noopener noreferrer>` null-safe) + PeWorkspaceCreateView.tsx + types/purchaseEvaluation.ts (+hoSoLink). Ship Run #293 PASS, bundle both-rotate, GET phiếu thật `"hoSoLink":null` backward-compat ✓. SURPRISE: render landed on disk despite empty-return — work COMPLETE, chỉ MEMORY-update bị cắt (#53, lần này trong Workflow fan-out); em main self-gate bắt **badge "DỰ TRÙ PRO" sót rename** (agent chỉ đổi row label 1120/1126, sót badge 1078) → vá nốt ×2 app. Tag `[s65, pe-section-e-link-FE, hosolink-row, em-main-proxy-truncated-53, workflow-fanout]`. - **2026-06-16 (S65 PE mục E "Link hồ sơ" FE ×2 app — em-main PROXY, PE-Workflow FE-stage died-empty #53):** Thêm mục "e. Link hồ sơ" hyperlink NAS dưới mục "d. Bản so sánh" + rename "Dự trù PRO"→"Ngân sách PRO". 4-file ×{user,admin} SHA256-mirror: PeDetailTabs.tsx (`HoSoLinkRow` :1353/1386 — useState(ev.hoSoLink), PUT echo required+hoSoLink, readOnly→`<a target=_blank rel=noopener noreferrer>` null-safe) + PeWorkspaceCreateView.tsx + types/purchaseEvaluation.ts (+hoSoLink). Ship Run #293 PASS, bundle both-rotate, GET phiếu thật `"hoSoLink":null` backward-compat ✓. SURPRISE: render landed on disk despite empty-return — work COMPLETE, chỉ MEMORY-update bị cắt (#53, lần này trong Workflow fan-out); em main self-gate bắt **badge "DỰ TRÙ PRO" sót rename** (agent chỉ đổi row label 1120/1126, sót badge 1078) → vá nốt ×2 app. Tag `[s65, pe-section-e-link-FE, hosolink-row, em-main-proxy-truncated-53, workflow-fanout]`.
- **2026-06-16 (Department parentId — cây tổ chức, fe-admin ONLY):** Case 1 master-data enrich (NO 4-place, NO menu/route/Layout — chỉ Place-1 page+type). BE đã sẵn (local `0f44d97`): `DepartmentDto.parentId:Guid?` + POST/PUT nhận `parentId?` + cycle-guard 409 ConflictException. fe-admin ONLY (intentional — KHÔNG fe-user, KHÔNG SHA256). 2 file: (1) types/master.ts `Department` +`parentId:string|null` (sau name, trước managerUserId) — DepartmentInput auto-inherit via Omit · (2) DepartmentsPage.tsx: import Select + FormState/emptyForm +`parentId:string` ('' khi rỗng) + load-all query `['departments-all']` pageSize:200 (reuse pattern proven UsersPage/Workflows/AttendanceReport) + `deptNameById` Map từ allDepts cho cột "Thuộc" + mutate payload `parentId:d.parentId||null` + openEdit `parentId:d.parentId??''` + Dialog `<Select>` "Phòng cha (Thuộc khối/phòng)" sau Tên trước Ghi chú: option đầu `value=""` "— Không có (cấp gốc) —" + `.filter(d=>d.id!==form.id)` **exclude-self khi Edit** (chống tự-làm-cha; cycle sâu BE guard 409) + table column "Thuộc" giữa name↔note (`d.parentId?deptNameById.get()??'—':'—'`). Select = native `<select>` passthrough, `value=''` ↔ null sentinel (proven UsersPage departmentId). Build PASS (1941 mod, 0 TS err — tsc -b clean trước vite). NO ambiguity, full precedent (S55 enrich + UsersPage Select). - **2026-06-16 (Department parentId — cây tổ chức, fe-admin ONLY):** Case 1 master-data enrich (NO 4-place, NO menu/route/Layout — chỉ Place-1 page+type). BE đã sẵn (local `0f44d97`): `DepartmentDto.parentId:Guid?` + POST/PUT nhận `parentId?` + cycle-guard 409 ConflictException. fe-admin ONLY (intentional — KHÔNG fe-user, KHÔNG SHA256). 2 file: (1) types/master.ts `Department` +`parentId:string|null` (sau name, trước managerUserId) — DepartmentInput auto-inherit via Omit · (2) DepartmentsPage.tsx: import Select + FormState/emptyForm +`parentId:string` ('' khi rỗng) + load-all query `['departments-all']` pageSize:200 (reuse pattern proven UsersPage/Workflows/AttendanceReport) + `deptNameById` Map từ allDepts cho cột "Thuộc" + mutate payload `parentId:d.parentId||null` + openEdit `parentId:d.parentId??''` + Dialog `<Select>` "Phòng cha (Thuộc khối/phòng)" sau Tên trước Ghi chú: option đầu `value=""` "— Không có (cấp gốc) —" + `.filter(d=>d.id!==form.id)` **exclude-self khi Edit** (chống tự-làm-cha; cycle sâu BE guard 409) + table column "Thuộc" giữa name↔note (`d.parentId?deptNameById.get()??'—':'—'`). Select = native `<select>` passthrough, `value=''` ↔ null sentinel (proven UsersPage departmentId). Build PASS (1941 mod, 0 TS err — tsc -b clean trước vite). NO ambiguity, full precedent (S55 enrich + UsersPage Select).
- **2026-06-11 (S57bis PE WorkItem FE ×2 app — PARTIAL, on-behalf em main ghi hộ, H2-proposed):** Task = PeWorkspaceCreateView select "c. Hạng mục công việc *" sau Dự án + PeHeaderForm (select + load existing + PUT/POST workItemId) + PeDetailTabs (subtitle "Dự án Hạng mục" + FormRow + inline-edit khóa) + types +3 field. Return-truncated #53 GIỮA mirror fe-admin → em main solo mirror 7 edits PeHeaderForm + 3 edits PeDetailTabs ×2 app; PeHeaderForm SHA256 IDENTICAL. LEARNED: mirror đo bằng SHA256 (không diff mắt); option label `[Category] Code — Name` + canSubmit require; route reuse `/catalogs/work-items` (KHÔNG endpoint mới). SURPRISE: điểm gãy lặp tại mirror-2-app-trong-1-spawn → cân nhắc per-app stage khi slice lớn. Tag `[s57bis, truncated-53, sha256-mirror, on-behalf]`. - **2026-06-11 (S57bis PE WorkItem FE ×2 app — PARTIAL, on-behalf em main ghi hộ, H2-proposed):** Task = PeWorkspaceCreateView select "c. Hạng mục công việc *" sau Dự án + PeHeaderForm (select + load existing + PUT/POST workItemId) + PeDetailTabs (subtitle "Dự án Hạng mục" + FormRow + inline-edit khóa) + types +3 field. Return-truncated #53 GIỮA mirror fe-admin → em main solo mirror 7 edits PeHeaderForm + 3 edits PeDetailTabs ×2 app; PeHeaderForm SHA256 IDENTICAL. LEARNED: mirror đo bằng SHA256 (không diff mắt); option label `[Category] Code — Name` + canSubmit require; route reuse `/catalogs/work-items` (KHÔNG endpoint mới). SURPRISE: điểm gãy lặp tại mirror-2-app-trong-1-spawn → cân nhắc per-app stage khi slice lớn. Tag `[s57bis, truncated-53, sha256-mirror, on-behalf]`.
- **2026-06-09 (S55 HMW P2 — Project +4 optional master fields):** Case 1 master-data enrich (NO 4-place, NO menu/route/Layout — chỉ Place-1 page+type). BE adds 4 nullable Project fields parallel (implementer-backend). 2 file × 2 app: (1) types/master.ts `Project` +`year:number|null`+`investor/location/package:string|null` (sau `note`) +ProjectInput auto-inherit via Omit · (2) ProjectsPage.tsx (single-Dialog CRUD, NO separate pages): FormState +4 `string` (form dùng string, convert on submit) + emptyForm +4 '' + mutate payload `year:d.year?Number():null, investor/location/package:d.x||null` + openEdit `year:p.year?.toString()??'', x:p.x??''` + Dialog 4 Input sau "Ngày kết thúc" trước "Ghi chú" (Năm type=number, Chủ đầu tư, Địa điểm col-span-2, Gói thầu col-span-2) + table column "Chủ đầu tư" (`p.investor??'—'`) giữa name↔startDate. **`package` = valid TS object KEY** (reserved chỉ khi binding-identifier) → `form.package`/`{...form,package:x}` build sạch, KHÔNG cần rename. cp admin→user SHA256 IDENTICAL: master.ts `93ac1b0f…`, ProjectsPage `b002061…`. Build PASS ×2 (admin 1945mod, user 1934mod, 0 TS err — tsc -b clean trước vite). Reuse S42 enrich-pattern (string-form + convert-on-submit). NO ambiguity, full precedent. - **2026-06-09 (S55 HMW P2 — Project +4 optional master fields):** Case 1 master-data enrich (NO 4-place, NO menu/route/Layout — chỉ Place-1 page+type). BE adds 4 nullable Project fields parallel (implementer-backend). 2 file × 2 app: (1) types/master.ts `Project` +`year:number|null`+`investor/location/package:string|null` (sau `note`) +ProjectInput auto-inherit via Omit · (2) ProjectsPage.tsx (single-Dialog CRUD, NO separate pages): FormState +4 `string` (form dùng string, convert on submit) + emptyForm +4 '' + mutate payload `year:d.year?Number():null, investor/location/package:d.x||null` + openEdit `year:p.year?.toString()??'', x:p.x??''` + Dialog 4 Input sau "Ngày kết thúc" trước "Ghi chú" (Năm type=number, Chủ đầu tư, Địa điểm col-span-2, Gói thầu col-span-2) + table column "Chủ đầu tư" (`p.investor??'—'`) giữa name↔startDate. **`package` = valid TS object KEY** (reserved chỉ khi binding-identifier) → `form.package`/`{...form,package:x}` build sạch, KHÔNG cần rename. cp admin→user SHA256 IDENTICAL: master.ts `93ac1b0f…`, ProjectsPage `b002061…`. Build PASS ×2 (admin 1945mod, user 1934mod, 0 TS err — tsc -b clean trước vite). Reuse S42 enrich-pattern (string-form + convert-on-submit). NO ambiguity, full precedent.
- **2026-06-08 (S54 ItTicket reassign → CONVERGE 2 app, REVERSE S53 divergence) [harvested by em main — agent MEMORY write mis-landed, B2/B3]:** S53 đã tách fe-admin-only (admin reassign). S54 cho tổ IT tự reassign → cả 2 app cần nút. **Pattern mới: BE capability-flag gate thay vì FE đoán role.** Dropdown đổi `GET /users``GET /it-tickets/assignable-staff` trả `{canReassign:bool, staff:[{id,fullName}]}` (`[Authorize]` any-auth, KHÔNG 403 → chống gotcha #44). `canReassign = staffQ.data?.canReassign ?? false` (fetch on mount, KHÔNG `enabled`) → nút Pencil bọc `{canReassign && …}`. types/workflowApps.ts +`AssignableStaff`+`AssignableStaffResult` (mirror cả 2). fe-admin rewrite (bỏ UserOption/Paged<T>) + fe-user full-add → **SHA256 IDENTICAL `4bcaf2f…`** (viết admin canonical → `cp` → verify; cùng `@/...` import + shadcn Dialog/Select/Button identical 2 app). Build PASS ×2 (0 TS err). **Gotcha (pre-existing, NOT từ change này):** types/workflowApps.ts 2 app NOT SHA-identical — fe-admin có `AttendanceReportDto` (P11-E) mà fe-user thiếu; 2 type S54 mirror đúng cả 2. Lesson: BE-computed capability flag = single-source → 2 app converge lại sau intentional divergence. - **2026-06-08 (S54 ItTicket reassign → CONVERGE 2 app, REVERSE S53 divergence) [harvested by em main — agent MEMORY write mis-landed, B2/B3]:** S53 đã tách fe-admin-only (admin reassign). S54 cho tổ IT tự reassign → cả 2 app cần nút. **Pattern mới: BE capability-flag gate thay vì FE đoán role.** Dropdown đổi `GET /users``GET /it-tickets/assignable-staff` trả `{canReassign:bool, staff:[{id,fullName}]}` (`[Authorize]` any-auth, KHÔNG 403 → chống gotcha #44). `canReassign = staffQ.data?.canReassign ?? false` (fetch on mount, KHÔNG `enabled`) → nút Pencil bọc `{canReassign && …}`. types/workflowApps.ts +`AssignableStaff`+`AssignableStaffResult` (mirror cả 2). fe-admin rewrite (bỏ UserOption/Paged<T>) + fe-user full-add → **SHA256 IDENTICAL `4bcaf2f…`** (viết admin canonical → `cp` → verify; cùng `@/...` import + shadcn Dialog/Select/Button identical 2 app). Build PASS ×2 (0 TS err). **Gotcha (pre-existing, NOT từ change này):** types/workflowApps.ts 2 app NOT SHA-identical — fe-admin có `AttendanceReportDto` (P11-E) mà fe-user thiếu; 2 type S54 mirror đúng cả 2. Lesson: BE-computed capability flag = single-source → 2 app converge lại sau intentional divergence.
- **2026-06-08 (S52 Task C+D-FE — ItTicket admin reassign + AttendanceReport menu, fe-admin ONLY):** Both intentional mirror-break (admin-only, NO fe-user touch, NO SHA256). **Task D-FE menu wiring:** Page+App.tsx route `/attendance/report` ALREADY exist (S52 prior). Only 2 of 4-place needed: (1) menuKeys.ts +OffAttendanceReport='Off_AttendanceReport' (mirror BE string exact, after OffChamCong) · (2) Layout.tsx staticMap +Off_AttendanceReport:'/attendance/report' (4th place gotcha #50). `types/menu.ts` = MenuNode tree type, key:string NOT typed-union → NO mirror there (resolvePath(key:string)). Leaf perm-gated via BE All[]→admin auto. **Task C reassign:** ItTicketsPage.tsx top-comment updated DIVERGES fe-user. Per-card Pencil button (cạnh 👤 assignee) → Dialog (size sm) + Select user. Users source = **GET /users {params:{page:1,pageSize:200}}→{items:UserOption{id,fullName,email}}** (reuse, proven PeWorkflows/Workflows/MeetingCalendar — `enabled:target!==null` lazy fetch). useMutation api.put(`/it-tickets/${id}/assign`,{assignedToUserId})→204 (NO json read)→invalidate['it-tickets']+toast.success+close. preselect t.assignedToUserId. UI deps: Dialog(open/onClose/title/children/footer?/size) + Select(native passthrough) + Button(variant=outline) + toast(sonner) + getErrorMessage(@/lib/apiError). Build PASS (0 err, 1945 mod). git: only 3 fe-admin file, fe-user untouched. - **2026-06-08 (S52 Task C+D-FE — ItTicket admin reassign + AttendanceReport menu, fe-admin ONLY):** Both intentional mirror-break (admin-only, NO fe-user touch, NO SHA256). **Task D-FE menu wiring:** Page+App.tsx route `/attendance/report` ALREADY exist (S52 prior). Only 2 of 4-place needed: (1) menuKeys.ts +OffAttendanceReport='Off_AttendanceReport' (mirror BE string exact, after OffChamCong) · (2) Layout.tsx staticMap +Off_AttendanceReport:'/attendance/report' (4th place gotcha #50). `types/menu.ts` = MenuNode tree type, key:string NOT typed-union → NO mirror there (resolvePath(key:string)). Leaf perm-gated via BE All[]→admin auto. **Task C reassign:** ItTicketsPage.tsx top-comment updated DIVERGES fe-user. Per-card Pencil button (cạnh 👤 assignee) → Dialog (size sm) + Select user. Users source = **GET /users {params:{page:1,pageSize:200}}→{items:UserOption{id,fullName,email}}** (reuse, proven PeWorkflows/Workflows/MeetingCalendar — `enabled:target!==null` lazy fetch). useMutation api.put(`/it-tickets/${id}/assign`,{assignedToUserId})→204 (NO json read)→invalidate['it-tickets']+toast.success+close. preselect t.assignedToUserId. UI deps: Dialog(open/onClose/title/children/footer?/size) + Select(native passthrough) + Button(variant=outline) + toast(sonner) + getErrorMessage(@/lib/apiError). Build PASS (0 err, 1945 mod). git: only 3 fe-admin file, fe-user untouched.
- **2026-06-08 (P11-E Wave 1 — AttendanceReportPage fe-admin ONLY):** Report endpoint `[Authorize(Roles=Admin)]` → KHÔNG fe-user page → NO SHA256 mirror (intentional). 4 FE file: (1) types/workflowApps.ts +AttendanceReportRowDto{userId,fullName,departmentName?,daysPresent,totalWorkHours,otRaw,otWeekday,otWeekend,otHoliday,otWeighted}+AttendanceReportDto{year,month,rows,grandTotalWorkHours,grandTotalOtWeighted} (decimal→number) · (2) pages/office/AttendanceReportPage.tsx NEW: PageHeader+filter(Year Input number / Month Select 1-12 / Phòng ban Select fetch /departments) + TanStack key ['attendance-report',year,month,deptId] GET /attendances/report + Table 9 col STT/Họ tên/Phòng ban/Ngày công/Tổng giờ/OT thường/OT cuối tuần/OT lễ/OT quy đổi + tfoot Tổng(colSpan trick) + fmtNum vi-VN · (3) App.tsx import+route /attendance/report · (4) MyAttendancePage.tsx +button "Báo cáo" admin-only (user?.roles.includes('Admin')) navigate → DIVERGED fe-user (header comment cảnh báo). **Download Excel: `api.get(url,{params,responseType:'blob'})` (api instance inject JWT interceptor + refresh-retry — CHUẨN HƠN raw fetch spec gợi ý; proven ReportsPage/FormsPage/PeDetailTabs) → blob → createObjectURL → anchor.download.click → revoke. Filename content-disposition regex, fallback BaoCao-ChamCong-{Y}-{MM}.xlsx.** Build PASS (0 err, 1945 mod). KHÔNG menu key (button-reachable MVP).
--- ---
## ⚠️ Anti-patterns (DO NOT) ## ⚠️ Anti-patterns (DO NOT)

View File

@ -70,6 +70,8 @@ Bearer từ `POST api.solutions.com.vn/api/auth/login` → status matrix expecte
## 📅 Recent activity (FIFO — older → archive/git) ## 📅 Recent activity (FIFO — older → archive/git)
- **2026-06-17 (S69 recon — Office-module inventory + Hồ sơ-NS CSS-contract, on-disk):** ⭐ **PART A Office:** 21 `Off_*` keys (`MenuKeys.cs:99-121`): root `Off` + DanhBa(card-grid), `Off_PhongHop`{View=cal/Manage=room-CRUD-admin/Book}, `Off_DeXuat`{List/Create/Inbox=Proposal-V2}, `Off_DonTu`{Leave/Ot/Travel}, `Off_DatXe`, `Off_ItTicket`, `Off_ChamCong`(re-parent→Personal S57), `Off_AttendanceReport`(admin). 10 office pages `{fe-admin,fe-user}/src/pages/office/` ALL SHA256-MIRROR except **MyAttendancePage DIFFERS** + AttendanceReportPage ADMIN-ONLY. Routes `App.tsx` user:70-80/admin:88-100; staticMap `Layout.tsx:87-103` (workflow-apps :kind `/workflow-apps/{leave,ot,travel,vehicle}`); menuKeys.ts:45-63. **HIDE-FLAG** `RevokeTemporarilyHiddenModulesAsync` (`DbInitializer.cs:2157-2190` called :2040 LAST) wipes CRUD on `MenuKey.StartsWith("Off")||"Hrm"||==Personal` non-Admin, idempotent. **Golive flip:** remove :2040 call (+ re-add prefix InReviewScope grant). Office already S55-shell polished NOT bare. **PART B Hồ sơ-NS CSS:** layout=3-col flex (`EmployeesListPage.tsx` SHA256-identical x2, 1597 LOC): cây-tổ-chức TRÁI(:178) + NV-list MID(:244) + detail PHẢI = avatar-header `app-gradient-brand`(:643)+`text-white!`(:653)+initials chip bg-white/15 → 5-TAB(:507 Tổng quan/Thân nhân/Trình độ/Kinh nghiệm/Hợp đồng) → `Card`(:1526 left-rail+icon-chip) w/ `Field`(:1572 label uppercase accent-tint + value `font-medium text-brand-800`, empty=`text-slate-300 —`). `ACCENT` map :497-503 Record<5,{chipBg/chipFg/head/rail/labelText}> accent∈{brand,teal,violet,amberx,greenx}, palettes stops 50/100/500/600/700 only no-800→headings -700 (brand -800 OK). Tokens `index.css`: brand-600=#1f7dc1 brand-800=#175685 @theme:5-55, font Be-Vietnam-Pro:53; classes `.app-gradient-brand`(:105 120deg b600→700→800),`.card-accent`(:112),`.icon-chip`(:128 --chip-bg/--chip-fg),`.stat-value`(:140),`.label-eyebrow`(:89). ⚠️ **GOTCHA #66 = `index.css:79-83` `h1,h2,h3,h4{color:#0b1220;font-weight:700}` OUTSIDE @layer** → TW-v4 unlayered wins → heading-tag inside gradient MUST `text-white!`. ⚠️ **CROSS-APP DRIFT:** fe-user=S68 (h1-4 #0b1220/700, label-eyebrow brand-600, 175L); **fe-admin STILL OLD** (h1-4 #0f172a/600, label-eyebrow #64748b slate, 167L) — fe-admin NOT synced S66-68 heading bump → mirror Office to fe-admin needs index.css sync. Tag `[s69, office-inventory, hoso-css-contract, gotcha66, fe-admin-css-drift]`.
- **[→ git pre-S60]** S60 recon#2 V2-engine-map (ApprovalWorkflow.cs Step/Level Order 1-based per-step; OR-of-N=N rows cùng Order service GroupBy:475; ApproveV2Async:446-634 guard+UPSERT+advance; notify DRAFTER-only:748; skipToFinal F2:561-602 = precedent advance-không-ghi-opinion) · S60 PE Section-3 submit-guard (submit path POST/pe/{id}/transitions→TransitionAsync:38 ROLE-only guard NO data-check; Section-3 mục a/b/c/d map — SUPERSEDED bởi S65ter post-Mig50 Budget-drop; test mirror PurchaseEvaluationWorkflowServiceGuardTests). Full text git. - **[→ git pre-S60]** S60 recon#2 V2-engine-map (ApprovalWorkflow.cs Step/Level Order 1-based per-step; OR-of-N=N rows cùng Order service GroupBy:475; ApproveV2Async:446-634 guard+UPSERT+advance; notify DRAFTER-only:748; skipToFinal F2:561-602 = precedent advance-không-ghi-opinion) · S60 PE Section-3 submit-guard (submit path POST/pe/{id}/transitions→TransitionAsync:38 ROLE-only guard NO data-check; Section-3 mục a/b/c/d map — SUPERSEDED bởi S65ter post-Mig50 Budget-drop; test mirror PurchaseEvaluationWorkflowServiceGuardTests). Full text git.
- **2026-06-11 (S59 recon — prod test-data wipe + PE tree Hạng mục, prod+on-disk):** ⭐ **Prod:** PE=10 active (1 Nháp + 1 DaDuyet(7) + 8 ChoDuyet(10), MaPhieu A/031-040, ALL WorkItemId NULL) + child 20/10/20/28/138/18/18 (Sup/Det/Quote/Appr/Chg/Att/LvlOp); Contracts=7 ALL `[DEMO]` 05-08 pin V1 (AwId NULL) + Appr15 + details15; Budgets/WorkflowApps/Proposals/Attendances/Meetings ALL 0; Notifications 64. Seq: PE/2026/A=40 B=1; CT=7 demo prefix LastSeq=1. **FK:** PE child CASCADE trừ `Quotes→PE NO_ACTION` (multi-path; Plan R S23 proved single `DELETE FROM PurchaseEvaluations` OK — NO_ACTION check end-of-statement sau cascade Details→Quotes). Contract child ALL CASCADE. PE.ApprovalWorkflowId Restrict → wipe PE trước khi xóa AW QT-DN-V2-001 v1 (inactive, còn 1 PE pin). AW V2=8: 7 ghim KEEP. **Uploads orphan:** purchase-evaluations/ 19 folder vs 10 PE → ~10 orphan từ S23 (file không xóa); contracts/ 1. **Demo gate OK:** SeedDemoContracts/PE TRONG `DemoSeed:Disabled` (DbInitializer:80,131-132) → wipe không resurrect. **Surprise:** Users 55 total / 21 active — 20 user THẬT batch 2026-06-11 06:01 (S58 seed fix ăn; thanh.lethanh NOW EXISTS — stale S57bis mem; chuong.phan typo-domain VẪN active song song twin). **FE tree:** `pe/PurchaseEvaluationsListPage.tsx:138-179` Project>Year(createdAt :150)>Supplier; SHA256 identical 2 app; PeListItem ĐÃ có workItemId/Name (types :116-118, BE Features :514/570/644) → đổi tree FE-only. Tag `[s59-recon, prod-wipe, pe-tree-workitem]`. - **2026-06-11 (S59 recon — prod test-data wipe + PE tree Hạng mục, prod+on-disk):** ⭐ **Prod:** PE=10 active (1 Nháp + 1 DaDuyet(7) + 8 ChoDuyet(10), MaPhieu A/031-040, ALL WorkItemId NULL) + child 20/10/20/28/138/18/18 (Sup/Det/Quote/Appr/Chg/Att/LvlOp); Contracts=7 ALL `[DEMO]` 05-08 pin V1 (AwId NULL) + Appr15 + details15; Budgets/WorkflowApps/Proposals/Attendances/Meetings ALL 0; Notifications 64. Seq: PE/2026/A=40 B=1; CT=7 demo prefix LastSeq=1. **FK:** PE child CASCADE trừ `Quotes→PE NO_ACTION` (multi-path; Plan R S23 proved single `DELETE FROM PurchaseEvaluations` OK — NO_ACTION check end-of-statement sau cascade Details→Quotes). Contract child ALL CASCADE. PE.ApprovalWorkflowId Restrict → wipe PE trước khi xóa AW QT-DN-V2-001 v1 (inactive, còn 1 PE pin). AW V2=8: 7 ghim KEEP. **Uploads orphan:** purchase-evaluations/ 19 folder vs 10 PE → ~10 orphan từ S23 (file không xóa); contracts/ 1. **Demo gate OK:** SeedDemoContracts/PE TRONG `DemoSeed:Disabled` (DbInitializer:80,131-132) → wipe không resurrect. **Surprise:** Users 55 total / 21 active — 20 user THẬT batch 2026-06-11 06:01 (S58 seed fix ăn; thanh.lethanh NOW EXISTS — stale S57bis mem; chuong.phan typo-domain VẪN active song song twin). **FE tree:** `pe/PurchaseEvaluationsListPage.tsx:138-179` Project>Year(createdAt :150)>Supplier; SHA256 identical 2 app; PeListItem ĐÃ có workItemId/Name (types :116-118, BE Features :514/570/644) → đổi tree FE-only. Tag `[s59-recon, prod-wipe, pe-tree-workitem]`.
@ -92,6 +94,7 @@ Bearer từ `POST api.solutions.com.vn/api/auth/login` → status matrix expecte
- **2026-06-16 (S65bis recon — Employee profile master-detail vs NamGroup, on-disk):** ⭐ **STALE-PREMISE CORRECTION:** fe-user `/employees` KHÔNG list-only — `hrm/EmployeesListPage.tsx` (1201 LOC) ĐÃ master-detail 2-panel (filter sidebar :117 + list table :197 + inline detail :234) với **6 collapsible section** (`<details>` :1157, KHÔNG tab) + 5 satellite inline CRUD (WorkHistory/Education/FamilyRelation/Skill/Document, `setEditing{X}Id`+`adding{X}` mutex pattern 12-ter S35). **fe-admin == fe-user `diff -q` IDENTICAL** (SHA256 same). **Entity gần đủ screenshot:** `Domain/Hrm/EmployeeProfile.cs` (137 LOC) CÓ: DOB/Gender/Ethnicity/Religion/Nationality/Height/Weight(:98-99)/IdCard(số+ngàycấp+nơicấp :52-54)/permanent+temporary addr/phone/personalEmail/code/hireDate/qualification/salary(Base+Total)/bank/4×leave-days. **THIẾU vs screenshot:** (a) BloodType CÓ nhưng "sức khỏe loại" (health-grade A/B/C) KHÔNG; (b) thâm niên = DERIVED từ HireDate (no column); (c) chức danh = `User.Position`/`PositionLevel` (Identity, KHÔNG ở EmployeeProfile) — list/detail JOIN Users (`EmployeeFeatures.cs:467`); (d) "lương BHXH/phụ cấp" tách riêng KHÔNG có (chỉ Base+Total); đơn vị=DepartmentName JOIN. **5 satellite entity + 15 endpoint FULL** (`EmployeesController.cs:75-233` 5 region×Create/Update/Delete; GET detail Include cả 5 :455-459). Skill polymorphic gộp 3 NamGroup table (Computer/Language/Other Kind :69). **GAP THẬT:** (1) **NO org-tree**`Department.cs` FLAT (Code/Name/ManagerUserId/Note, KHÔNG ParentId), `DepartmentsController` chỉ GET list+byId (NO /tree), KHÔNG endpoint count-per-dept → cây trái + badge phải build CLIENT-side group-by departmentId từ list; (2) **5-tab layout** screenshot = 6-section `<details>` hiện tại (re-skin UI, data đủ); (3) "Hợp đồng lao động" tab = chỉ có `EmployeeDocument` type=LaborContract(5), KHÔNG entity HĐLĐ riêng (3 HĐLĐ table DEFER Plan H2 per `EmployeeProfile.cs:10`). **NamGroup source:** `D:\...\NAMGROUP\SOURCECODE_CÔNG_TY\` find .tsx/.razor = 0 hit (KHÔNG phải React/archived) — RAG `proj_namgroup_main` 0 component; tham khảo layout = screenshot anh gửi, KHÔNG có code mirror trực tiếp. ⇒ **Wire-lại-là-xong:** data + API + satellite CRUD 100% sẵn. **Build mới:** Department.ParentId migration + /tree + count endpoint (nếu muốn org-tree thật thay client-group). **Re-skin:** 6-section→5-tab + avatar header. **No new field bắt buộc** trừ health-grade nếu anh cần. Tag `[s65bis, employee-profile, master-detail-EXISTS, dept-flat-no-tree, stale-list-only-corrected]`. - **2026-06-16 (S65bis recon — Employee profile master-detail vs NamGroup, on-disk):** ⭐ **STALE-PREMISE CORRECTION:** fe-user `/employees` KHÔNG list-only — `hrm/EmployeesListPage.tsx` (1201 LOC) ĐÃ master-detail 2-panel (filter sidebar :117 + list table :197 + inline detail :234) với **6 collapsible section** (`<details>` :1157, KHÔNG tab) + 5 satellite inline CRUD (WorkHistory/Education/FamilyRelation/Skill/Document, `setEditing{X}Id`+`adding{X}` mutex pattern 12-ter S35). **fe-admin == fe-user `diff -q` IDENTICAL** (SHA256 same). **Entity gần đủ screenshot:** `Domain/Hrm/EmployeeProfile.cs` (137 LOC) CÓ: DOB/Gender/Ethnicity/Religion/Nationality/Height/Weight(:98-99)/IdCard(số+ngàycấp+nơicấp :52-54)/permanent+temporary addr/phone/personalEmail/code/hireDate/qualification/salary(Base+Total)/bank/4×leave-days. **THIẾU vs screenshot:** (a) BloodType CÓ nhưng "sức khỏe loại" (health-grade A/B/C) KHÔNG; (b) thâm niên = DERIVED từ HireDate (no column); (c) chức danh = `User.Position`/`PositionLevel` (Identity, KHÔNG ở EmployeeProfile) — list/detail JOIN Users (`EmployeeFeatures.cs:467`); (d) "lương BHXH/phụ cấp" tách riêng KHÔNG có (chỉ Base+Total); đơn vị=DepartmentName JOIN. **5 satellite entity + 15 endpoint FULL** (`EmployeesController.cs:75-233` 5 region×Create/Update/Delete; GET detail Include cả 5 :455-459). Skill polymorphic gộp 3 NamGroup table (Computer/Language/Other Kind :69). **GAP THẬT:** (1) **NO org-tree**`Department.cs` FLAT (Code/Name/ManagerUserId/Note, KHÔNG ParentId), `DepartmentsController` chỉ GET list+byId (NO /tree), KHÔNG endpoint count-per-dept → cây trái + badge phải build CLIENT-side group-by departmentId từ list; (2) **5-tab layout** screenshot = 6-section `<details>` hiện tại (re-skin UI, data đủ); (3) "Hợp đồng lao động" tab = chỉ có `EmployeeDocument` type=LaborContract(5), KHÔNG entity HĐLĐ riêng (3 HĐLĐ table DEFER Plan H2 per `EmployeeProfile.cs:10`). **NamGroup source:** `D:\...\NAMGROUP\SOURCECODE_CÔNG_TY\` find .tsx/.razor = 0 hit (KHÔNG phải React/archived) — RAG `proj_namgroup_main` 0 component; tham khảo layout = screenshot anh gửi, KHÔNG có code mirror trực tiếp. ⇒ **Wire-lại-là-xong:** data + API + satellite CRUD 100% sẵn. **Build mới:** Department.ParentId migration + /tree + count endpoint (nếu muốn org-tree thật thay client-group). **Re-skin:** 6-section→5-tab + avatar header. **No new field bắt buộc** trừ health-grade nếu anh cần. Tag `[s65bis, employee-profile, master-detail-EXISTS, dept-flat-no-tree, stale-list-only-corrected]`.
- **2026-06-17 (S69 recon — NamGroup "PURO" digital-office layout, CROSS-REPO `D:\...\NAMGROUP\`):** ⭐ **PURO = UI design-language/skin (ref ERP demo.purocorp.vn), KHÔNG phải app riêng** — NamGroup mirror sidebar/typography của nó (comments `InternalLayout.tsx:33,74,109,200,332` "PURO exact spec"). Digital-office sống trong `namgroup.client/` (app NV; admin = config-only). **Shell = `components/layout/InternalLayout.tsx`** (724L): sidebar trái fixed h-screen + `<main flex-1 overflow-auto p-2.5..lg:p-4>` :609 chứa `<Outlet/>`. Sidebar = **`navTree` hardcoded array :76-122** (KHÔNG DB), flat 2-tier group→leaf, 4 group: **"Văn phòng số" :90-100 = 6 leaf** {Danh bạ `/danhba` · Phòng họp `/phonghop` · Đề xuất `/dexuat` · Đơn từ `/dontu` · Đặt xe công `/xecong` · Ticket CNTT `/ticket`}; + Nhân sự(3) + Cá nhân{Chấm công}(1) + Hệ thống(5). Routing = `App.tsx:81-140` flat `<Route element={InternalLayout}>` > `<RouteGuard>` (perm) > index=HomePage. **Landing `/` = `internal/HomePage.tsx`** (296L): grid 2-col (LEFT 2/3 stack 4 WidgetCard: Đề xuất/Nghỉ phép/Bình luận/Truyền-thông · RIGHT 1/3 Công-việc-của-tôi); WidgetCard = gradient-blue header + inline stat-chips + body/EmptyState (shared comp tại :219). **Layout pattern mỗi feature (KHÔNG dùng tab — dùng KpiCard-row + view-toggle):** (a) `<PageHeader>` shared (icon-badge accent + breadcrumb + actions slot, `components/shared/PageHeader.tsx`); (b) **KPI stat-cards clickable filter** (DeXuat :1643 6-card / Ticket :197 5-card — "PURO KPI cards" comment); (c) body = **list-table** (DeXuat/Ticket: `<table>` master + right detail panel grid-cols-3) HOẶC **list↔calendar ViewToggle** (DonTu :683 + XeCong + PhongHop: custom month-grid Sun-Sat, NO FullCalendar — comment :PhongHop "saves install friction"); DanhBa = dept-tree trái + card-grid phải. **Shared comp tái dùng:** PageHeader · DataTable · KpiCard · CrudToolbar · ActionLogList · DatePickerVN · MasterDataPage · DonutChart/MiniBarChart (`components/shared/` 16 file). **Top files mirror:** InternalLayout.tsx(shell+nav) · HomePage.tsx(dashboard) · PageHeader.tsx · DeXuatPage.tsx(1676 KPI+table+detail) · DonTuPage.tsx(1269 +DonTuCalendar.tsx toggle) · XeCongPage/PhongHopPage(month-grid) · TicketPage.tsx(595) · DanhBaPage.tsx(756 tree+grid) · ChamCongPage.tsx(809). ⚠️ Style/màu lấy chỗ khác per task — chỉ structure. SE đã có analog (fe-user `InternalLayout`/office pages) nhưng layout khác (deep-nested vs PURO flat). Tag `[s69, namgroup-puro-recon, digital-office-layout, hardcoded-navtree, kpicard-not-tab, cross-repo]`.
- **2026-06-16 (S66 recon — mirror Hồ sơ NS fe-user→fe-admin, on-disk):** ⭐ **VERDICT (B): vá `fe-admin/src/index.css` TRƯỚC rồi cookie-cutter SẠCH.** Copy page thuần = VỠ MÀU. **fe-admin index.css = 86 dòng (chốt `7feb53e`, TRƯỚC redesign S58 `e959f72`/`c98030f`) → THIẾU:** 4 accent palette `teal/amberx/violet/greenx` (mỗi cái 50/100/500/600/700) + 3 utility `.icon-chip`/`.app-gradient-brand`/`.card-accent`/`.stat-value`. **CÓ SẴN:** `--color-brand-50..900` (hex y hệt fe-user, incl brand-800 #175685 :15), `.label-eyebrow` :54, font Be Vietnam Pro :22. ⚠️ heading-weight CẦN CHECK (fe-user S66 `h1-h4 font-weight:700 color:#0b1220`; fe-admin có thể còn 600). **Page fe-user phụ thuộc THẬT:** text-brand-800 ×9, teal/amberx/violet/greenx-50/500/700 ×4 mỗi, icon-chip ×3, app-gradient-brand ×1. **Wiring fe-admin ĐỦ SẴN (0 đụng):** route `/employees`+`/new` `App.tsx:82-83` · `EmployeeCreatePage.tsx` **identical** (diff rỗng) · menu `Hrm_HoSo` `menuKeys.ts:33`+staticMap `Layout.tsx:53`. **Lib parity ✅:** ui/{Input,Select,Textarea,Button} prop-sig **identical** (HTMLAttributes passthrough, content khác chỉ className=chủ ý non-breaking) · EmptyState/cn/api/apiError ✅ · `types/employee.ts` **identical** · Paged `types/master` ✅ · `DepartmentTreeNode` định nghĩa INLINE trong page (:65 mirror BE DepartmentTreeNodeDto, đi theo copy — không cần type file). **KHÔNG khác chủ ý admin/user** — mirror y hệt như S35 (`9616ae2`/`c3cd343` cùng commit 2 app); write Admin-gated ở BE controller. **Cấu trúc:** admin=1200 dòng (S35 cũ, 2-panel + 5 `<details>` + 5 satellite mutex); user=1602 dòng (redesign: cây "SOLUTION COMPANY" đệ quy + list cột trái dọc · detail phải 5 tab accent + avatar gradient). **Scope: 3 file** = index.css (chèn ~40 dòng token+class block từ fe-user :29-51+:100-160) + overwrite EmployeesListPage.tsx (import path KHÔNG chỉnh, đều `@/`) + (tùy chọn heading-700). KHÔNG đụng route/menu/types/primitives/CreatePage. Tag `[s66, mirror-employee-page, accent-token-missing-fe-admin, verdict-B, cookie-cutter-after-css]`. - **2026-06-16 (S66 recon — mirror Hồ sơ NS fe-user→fe-admin, on-disk):** ⭐ **VERDICT (B): vá `fe-admin/src/index.css` TRƯỚC rồi cookie-cutter SẠCH.** Copy page thuần = VỠ MÀU. **fe-admin index.css = 86 dòng (chốt `7feb53e`, TRƯỚC redesign S58 `e959f72`/`c98030f`) → THIẾU:** 4 accent palette `teal/amberx/violet/greenx` (mỗi cái 50/100/500/600/700) + 3 utility `.icon-chip`/`.app-gradient-brand`/`.card-accent`/`.stat-value`. **CÓ SẴN:** `--color-brand-50..900` (hex y hệt fe-user, incl brand-800 #175685 :15), `.label-eyebrow` :54, font Be Vietnam Pro :22. ⚠️ heading-weight CẦN CHECK (fe-user S66 `h1-h4 font-weight:700 color:#0b1220`; fe-admin có thể còn 600). **Page fe-user phụ thuộc THẬT:** text-brand-800 ×9, teal/amberx/violet/greenx-50/500/700 ×4 mỗi, icon-chip ×3, app-gradient-brand ×1. **Wiring fe-admin ĐỦ SẴN (0 đụng):** route `/employees`+`/new` `App.tsx:82-83` · `EmployeeCreatePage.tsx` **identical** (diff rỗng) · menu `Hrm_HoSo` `menuKeys.ts:33`+staticMap `Layout.tsx:53`. **Lib parity ✅:** ui/{Input,Select,Textarea,Button} prop-sig **identical** (HTMLAttributes passthrough, content khác chỉ className=chủ ý non-breaking) · EmptyState/cn/api/apiError ✅ · `types/employee.ts` **identical** · Paged `types/master` ✅ · `DepartmentTreeNode` định nghĩa INLINE trong page (:65 mirror BE DepartmentTreeNodeDto, đi theo copy — không cần type file). **KHÔNG khác chủ ý admin/user** — mirror y hệt như S35 (`9616ae2`/`c3cd343` cùng commit 2 app); write Admin-gated ở BE controller. **Cấu trúc:** admin=1200 dòng (S35 cũ, 2-panel + 5 `<details>` + 5 satellite mutex); user=1602 dòng (redesign: cây "SOLUTION COMPANY" đệ quy + list cột trái dọc · detail phải 5 tab accent + avatar gradient). **Scope: 3 file** = index.css (chèn ~40 dòng token+class block từ fe-user :29-51+:100-160) + overwrite EmployeesListPage.tsx (import path KHÔNG chỉnh, đều `@/`) + (tùy chọn heading-700). KHÔNG đụng route/menu/types/primitives/CreatePage. Tag `[s66, mirror-employee-page, accent-token-missing-fe-admin, verdict-B, cookie-cutter-after-css]`.
- **2026-06-16 (S65ter recon — Mục E "Link hồ sơ" phiếu PE, on-disk):** ⭐ Anh Kiệt: chèn mục E "Link hồ sơ" NGAY DƯỚI mục D "Bản so sánh". **Render 4 file** (SHA256-identical 2 app): `components/pe/PeDetailTabs.tsx` (detail+edit, 2770 LOC) + `PeWorkspaceCreateView.tsx` (create) × {fe-user,fe-admin}. KHÔNG tabs — 5 `<Section>` dọc, tiêu đề "1./2./3./4." + sub-item chữ thường "a./b./c./d." (label cột trái w-44). **Mục D ∈ Section "3. Đơn vị NCC/TP" = `ChonNccSection`** (`PeDetailTabs.tsx:1302-1375`): a.NCC(:1321) · b.Tổng hợp NS trình ký(:1324 `PeBudgetSummaryTable` — S61 thay Budget) · c.Giá chào thầu(:1326 auto) · **d.Bản so sánh(:1337-1348)** = `GeneralAttachmentsSection`(:2613) upload N FILE filter `supplierId===null` purpose=ComparisonTable(4). **INSERT E: `PeDetailTabs.tsx:1348`** (sau `</div>` mục D, trước paymentTerms :1350); mẫu = block :1337-1348. Create: `PeWorkspaceCreateView.tsx:277` (sau FormRow d). **BE: `PurchaseEvaluation.cs`(:1-72) KHÔNG có field URL** — DiaDiem/MoTa/PaymentTerms semantic khác. 1 link → `string? HoSoLink`(1000)+Mig AddColumn+cmd+DTO+validator; nhiều link → entity con `PurchaseEvaluationLink`+CREATE TABLE+CRUD (nặng). **Attachment KHÔNG reuse URL**`PurchaseEvaluationAttachmentFeatures.cs:18-55` IFormFile thuần (FileSize>0+ContentType whitelist+IFileStorage). Mục D multi-row → E nên multi-row đối xứng. ⚠️ Surprise: comment :1314 nói "purpose=ComparisonTable hoặc supplier-row null" SAI — filter thực :1315-17 CHỈ `supplierId===null`. Tag `[s65ter, pe-section-e-link, attachment-file-only, insert-1348]`. - **2026-06-16 (S65ter recon — Mục E "Link hồ sơ" phiếu PE, on-disk):** ⭐ Anh Kiệt: chèn mục E "Link hồ sơ" NGAY DƯỚI mục D "Bản so sánh". **Render 4 file** (SHA256-identical 2 app): `components/pe/PeDetailTabs.tsx` (detail+edit, 2770 LOC) + `PeWorkspaceCreateView.tsx` (create) × {fe-user,fe-admin}. KHÔNG tabs — 5 `<Section>` dọc, tiêu đề "1./2./3./4." + sub-item chữ thường "a./b./c./d." (label cột trái w-44). **Mục D ∈ Section "3. Đơn vị NCC/TP" = `ChonNccSection`** (`PeDetailTabs.tsx:1302-1375`): a.NCC(:1321) · b.Tổng hợp NS trình ký(:1324 `PeBudgetSummaryTable` — S61 thay Budget) · c.Giá chào thầu(:1326 auto) · **d.Bản so sánh(:1337-1348)** = `GeneralAttachmentsSection`(:2613) upload N FILE filter `supplierId===null` purpose=ComparisonTable(4). **INSERT E: `PeDetailTabs.tsx:1348`** (sau `</div>` mục D, trước paymentTerms :1350); mẫu = block :1337-1348. Create: `PeWorkspaceCreateView.tsx:277` (sau FormRow d). **BE: `PurchaseEvaluation.cs`(:1-72) KHÔNG có field URL** — DiaDiem/MoTa/PaymentTerms semantic khác. 1 link → `string? HoSoLink`(1000)+Mig AddColumn+cmd+DTO+validator; nhiều link → entity con `PurchaseEvaluationLink`+CREATE TABLE+CRUD (nặng). **Attachment KHÔNG reuse URL**`PurchaseEvaluationAttachmentFeatures.cs:18-55` IFormFile thuần (FileSize>0+ContentType whitelist+IFileStorage). Mục D multi-row → E nên multi-row đối xứng. ⚠️ Surprise: comment :1314 nói "purpose=ComparisonTable hoặc supplier-row null" SAI — filter thực :1315-17 CHỈ `supplierId===null`. Tag `[s65ter, pe-section-e-link, attachment-file-only, insert-1348]`.
- **2026-06-16 (S65 recon — public HRM module for all-role, on-disk):** ⭐ **Mục 6 CRITICAL (gotcha #44 family) RESOLVED-FAVORABLE:** `EmployeesController.cs:23-25` = class `[Authorize(Policy="Hrm_HoSo.Read")]` (NOT `Roles="Admin"`) + per-action `Hrm_HoSo.{Create/Update/Delete}` (:45/:54). Policy resolves THROUGH permission matrix (`MenuPermissionHandler.cs:40-52` baseQuery role×menuKey CanRead; Admin-bypass :27) → seed CanRead row = API ALSO unlocked, NO 403. `HrDashboardController.cs:8-11` = `[Authorize]` any-auth only (`/api/hr/dashboard`). GET list = `/api/employees` (:28). ⇒ **seed BE permission ĐỦ, không cần đụng controller.** **Menu keys dưới `Hrm` (prefix THẬT = `Hrm_`):** root `Hrm`="Nhân sự" parent=null Order=28 (`DbInitializer.cs:1805`); `Hrm_Dashboard`="Dashboard NS" parent=Hrm Order=1 (:1850); `Hrm_HoSo`="Hồ sơ Nhân sự" parent=Hrm Order=2 (:1806). Hrm_Config* (6 leaf: LeaveTypes/Holidays/Shifts/OtPolicies/Vehicles/Drivers) parent=**Master** Order=25 (S57 re-parent :1812 — KHÔNG dưới Hrm). **Revoke (Mục 2):** `RevokeTemporarilyHiddenModulesAsync` :2151 — match `StartsWith("Hrm")||StartsWith("Off")||==Personal` AND role!=Admin AND any-flag-true (:2162-67) → set 4 cờ CRUD=false. ⚠️ **THỨ TỰ: gọi CUỐI CÙNG `:2040` trong SeedAsync, SAU grant `:2033`** → revoke THẮNG mọi grant trước nó. Mở Hrm = phải (a) sửa revoke loại trừ Hrm_HoSo/Hrm_Dashboard HOẶC (b) thêm grant SAU :2040. **Pe pattern (Mục 3):** `SeedAllRolesReviewReadPermissionsAsync:2055``roleManager.Roles.ToListAsync():2090` loop ALL role × reviewKeys, upsert CanRead (+CanCreate cho Pe_*), additive idempotent (skip-existing non-Pe :2115). **Seed entity (Mục 4):** `Permission`(RoleId,MenuKey,4 CRUD); idempotent = app-level skip per (RoleId,MenuKey); **13 role** `AppRoles.All` (Admin/Drafter/DeptManager/ProjectManager/Procurement/CostControl/Finance/Accounting/Equipment/Director/AuthorizedSigner/HrAdmin/CatalogManager). `Hrm_HoSo`+`Hrm_Dashboard` ĐỀU ∈ `MenuKeys.All:153,160` (khác Pe_* leaf NOT in All). **FE (Mục 5):** menu-tree-API-driven via `GetMyMenuTreeQuery.cs` (`/api/menus/me`); Hrm NOT inherit-root (chỉ 4: Contracts/Workflows/Pe/PeWf :51-59) → MỖI leaf cần CanRead row riêng, NHƯNG root `Hrm` auto-hiện nếu child có access (`HasAccess:96` CanRead OR child). `Layout.tsx:145 USER_HIDDEN_KEYS`={System,Users,Roles,Permissions,Forms,Reports} — KHÔNG chứa Hrm → fe-user auto-render; `staticMap Hrm_HoSo→/employees :75`, `Hrm_Dashboard→/hr/dashboard :104`. NO PermissionGuard per-route fe-user. ⇒ **chỉ seed BE, FE tự hiện.** Tag `[s65-recon, public-hrm, policy-based-authz-not-roles, revoke-runs-last]`. - **2026-06-16 (S65 recon — public HRM module for all-role, on-disk):** ⭐ **Mục 6 CRITICAL (gotcha #44 family) RESOLVED-FAVORABLE:** `EmployeesController.cs:23-25` = class `[Authorize(Policy="Hrm_HoSo.Read")]` (NOT `Roles="Admin"`) + per-action `Hrm_HoSo.{Create/Update/Delete}` (:45/:54). Policy resolves THROUGH permission matrix (`MenuPermissionHandler.cs:40-52` baseQuery role×menuKey CanRead; Admin-bypass :27) → seed CanRead row = API ALSO unlocked, NO 403. `HrDashboardController.cs:8-11` = `[Authorize]` any-auth only (`/api/hr/dashboard`). GET list = `/api/employees` (:28). ⇒ **seed BE permission ĐỦ, không cần đụng controller.** **Menu keys dưới `Hrm` (prefix THẬT = `Hrm_`):** root `Hrm`="Nhân sự" parent=null Order=28 (`DbInitializer.cs:1805`); `Hrm_Dashboard`="Dashboard NS" parent=Hrm Order=1 (:1850); `Hrm_HoSo`="Hồ sơ Nhân sự" parent=Hrm Order=2 (:1806). Hrm_Config* (6 leaf: LeaveTypes/Holidays/Shifts/OtPolicies/Vehicles/Drivers) parent=**Master** Order=25 (S57 re-parent :1812 — KHÔNG dưới Hrm). **Revoke (Mục 2):** `RevokeTemporarilyHiddenModulesAsync` :2151 — match `StartsWith("Hrm")||StartsWith("Off")||==Personal` AND role!=Admin AND any-flag-true (:2162-67) → set 4 cờ CRUD=false. ⚠️ **THỨ TỰ: gọi CUỐI CÙNG `:2040` trong SeedAsync, SAU grant `:2033`** → revoke THẮNG mọi grant trước nó. Mở Hrm = phải (a) sửa revoke loại trừ Hrm_HoSo/Hrm_Dashboard HOẶC (b) thêm grant SAU :2040. **Pe pattern (Mục 3):** `SeedAllRolesReviewReadPermissionsAsync:2055``roleManager.Roles.ToListAsync():2090` loop ALL role × reviewKeys, upsert CanRead (+CanCreate cho Pe_*), additive idempotent (skip-existing non-Pe :2115). **Seed entity (Mục 4):** `Permission`(RoleId,MenuKey,4 CRUD); idempotent = app-level skip per (RoleId,MenuKey); **13 role** `AppRoles.All` (Admin/Drafter/DeptManager/ProjectManager/Procurement/CostControl/Finance/Accounting/Equipment/Director/AuthorizedSigner/HrAdmin/CatalogManager). `Hrm_HoSo`+`Hrm_Dashboard` ĐỀU ∈ `MenuKeys.All:153,160` (khác Pe_* leaf NOT in All). **FE (Mục 5):** menu-tree-API-driven via `GetMyMenuTreeQuery.cs` (`/api/menus/me`); Hrm NOT inherit-root (chỉ 4: Contracts/Workflows/Pe/PeWf :51-59) → MỖI leaf cần CanRead row riêng, NHƯNG root `Hrm` auto-hiện nếu child có access (`HasAccess:96` CanRead OR child). `Layout.tsx:145 USER_HIDDEN_KEYS`={System,Users,Roles,Permissions,Forms,Reports} — KHÔNG chứa Hrm → fe-user auto-render; `staticMap Hrm_HoSo→/employees :75`, `Hrm_Dashboard→/hr/dashboard :104`. NO PermissionGuard per-route fe-user. ⇒ **chỉ seed BE, FE tự hiện.** Tag `[s65-recon, public-hrm, policy-based-authz-not-roles, revoke-runs-last]`.

View File

@ -38,6 +38,7 @@ import { WorkflowAppDetailPage } from '@/pages/office/WorkflowAppDetailPage'
import { ItTicketsPage } from '@/pages/office/ItTicketsPage' import { ItTicketsPage } from '@/pages/office/ItTicketsPage'
import { MyAttendancePage } from '@/pages/office/MyAttendancePage' import { MyAttendancePage } from '@/pages/office/MyAttendancePage'
import { AttendanceReportPage } from '@/pages/office/AttendanceReportPage' import { AttendanceReportPage } from '@/pages/office/AttendanceReportPage'
import { OfficeDashboardPage } from '@/pages/office/OfficeDashboardPage'
import { HrmDashboardPage } from '@/pages/hrm/HrmDashboardPage' import { HrmDashboardPage } from '@/pages/hrm/HrmDashboardPage'
function App() { function App() {
@ -84,6 +85,8 @@ function App() {
{/* Cấu hình HRM (Phase 10.2 G-H2 — Mig 35) */} {/* Cấu hình HRM (Phase 10.2 G-H2 — Mig 35) */}
<Route path="/hrm/configs" element={<Navigate to="/hrm/configs/leave-types" replace />} /> <Route path="/hrm/configs" element={<Navigate to="/hrm/configs/leave-types" replace />} />
<Route path="/hrm/configs/:kind" element={<HrmConfigsPage />} /> <Route path="/hrm/configs/:kind" element={<HrmConfigsPage />} />
{/* Văn phòng số — Bảng điều khiển (landing dashboard, Off_Dashboard) */}
<Route path="/office/dashboard" element={<OfficeDashboardPage />} />
{/* Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1) */} {/* Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1) */}
<Route path="/directory" element={<InternalDirectoryPage />} /> <Route path="/directory" element={<InternalDirectoryPage />} />
{/* Văn phòng số — Phòng họp Booking + Catalog (Phase 10.2 G-O2 — Mig 36 S36) */} {/* Văn phòng số — Phòng họp Booking + Catalog (Phase 10.2 G-O2 — Mig 36 S36) */}

View File

@ -62,6 +62,7 @@ function resolvePath(key: string): string | null {
Hrm_Config_Drivers: '/hrm/configs/drivers', Hrm_Config_Drivers: '/hrm/configs/drivers',
// [Phase 10.2 G-O1 S34 2026-05-27] Module Văn phòng số — Danh bạ nội bộ. // [Phase 10.2 G-O1 S34 2026-05-27] Module Văn phòng số — Danh bạ nội bộ.
// 4-place mirror Pattern 16-bis: types/ + pages/ + App.tsx + menuKeys + staticMap. // 4-place mirror Pattern 16-bis: types/ + pages/ + App.tsx + menuKeys + staticMap.
Off_Dashboard: '/office/dashboard',
Off_DanhBa: '/directory', Off_DanhBa: '/directory',
// [Phase 10.2 G-O2 S36 2026-05-28] Phòng họp Booking + Catalog (Mig 36). // [Phase 10.2 G-O2 S36 2026-05-28] Phòng họp Booking + Catalog (Mig 36).
// Pattern 16-bis 4-place mirror 7× cumulative — staticMap = 4th place dễ miss. // Pattern 16-bis 4-place mirror 7× cumulative — staticMap = 4th place dễ miss.

View File

@ -0,0 +1,107 @@
import type { KeyboardEvent, ReactNode } from 'react'
import { cn } from '@/lib/cn'
// KpiCard — clickable stat card used as a FILTER chip (PURO pattern: a row of
// KpiCards filters the list, replacing tabs). icon-chip + big stat-value +
// .label-eyebrow. The ACTIVE state tints the background and rings in the accent;
// hover lifts.
//
// Visual idiom from pages/hrm/EmployeesListPage.tsx (icon-chip + stat-value +
// label-eyebrow tokens) and index.css. Accent palettes ship stops
// 50/100/500/600/700 only (no -800) → stat text uses -700 (brand uses -800,
// which exists). Mismatched stop = silent no-class in Tailwind v4.
//
// a11y: when onClick is given the card becomes role="button", focusable
// (tabIndex 0), responds to Enter + Space, exposes aria-pressed=active, and
// shows a focus-visible ring. Without onClick it is an inert presentational card.
export type Accent = 'brand' | 'teal' | 'violet' | 'amberx' | 'greenx'
type AccentTokens = {
chipBg: string
chipFg: string
value: string
activeBg: string
activeRing: string
activeBorder: string
}
// activeBorder uses the -500 stop: the accent palettes ship only
// 50/100/500/600/700, so -300 (which only brand has) would silently fall to
// Tailwind's DEFAULT teal/violet — or drop entirely for amberx/greenx (custom
// names). -500 exists for EVERY accent → the active border always renders the
// intended brand-aligned tone. (gotcha "vỡ màu im lặng" Tailwind v4.)
const ACCENT: Record<Accent, AccentTokens> = {
brand: { chipBg: 'var(--color-brand-50)', chipFg: 'var(--color-brand-600)', value: 'text-brand-800', activeBg: 'bg-brand-50', activeRing: 'ring-brand-500', activeBorder: 'border-brand-500' },
teal: { chipBg: 'var(--color-teal-50)', chipFg: 'var(--color-teal-700)', value: 'text-teal-700', activeBg: 'bg-teal-50', activeRing: 'ring-teal-500', activeBorder: 'border-teal-500' },
violet: { chipBg: 'var(--color-violet-50)', chipFg: 'var(--color-violet-700)', value: 'text-violet-700', activeBg: 'bg-violet-50', activeRing: 'ring-violet-500', activeBorder: 'border-violet-500' },
amberx: { chipBg: 'var(--color-amberx-50)', chipFg: 'var(--color-amberx-700)', value: 'text-amberx-700', activeBg: 'bg-amberx-50', activeRing: 'ring-amberx-500', activeBorder: 'border-amberx-500' },
greenx: { chipBg: 'var(--color-greenx-50)', chipFg: 'var(--color-greenx-700)', value: 'text-greenx-700', activeBg: 'bg-greenx-50', activeRing: 'ring-greenx-500', activeBorder: 'border-greenx-500' },
}
export function KpiCard({
label,
value,
icon,
accent = 'brand',
active = false,
onClick,
className,
}: {
/** Caption under the value (.label-eyebrow). */
label: string
/** The headline stat. */
value: number | string
/** Optional lucide icon node, shown inside the accent-tinted chip. */
icon?: ReactNode
/** Accent colour for the chip, value, and active ring (default brand). */
accent?: Accent
/** Highlight as the currently selected filter. */
active?: boolean
/** When set, the card behaves as a button (keyboard + pointer). */
onClick?: () => void
className?: string
}) {
const a = ACCENT[accent]
const clickable = !!onClick
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
if (!onClick) return
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onClick()
}
}
return (
<div
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
aria-pressed={clickable ? active : undefined}
onClick={onClick}
onKeyDown={handleKeyDown}
className={cn(
'group flex items-center gap-3 rounded-xl border bg-white p-3.5 text-left transition',
'shadow-[0_1px_2px_rgb(15_23_42/0.04),0_1px_3px_rgb(15_23_42/0.06)]',
active ? cn(a.activeBg, a.activeBorder) : 'border-slate-200',
clickable && 'cursor-pointer hover:-translate-y-0.5 hover:shadow-md motion-reduce:transform-none motion-reduce:transition-none',
clickable && 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-offset-white',
clickable && a.activeRing,
className,
)}
>
{icon && (
<span
className="icon-chip shrink-0"
style={{ ['--chip-bg' as string]: a.chipBg, ['--chip-fg' as string]: a.chipFg }}
aria-hidden
>
{icon}
</span>
)}
<div className="min-w-0">
<div className={cn('stat-value text-2xl', a.value)}>{value}</div>
<div className="label-eyebrow mt-0.5 truncate">{label}</div>
</div>
</div>
)
}

View File

@ -0,0 +1,82 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/cn'
// PageHeader (ui) — standard page header for the Văn phòng số (E-Office) module,
// PURO-style: accent-tinted icon-chip + title + actions slot. Richer than the
// constrained @/components/PageHeader ({title,description,actions}); this one
// adds eyebrow / icon / accent / breadcrumb for module landing pages.
//
// Visual idiom copied from pages/hrm/EmployeesListPage.tsx: the ACCENT map
// recolours the icon-chip (via --chip-bg / --chip-fg) and the heading. Accent
// palettes (teal/violet/amberx/greenx) ship stops 50/100/500/600/700 ONLY — no
// -800 — so headings use -700 (brand uses brand-800, which DOES exist). Using a
// non-existent stop would silently emit no class in Tailwind v4.
//
// gotcha 66: index.css has `h1,h2,h3,h4 { color:#0b1220 }` OUTSIDE @layer, which
// in Tailwind v4 beats `text-white`. There is no dark background in THIS header
// (it sits on the light page), so the title uses the accent ink directly. Any
// heading placed on a dark/gradient surface must use `text-white!`.
export type Accent = 'brand' | 'teal' | 'violet' | 'amberx' | 'greenx'
type AccentTokens = { chipBg: string; chipFg: string; head: string }
const ACCENT: Record<Accent, AccentTokens> = {
brand: { chipBg: 'var(--color-brand-50)', chipFg: 'var(--color-brand-600)', head: 'text-brand-800' },
teal: { chipBg: 'var(--color-teal-50)', chipFg: 'var(--color-teal-700)', head: 'text-teal-700' },
violet: { chipBg: 'var(--color-violet-50)', chipFg: 'var(--color-violet-700)', head: 'text-violet-700' },
amberx: { chipBg: 'var(--color-amberx-50)', chipFg: 'var(--color-amberx-700)', head: 'text-amberx-700' },
greenx: { chipBg: 'var(--color-greenx-50)', chipFg: 'var(--color-greenx-700)', head: 'text-greenx-700' },
}
export function PageHeader({
eyebrow,
title,
subtitle,
icon,
accent = 'brand',
actions,
breadcrumb,
className,
}: {
/** Optional uppercase kicker rendered above the title (.label-eyebrow). */
eyebrow?: string
/** Page title. */
title: string
/** Optional one-line description rendered below the title. */
subtitle?: string
/** Optional lucide icon node, shown inside the accent-tinted chip. */
icon?: ReactNode
/** Accent colour for the chip + title (default brand). */
accent?: Accent
/** Optional right-aligned actions slot (buttons, filters…). */
actions?: ReactNode
/** Optional breadcrumb node rendered above the whole header row. */
breadcrumb?: ReactNode
className?: string
}) {
const a = ACCENT[accent]
return (
<div className={cn('mb-5 border-b border-slate-200 pb-3.5', className)}>
{breadcrumb && <div className="mb-2 text-xs text-slate-500">{breadcrumb}</div>}
<div className="flex items-start justify-between gap-6">
<div className="flex min-w-0 items-start gap-3">
{icon && (
<span
className="icon-chip mt-0.5 shrink-0"
style={{ ['--chip-bg' as string]: a.chipBg, ['--chip-fg' as string]: a.chipFg }}
aria-hidden
>
{icon}
</span>
)}
<div className="min-w-0">
{eyebrow && <div className="label-eyebrow mb-1">{eyebrow}</div>}
<h1 className={cn('text-xl font-bold leading-tight tracking-tight', a.head)}>{title}</h1>
{subtitle && <p className="mt-1 text-[13px] leading-relaxed text-slate-500">{subtitle}</p>}
</div>
</div>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</div>
</div>
)
}

View File

@ -0,0 +1,219 @@
import type { KeyboardEvent, ReactNode } from 'react'
import { Inbox, Maximize2, RefreshCw } from 'lucide-react'
import { cn } from '@/lib/cn'
// WidgetCard — dashboard widget container (PURO HomePage widget). A header
// (brand gradient, or an accent-tinted bar) carries the title + optional refresh
// / expand icon-buttons; an optional clickable stat-chip row sits under it; the
// body is `children`, or a muted EmptyState when `empty`. The whole card is
// wrapped with .card-accent so it gets the colored left rail.
//
// Visual idiom from pages/hrm/EmployeesListPage.tsx + index.css: .card-accent
// (rail via --accent), .app-gradient-brand (gradient surface), .icon-chip, and
// the stat-chip treatment. Accent palettes ship stops 50/100/500/600/700 only
// (no -800).
//
// gotcha 66: index.css declares `h1,h2,h3,h4 { color:#0b1220 }` OUTSIDE any
// @layer, so in Tailwind v4 it beats `text-white`. The brand-gradient header
// uses an <h3>, therefore its title MUST be `text-white!` (with the important
// bang) — plain `text-white` would render dark ink on the gradient. Accent
// (non-brand) headers sit on a light tinted bar and use the accent ink instead.
export type Accent = 'brand' | 'teal' | 'violet' | 'amberx' | 'greenx'
export type WidgetStat = {
label: string
value: number | string
onClick?: () => void
}
type AccentTokens = {
rail: string
chipBg: string
chipFg: string
head: string
headBar: string
statValue: string
}
// rail = value for --accent on .card-accent; headBar = light tinted header bg
// used for non-brand accents (brand uses the gradient instead).
const ACCENT: Record<Accent, AccentTokens> = {
brand: { rail: 'var(--color-brand-500)', chipBg: 'var(--color-brand-50)', chipFg: 'var(--color-brand-600)', head: 'text-brand-800', headBar: 'bg-brand-50', statValue: 'text-brand-800' },
teal: { rail: 'var(--color-teal-500)', chipBg: 'var(--color-teal-50)', chipFg: 'var(--color-teal-700)', head: 'text-teal-700', headBar: 'bg-teal-50', statValue: 'text-teal-700' },
violet: { rail: 'var(--color-violet-500)', chipBg: 'var(--color-violet-50)', chipFg: 'var(--color-violet-700)', head: 'text-violet-700', headBar: 'bg-violet-50', statValue: 'text-violet-700' },
amberx: { rail: 'var(--color-amberx-500)', chipBg: 'var(--color-amberx-50)', chipFg: 'var(--color-amberx-700)', head: 'text-amberx-700', headBar: 'bg-amberx-50', statValue: 'text-amberx-700' },
greenx: { rail: 'var(--color-greenx-500)', chipBg: 'var(--color-greenx-50)', chipFg: 'var(--color-greenx-700)', head: 'text-greenx-700', headBar: 'bg-greenx-50', statValue: 'text-greenx-700' },
}
export function WidgetCard({
title,
icon,
accent = 'brand',
stats,
onExpand,
onRefresh,
children,
empty = false,
emptyText = 'Chưa có dữ liệu.',
className,
}: {
/** Widget title (rendered in the header). */
title: string
/** Optional lucide icon node shown before the title. */
icon?: ReactNode
/** Accent colour — brand uses the gradient header, others a tinted bar. */
accent?: Accent
/** Optional clickable stat chips rendered under the header. */
stats?: WidgetStat[]
/** When set, shows an expand icon-button in the header. */
onExpand?: () => void
/** When set, shows a refresh icon-button in the header. */
onRefresh?: () => void
/** Widget body. Ignored when `empty` is true. */
children?: ReactNode
/** Render the empty state instead of children. */
empty?: boolean
/** Message for the empty state. */
emptyText?: string
className?: string
}) {
const a = ACCENT[accent]
const isBrand = accent === 'brand'
return (
<section
className={cn('card-accent flex min-w-0 flex-col overflow-hidden', className)}
style={{ ['--accent' as string]: a.rail }}
>
{/* ===== Header ===== brand = gradient (white text, gotcha 66), else tinted bar */}
<header
className={cn(
'flex items-center justify-between gap-2 px-4 py-2.5',
isBrand ? 'app-gradient-brand text-white' : cn(a.headBar, 'border-b border-slate-100'),
)}
>
<div className="flex min-w-0 items-center gap-2">
{icon && (
<span
className={cn(
'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg',
isBrand ? 'bg-white/15 text-white ring-1 ring-white/25' : 'icon-chip',
)}
style={isBrand ? undefined : { ['--chip-bg' as string]: a.chipBg, ['--chip-fg' as string]: a.chipFg }}
aria-hidden
>
{icon}
</span>
)}
{/* gotcha 66: gradient header → text-white! (important) so the
unlayered h1-h4 dark rule does not win. */}
<h3 className={cn('truncate text-sm font-semibold tracking-tight', isBrand ? 'text-white!' : a.head)}>
{title}
</h3>
</div>
{(onRefresh || onExpand) && (
<div className="flex shrink-0 items-center gap-0.5">
{onRefresh && (
<IconButton onClick={onRefresh} label={`Làm mới ${title}`} onGradient={isBrand}>
<RefreshCw className="h-3.5 w-3.5" />
</IconButton>
)}
{onExpand && (
<IconButton onClick={onExpand} label={`Mở rộng ${title}`} onGradient={isBrand}>
<Maximize2 className="h-3.5 w-3.5" />
</IconButton>
)}
</div>
)}
</header>
{/* ===== Stat-chip row ===== */}
{stats && stats.length > 0 && (
<div className="flex flex-wrap gap-2 border-b border-slate-100 bg-slate-50/50 px-4 py-3">
{stats.map((s, i) => (
<StatChip key={i} stat={s} valueClass={a.statValue} />
))}
</div>
)}
{/* ===== Body / EmptyState ===== */}
<div className="min-h-0 flex-1 p-4">
{empty ? (
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center">
<span
className="icon-chip"
style={{ ['--chip-bg' as string]: '#f1f5f9', ['--chip-fg' as string]: '#94a3b8' }}
aria-hidden
>
<Inbox className="h-4 w-4" />
</span>
<p className="text-xs text-slate-500">{emptyText}</p>
</div>
) : (
children
)}
</div>
</section>
)
}
// Small clickable stat — value + label. Falls back to an inert div when no
// onClick (no button affordance, no focus ring).
function StatChip({ stat, valueClass }: { stat: WidgetStat; valueClass: string }) {
const clickable = !!stat.onClick
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
if (!stat.onClick) return
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
stat.onClick()
}
}
return (
<div
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={stat.onClick}
onKeyDown={handleKeyDown}
className={cn(
'min-w-[5rem] rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-left transition',
clickable &&
'cursor-pointer hover:border-brand-300 hover:bg-brand-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white',
)}
>
<div className={cn('stat-value text-lg', valueClass)}>{stat.value}</div>
<div className="label-eyebrow mt-0.5 truncate">{stat.label}</div>
</div>
)
}
// Header icon-button — adapts contrast to the gradient vs tinted header.
function IconButton({
onClick,
label,
onGradient,
children,
}: {
onClick: () => void
label: string
onGradient: boolean
children: ReactNode
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
title={label}
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-lg transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1',
onGradient
? 'text-white/80 hover:bg-white/15 hover:text-white focus-visible:ring-white/70 focus-visible:ring-offset-transparent'
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-700 focus-visible:ring-brand-500 focus-visible:ring-offset-white',
)}
>
{children}
</button>
)
}

View File

@ -3,7 +3,7 @@
@import url("https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap"); @import url("https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap");
@theme { @theme {
/* Solutions brand palette (derived from logo #1F7DC1) */ /* Solutions brand palette (derived from logo #1F7DC1) — PRIMARY, unchanged */
--color-brand-50: #f0f7fc; --color-brand-50: #f0f7fc;
--color-brand-100: #dbeaf7; --color-brand-100: #dbeaf7;
--color-brand-200: #b8d5ef; --color-brand-200: #b8d5ef;
@ -20,9 +20,10 @@
--color-accent-600: #b91c1c; --color-accent-600: #b91c1c;
/* ─────────────────────────────────────────────────────────────────── /* ───────────────────────────────────────────────────────────────────
ACCENT PALETTE (mirror fe-user — anh chốt 2026-06-16 "nâng màu, giữ nền ACCENT PALETTE (anh chốt 2026-06-16 "nâng màu, giữ nền xanh brand").
xanh brand"). Dùng cho Hồ sơ NS master-detail + KPI/badge đa-tông. Mỗi These ADD to the brand — primary stays brand-600. Used for KPI cards,
palette ship 50/100/500/600/700 → bg-{x}-50 chip + text-{x}-700 đạt WCAG-AA. status badges, multi-tone affordances. Each ships a 50/500/600/700 stop
so `bg-{x}-50` chip + `text-{x}-700` label both clear WCAG-AA.
─────────────────────────────────────────────────────────────────── */ ─────────────────────────────────────────────────────────────────── */
/* teal — info / secondary metric */ /* teal — info / secondary metric */
--color-teal-50: #e9faf9; --color-teal-50: #e9faf9;
@ -70,36 +71,44 @@ body {
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
} }
/* Heading tightening + better hierarchy with Be Vietnam Pro. /* Heading hierarchy with Be Vietnam Pro.
Density-first (NAMGROUP convention): semibold ladder, never font-bold — 2026-06-16: anh phản hồi "đơn điệu / có thấy khác gì đâu" → bump weight to
weight carries hierarchy without shouting. */ 700 + tighter tracking so headings read BOLD and high-contrast (was 600
semibold = too quiet). Hierarchy now carried by both weight AND a darker
ink (#0b1220 vs body #0f172a). */
h1, h2, h3, h4 { h1, h2, h3, h4 {
letter-spacing: -0.014em; letter-spacing: -0.018em;
color: #0f172a; color: #0b1220;
font-weight: 600; font-weight: 700;
} }
h1 { letter-spacing: -0.022em; }
/* Section / form labels — uppercase scan-pattern shared across the app. /* Section / form labels — uppercase scan-pattern shared across the app.
Use class="label-eyebrow" for the dense ERP label treatment. */ Use class="label-eyebrow" for the dense ERP label treatment. Brand-tinted
(was plain slate) so eyebrows carry the identity colour at a glance. */
.label-eyebrow { .label-eyebrow {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 700;
letter-spacing: 0.06em; letter-spacing: 0.07em;
text-transform: uppercase; text-transform: uppercase;
color: #64748b; /* slate-500 — WCAG-AA on white (4.6:1) */ color: var(--color-brand-600);
} }
/* ───────────────────────────────────────────────────────────────────────── /* ─────────────────────────────────────────────────────────────────────────
REUSABLE VISUAL UTILITIES (mirror fe-user — Hồ sơ NS master-detail dùng REUSABLE VISUAL UTILITIES (anh chốt 2026-06-16). Component-classes so the
.icon-chip + .app-gradient-brand; .card-accent/.stat-value cho KPI sau). whole app picks up the richer look without touching 65 leaf pages — any
page can add class="card-accent" / "icon-chip" etc.
───────────────────────────────────────────────────────────────────────── */ ───────────────────────────────────────────────────────────────────────── */
/* Brand gradient surface — page/section headers, hero strips (avatar header). */ /* Brand gradient surface — page/section headers, hero strips.
xanh → xanh đậm theo hướng anh chốt. Text on top must be white. */
.app-gradient-brand { .app-gradient-brand {
background-image: linear-gradient(120deg, var(--color-brand-600) 0%, var(--color-brand-700) 55%, var(--color-brand-800) 100%); background-image: linear-gradient(120deg, var(--color-brand-600) 0%, var(--color-brand-700) 55%, var(--color-brand-800) 100%);
} }
/* KPI / metric card — colored LEFT border + soft lift. Recolour via --accent. */ /* KPI / metric card — colored LEFT border + soft lift. Pair with a
--accent custom property to recolour the rail per card, e.g.
style={{ ['--accent' as any]: 'var(--color-teal-500)' }}. Defaults brand. */
.card-accent { .card-accent {
position: relative; position: relative;
border-radius: 0.75rem; border-radius: 0.75rem;
@ -114,7 +123,8 @@ h1, h2, h3, h4 {
transform: translateY(-1px); transform: translateY(-1px);
} }
/* Icon chip — soft tinted square behind a lucide icon. Recolour via --chip-bg / --chip-fg. */ /* Icon chip — soft tinted square behind a lucide icon. Recolour via --chip-bg
/ --chip-fg, defaults brand. Used in KPI cards + section headers. */
.icon-chip { .icon-chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -145,12 +155,10 @@ table, .tabular-nums {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* Smoother form focus */
input:focus-visible, textarea:focus-visible, select:focus-visible, button:focus-visible { input:focus-visible, textarea:focus-visible, select:focus-visible, button:focus-visible {
outline: none; outline: none;
} }
/* Subtle scrollbar that fits the brand */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;
height: 8px; height: 8px;

View File

@ -42,6 +42,8 @@ export const MenuKeys = {
HrmConfigDrivers: 'Hrm_Config_Drivers', HrmConfigDrivers: 'Hrm_Config_Drivers',
// Module Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1 Session 34, 2026-05-27) // Module Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1 Session 34, 2026-05-27)
Off: 'Off', Off: 'Off',
// Bảng điều khiển Văn phòng số (landing dashboard — admin auto via All)
OffDashboard: 'Off_Dashboard',
OffDanhBa: 'Off_DanhBa', OffDanhBa: 'Off_DanhBa',
// Văn phòng số — Phòng họp (Phase 10.2 G-O2 — Mig 36 Session 36, 2026-05-28) // Văn phòng số — Phòng họp (Phase 10.2 G-O2 — Mig 36 Session 36, 2026-05-28)
OffPhongHop: 'Off_PhongHop', OffPhongHop: 'Off_PhongHop',

View File

@ -0,0 +1,474 @@
// Bảng điều khiển Văn phòng số (E-Office) — landing dashboard, PURO HomePage style.
// Composes the 3 shared ui widgets (PageHeader / KpiCard / WidgetCard) over the
// EXISTING data hooks of the four E-Office modules. NO new API calls, NO new BE:
// every query below mirrors the queryKey + endpoint already used by the module
// pages, so the TanStack cache is shared and counts are computed client-side.
//
// • Đề xuất → GET /proposals (ProposalsListPage)
// • Đơn từ → GET /leave|ot|travel-requests (WorkflowAppsListPage KIND_CONFIG)
// • Ticket CNTT → GET /it-tickets (ItTicketsPage)
// • Phòng họp → GET /meeting-bookings (MeetingCalendarPage)
//
// Layout: PageHeader (brand) on top, then a 2-col grid — LEFT (~2/3) a stack of
// WidgetCards, RIGHT (~1/3) a "Công việc của tôi" panel + quick-action buttons.
// Stacks to 1 column under lg. Each widget's onExpand navigates to its real route.
// gotcha 66: any heading on the brand gradient lives inside WidgetCard, which
// already uses text-white! — this page adds no gradient headings of its own.
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import {
AlertTriangle,
CalendarDays,
ClipboardList,
FilePlus2,
FileSignature,
Inbox,
LayoutDashboard,
ListChecks,
Plus,
Ticket,
} from 'lucide-react'
import { PageHeader } from '@/components/ui/PageHeader'
import { KpiCard } from '@/components/ui/KpiCard'
import { WidgetCard } from '@/components/ui/WidgetCard'
import { Button } from '@/components/ui/Button'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import { ProposalStatus, type PagedResult, type ProposalListItemDto } from '@/types/proposal'
import {
IT_TICKET_STATUS_LABELS,
ItTicketStatus,
WorkflowAppStatus,
type ItTicketDto,
type LeaveRequestDto,
type OtRequestDto,
type TravelRequestDto,
} from '@/types/workflowApps'
import type { MeetingBookingDto } from '@/types/meeting'
// ── date window for "today" meeting bookings (local midnight → next midnight) ──
function todayWindow(): { start: string; end: string } {
const start = new Date()
start.setHours(0, 0, 0, 0)
const end = new Date(start)
end.setDate(end.getDate() + 1)
return { start: start.toISOString(), end: end.toISOString() }
}
function countByStatus<T extends { status: number }>(items: T[], status: number): number {
return items.reduce((n, x) => (x.status === status ? n + 1 : n), 0)
}
// A small skeleton body used while a widget's data is loading. Mimics a couple of
// stat rows so the card keeps its height (no layout shift on resolve).
function WidgetSkeleton() {
return (
<div className="space-y-2.5" aria-hidden>
<div className="h-3.5 w-2/3 animate-pulse rounded bg-slate-100 motion-reduce:animate-none" />
<div className="h-3.5 w-1/2 animate-pulse rounded bg-slate-100 motion-reduce:animate-none" />
<div className="h-3.5 w-3/5 animate-pulse rounded bg-slate-100 motion-reduce:animate-none" />
</div>
)
}
// Inline error body — graceful, never blocks the page.
function WidgetError({ onRetry }: { onRetry: () => void }) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-6 text-center">
<span
className="icon-chip"
style={{ ['--chip-bg' as string]: 'var(--color-accent-500)', ['--chip-fg' as string]: '#fff' }}
aria-hidden
>
<AlertTriangle className="h-4 w-4" />
</span>
<p className="text-xs text-slate-500">Không tải đưc dữ liệu.</p>
<button
type="button"
onClick={onRetry}
className="rounded-md px-2 py-1 text-xs font-medium text-brand-600 transition hover:bg-brand-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
>
Thử lại
</button>
</div>
)
}
// A labelled metric line inside a widget body. `tone` tints the value; clickable
// rows expose a button affordance (used to deep-link a filtered list view).
function MetricRow({
label,
value,
tone,
onClick,
}: {
label: string
value: number
tone: string
onClick?: () => void
}) {
const clickable = !!onClick
return (
<div
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={onClick}
onKeyDown={
clickable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onClick?.()
}
}
: undefined
}
className={cn(
'flex items-center justify-between gap-3 rounded-lg px-2.5 py-1.5 transition',
clickable &&
'cursor-pointer hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500',
)}
>
<span className="text-[13px] text-slate-600">{label}</span>
<span className={cn('stat-value text-base', tone)}>{value}</span>
</div>
)
}
export function OfficeDashboardPage() {
const navigate = useNavigate()
const { start, end } = useMemo(todayWindow, [])
// ── Đề xuất ── same queryKey/endpoint as ProposalsListPage (shared cache).
// First page, large size: enough to count the active workload client-side.
const proposalsQ = useQuery({
queryKey: ['proposals', { status: null, inboxOnly: false, search: '', page: 1, dashboard: true }],
queryFn: async () =>
(await api.get<PagedResult<ProposalListItemDto>>('/proposals', { params: { page: 1, pageSize: 100 } })).data,
})
// "Cần duyệt" = items in MY approval inbox (BE inboxOnly filter — the real
// needs-my-action signal). Mirrors the inbox toggle on ProposalsListPage.
const proposalsInboxQ = useQuery({
queryKey: ['proposals', { status: null, inboxOnly: true, search: '', page: 1, dashboard: true }],
queryFn: async () =>
(await api.get<PagedResult<ProposalListItemDto>>('/proposals', { params: { inboxOnly: true, page: 1, pageSize: 100 } }))
.data,
})
// ── Đơn từ ── three endpoints from WorkflowAppsListPage KIND_CONFIG.
const leaveQ = useQuery({
queryKey: ['/leave-requests', { page: 1 }],
queryFn: async () => (await api.get<PagedResult<LeaveRequestDto>>('/leave-requests', { params: { page: 1, pageSize: 50 } })).data,
})
const otQ = useQuery({
queryKey: ['/ot-requests', { page: 1 }],
queryFn: async () => (await api.get<PagedResult<OtRequestDto>>('/ot-requests', { params: { page: 1, pageSize: 50 } })).data,
})
const travelQ = useQuery({
queryKey: ['/travel-requests', { page: 1 }],
queryFn: async () => (await api.get<PagedResult<TravelRequestDto>>('/travel-requests', { params: { page: 1, pageSize: 50 } })).data,
})
// ── Ticket CNTT ── same queryKey/endpoint as ItTicketsPage (shared cache).
const ticketsQ = useQuery({
queryKey: ['it-tickets'],
queryFn: async () => (await api.get<PagedResult<ItTicketDto>>('/it-tickets', { params: { pageSize: 100 } })).data,
})
// ── Phòng họp hôm nay ── /meeting-bookings windowed to today (no room filter).
const meetingsTodayQ = useQuery({
queryKey: ['meeting-bookings', { dashboard: 'today', start }],
queryFn: async () =>
(await api.get<MeetingBookingDto[]>('/meeting-bookings', { params: { startDate: start, endDate: end } })).data,
})
// ── derived counts (client-side) ──
const proposals = proposalsQ.data?.items ?? []
const proposalTotal = proposalsQ.data?.total ?? proposals.length
const proposalPending = countByStatus(proposals, ProposalStatus.DaGuiDuyet)
const proposalInbox = proposalsInboxQ.data?.total ?? (proposalsInboxQ.data?.items.length ?? 0)
const donTu = useMemo(
() => [...(leaveQ.data?.items ?? []), ...(otQ.data?.items ?? []), ...(travelQ.data?.items ?? [])],
[leaveQ.data, otQ.data, travelQ.data],
)
const donTuSubmitted = countByStatus(donTu, WorkflowAppStatus.DaGuiDuyet)
const donTuReturned = countByStatus(donTu, WorkflowAppStatus.TraLai)
const donTuApproved = countByStatus(donTu, WorkflowAppStatus.DaDuyet)
const donTuLoading = leaveQ.isLoading || otQ.isLoading || travelQ.isLoading
const donTuError = leaveQ.isError || otQ.isError || travelQ.isError
const tickets = ticketsQ.data?.items ?? []
const ticketOpen = countByStatus(tickets, ItTicketStatus.Open)
const ticketInProgress = countByStatus(tickets, ItTicketStatus.InProgress)
const ticketBreached = tickets.reduce((n, t) => (t.slaBreached ? n + 1 : n), 0)
const meetingsToday = meetingsTodayQ.data ?? []
const meetingsTodayCount = meetingsToday.length
// "Công việc của tôi" — total items currently awaiting THIS user's action across
// the modules. Proposals come from the BE inbox filter; đơn-từ in "Đã gửi duyệt"
// are pending approval. (Tickets have their own assignment flow, surfaced in
// their widget rather than double-counted here.)
const myTodo = proposalInbox + donTuSubmitted
return (
<div className="p-6">
<PageHeader
eyebrow="Văn phòng số"
title="Bảng điều khiển"
subtitle="Tổng quan đề xuất, đơn từ, ticket và lịch họp trong ngày"
icon={<LayoutDashboard className="h-5 w-5" />}
accent="brand"
/>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
{/* ───────────────────────── LEFT (~2/3) — widget stack ───────────────────────── */}
<div className="flex flex-col gap-5 lg:col-span-2">
{/* Đề xuất */}
<WidgetCard
title="Đề xuất"
icon={<FileSignature className="h-4 w-4" />}
accent="brand"
onExpand={() => navigate('/proposals')}
onRefresh={() => {
proposalsQ.refetch()
proposalsInboxQ.refetch()
}}
empty={!proposalsQ.isLoading && !proposalsQ.isError && proposalTotal === 0}
emptyText="Chưa có đề xuất nào."
>
{proposalsQ.isError ? (
<WidgetError onRetry={() => proposalsQ.refetch()} />
) : proposalsQ.isLoading ? (
<WidgetSkeleton />
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<KpiCard
label="Cần duyệt"
value={proposalInbox}
icon={<Inbox className="h-4 w-4" />}
accent="amberx"
onClick={() => navigate('/proposals?inboxOnly=true')}
/>
<KpiCard
label="Chờ duyệt"
value={proposalPending}
icon={<ClipboardList className="h-4 w-4" />}
accent="brand"
onClick={() => navigate(`/proposals?status=${ProposalStatus.DaGuiDuyet}`)}
/>
<KpiCard
label="Tất cả"
value={proposalTotal}
icon={<ListChecks className="h-4 w-4" />}
accent="teal"
onClick={() => navigate('/proposals')}
/>
</div>
)}
</WidgetCard>
{/* Đơn từ (nghỉ phép / OT / công tác) */}
<WidgetCard
title="Đơn từ"
icon={<FileSignature className="h-4 w-4" />}
accent="teal"
onExpand={() => navigate('/workflow-apps/leave')}
onRefresh={() => {
leaveQ.refetch()
otQ.refetch()
travelQ.refetch()
}}
empty={!donTuLoading && !donTuError && donTu.length === 0}
emptyText="Chưa có đơn từ nào."
>
{donTuError ? (
<WidgetError
onRetry={() => {
leaveQ.refetch()
otQ.refetch()
travelQ.refetch()
}}
/>
) : donTuLoading ? (
<WidgetSkeleton />
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<KpiCard
label="Đã gửi duyệt"
value={donTuSubmitted}
accent="amberx"
onClick={() => navigate('/workflow-apps/leave')}
/>
<KpiCard
label="Trả lại"
value={donTuReturned}
accent="violet"
onClick={() => navigate('/workflow-apps/leave')}
/>
<KpiCard
label="Đã duyệt"
value={donTuApproved}
accent="greenx"
onClick={() => navigate('/workflow-apps/leave')}
/>
</div>
)}
</WidgetCard>
{/* Ticket CNTT */}
<WidgetCard
title="Ticket CNTT"
icon={<Ticket className="h-4 w-4" />}
accent="violet"
onExpand={() => navigate('/it-tickets')}
onRefresh={() => ticketsQ.refetch()}
empty={!ticketsQ.isLoading && !ticketsQ.isError && tickets.length === 0}
emptyText="Chưa có ticket nào."
>
{ticketsQ.isError ? (
<WidgetError onRetry={() => ticketsQ.refetch()} />
) : ticketsQ.isLoading ? (
<WidgetSkeleton />
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<KpiCard
label={IT_TICKET_STATUS_LABELS[ItTicketStatus.Open]}
value={ticketOpen}
icon={<Inbox className="h-4 w-4" />}
accent="violet"
onClick={() => navigate('/it-tickets')}
/>
<KpiCard
label={IT_TICKET_STATUS_LABELS[ItTicketStatus.InProgress]}
value={ticketInProgress}
icon={<ClipboardList className="h-4 w-4" />}
accent="brand"
onClick={() => navigate('/it-tickets')}
/>
<KpiCard
label="Quá hạn SLA"
value={ticketBreached}
icon={<AlertTriangle className="h-4 w-4" />}
accent="amberx"
onClick={() => navigate('/it-tickets')}
/>
</div>
)}
</WidgetCard>
{/* Phòng họp hôm nay */}
<WidgetCard
title="Phòng họp hôm nay"
icon={<CalendarDays className="h-4 w-4" />}
accent="amberx"
onExpand={() => navigate('/meeting-calendar')}
onRefresh={() => meetingsTodayQ.refetch()}
empty={!meetingsTodayQ.isLoading && !meetingsTodayQ.isError && meetingsTodayCount === 0}
emptyText="Hôm nay chưa có lịch họp."
>
{meetingsTodayQ.isError ? (
<WidgetError onRetry={() => meetingsTodayQ.refetch()} />
) : meetingsTodayQ.isLoading ? (
<WidgetSkeleton />
) : (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<KpiCard
label="Lịch họp hôm nay"
value={meetingsTodayCount}
icon={<CalendarDays className="h-4 w-4" />}
accent="amberx"
onClick={() => navigate('/meeting-calendar')}
/>
</div>
{/* A compact peek of the next few bookings today. */}
<ul className="divide-y divide-slate-100 overflow-hidden rounded-lg border border-slate-200">
{meetingsToday
.slice()
.sort((a, b) => new Date(a.startAt).getTime() - new Date(b.startAt).getTime())
.slice(0, 4)
.map((b) => (
<li key={b.id} className="flex items-center justify-between gap-3 px-3 py-2 text-xs">
<span className="min-w-0 truncate font-medium text-slate-700">{b.title}</span>
<span className="shrink-0 tabular-nums text-slate-500">
{new Date(b.startAt).toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' })} ·{' '}
{b.roomCode}
</span>
</li>
))}
</ul>
</div>
)}
</WidgetCard>
</div>
{/* ───────────────────────── RIGHT (~1/3) — my work + actions ───────────────────────── */}
<div className="flex flex-col gap-5 lg:col-span-1">
{/* Công việc của tôi */}
<WidgetCard
title="Công việc của tôi"
icon={<ListChecks className="h-4 w-4" />}
accent="brand"
empty={false}
>
{proposalsInboxQ.isError ? (
<WidgetError onRetry={() => proposalsInboxQ.refetch()} />
) : (
<div className="space-y-3">
<div className="flex items-end justify-between gap-3 rounded-xl bg-brand-50 px-3.5 py-3">
<div>
<div className="label-eyebrow">Cần xử </div>
<p className="mt-0.5 text-[13px] text-slate-600">Mục đang chờ thao tác của bạn</p>
</div>
<div className="stat-value text-3xl text-brand-800">
{proposalsInboxQ.isLoading || donTuLoading ? '—' : myTodo}
</div>
</div>
<div className="space-y-1">
<MetricRow
label="Đề xuất chờ tôi duyệt"
value={proposalsInboxQ.isLoading ? 0 : proposalInbox}
tone="text-brand-800"
onClick={() => navigate('/proposals?inboxOnly=true')}
/>
<MetricRow
label="Đơn từ đã gửi duyệt"
value={donTuLoading ? 0 : donTuSubmitted}
tone="text-teal-700"
onClick={() => navigate('/workflow-apps/leave')}
/>
<MetricRow
label="Ticket đang mở"
value={ticketsQ.isLoading ? 0 : ticketOpen}
tone="text-violet-700"
onClick={() => navigate('/it-tickets')}
/>
</div>
</div>
)}
</WidgetCard>
{/* Thao tác nhanh */}
<section className="card-accent flex flex-col gap-2 p-4" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
<h3 className="mb-1 text-sm font-semibold tracking-tight text-slate-800">Thao tác nhanh</h3>
<Button variant="primary" className="justify-start" onClick={() => navigate('/proposals/new')}>
<FilePlus2 className="h-4 w-4" />
Tạo đ xuất
</Button>
<Button variant="secondary" className="justify-start" onClick={() => navigate('/workflow-apps/leave')}>
<FileSignature className="h-4 w-4" />
Tạo đơn
</Button>
<Button variant="outline" className="justify-start" onClick={() => navigate('/it-tickets')}>
<Plus className="h-4 w-4" />
Tạo ticket
</Button>
</section>
</div>
</div>
</div>
)
}

View File

@ -30,6 +30,7 @@ import { WorkflowAppsListPage } from '@/pages/office/WorkflowAppsListPage'
import { WorkflowAppDetailPage } from '@/pages/office/WorkflowAppDetailPage' import { WorkflowAppDetailPage } from '@/pages/office/WorkflowAppDetailPage'
import { ItTicketsPage } from '@/pages/office/ItTicketsPage' import { ItTicketsPage } from '@/pages/office/ItTicketsPage'
import { MyAttendancePage } from '@/pages/office/MyAttendancePage' import { MyAttendancePage } from '@/pages/office/MyAttendancePage'
import { OfficeDashboardPage } from '@/pages/office/OfficeDashboardPage'
import { HrmDashboardPage } from '@/pages/hrm/HrmDashboardPage' import { HrmDashboardPage } from '@/pages/hrm/HrmDashboardPage'
function App() { function App() {
@ -66,6 +67,8 @@ function App() {
{/* Cấu hình HRM (Phase 10.2 G-H2 — Mig 35) */} {/* Cấu hình HRM (Phase 10.2 G-H2 — Mig 35) */}
<Route path="/hrm/configs" element={<Navigate to="/hrm/configs/leave-types" replace />} /> <Route path="/hrm/configs" element={<Navigate to="/hrm/configs/leave-types" replace />} />
<Route path="/hrm/configs/:kind" element={<HrmConfigsPage />} /> <Route path="/hrm/configs/:kind" element={<HrmConfigsPage />} />
{/* Văn phòng số — Bảng điều khiển (landing dashboard, Off_Dashboard) */}
<Route path="/office/dashboard" element={<OfficeDashboardPage />} />
{/* Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1) */} {/* Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1) */}
<Route path="/directory" element={<InternalDirectoryPage />} /> <Route path="/directory" element={<InternalDirectoryPage />} />
{/* Văn phòng số — Phòng họp Booking + Catalog (Phase 10.2 G-O2 — Mig 36 S36) */} {/* Văn phòng số — Phòng họp Booking + Catalog (Phase 10.2 G-O2 — Mig 36 S36) */}

View File

@ -84,6 +84,7 @@ function resolvePath(key: string): string | null {
Hrm_Config_Drivers: '/hrm/configs/drivers', Hrm_Config_Drivers: '/hrm/configs/drivers',
// [Phase 10.2 G-O1 S34 2026-05-27] Module Văn phòng số — Danh bạ nội bộ. // [Phase 10.2 G-O1 S34 2026-05-27] Module Văn phòng số — Danh bạ nội bộ.
// 4-place mirror Pattern 16-bis: types/ + pages/ + App.tsx + menuKeys + staticMap. // 4-place mirror Pattern 16-bis: types/ + pages/ + App.tsx + menuKeys + staticMap.
Off_Dashboard: '/office/dashboard',
Off_DanhBa: '/directory', Off_DanhBa: '/directory',
// [Phase 10.2 G-O2 S36 2026-05-28] Phòng họp Booking + Catalog (Mig 36). // [Phase 10.2 G-O2 S36 2026-05-28] Phòng họp Booking + Catalog (Mig 36).
// Pattern 16-bis 4-place mirror 7× cumulative — staticMap = 4th place dễ miss. // Pattern 16-bis 4-place mirror 7× cumulative — staticMap = 4th place dễ miss.

View File

@ -0,0 +1,107 @@
import type { KeyboardEvent, ReactNode } from 'react'
import { cn } from '@/lib/cn'
// KpiCard — clickable stat card used as a FILTER chip (PURO pattern: a row of
// KpiCards filters the list, replacing tabs). icon-chip + big stat-value +
// .label-eyebrow. The ACTIVE state tints the background and rings in the accent;
// hover lifts.
//
// Visual idiom from pages/hrm/EmployeesListPage.tsx (icon-chip + stat-value +
// label-eyebrow tokens) and index.css. Accent palettes ship stops
// 50/100/500/600/700 only (no -800) → stat text uses -700 (brand uses -800,
// which exists). Mismatched stop = silent no-class in Tailwind v4.
//
// a11y: when onClick is given the card becomes role="button", focusable
// (tabIndex 0), responds to Enter + Space, exposes aria-pressed=active, and
// shows a focus-visible ring. Without onClick it is an inert presentational card.
export type Accent = 'brand' | 'teal' | 'violet' | 'amberx' | 'greenx'
type AccentTokens = {
chipBg: string
chipFg: string
value: string
activeBg: string
activeRing: string
activeBorder: string
}
// activeBorder uses the -500 stop: the accent palettes ship only
// 50/100/500/600/700, so -300 (which only brand has) would silently fall to
// Tailwind's DEFAULT teal/violet — or drop entirely for amberx/greenx (custom
// names). -500 exists for EVERY accent → the active border always renders the
// intended brand-aligned tone. (gotcha "vỡ màu im lặng" Tailwind v4.)
const ACCENT: Record<Accent, AccentTokens> = {
brand: { chipBg: 'var(--color-brand-50)', chipFg: 'var(--color-brand-600)', value: 'text-brand-800', activeBg: 'bg-brand-50', activeRing: 'ring-brand-500', activeBorder: 'border-brand-500' },
teal: { chipBg: 'var(--color-teal-50)', chipFg: 'var(--color-teal-700)', value: 'text-teal-700', activeBg: 'bg-teal-50', activeRing: 'ring-teal-500', activeBorder: 'border-teal-500' },
violet: { chipBg: 'var(--color-violet-50)', chipFg: 'var(--color-violet-700)', value: 'text-violet-700', activeBg: 'bg-violet-50', activeRing: 'ring-violet-500', activeBorder: 'border-violet-500' },
amberx: { chipBg: 'var(--color-amberx-50)', chipFg: 'var(--color-amberx-700)', value: 'text-amberx-700', activeBg: 'bg-amberx-50', activeRing: 'ring-amberx-500', activeBorder: 'border-amberx-500' },
greenx: { chipBg: 'var(--color-greenx-50)', chipFg: 'var(--color-greenx-700)', value: 'text-greenx-700', activeBg: 'bg-greenx-50', activeRing: 'ring-greenx-500', activeBorder: 'border-greenx-500' },
}
export function KpiCard({
label,
value,
icon,
accent = 'brand',
active = false,
onClick,
className,
}: {
/** Caption under the value (.label-eyebrow). */
label: string
/** The headline stat. */
value: number | string
/** Optional lucide icon node, shown inside the accent-tinted chip. */
icon?: ReactNode
/** Accent colour for the chip, value, and active ring (default brand). */
accent?: Accent
/** Highlight as the currently selected filter. */
active?: boolean
/** When set, the card behaves as a button (keyboard + pointer). */
onClick?: () => void
className?: string
}) {
const a = ACCENT[accent]
const clickable = !!onClick
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
if (!onClick) return
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onClick()
}
}
return (
<div
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
aria-pressed={clickable ? active : undefined}
onClick={onClick}
onKeyDown={handleKeyDown}
className={cn(
'group flex items-center gap-3 rounded-xl border bg-white p-3.5 text-left transition',
'shadow-[0_1px_2px_rgb(15_23_42/0.04),0_1px_3px_rgb(15_23_42/0.06)]',
active ? cn(a.activeBg, a.activeBorder) : 'border-slate-200',
clickable && 'cursor-pointer hover:-translate-y-0.5 hover:shadow-md motion-reduce:transform-none motion-reduce:transition-none',
clickable && 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-offset-white',
clickable && a.activeRing,
className,
)}
>
{icon && (
<span
className="icon-chip shrink-0"
style={{ ['--chip-bg' as string]: a.chipBg, ['--chip-fg' as string]: a.chipFg }}
aria-hidden
>
{icon}
</span>
)}
<div className="min-w-0">
<div className={cn('stat-value text-2xl', a.value)}>{value}</div>
<div className="label-eyebrow mt-0.5 truncate">{label}</div>
</div>
</div>
)
}

View File

@ -0,0 +1,82 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/cn'
// PageHeader (ui) — standard page header for the Văn phòng số (E-Office) module,
// PURO-style: accent-tinted icon-chip + title + actions slot. Richer than the
// constrained @/components/PageHeader ({title,description,actions}); this one
// adds eyebrow / icon / accent / breadcrumb for module landing pages.
//
// Visual idiom copied from pages/hrm/EmployeesListPage.tsx: the ACCENT map
// recolours the icon-chip (via --chip-bg / --chip-fg) and the heading. Accent
// palettes (teal/violet/amberx/greenx) ship stops 50/100/500/600/700 ONLY — no
// -800 — so headings use -700 (brand uses brand-800, which DOES exist). Using a
// non-existent stop would silently emit no class in Tailwind v4.
//
// gotcha 66: index.css has `h1,h2,h3,h4 { color:#0b1220 }` OUTSIDE @layer, which
// in Tailwind v4 beats `text-white`. There is no dark background in THIS header
// (it sits on the light page), so the title uses the accent ink directly. Any
// heading placed on a dark/gradient surface must use `text-white!`.
export type Accent = 'brand' | 'teal' | 'violet' | 'amberx' | 'greenx'
type AccentTokens = { chipBg: string; chipFg: string; head: string }
const ACCENT: Record<Accent, AccentTokens> = {
brand: { chipBg: 'var(--color-brand-50)', chipFg: 'var(--color-brand-600)', head: 'text-brand-800' },
teal: { chipBg: 'var(--color-teal-50)', chipFg: 'var(--color-teal-700)', head: 'text-teal-700' },
violet: { chipBg: 'var(--color-violet-50)', chipFg: 'var(--color-violet-700)', head: 'text-violet-700' },
amberx: { chipBg: 'var(--color-amberx-50)', chipFg: 'var(--color-amberx-700)', head: 'text-amberx-700' },
greenx: { chipBg: 'var(--color-greenx-50)', chipFg: 'var(--color-greenx-700)', head: 'text-greenx-700' },
}
export function PageHeader({
eyebrow,
title,
subtitle,
icon,
accent = 'brand',
actions,
breadcrumb,
className,
}: {
/** Optional uppercase kicker rendered above the title (.label-eyebrow). */
eyebrow?: string
/** Page title. */
title: string
/** Optional one-line description rendered below the title. */
subtitle?: string
/** Optional lucide icon node, shown inside the accent-tinted chip. */
icon?: ReactNode
/** Accent colour for the chip + title (default brand). */
accent?: Accent
/** Optional right-aligned actions slot (buttons, filters…). */
actions?: ReactNode
/** Optional breadcrumb node rendered above the whole header row. */
breadcrumb?: ReactNode
className?: string
}) {
const a = ACCENT[accent]
return (
<div className={cn('mb-5 border-b border-slate-200 pb-3.5', className)}>
{breadcrumb && <div className="mb-2 text-xs text-slate-500">{breadcrumb}</div>}
<div className="flex items-start justify-between gap-6">
<div className="flex min-w-0 items-start gap-3">
{icon && (
<span
className="icon-chip mt-0.5 shrink-0"
style={{ ['--chip-bg' as string]: a.chipBg, ['--chip-fg' as string]: a.chipFg }}
aria-hidden
>
{icon}
</span>
)}
<div className="min-w-0">
{eyebrow && <div className="label-eyebrow mb-1">{eyebrow}</div>}
<h1 className={cn('text-xl font-bold leading-tight tracking-tight', a.head)}>{title}</h1>
{subtitle && <p className="mt-1 text-[13px] leading-relaxed text-slate-500">{subtitle}</p>}
</div>
</div>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</div>
</div>
)
}

View File

@ -0,0 +1,219 @@
import type { KeyboardEvent, ReactNode } from 'react'
import { Inbox, Maximize2, RefreshCw } from 'lucide-react'
import { cn } from '@/lib/cn'
// WidgetCard — dashboard widget container (PURO HomePage widget). A header
// (brand gradient, or an accent-tinted bar) carries the title + optional refresh
// / expand icon-buttons; an optional clickable stat-chip row sits under it; the
// body is `children`, or a muted EmptyState when `empty`. The whole card is
// wrapped with .card-accent so it gets the colored left rail.
//
// Visual idiom from pages/hrm/EmployeesListPage.tsx + index.css: .card-accent
// (rail via --accent), .app-gradient-brand (gradient surface), .icon-chip, and
// the stat-chip treatment. Accent palettes ship stops 50/100/500/600/700 only
// (no -800).
//
// gotcha 66: index.css declares `h1,h2,h3,h4 { color:#0b1220 }` OUTSIDE any
// @layer, so in Tailwind v4 it beats `text-white`. The brand-gradient header
// uses an <h3>, therefore its title MUST be `text-white!` (with the important
// bang) — plain `text-white` would render dark ink on the gradient. Accent
// (non-brand) headers sit on a light tinted bar and use the accent ink instead.
export type Accent = 'brand' | 'teal' | 'violet' | 'amberx' | 'greenx'
export type WidgetStat = {
label: string
value: number | string
onClick?: () => void
}
type AccentTokens = {
rail: string
chipBg: string
chipFg: string
head: string
headBar: string
statValue: string
}
// rail = value for --accent on .card-accent; headBar = light tinted header bg
// used for non-brand accents (brand uses the gradient instead).
const ACCENT: Record<Accent, AccentTokens> = {
brand: { rail: 'var(--color-brand-500)', chipBg: 'var(--color-brand-50)', chipFg: 'var(--color-brand-600)', head: 'text-brand-800', headBar: 'bg-brand-50', statValue: 'text-brand-800' },
teal: { rail: 'var(--color-teal-500)', chipBg: 'var(--color-teal-50)', chipFg: 'var(--color-teal-700)', head: 'text-teal-700', headBar: 'bg-teal-50', statValue: 'text-teal-700' },
violet: { rail: 'var(--color-violet-500)', chipBg: 'var(--color-violet-50)', chipFg: 'var(--color-violet-700)', head: 'text-violet-700', headBar: 'bg-violet-50', statValue: 'text-violet-700' },
amberx: { rail: 'var(--color-amberx-500)', chipBg: 'var(--color-amberx-50)', chipFg: 'var(--color-amberx-700)', head: 'text-amberx-700', headBar: 'bg-amberx-50', statValue: 'text-amberx-700' },
greenx: { rail: 'var(--color-greenx-500)', chipBg: 'var(--color-greenx-50)', chipFg: 'var(--color-greenx-700)', head: 'text-greenx-700', headBar: 'bg-greenx-50', statValue: 'text-greenx-700' },
}
export function WidgetCard({
title,
icon,
accent = 'brand',
stats,
onExpand,
onRefresh,
children,
empty = false,
emptyText = 'Chưa có dữ liệu.',
className,
}: {
/** Widget title (rendered in the header). */
title: string
/** Optional lucide icon node shown before the title. */
icon?: ReactNode
/** Accent colour — brand uses the gradient header, others a tinted bar. */
accent?: Accent
/** Optional clickable stat chips rendered under the header. */
stats?: WidgetStat[]
/** When set, shows an expand icon-button in the header. */
onExpand?: () => void
/** When set, shows a refresh icon-button in the header. */
onRefresh?: () => void
/** Widget body. Ignored when `empty` is true. */
children?: ReactNode
/** Render the empty state instead of children. */
empty?: boolean
/** Message for the empty state. */
emptyText?: string
className?: string
}) {
const a = ACCENT[accent]
const isBrand = accent === 'brand'
return (
<section
className={cn('card-accent flex min-w-0 flex-col overflow-hidden', className)}
style={{ ['--accent' as string]: a.rail }}
>
{/* ===== Header ===== brand = gradient (white text, gotcha 66), else tinted bar */}
<header
className={cn(
'flex items-center justify-between gap-2 px-4 py-2.5',
isBrand ? 'app-gradient-brand text-white' : cn(a.headBar, 'border-b border-slate-100'),
)}
>
<div className="flex min-w-0 items-center gap-2">
{icon && (
<span
className={cn(
'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg',
isBrand ? 'bg-white/15 text-white ring-1 ring-white/25' : 'icon-chip',
)}
style={isBrand ? undefined : { ['--chip-bg' as string]: a.chipBg, ['--chip-fg' as string]: a.chipFg }}
aria-hidden
>
{icon}
</span>
)}
{/* gotcha 66: gradient header → text-white! (important) so the
unlayered h1-h4 dark rule does not win. */}
<h3 className={cn('truncate text-sm font-semibold tracking-tight', isBrand ? 'text-white!' : a.head)}>
{title}
</h3>
</div>
{(onRefresh || onExpand) && (
<div className="flex shrink-0 items-center gap-0.5">
{onRefresh && (
<IconButton onClick={onRefresh} label={`Làm mới ${title}`} onGradient={isBrand}>
<RefreshCw className="h-3.5 w-3.5" />
</IconButton>
)}
{onExpand && (
<IconButton onClick={onExpand} label={`Mở rộng ${title}`} onGradient={isBrand}>
<Maximize2 className="h-3.5 w-3.5" />
</IconButton>
)}
</div>
)}
</header>
{/* ===== Stat-chip row ===== */}
{stats && stats.length > 0 && (
<div className="flex flex-wrap gap-2 border-b border-slate-100 bg-slate-50/50 px-4 py-3">
{stats.map((s, i) => (
<StatChip key={i} stat={s} valueClass={a.statValue} />
))}
</div>
)}
{/* ===== Body / EmptyState ===== */}
<div className="min-h-0 flex-1 p-4">
{empty ? (
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center">
<span
className="icon-chip"
style={{ ['--chip-bg' as string]: '#f1f5f9', ['--chip-fg' as string]: '#94a3b8' }}
aria-hidden
>
<Inbox className="h-4 w-4" />
</span>
<p className="text-xs text-slate-500">{emptyText}</p>
</div>
) : (
children
)}
</div>
</section>
)
}
// Small clickable stat — value + label. Falls back to an inert div when no
// onClick (no button affordance, no focus ring).
function StatChip({ stat, valueClass }: { stat: WidgetStat; valueClass: string }) {
const clickable = !!stat.onClick
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
if (!stat.onClick) return
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
stat.onClick()
}
}
return (
<div
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={stat.onClick}
onKeyDown={handleKeyDown}
className={cn(
'min-w-[5rem] rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-left transition',
clickable &&
'cursor-pointer hover:border-brand-300 hover:bg-brand-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white',
)}
>
<div className={cn('stat-value text-lg', valueClass)}>{stat.value}</div>
<div className="label-eyebrow mt-0.5 truncate">{stat.label}</div>
</div>
)
}
// Header icon-button — adapts contrast to the gradient vs tinted header.
function IconButton({
onClick,
label,
onGradient,
children,
}: {
onClick: () => void
label: string
onGradient: boolean
children: ReactNode
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
title={label}
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-lg transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1',
onGradient
? 'text-white/80 hover:bg-white/15 hover:text-white focus-visible:ring-white/70 focus-visible:ring-offset-transparent'
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-700 focus-visible:ring-brand-500 focus-visible:ring-offset-white',
)}
>
{children}
</button>
)
}

View File

@ -42,6 +42,8 @@ export const MenuKeys = {
HrmConfigDrivers: 'Hrm_Config_Drivers', HrmConfigDrivers: 'Hrm_Config_Drivers',
// Module Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1 Session 34, 2026-05-27) // Module Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1 Session 34, 2026-05-27)
Off: 'Off', Off: 'Off',
// Bảng điều khiển Văn phòng số (landing dashboard — admin auto via All)
OffDashboard: 'Off_Dashboard',
OffDanhBa: 'Off_DanhBa', OffDanhBa: 'Off_DanhBa',
// Văn phòng số — Phòng họp (Phase 10.2 G-O2 — Mig 36 Session 36, 2026-05-28) // Văn phòng số — Phòng họp (Phase 10.2 G-O2 — Mig 36 Session 36, 2026-05-28)
OffPhongHop: 'Off_PhongHop', OffPhongHop: 'Off_PhongHop',

View File

@ -0,0 +1,474 @@
// Bảng điều khiển Văn phòng số (E-Office) — landing dashboard, PURO HomePage style.
// Composes the 3 shared ui widgets (PageHeader / KpiCard / WidgetCard) over the
// EXISTING data hooks of the four E-Office modules. NO new API calls, NO new BE:
// every query below mirrors the queryKey + endpoint already used by the module
// pages, so the TanStack cache is shared and counts are computed client-side.
//
// • Đề xuất → GET /proposals (ProposalsListPage)
// • Đơn từ → GET /leave|ot|travel-requests (WorkflowAppsListPage KIND_CONFIG)
// • Ticket CNTT → GET /it-tickets (ItTicketsPage)
// • Phòng họp → GET /meeting-bookings (MeetingCalendarPage)
//
// Layout: PageHeader (brand) on top, then a 2-col grid — LEFT (~2/3) a stack of
// WidgetCards, RIGHT (~1/3) a "Công việc của tôi" panel + quick-action buttons.
// Stacks to 1 column under lg. Each widget's onExpand navigates to its real route.
// gotcha 66: any heading on the brand gradient lives inside WidgetCard, which
// already uses text-white! — this page adds no gradient headings of its own.
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import {
AlertTriangle,
CalendarDays,
ClipboardList,
FilePlus2,
FileSignature,
Inbox,
LayoutDashboard,
ListChecks,
Plus,
Ticket,
} from 'lucide-react'
import { PageHeader } from '@/components/ui/PageHeader'
import { KpiCard } from '@/components/ui/KpiCard'
import { WidgetCard } from '@/components/ui/WidgetCard'
import { Button } from '@/components/ui/Button'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import { ProposalStatus, type PagedResult, type ProposalListItemDto } from '@/types/proposal'
import {
IT_TICKET_STATUS_LABELS,
ItTicketStatus,
WorkflowAppStatus,
type ItTicketDto,
type LeaveRequestDto,
type OtRequestDto,
type TravelRequestDto,
} from '@/types/workflowApps'
import type { MeetingBookingDto } from '@/types/meeting'
// ── date window for "today" meeting bookings (local midnight → next midnight) ──
function todayWindow(): { start: string; end: string } {
const start = new Date()
start.setHours(0, 0, 0, 0)
const end = new Date(start)
end.setDate(end.getDate() + 1)
return { start: start.toISOString(), end: end.toISOString() }
}
function countByStatus<T extends { status: number }>(items: T[], status: number): number {
return items.reduce((n, x) => (x.status === status ? n + 1 : n), 0)
}
// A small skeleton body used while a widget's data is loading. Mimics a couple of
// stat rows so the card keeps its height (no layout shift on resolve).
function WidgetSkeleton() {
return (
<div className="space-y-2.5" aria-hidden>
<div className="h-3.5 w-2/3 animate-pulse rounded bg-slate-100 motion-reduce:animate-none" />
<div className="h-3.5 w-1/2 animate-pulse rounded bg-slate-100 motion-reduce:animate-none" />
<div className="h-3.5 w-3/5 animate-pulse rounded bg-slate-100 motion-reduce:animate-none" />
</div>
)
}
// Inline error body — graceful, never blocks the page.
function WidgetError({ onRetry }: { onRetry: () => void }) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-6 text-center">
<span
className="icon-chip"
style={{ ['--chip-bg' as string]: 'var(--color-accent-500)', ['--chip-fg' as string]: '#fff' }}
aria-hidden
>
<AlertTriangle className="h-4 w-4" />
</span>
<p className="text-xs text-slate-500">Không tải đưc dữ liệu.</p>
<button
type="button"
onClick={onRetry}
className="rounded-md px-2 py-1 text-xs font-medium text-brand-600 transition hover:bg-brand-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
>
Thử lại
</button>
</div>
)
}
// A labelled metric line inside a widget body. `tone` tints the value; clickable
// rows expose a button affordance (used to deep-link a filtered list view).
function MetricRow({
label,
value,
tone,
onClick,
}: {
label: string
value: number
tone: string
onClick?: () => void
}) {
const clickable = !!onClick
return (
<div
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={onClick}
onKeyDown={
clickable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onClick?.()
}
}
: undefined
}
className={cn(
'flex items-center justify-between gap-3 rounded-lg px-2.5 py-1.5 transition',
clickable &&
'cursor-pointer hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500',
)}
>
<span className="text-[13px] text-slate-600">{label}</span>
<span className={cn('stat-value text-base', tone)}>{value}</span>
</div>
)
}
export function OfficeDashboardPage() {
const navigate = useNavigate()
const { start, end } = useMemo(todayWindow, [])
// ── Đề xuất ── same queryKey/endpoint as ProposalsListPage (shared cache).
// First page, large size: enough to count the active workload client-side.
const proposalsQ = useQuery({
queryKey: ['proposals', { status: null, inboxOnly: false, search: '', page: 1, dashboard: true }],
queryFn: async () =>
(await api.get<PagedResult<ProposalListItemDto>>('/proposals', { params: { page: 1, pageSize: 100 } })).data,
})
// "Cần duyệt" = items in MY approval inbox (BE inboxOnly filter — the real
// needs-my-action signal). Mirrors the inbox toggle on ProposalsListPage.
const proposalsInboxQ = useQuery({
queryKey: ['proposals', { status: null, inboxOnly: true, search: '', page: 1, dashboard: true }],
queryFn: async () =>
(await api.get<PagedResult<ProposalListItemDto>>('/proposals', { params: { inboxOnly: true, page: 1, pageSize: 100 } }))
.data,
})
// ── Đơn từ ── three endpoints from WorkflowAppsListPage KIND_CONFIG.
const leaveQ = useQuery({
queryKey: ['/leave-requests', { page: 1 }],
queryFn: async () => (await api.get<PagedResult<LeaveRequestDto>>('/leave-requests', { params: { page: 1, pageSize: 50 } })).data,
})
const otQ = useQuery({
queryKey: ['/ot-requests', { page: 1 }],
queryFn: async () => (await api.get<PagedResult<OtRequestDto>>('/ot-requests', { params: { page: 1, pageSize: 50 } })).data,
})
const travelQ = useQuery({
queryKey: ['/travel-requests', { page: 1 }],
queryFn: async () => (await api.get<PagedResult<TravelRequestDto>>('/travel-requests', { params: { page: 1, pageSize: 50 } })).data,
})
// ── Ticket CNTT ── same queryKey/endpoint as ItTicketsPage (shared cache).
const ticketsQ = useQuery({
queryKey: ['it-tickets'],
queryFn: async () => (await api.get<PagedResult<ItTicketDto>>('/it-tickets', { params: { pageSize: 100 } })).data,
})
// ── Phòng họp hôm nay ── /meeting-bookings windowed to today (no room filter).
const meetingsTodayQ = useQuery({
queryKey: ['meeting-bookings', { dashboard: 'today', start }],
queryFn: async () =>
(await api.get<MeetingBookingDto[]>('/meeting-bookings', { params: { startDate: start, endDate: end } })).data,
})
// ── derived counts (client-side) ──
const proposals = proposalsQ.data?.items ?? []
const proposalTotal = proposalsQ.data?.total ?? proposals.length
const proposalPending = countByStatus(proposals, ProposalStatus.DaGuiDuyet)
const proposalInbox = proposalsInboxQ.data?.total ?? (proposalsInboxQ.data?.items.length ?? 0)
const donTu = useMemo(
() => [...(leaveQ.data?.items ?? []), ...(otQ.data?.items ?? []), ...(travelQ.data?.items ?? [])],
[leaveQ.data, otQ.data, travelQ.data],
)
const donTuSubmitted = countByStatus(donTu, WorkflowAppStatus.DaGuiDuyet)
const donTuReturned = countByStatus(donTu, WorkflowAppStatus.TraLai)
const donTuApproved = countByStatus(donTu, WorkflowAppStatus.DaDuyet)
const donTuLoading = leaveQ.isLoading || otQ.isLoading || travelQ.isLoading
const donTuError = leaveQ.isError || otQ.isError || travelQ.isError
const tickets = ticketsQ.data?.items ?? []
const ticketOpen = countByStatus(tickets, ItTicketStatus.Open)
const ticketInProgress = countByStatus(tickets, ItTicketStatus.InProgress)
const ticketBreached = tickets.reduce((n, t) => (t.slaBreached ? n + 1 : n), 0)
const meetingsToday = meetingsTodayQ.data ?? []
const meetingsTodayCount = meetingsToday.length
// "Công việc của tôi" — total items currently awaiting THIS user's action across
// the modules. Proposals come from the BE inbox filter; đơn-từ in "Đã gửi duyệt"
// are pending approval. (Tickets have their own assignment flow, surfaced in
// their widget rather than double-counted here.)
const myTodo = proposalInbox + donTuSubmitted
return (
<div className="p-6">
<PageHeader
eyebrow="Văn phòng số"
title="Bảng điều khiển"
subtitle="Tổng quan đề xuất, đơn từ, ticket và lịch họp trong ngày"
icon={<LayoutDashboard className="h-5 w-5" />}
accent="brand"
/>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
{/* ───────────────────────── LEFT (~2/3) — widget stack ───────────────────────── */}
<div className="flex flex-col gap-5 lg:col-span-2">
{/* Đề xuất */}
<WidgetCard
title="Đề xuất"
icon={<FileSignature className="h-4 w-4" />}
accent="brand"
onExpand={() => navigate('/proposals')}
onRefresh={() => {
proposalsQ.refetch()
proposalsInboxQ.refetch()
}}
empty={!proposalsQ.isLoading && !proposalsQ.isError && proposalTotal === 0}
emptyText="Chưa có đề xuất nào."
>
{proposalsQ.isError ? (
<WidgetError onRetry={() => proposalsQ.refetch()} />
) : proposalsQ.isLoading ? (
<WidgetSkeleton />
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<KpiCard
label="Cần duyệt"
value={proposalInbox}
icon={<Inbox className="h-4 w-4" />}
accent="amberx"
onClick={() => navigate('/proposals?inboxOnly=true')}
/>
<KpiCard
label="Chờ duyệt"
value={proposalPending}
icon={<ClipboardList className="h-4 w-4" />}
accent="brand"
onClick={() => navigate(`/proposals?status=${ProposalStatus.DaGuiDuyet}`)}
/>
<KpiCard
label="Tất cả"
value={proposalTotal}
icon={<ListChecks className="h-4 w-4" />}
accent="teal"
onClick={() => navigate('/proposals')}
/>
</div>
)}
</WidgetCard>
{/* Đơn từ (nghỉ phép / OT / công tác) */}
<WidgetCard
title="Đơn từ"
icon={<FileSignature className="h-4 w-4" />}
accent="teal"
onExpand={() => navigate('/workflow-apps/leave')}
onRefresh={() => {
leaveQ.refetch()
otQ.refetch()
travelQ.refetch()
}}
empty={!donTuLoading && !donTuError && donTu.length === 0}
emptyText="Chưa có đơn từ nào."
>
{donTuError ? (
<WidgetError
onRetry={() => {
leaveQ.refetch()
otQ.refetch()
travelQ.refetch()
}}
/>
) : donTuLoading ? (
<WidgetSkeleton />
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<KpiCard
label="Đã gửi duyệt"
value={donTuSubmitted}
accent="amberx"
onClick={() => navigate('/workflow-apps/leave')}
/>
<KpiCard
label="Trả lại"
value={donTuReturned}
accent="violet"
onClick={() => navigate('/workflow-apps/leave')}
/>
<KpiCard
label="Đã duyệt"
value={donTuApproved}
accent="greenx"
onClick={() => navigate('/workflow-apps/leave')}
/>
</div>
)}
</WidgetCard>
{/* Ticket CNTT */}
<WidgetCard
title="Ticket CNTT"
icon={<Ticket className="h-4 w-4" />}
accent="violet"
onExpand={() => navigate('/it-tickets')}
onRefresh={() => ticketsQ.refetch()}
empty={!ticketsQ.isLoading && !ticketsQ.isError && tickets.length === 0}
emptyText="Chưa có ticket nào."
>
{ticketsQ.isError ? (
<WidgetError onRetry={() => ticketsQ.refetch()} />
) : ticketsQ.isLoading ? (
<WidgetSkeleton />
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<KpiCard
label={IT_TICKET_STATUS_LABELS[ItTicketStatus.Open]}
value={ticketOpen}
icon={<Inbox className="h-4 w-4" />}
accent="violet"
onClick={() => navigate('/it-tickets')}
/>
<KpiCard
label={IT_TICKET_STATUS_LABELS[ItTicketStatus.InProgress]}
value={ticketInProgress}
icon={<ClipboardList className="h-4 w-4" />}
accent="brand"
onClick={() => navigate('/it-tickets')}
/>
<KpiCard
label="Quá hạn SLA"
value={ticketBreached}
icon={<AlertTriangle className="h-4 w-4" />}
accent="amberx"
onClick={() => navigate('/it-tickets')}
/>
</div>
)}
</WidgetCard>
{/* Phòng họp hôm nay */}
<WidgetCard
title="Phòng họp hôm nay"
icon={<CalendarDays className="h-4 w-4" />}
accent="amberx"
onExpand={() => navigate('/meeting-calendar')}
onRefresh={() => meetingsTodayQ.refetch()}
empty={!meetingsTodayQ.isLoading && !meetingsTodayQ.isError && meetingsTodayCount === 0}
emptyText="Hôm nay chưa có lịch họp."
>
{meetingsTodayQ.isError ? (
<WidgetError onRetry={() => meetingsTodayQ.refetch()} />
) : meetingsTodayQ.isLoading ? (
<WidgetSkeleton />
) : (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<KpiCard
label="Lịch họp hôm nay"
value={meetingsTodayCount}
icon={<CalendarDays className="h-4 w-4" />}
accent="amberx"
onClick={() => navigate('/meeting-calendar')}
/>
</div>
{/* A compact peek of the next few bookings today. */}
<ul className="divide-y divide-slate-100 overflow-hidden rounded-lg border border-slate-200">
{meetingsToday
.slice()
.sort((a, b) => new Date(a.startAt).getTime() - new Date(b.startAt).getTime())
.slice(0, 4)
.map((b) => (
<li key={b.id} className="flex items-center justify-between gap-3 px-3 py-2 text-xs">
<span className="min-w-0 truncate font-medium text-slate-700">{b.title}</span>
<span className="shrink-0 tabular-nums text-slate-500">
{new Date(b.startAt).toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' })} ·{' '}
{b.roomCode}
</span>
</li>
))}
</ul>
</div>
)}
</WidgetCard>
</div>
{/* ───────────────────────── RIGHT (~1/3) — my work + actions ───────────────────────── */}
<div className="flex flex-col gap-5 lg:col-span-1">
{/* Công việc của tôi */}
<WidgetCard
title="Công việc của tôi"
icon={<ListChecks className="h-4 w-4" />}
accent="brand"
empty={false}
>
{proposalsInboxQ.isError ? (
<WidgetError onRetry={() => proposalsInboxQ.refetch()} />
) : (
<div className="space-y-3">
<div className="flex items-end justify-between gap-3 rounded-xl bg-brand-50 px-3.5 py-3">
<div>
<div className="label-eyebrow">Cần xử </div>
<p className="mt-0.5 text-[13px] text-slate-600">Mục đang chờ thao tác của bạn</p>
</div>
<div className="stat-value text-3xl text-brand-800">
{proposalsInboxQ.isLoading || donTuLoading ? '—' : myTodo}
</div>
</div>
<div className="space-y-1">
<MetricRow
label="Đề xuất chờ tôi duyệt"
value={proposalsInboxQ.isLoading ? 0 : proposalInbox}
tone="text-brand-800"
onClick={() => navigate('/proposals?inboxOnly=true')}
/>
<MetricRow
label="Đơn từ đã gửi duyệt"
value={donTuLoading ? 0 : donTuSubmitted}
tone="text-teal-700"
onClick={() => navigate('/workflow-apps/leave')}
/>
<MetricRow
label="Ticket đang mở"
value={ticketsQ.isLoading ? 0 : ticketOpen}
tone="text-violet-700"
onClick={() => navigate('/it-tickets')}
/>
</div>
</div>
)}
</WidgetCard>
{/* Thao tác nhanh */}
<section className="card-accent flex flex-col gap-2 p-4" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
<h3 className="mb-1 text-sm font-semibold tracking-tight text-slate-800">Thao tác nhanh</h3>
<Button variant="primary" className="justify-start" onClick={() => navigate('/proposals/new')}>
<FilePlus2 className="h-4 w-4" />
Tạo đ xuất
</Button>
<Button variant="secondary" className="justify-start" onClick={() => navigate('/workflow-apps/leave')}>
<FileSignature className="h-4 w-4" />
Tạo đơn
</Button>
<Button variant="outline" className="justify-start" onClick={() => navigate('/it-tickets')}>
<Plus className="h-4 w-4" />
Tạo ticket
</Button>
</section>
</div>
</div>
</div>
)
}

View File

@ -97,6 +97,7 @@ public static class MenuKeys
// workflow apps Off_DeXuat / Off_DonTu / Off_DatXe / Off_ItTicket. // workflow apps Off_DeXuat / Off_DonTu / Off_DatXe / Off_ItTicket.
// ============================================================ // ============================================================
public const string Off = "Off"; // root group văn phòng số public const string Off = "Off"; // root group văn phòng số
public const string OffDashboard = "Off_Dashboard"; // Bảng điều khiển Văn phòng số (landing, S? — admin auto via All)
public const string OffDanhBa = "Off_DanhBa"; // Danh bạ nội bộ (card grid) public const string OffDanhBa = "Off_DanhBa"; // Danh bạ nội bộ (card grid)
// Phase 10.2 G-O2 (Mig 36 — S36 2026-05-28) — Phòng họp + Booking calendar. // Phase 10.2 G-O2 (Mig 36 — S36 2026-05-28) — Phòng họp + Booking calendar.
public const string OffPhongHop = "Off_PhongHop"; // sub-group phòng họp public const string OffPhongHop = "Off_PhongHop"; // sub-group phòng họp
@ -153,7 +154,7 @@ public static class MenuKeys
Hrm, HrmHoSo, // Mig 34 — Phase 10.1 Hrm, HrmHoSo, // Mig 34 — Phase 10.1
HrmConfig, HrmConfigLeaveTypes, HrmConfigHolidays, HrmConfigShifts, HrmConfigOtPolicies, // Mig 35 — Phase 10.2 G-H2 HrmConfig, HrmConfigLeaveTypes, HrmConfigHolidays, HrmConfigShifts, HrmConfigOtPolicies, // Mig 35 — Phase 10.2 G-H2
HrmConfigVehicles, HrmConfigDrivers, // Mig 44 — Phase 11 P11-C HrmConfigVehicles, HrmConfigDrivers, // Mig 44 — Phase 11 P11-C
Off, OffDanhBa, // Phase 10.2 G-O1 — Văn phòng số Off, OffDashboard, OffDanhBa, // Phase 10.2 G-O1 — Văn phòng số (+Off_Dashboard landing)
OffPhongHop, OffPhongHopView, OffPhongHopManage, OffPhongHopBook, // Phase 10.2 G-O2 — Phòng họp OffPhongHop, OffPhongHopView, OffPhongHopManage, OffPhongHopBook, // Phase 10.2 G-O2 — Phòng họp
OffDeXuat, OffDeXuatList, OffDeXuatCreate, OffDeXuatInbox, // Phase 10.3 G-O3 — Đề xuất OffDeXuat, OffDeXuatList, OffDeXuatCreate, OffDeXuatInbox, // Phase 10.3 G-O3 — Đề xuất
OffDonTu, OffDonTuLeave, OffDonTuOt, OffDonTuTravel, // Phase 10.3 G-O4 — Đơn từ OffDonTu, OffDonTuLeave, OffDonTuOt, OffDonTuTravel, // Phase 10.3 G-O4 — Đơn từ

View File

@ -1821,6 +1821,8 @@ public static class DbInitializer
// Module Văn phòng số (Phase 10.2 G-O1+ S34). 1 root + leaf Danh bạ. // Module Văn phòng số (Phase 10.2 G-O1+ S34). 1 root + leaf Danh bạ.
// Future leaf: Off_PhongHop (G-O2) + workflow apps Off_DeXuat/DonTu/DatXe/ItTicket. // Future leaf: Off_PhongHop (G-O2) + workflow apps Off_DeXuat/DonTu/DatXe/ItTicket.
(MenuKeys.Off, "Văn phòng số", null, 29, "Briefcase"), (MenuKeys.Off, "Văn phòng số", null, 29, "Briefcase"),
// Bảng điều khiển Văn phòng số — leaf đầu nhóm (Order 0 = landing, đứng trước Danh bạ=1).
(MenuKeys.OffDashboard, "Bảng điều khiển Văn phòng số", MenuKeys.Off, 0, "LayoutDashboard"),
(MenuKeys.OffDanhBa, "Danh bạ nội bộ", MenuKeys.Off, 1, "BookUser"), (MenuKeys.OffDanhBa, "Danh bạ nội bộ", MenuKeys.Off, 1, "BookUser"),
// Phase 10.2 G-O2 (Mig 36 — S36 2026-05-28). Sub-group "Phòng họp" + 3 leaf. // Phase 10.2 G-O2 (Mig 36 — S36 2026-05-28). Sub-group "Phòng họp" + 3 leaf.
(MenuKeys.OffPhongHop, "Phòng họp", MenuKeys.Off, 2, "CalendarRange"), (MenuKeys.OffPhongHop, "Phòng họp", MenuKeys.Off, 2, "CalendarRange"),