[CLAUDE] FE: Văn phòng số re-skin toàn module — 10 page PURO layout + CSS Hồ sơ NS (PageHeader/KpiCard/WidgetCard), phẫu thuật giữ nguyên logic
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m42s

Re-skin TRỌN module Office sang bố cục PURO (NamGroup) + ngôn ngữ thị giác Hồ sơ Nhân sự, tái dùng 3 shared component foundation. Phẫu thuật trình bày — logic byte-identical (reviewer verify mọi api.get/post/put/delete + queryKey zero-delta HEAD vs working tree, cả 2 app build PASS).

10 page (9 fe-user → mirror fe-admin SHA256-identical + AttendanceReport fe-admin-only):
- Danh bạ nội bộ — PageHeader + KpiCard tổng hợp (NV/phòng ban) + card icon-chip.
- Phòng họp (lịch + quản lý phòng) — PageHeader amberx + calendar/table trong card-accent.
- Đề xuất (List/Create/Detail) — List: status filter → KpiCard row (6 trạng thái + inbox "Cần tôi duyệt"); Create/Detail: card-accent section + Field idiom.
- Đơn từ/Đặt xe (List/Detail, :kind leave/ot/travel/vehicle) — PageHeader teal + KpiCard status filter (client-side view over fetched) + card-accent detail.
- Ticket CNTT — PageHeader violet + KpiCard 5-status filter + Quá hạn SLA + kanban card-accent.
- Báo cáo chấm công (fe-admin only) — PageHeader + KpiCard tổng hợp + bảng card-accent + Excel-export giữ nguyên.

- Accent chỉ dùng stop hợp lệ (teal/violet/amberx/greenx 50/100/500/600/700; brand 50-900); gotcha #66 clean. a11y giữ/nâng (focus-visible, KpiCard role/aria-pressed/keyboard).
- Build PASS x2 (fe-user index-C8-p69Kn / fe-admin index-yFhLO2Wp). reviewer PASS 0 blocker; 2 concern cosmetic (badge dup ProposalDetail header+row; KpiCard filter lọc trên trang đầu đã fetch — giới hạn pagination có sẵn).
- Office VẪN ẨN non-Admin (chưa golive). 9 page fe-user↔fe-admin SHA256-identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-17 09:57:46 +07:00
parent a8bbdaeeea
commit c556f6cfa2
22 changed files with 1907 additions and 929 deletions

File diff suppressed because one or more lines are too long

View File

@ -44,6 +44,7 @@
## 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]. - **S69 (2026-06-17) OfficeDashboardPage.tsx fe-user E-Office landing, PURO HomePage, COMPOSES 3 shared widgets:** built `pages/office/OfficeDashboardPage.tsx` (~400 LOC) over EXISTING data hooks of 4 modules. Read-first: 3 ui widgets (exact prop sigs) + 4 source pages (ProposalsListPage/WorkflowAppsListPage/ItTicketsPage/MeetingCalendarPage) to harvest queryKey+endpoint + types + App.tsx routes. **Reused hooks verbatim** shared TanStack cache, ZERO new API/BE: `GET /proposals` (+inboxOnly query for needs-my-action) · `/leave|ot|travel-requests` (merged, countByStatus client-side) · `/it-tickets` · `/meeting-bookings` (today window). Layout = PageHeader(brand) + `lg:grid-cols-3` [LEFT col-span-2 = 4 WidgetCards w/ KpiCard filter-chip bodies | RIGHT col-span-1 = "Công việc của tôi" hero+MetricRows + "Thao tác nhanh" 3 buttons], 1-col <lg. **Routing insight (verified App.tsx):** đơn-từ + ticket have NO standalone `/new` route (creation in-page) quick-actions point at landings `/workflow-apps/leave` + `/it-tickets` (only `/proposals/new` is a real create route) so no link hits the `*` "chưa build" fallback. Per-widget graceful states: WidgetError(retry)/WidgetSkeleton(pulse, motion-reduce)/empty never blocks. a11y full (role=button+Enter/Space+focus-ring on clickables, reduced-motion). `npm run build` (tsc -b strict + vite v8) **PASS 0 TS err, 434ms** (only pre-existing @import-order + chunk-size + INEFFECTIVE_DYNAMIC_IMPORT warnings none from new file). Routing/menu NOT wired (next agent; page not yet in App.tsx). fe-admin NOT mirrored. FD2 authed-screenshot SKIP (ProtectedRoute + rig gotcha #3 verify via deploy). Tag [s69, office-dashboard, compose-3-widgets, reuse-hooks-shared-cache, no-new-be, routing-existing-only, build-pass].
- **S69 (2026-06-17) Văn phòng số / Đơn từ fe-user RE-SKIN (PURO + HRM visual lang) 2 file, CONSUMES shared ui (parallel fan-out, 7 agents same app):** surgical re-skin (KHÔNG rewrite) `pages/office/WorkflowAppsListPage.tsx` + `WorkflowAppDetailPage.tsx` (`:kind`-driven leave/ot/travel/vehicle via KIND_CONFIG). **LIST:** swap `@/components/PageHeader`(constrained)→`@/components/ui/PageHeader`(eyebrow "Văn phòng số · Đơn từ" / title từ KIND_CONFIG / icon per-kind / **accent teal**) + status filter = ROW 4 `ui/KpiCard` (Tất cả teal / Đã gửi duyệt amberx / Trả lại violet / Đã duyệt greenx, `grid-cols-2 sm:grid-cols-4`) + slate table chrome (thead uppercase 11px slate-500, hover teal-50). **Filter là CLIENT-SIDE view** added `useState<StatusFilter>` + 2 `useMemo` (counts + visibleItems) DERIVED over already-fetched `items`; **KHÔNG touch query/endpoint/queryKey/navigation** (page chỉ fetch `page:1` như , không filter-state sẵn nên thêm view-layer = thuần presentation; empty-state phân biệt "chưa data" vs "không đơn trạng thái này"). **DETAIL:** ui/PageHeader teal + 4 section dùng local `Card`(accent-rail pseudo `before:bg-{x}-500` + icon-chip) + `Field`(label uppercase `text-{x}-700`, value `text-brand-800`) copy idiom HRM EmployeesListPage (KHÔNG import HRM, helper local riêng). Accent gán: Thông tin=teal · Số phép=greenx · Quy trình=violet · Ý kiến=brand. Status badge giữ `WORKFLOW_APP_STATUS_BADGE`. Drop raw "⚠" emoji trong over-budget bannertext thuần (anti-slop FD3). **ALL data logic VERBATIM** (grep-verified): 2 query `[endpoint,id]`+`['approval-workflows-v2',applicableType]` (key/endpoint/`enabled` y nguyên) · 3 mutation pinWorkflow(PUT /workflow)/submit(POST /submit)/action(POST /{k}) body+onSuccess+invalidate identical · 3 state + flags isDraft/isInWorkflow/hasWorkflow + mọi onClick/nav target bất biến. **gotcha Tailwind-v4 stop:** dùng CHỈ -50/-500/-700 cho teal/violet/amberx/greenx (no -800) `border-l-greenx-500`/`bg-amberx-50`/`text-amberx-700` đều stop tồn tại (index.css verified). **Self-caught:** `SendHorizonal` (KpiCard "Đã gửi duyệt" icon) export-aggregation-line trong lucide d.ts đổi `Send` (proven-safe export) tránh alias-risk. Self-review: mọi import resolve, 0 unused local (noUnusedLocals strict) `Info`/`Wallet`/`GitBranch`/`MessageSquareText` đều dùng. **KHÔNG run npm build** (em main builds central, 7-agent parallel interference) + KHÔNG modify ui/index.css (other agents edit). FD2 authed-screenshot SKIP (ProtectedRoute + rig gotcha #3 verify via deploy). fe-admin NOT mirrored. Tag [s69, eoffice-donutu-reskin, puro-hrm-visual, consume-ui-pageheader-kpicard, client-side-filter-view, logic-verbatim, no-build-parallel-fanout, lucide-alias-dodge].
- **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

@ -61,6 +61,7 @@ Adversarial pre-commit reviewer SOLUTION_ERP. Read-only verify + live curl prod
## 📅 Recent activity (FIFO — older → archive/git) ## 📅 Recent activity (FIFO — older → archive/git)
- **2026-06-17 (S69 Văn phòng số RE-SKIN static logic-preservation — PASS, 0 blocker):** 10 pages presentation-only re-skin → PURO PageHeader/KpiCard + Hồ sơ-NS idiom (9 fe-user office + 1 fe-admin AttendanceReport). NOT built yet, fe-admin not mirrored (em main next). **Strongest proof = exact API/queryKey diff OLD-vs-NEW byte-identical ALL 8 fe-user pages** (grep `api\.(get|post|put|delete)` + `queryKey:[...]` sorted -u, zero delta): proposals POST /submit + /{kind} · workflow-apps POST /{k}+/submit+PUT /workflow · meeting-bookings POST/DELETE+invalidate · it-tickets PUT /{id}/assign · directory/departments/attendance-report/excel-blob all UNCHANGED. Mutation side-effects (onSuccess/onError/invalidateQueries/setActionDialog/setComment/navigate) 1:1 (line-shift only). ProposalCreate validation `!title.trim()` throw + required + submit-disabled intact. AttendanceReport exportExcel blob (createObjectURL→a.download→click→revoke) intact. **Cat2 orphans CLEAN:** 0 unused import — flagged Users(=UsersIcon alias) + FormEvent/ReactNode (React.* namespace not named-import) + Accent(comment word) all FALSE-alarm verified. **Cat3 shared-comp contract:** PageHeader{eyebrow,title,subtitle,icon,accent,actions} + KpiCard{label,value,icon,accent,active,onClick} props all match real sig; KpiCard onClick wired to REAL filter state (ItTickets `setFilter`/WorkflowAppsList `setStatusFilter`/ProposalsList — driving actual client `.filter()`), InternalDirectory 2 KpiCards INTENTIONALLY inert (no onClick=presentational counts, matches comp design — NOT dummy). **Shared comps + index.css NOT modified** (git status -- ui/ + *.css EMPTY; sha256 identical fe-user==fe-admin per ls). **Cat4 color-trap CLEAN:** grep added lines for `(teal|violet|amberx|greenx)-(200|300|400|800|900)` = ZERO; index.css confirms accents ship only 50/100/500/600/700 (brand has full 50-900 so brand-800 valid); gotcha #66 — 0 gradient/dark-bg headings added (all headers on light surface use accent-ink text-brand-800/{accent}-700 via PageHeader). **Cat1 mock-markers:** 0 //Mock/alert/TODO-wire. **Client-side filter additions** (ItTickets filter/breached, WorkflowAppsList statusFilter useMemo) = presentation views over fetched items, NO new query/endpoint. **2 MINOR (non-block):** (a) ProposalDetail status badge now renders TWICE — PageHeader actions slot + existing status-row (cosmetic dup, both presentation); (b) it-tickets/workflow-apps client-filter is view-only over a `pageSize:100/50` first-page fetch (pre-existing pagination limit, re-skin doesn't worsen). **Learned:** for pure re-skin, the decisive logic-preservation proof is `grep api-call + queryKey sorted -u` OLD-vs-NEW byte-equality across every page — faster + more rigorous than reading each hunk; orphan-import heuristic (body-occ<=1) flags `X as Y` aliases + `React.X` namespace + comment-words as false-positives, always grep the actual usage line before flagging build-break. **surprise:** custom accent palettes (amberx/greenx/teal/violet) deliberately ship NO -800 stop so headings MUST use -700 (brand is the only -800-bearing accent) — a -800 on a non-brand accent = silent no-class Tailwind v4, the re-skin respected this everywhere. Verdict PASS — safe for em main to build+mirror. Tag [s69, office-reskin, presentation-only, api-querykey-byte-equal, color-trap-clean, kpicard-inert-vs-filter, gotcha66-clean].
- **2026-06-16 (S65 PE mục E HoSoLink review — em-main PROXY, PE-Workflow reviewer-stage died-empty):** Review mục-E hyperlink render + HoSoLink BE wiring (`5a0aaa4`). Reviewer-stage trong Workflow `pe-hoso-link-rename-pro` return RỖNG → em main self-gate evidence: Detail DTO `hoSoLink` present + `null` backward-compat phiếu thật (Run #293 GET 200); Create/Update +trailing-optional `HoSoLink=null` KHÔNG vỡ call-site (grep 0 manual ctor — KHÁC CreateDepartmentCommand #291 CS7036 vì positional-required vs trailing-optional); mirror fe-user==fe-admin SHA256 IDENTICAL (PeDetailTabs+PeWorkspaceCreateView); hyperlink `<a target=_blank rel=noopener noreferrer>` no reverse-tabnabbing; rename "Dự trù PRO"→"Ngân sách PRO" CHỈ display (giữ "Ghi chú từ PRO" + field-code). LEARNED: hyperlink free-text = no server-side XSS (render-as-href client-only); absolute-set Update (null=clear) chủ đích. SURPRISE: reviewer-stage chết-rỗng trong fan-out = lý do verify-heavy task vẫn cần em-main self-gate dù có Workflow (verdict `feedback_workflow_fanout_reliability`). Tag `[s65, pe-section-e-review, em-main-proxy-self-gate, hosolink-backward-compat, workflow-fanout]`. - **2026-06-16 (S65 PE mục E HoSoLink review — em-main PROXY, PE-Workflow reviewer-stage died-empty):** Review mục-E hyperlink render + HoSoLink BE wiring (`5a0aaa4`). Reviewer-stage trong Workflow `pe-hoso-link-rename-pro` return RỖNG → em main self-gate evidence: Detail DTO `hoSoLink` present + `null` backward-compat phiếu thật (Run #293 GET 200); Create/Update +trailing-optional `HoSoLink=null` KHÔNG vỡ call-site (grep 0 manual ctor — KHÁC CreateDepartmentCommand #291 CS7036 vì positional-required vs trailing-optional); mirror fe-user==fe-admin SHA256 IDENTICAL (PeDetailTabs+PeWorkspaceCreateView); hyperlink `<a target=_blank rel=noopener noreferrer>` no reverse-tabnabbing; rename "Dự trù PRO"→"Ngân sách PRO" CHỈ display (giữ "Ghi chú từ PRO" + field-code). LEARNED: hyperlink free-text = no server-side XSS (render-as-href client-only); absolute-set Update (null=clear) chủ đích. SURPRISE: reviewer-stage chết-rỗng trong fan-out = lý do verify-heavy task vẫn cần em-main self-gate dù có Workflow (verdict `feedback_workflow_fanout_reliability`). Tag `[s65, pe-section-e-review, em-main-proxy-self-gate, hosolink-backward-compat, workflow-fanout]`.
- **2026-06-16 (S65 public Hồ sơ NS read for all roles — static pre-commit, PASS, 0 blocker, gotcha #44 family CLEAN):** 1-file change DbInitializer.cs (+66, call-site :2046 SAU revoke :2040 + new `SeedAllRolesHrmProfileReadPermissionsAsync` :2203). Prod NOT deployed (static review, build PASS đã claim). **7 verify ALL PASS:** (1) **Ordering** — grant gọi SAU `RevokeTemporarilyHiddenModulesAsync` trong SeedAsync → grant thắng (git diff confirms call sits immediately after revoke). (2) **Upgrade path prod-critical** — method MUTATES existing row `if(!row.CanRead){row.CanRead=true;upgraded++}` (EF change-tracked → SaveChanges persists); NOT skip-existing-noop. Correctly fixes S58-class bug (revoke set CanRead=false on prod rows → upgrade flips true). (3) **Scope precise**`hrmKeys = new[]{MenuKeys.Hrm, MenuKeys.HrmHoSo}` EXACTLY 2; NO Hrm_Dashboard/Hrm_Config*/Off*/Personal. `Hrm` is NOT one of 4 inherit-roots (Contracts/Workflows/PE/PeWorkflows in GetMyMenuTree:56-59) so granting Hrm root does NOT cascade to Dashboard/Config children → they keep own false flags → filtered out by `HasAccess(n)=n.CanRead||Children.Any(HasAccess)`. Menu shows Hrm root → Hồ sơ NS leaf ONLY (HrmHoSo ParentKey=Hrm:1806, Dashboard sibling ParentKey=Hrm:1850 stays hidden). (4) **Read-only** — add-path CanCreate/Update/Delete=false; upgrade-path touches ONLY CanRead. (5) **No regression** — Admin bypass at MenuPermissionHandler:27 untouched; revoke unchanged; Off/Personal/Dashboard/Config stay hidden after full seed. (6) **Idempotent** — 2nd run: row.CanRead already true → `if(!row.CanRead)` false → 0 change. (7) **No non-Admin write path**`MenuPermissionHandler` Read→AnyAsync(CanRead) is what GET checks; all 19 EmployeesController write actions (main+5 satellite) require Hrm_HoSo.Create/Update/Delete which grant leaves false → 403. **surprise/monitor-note (NOT a defect, NOT introduced by this change):** HrDashboardController/HrmConfigsController/Attendances/LeaveBalances carry ONLY class-level `[Authorize]` (any-auth, NO per-action Hrm_*.Read policy) — so their data was already reachable by direct URL pre+post S65 (menu-hide ≠ API-lock; S58 revoke comment DbInit:2153-2155 explicitly acknowledged this). S65 does NOT widen it (only touches perm matrix rows Hrm+Hrm_HoSo + menu filter). cicd-monitor must NOT assume "Dashboard hidden in menu"=="dashboard data unreachable". Spec comment said "6 catalog Hrm_Config*" but there are 6 config leaves + Hrm_Config subgroup = 7 keys — cosmetic count, all stay hidden, not a code bug. **Learned:** for menu-key read-grant, verify the granted root is NOT an inherit-root (else cascade leaks siblings) + trace HasAccess filter + confirm leaf ParentKey chains to the visible root; upgrade-path correctness = grep that method MUTATES row (not skip-existing) when a prior revoke pre-set the flag false on prod. Verdict PASS — safe commit. Tag [s65, public-hrm-hoso, upgrade-path-correct, inherit-root-no-cascade, gotcha44-family-clean, menu-only-not-api-lock-monitor-note]. - **2026-06-16 (S65 public Hồ sơ NS read for all roles — static pre-commit, PASS, 0 blocker, gotcha #44 family CLEAN):** 1-file change DbInitializer.cs (+66, call-site :2046 SAU revoke :2040 + new `SeedAllRolesHrmProfileReadPermissionsAsync` :2203). Prod NOT deployed (static review, build PASS đã claim). **7 verify ALL PASS:** (1) **Ordering** — grant gọi SAU `RevokeTemporarilyHiddenModulesAsync` trong SeedAsync → grant thắng (git diff confirms call sits immediately after revoke). (2) **Upgrade path prod-critical** — method MUTATES existing row `if(!row.CanRead){row.CanRead=true;upgraded++}` (EF change-tracked → SaveChanges persists); NOT skip-existing-noop. Correctly fixes S58-class bug (revoke set CanRead=false on prod rows → upgrade flips true). (3) **Scope precise**`hrmKeys = new[]{MenuKeys.Hrm, MenuKeys.HrmHoSo}` EXACTLY 2; NO Hrm_Dashboard/Hrm_Config*/Off*/Personal. `Hrm` is NOT one of 4 inherit-roots (Contracts/Workflows/PE/PeWorkflows in GetMyMenuTree:56-59) so granting Hrm root does NOT cascade to Dashboard/Config children → they keep own false flags → filtered out by `HasAccess(n)=n.CanRead||Children.Any(HasAccess)`. Menu shows Hrm root → Hồ sơ NS leaf ONLY (HrmHoSo ParentKey=Hrm:1806, Dashboard sibling ParentKey=Hrm:1850 stays hidden). (4) **Read-only** — add-path CanCreate/Update/Delete=false; upgrade-path touches ONLY CanRead. (5) **No regression** — Admin bypass at MenuPermissionHandler:27 untouched; revoke unchanged; Off/Personal/Dashboard/Config stay hidden after full seed. (6) **Idempotent** — 2nd run: row.CanRead already true → `if(!row.CanRead)` false → 0 change. (7) **No non-Admin write path**`MenuPermissionHandler` Read→AnyAsync(CanRead) is what GET checks; all 19 EmployeesController write actions (main+5 satellite) require Hrm_HoSo.Create/Update/Delete which grant leaves false → 403. **surprise/monitor-note (NOT a defect, NOT introduced by this change):** HrDashboardController/HrmConfigsController/Attendances/LeaveBalances carry ONLY class-level `[Authorize]` (any-auth, NO per-action Hrm_*.Read policy) — so their data was already reachable by direct URL pre+post S65 (menu-hide ≠ API-lock; S58 revoke comment DbInit:2153-2155 explicitly acknowledged this). S65 does NOT widen it (only touches perm matrix rows Hrm+Hrm_HoSo + menu filter). cicd-monitor must NOT assume "Dashboard hidden in menu"=="dashboard data unreachable". Spec comment said "6 catalog Hrm_Config*" but there are 6 config leaves + Hrm_Config subgroup = 7 keys — cosmetic count, all stay hidden, not a code bug. **Learned:** for menu-key read-grant, verify the granted root is NOT an inherit-root (else cascade leaks siblings) + trace HasAccess filter + confirm leaf ParentKey chains to the visible root; upgrade-path correctness = grep that method MUTATES row (not skip-existing) when a prior revoke pre-set the flag false on prod. Verdict PASS — safe commit. Tag [s65, public-hrm-hoso, upgrade-path-correct, inherit-root-no-cascade, gotcha44-family-clean, menu-only-not-api-lock-monitor-note].
- **2026-06-12 (S60 đợt1 PE submit-guard + drafter-bypass gate — KHÔNG DELIVER, die mid-run, on-behalf em main ghi hộ, H2-proposed):** Task: review `37122f0` cross-stack (BE TransitionAsync submit-guard đủ-4-thông-tin mục 3 + bypass người-soạn-trong-chuỗi V2 BƯỚC-ĐẦU-only + FE PeDetailTabs ×2 + 14 PeSubmitGuardAndBypassTests 240→254). Die mid-run #53-class (commit body tự khai "Reviewer die mid-run → em main self-gate evidence-checklist PASS 0 blocker") → ship Run #283 PASS prod-verified, bundle rotate both. LEARNED: self-gate em main đứng vững lần 2 (sau S57bis) — checklist deterministic (test gate + diff scope + prod smoke 401/404-control) đủ cho PE refinement cross-stack. SURPRISE: die lần 3 trong 2 ngày (S57bis die-0-byte ×2 + S60 mid-run) DÙ promote-tier inherit Fable 5 → model-tier KHÔNG phải nguyên nhân die (nghi resume-kill/harness class) — trend data cho Harness-4. Tag `[s60, die-mid-run-3rd, self-gate, on-behalf]`. - **2026-06-12 (S60 đợt1 PE submit-guard + drafter-bypass gate — KHÔNG DELIVER, die mid-run, on-behalf em main ghi hộ, H2-proposed):** Task: review `37122f0` cross-stack (BE TransitionAsync submit-guard đủ-4-thông-tin mục 3 + bypass người-soạn-trong-chuỗi V2 BƯỚC-ĐẦU-only + FE PeDetailTabs ×2 + 14 PeSubmitGuardAndBypassTests 240→254). Die mid-run #53-class (commit body tự khai "Reviewer die mid-run → em main self-gate evidence-checklist PASS 0 blocker") → ship Run #283 PASS prod-verified, bundle rotate both. LEARNED: self-gate em main đứng vững lần 2 (sau S57bis) — checklist deterministic (test gate + diff scope + prod smoke 401/404-control) đủ cho PE refinement cross-stack. SURPRISE: die lần 3 trong 2 ngày (S57bis die-0-byte ×2 + S60 mid-run) DÙ promote-tier inherit Fable 5 → model-tier KHÔNG phải nguyên nhân die (nghi resume-kill/harness class) — trend data cho Harness-4. Tag `[s60, die-mid-run-3rd, self-gate, on-behalf]`.

View File

@ -2,11 +2,18 @@
// fe-admin ONLY: endpoint /attendances/report* là [Authorize(Roles=Admin)] → fe-user KHÔNG có page này. // fe-admin ONLY: endpoint /attendances/report* là [Authorize(Roles=Admin)] → fe-user KHÔNG có page này.
// Filter Năm/Tháng/Phòng ban → TanStack Query → Table + footer Tổng. Xuất Excel qua api.get responseType:'blob' // Filter Năm/Tháng/Phòng ban → TanStack Query → Table + footer Tổng. Xuất Excel qua api.get responseType:'blob'
// (api instance đã inject JWT qua interceptor + hỗ trợ refresh-token retry — chuẩn hơn raw fetch). // (api instance đã inject JWT qua interceptor + hỗ trợ refresh-token retry — chuẩn hơn raw fetch).
//
// Re-skin S69 (2026-06-17): áp PURO layout + visual language Hồ sơ Nhân sự — CHỈ trình bày,
// KHÔNG đổi logic. ui/PageHeader (icon-chip + actions) · KpiCard hàng tổng hợp · .card-accent
// cho bộ lọc + bảng · .label-eyebrow nhãn lọc · text-brand-800 cho số quan trọng. Mọi query /
// mutation / queryKey / endpoint / handler giữ NGUYÊN (attendance-report, /attendances/report,
// /attendances/report/excel, departments-all-attendance-report).
import { useState } from 'react' import { useState } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import { Download, ClipboardList } from 'lucide-react' import { Download, ClipboardList, CalendarCheck, Users, Clock, Hourglass } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/ui/PageHeader'
import { KpiCard } from '@/components/ui/KpiCard'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
@ -68,12 +75,17 @@ export function AttendanceReportPage() {
}) })
const rows = report.data?.rows ?? [] const rows = report.data?.rows ?? []
// Tổng hợp tô màu KpiCard — thuần dẫn xuất từ dữ liệu đã fetch (KHÔNG gọi API mới).
const totalDaysPresent = rows.reduce((s, r) => s + r.daysPresent, 0)
return ( return (
<div className="p-6"> <div className="p-6">
<PageHeader <PageHeader
eyebrow="Văn phòng số"
title="Báo cáo chấm công" title="Báo cáo chấm công"
description="Tổng hợp ngày công + giờ làm + OT quy đổi theo tháng và phòng ban." subtitle="Tổng hợp ngày công + giờ làm + OT quy đổi theo tháng và phòng ban."
icon={<CalendarCheck className="h-5 w-5" />}
accent="brand"
actions={ actions={
<Button onClick={() => exportExcel.mutate()} disabled={exportExcel.isPending || report.isLoading}> <Button onClick={() => exportExcel.mutate()} disabled={exportExcel.isPending || report.isLoading}>
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@ -82,10 +94,40 @@ export function AttendanceReportPage() {
} }
/> />
{/* ===== Tổng hợp (KpiCard) — chỉ hiện khi có dữ liệu ===== */}
{!report.isLoading && rows.length > 0 && report.data && (
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
<KpiCard
label="Nhân sự"
value={rows.length}
icon={<Users className="h-4 w-4" />}
accent="brand"
/>
<KpiCard
label="Tổng ngày công"
value={fmtNum(totalDaysPresent)}
icon={<CalendarCheck className="h-4 w-4" />}
accent="teal"
/>
<KpiCard
label="Tổng giờ làm"
value={fmtNum(report.data.grandTotalWorkHours)}
icon={<Clock className="h-4 w-4" />}
accent="violet"
/>
<KpiCard
label="Tổng OT quy đổi"
value={fmtNum(report.data.grandTotalOtWeighted)}
icon={<Hourglass className="h-4 w-4" />}
accent="amberx"
/>
</div>
)}
{/* ===== Bộ lọc ===== */} {/* ===== Bộ lọc ===== */}
<div className="mb-4 flex flex-wrap items-end gap-3 rounded-lg border border-slate-200 bg-white p-4"> <div className="card-accent mb-4 flex flex-wrap items-end gap-3 p-4">
<div className="w-28 space-y-1.5"> <div className="w-28 space-y-1.5">
<label className="block text-xs font-medium text-slate-600">Năm</label> <label className="label-eyebrow block">Năm</label>
<Input <Input
type="number" type="number"
min={2000} min={2000}
@ -95,7 +137,7 @@ export function AttendanceReportPage() {
/> />
</div> </div>
<div className="w-36 space-y-1.5"> <div className="w-36 space-y-1.5">
<label className="block text-xs font-medium text-slate-600">Tháng</label> <label className="label-eyebrow block">Tháng</label>
<Select value={month} onChange={e => setMonth(Number(e.target.value))}> <Select value={month} onChange={e => setMonth(Number(e.target.value))}>
{MONTHS.map(m => ( {MONTHS.map(m => (
<option key={m} value={m}>Tháng {m}</option> <option key={m} value={m}>Tháng {m}</option>
@ -103,7 +145,7 @@ export function AttendanceReportPage() {
</Select> </Select>
</div> </div>
<div className="min-w-56 flex-1 space-y-1.5"> <div className="min-w-56 flex-1 space-y-1.5">
<label className="block text-xs font-medium text-slate-600">Phòng ban</label> <label className="label-eyebrow block">Phòng ban</label>
<Select value={deptId} onChange={e => setDeptId(e.target.value)}> <Select value={deptId} onChange={e => setDeptId(e.target.value)}>
<option value="">Tất cả phòng ban</option> <option value="">Tất cả phòng ban</option>
{(departments.data ?? []).map(d => ( {(departments.data ?? []).map(d => (
@ -113,58 +155,75 @@ export function AttendanceReportPage() {
</div> </div>
</div> </div>
{/* ===== Bảng kết quả ===== */} {/* ===== Bảng kết quả ===== card-accent + header bar brand-tint */}
<div className="overflow-auto rounded-lg border border-slate-200 bg-white shadow-sm"> <section className="card-accent flex min-w-0 flex-col overflow-hidden">
<table className="w-full text-sm"> <header className="flex items-center gap-2 border-b border-slate-100 bg-brand-50 px-4 py-2.5">
<thead className="border-b border-slate-200 bg-slate-50 text-xs uppercase text-slate-500"> <span
<tr> className="icon-chip"
<th className="px-3 py-2 text-left font-medium">STT</th> style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-brand-100)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
<th className="px-3 py-2 text-left font-medium">Họ tên</th> aria-hidden
<th className="px-3 py-2 text-left font-medium">Phòng ban</th> >
<th className="px-3 py-2 text-right font-medium">Ngày công</th> <ClipboardList className="h-4 w-4" />
<th className="px-3 py-2 text-right font-medium">Tổng giờ làm</th> </span>
<th className="px-3 py-2 text-right font-medium">OT thường</th> <h3 className="text-sm font-semibold tracking-tight text-brand-800">Bảng tổng hợp chấm ng</h3>
<th className="px-3 py-2 text-right font-medium">OT cuối tuần</th> {rows.length > 0 && (
<th className="px-3 py-2 text-right font-medium">OT lễ</th> <span className="rounded-full bg-white px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-brand-700">
<th className="px-3 py-2 text-right font-medium">OT quy đi</th> {rows.length}
</tr> </span>
</thead>
<tbody>
{report.isLoading && (
<tr><td colSpan={9} className="px-3 py-8 text-center text-slate-500">Đang tải</td></tr>
)}
{!report.isLoading && rows.length === 0 && (
<tr><td colSpan={9} className="px-3 py-10 text-center text-slate-500">
<ClipboardList className="mx-auto mb-2 h-8 w-8 opacity-50" />
Không dữ liệu chấm công cho kỳ đã chọn.
</td></tr>
)}
{rows.map((r, i) => (
<tr key={r.userId} className="border-b border-slate-100 hover:bg-slate-50">
<td className="px-3 py-2 text-slate-500">{i + 1}</td>
<td className="px-3 py-2 font-medium text-slate-900">{r.fullName}</td>
<td className="px-3 py-2 text-slate-600">{r.departmentName ?? '—'}</td>
<td className="px-3 py-2 text-right tabular-nums">{r.daysPresent}</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtNum(r.totalWorkHours)}</td>
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{fmtNum(r.otWeekday)}</td>
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{fmtNum(r.otWeekend)}</td>
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{fmtNum(r.otHoliday)}</td>
<td className="px-3 py-2 text-right font-semibold tabular-nums text-slate-900">{fmtNum(r.otWeighted)}</td>
</tr>
))}
</tbody>
{!report.isLoading && rows.length > 0 && report.data && (
<tfoot className="border-t-2 border-slate-300 bg-slate-50 font-semibold text-slate-800">
<tr>
<td className="px-3 py-2.5 text-right" colSpan={4}>Tổng</td>
<td className="px-3 py-2.5 text-right tabular-nums">{fmtNum(report.data.grandTotalWorkHours)}</td>
<td className="px-3 py-2.5" colSpan={3}></td>
<td className="px-3 py-2.5 text-right tabular-nums">{fmtNum(report.data.grandTotalOtWeighted)}</td>
</tr>
</tfoot>
)} )}
</table> </header>
</div> <div className="overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 border-b border-slate-200 bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
<tr>
<th className="px-3 py-2 text-left font-semibold">STT</th>
<th className="px-3 py-2 text-left font-semibold">Họ tên</th>
<th className="px-3 py-2 text-left font-semibold">Phòng ban</th>
<th className="px-3 py-2 text-right font-semibold">Ngày công</th>
<th className="px-3 py-2 text-right font-semibold">Tổng giờ làm</th>
<th className="px-3 py-2 text-right font-semibold">OT thường</th>
<th className="px-3 py-2 text-right font-semibold">OT cuối tuần</th>
<th className="px-3 py-2 text-right font-semibold">OT lễ</th>
<th className="px-3 py-2 text-right font-semibold">OT quy đi</th>
</tr>
</thead>
<tbody>
{report.isLoading && (
<tr><td colSpan={9} className="px-3 py-8 text-center text-slate-500">Đang tải</td></tr>
)}
{!report.isLoading && rows.length === 0 && (
<tr><td colSpan={9} className="px-3 py-10 text-center text-slate-500">
<ClipboardList className="mx-auto mb-2 h-8 w-8 opacity-50" />
Không dữ liệu chấm công cho kỳ đã chọn.
</td></tr>
)}
{rows.map((r, i) => (
<tr key={r.userId} className="border-b border-slate-100 hover:bg-brand-50/40">
<td className="px-3 py-2 text-slate-500">{i + 1}</td>
<td className="px-3 py-2 font-medium text-brand-800">{r.fullName}</td>
<td className="px-3 py-2 text-slate-600">{r.departmentName ?? '—'}</td>
<td className="px-3 py-2 text-right tabular-nums text-slate-700">{r.daysPresent}</td>
<td className="px-3 py-2 text-right tabular-nums text-slate-700">{fmtNum(r.totalWorkHours)}</td>
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{fmtNum(r.otWeekday)}</td>
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{fmtNum(r.otWeekend)}</td>
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{fmtNum(r.otHoliday)}</td>
<td className="px-3 py-2 text-right font-semibold tabular-nums text-brand-800">{fmtNum(r.otWeighted)}</td>
</tr>
))}
</tbody>
{!report.isLoading && rows.length > 0 && report.data && (
<tfoot className="border-t-2 border-brand-200 bg-brand-50 font-semibold text-brand-800">
<tr>
<td className="px-3 py-2.5 text-right" colSpan={4}>Tổng</td>
<td className="px-3 py-2.5 text-right tabular-nums">{fmtNum(report.data.grandTotalWorkHours)}</td>
<td className="px-3 py-2.5" colSpan={3}></td>
<td className="px-3 py-2.5 text-right tabular-nums">{fmtNum(report.data.grandTotalOtWeighted)}</td>
</tr>
</tfoot>
)}
</table>
</div>
</section>
</div> </div>
) )
} }

View File

@ -1,12 +1,19 @@
// Danh bạ nội bộ (Internal Directory) — Phase 10.2 G-O1 (S34 2026-05-27). // Danh bạ nội bộ (Internal Directory) — Phase 10.2 G-O1 (S34 2026-05-27).
// Card grid responsive, filter search + department, avatar fallback gradient theo // Re-skin S69 (2026-06-17): PURO layout + ngôn ngữ thị giác Hồ sơ Nhân sự —
// userId hash stable. File này MIRROR SHA256 identical với fe-admin counterpart. // ui/PageHeader (eyebrow "Văn phòng số" + icon Contact + accent brand, search/
// Reuse BE GET /api/directory readonly (DirectoryFeatures.cs). // filter dồn vào actions slot) · KpiCard hàng tóm tắt (tổng người / số phòng ban,
// inert vì không phải filter-status) · card người dùng .icon-chip cho avatar +
// tên text-brand-800 + .label-eyebrow cho phòng ban + viền card sạch.
// GIỮ brand #1F7DC1 + Be Vietnam Pro.
// KHÔNG đổi logic — 100% chức năng giữ: 2 query (departments-all-directory /
// directory) NGUYÊN, search box + Select phòng ban, URL params (q, deptId),
// avatar gradient hash theo userId, mọi mailto/tel/handler giữ nguyên.
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { Mail, Phone, Search, UserCircle2, Users } from 'lucide-react' import { Building2, Contact, Mail, Phone, Search, UserCircle2, Users } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/ui/PageHeader'
import { KpiCard } from '@/components/ui/KpiCard'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
@ -87,43 +94,64 @@ export function InternalDirectoryPage() {
} }
const total = list.data?.length ?? 0 const total = list.data?.length ?? 0
const deptCount = departments.data?.length ?? 0
return ( return (
<div className="p-6"> <div className="p-6">
{/* PURO header: eyebrow + icon-chip brand + search/filter trong actions slot */}
<PageHeader <PageHeader
eyebrow="Văn phòng số"
title="Danh bạ nội bộ" title="Danh bạ nội bộ"
description={list.isLoading ? 'Đang tải...' : `${total} nhân viên`} subtitle={list.isLoading ? 'Đang tải...' : `${total} nhân viên`}
icon={<Contact className="h-5 w-5" />}
accent="brand"
actions={
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<form onSubmit={applySearch} className="relative">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input
value={localSearch}
onChange={e => setLocalSearch(e.target.value)}
onBlur={() => setParam('q', localSearch.trim() || null)}
placeholder="Tìm tên / email / SĐT / mã NV..."
className="pl-8 sm:w-72"
/>
</form>
<Select
value={departmentId}
onChange={e => setParam('deptId', e.target.value || null)}
className="sm:w-56"
>
<option value="">Tất cả phòng ban</option>
{(departments.data ?? []).map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</Select>
</div>
}
/> />
{/* Filter bar sticky top */} {/* Hàng tóm tắt — counts có sẵn từ data (inert, KHÔNG phải filter trạng thái) */}
<div className="sticky top-0 z-10 mb-4 flex flex-col gap-2 rounded-lg border border-slate-200 bg-white/95 p-3 shadow-sm backdrop-blur sm:flex-row sm:items-center"> <div className="mb-5 grid grid-cols-2 gap-3 sm:max-w-md">
<form onSubmit={applySearch} className="relative flex-1"> <KpiCard
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" /> label="Tổng nhân viên"
<Input value={total}
value={localSearch} icon={<Users className="h-4 w-4" />}
onChange={e => setLocalSearch(e.target.value)} accent="brand"
onBlur={() => setParam('q', localSearch.trim() || null)} />
placeholder="Tìm tên / email / SĐT / mã NV..." <KpiCard
className="pl-8" label="Số phòng ban"
/> value={deptCount}
</form> icon={<Building2 className="h-4 w-4" />}
<Select accent="teal"
value={departmentId} />
onChange={e => setParam('deptId', e.target.value || null)}
className="sm:w-64"
>
<option value="">Tất cả phòng ban</option>
{(departments.data ?? []).map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</Select>
</div> </div>
{/* Card grid */} {/* Card grid */}
{list.isLoading ? ( {list.isLoading ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => ( {Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-44 animate-pulse rounded-lg border border-slate-200 bg-slate-100" /> <div key={i} className="h-44 animate-pulse rounded-xl border border-slate-200 bg-slate-100" />
))} ))}
</div> </div>
) : total === 0 ? ( ) : total === 0 ? (
@ -145,7 +173,7 @@ export function InternalDirectoryPage() {
function DirectoryCard({ item }: { item: DirectoryItem }) { function DirectoryCard({ item }: { item: DirectoryItem }) {
return ( return (
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm transition hover:shadow-md"> <div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md motion-reduce:transform-none">
{/* Top row: avatar + name + code */} {/* Top row: avatar + name + code */}
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{item.photoUrl ? ( {item.photoUrl ? (
@ -166,7 +194,7 @@ function DirectoryCard({ item }: { item: DirectoryItem }) {
)} )}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1.5"> <div className="flex items-baseline gap-1.5">
<h3 className="truncate text-sm font-semibold text-slate-900" title={item.fullName}> <h3 className="truncate text-sm font-semibold text-brand-800" title={item.fullName}>
{item.fullName} {item.fullName}
</h3> </h3>
{item.employeeCode && ( {item.employeeCode && (
@ -181,8 +209,9 @@ function DirectoryCard({ item }: { item: DirectoryItem }) {
</p> </p>
)} )}
{item.departmentName && ( {item.departmentName && (
<span className="mt-1 inline-flex rounded bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-900"> <span className="label-eyebrow mt-1.5 inline-flex max-w-full items-center gap-1 truncate">
{item.departmentName} <Building2 className="h-3 w-3 shrink-0" />
<span className="truncate">{item.departmentName}</span>
</span> </span>
)} )}
</div> </div>
@ -225,7 +254,7 @@ function DirectoryCard({ item }: { item: DirectoryItem }) {
{item.internalPhone && ( {item.internalPhone && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<UserCircle2 className="h-3.5 w-3.5 shrink-0 text-slate-400" /> <UserCircle2 className="h-3.5 w-3.5 shrink-0 text-slate-400" />
<span className="inline-flex rounded bg-amber-100 px-1.5 py-0.5 font-mono text-[10px] font-medium text-amber-900"> <span className="inline-flex rounded bg-amberx-50 px-1.5 py-0.5 font-mono text-[10px] font-medium text-amberx-700">
Ext: {item.internalPhone} Ext: {item.internalPhone}
</span> </span>
</div> </div>

View File

@ -1,11 +1,19 @@
// Ticket CNTT — Phase 10.3 G-O6 (S38) + P11-D auto-assign round-robin + SLA timer (S52). // Ticket CNTT — Phase 10.3 G-O6 (S38) + P11-D auto-assign round-robin + SLA timer (S52).
// Read-only kanban list + MaTicket + người xử lý (auto-assign dept IT) + SLA badge (đỏ khi quá hạn). // Read-only kanban list + MaTicket + người xử lý (auto-assign dept IT) + SLA badge (đỏ khi quá hạn).
// S54: reassign cho Admin HOẶC tổ IT (BE capability /assignable-staff). fe-user MIRROR cùng logic. // S54: reassign cho Admin HOẶC tổ IT (BE capability /assignable-staff). fe-user MIRROR cùng logic.
// S69 re-skin: PURO chrome + Hồ sơ NS visual language (PageHeader ui + KpiCard filter-row + card-accent).
// KHÔNG đổi logic — mọi query/mutation/endpoint/handler/state giữ NGUYÊN:
// list ['it-tickets'] · staffQ ['it-tickets','assignable-staff'] · reassign PUT /it-tickets/{id}/assign
// · canReassign · staff · grouped · formatSlaDue · Dialog. statusKey/breached chỉ LỌC HIỂN THỊ
// client-side (presentation), KHÔNG gọi API mới, KHÔNG sửa cách fetch.
import { useState } from 'react' import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Pencil, Ticket } from 'lucide-react' import {
Pencil, Ticket, Inbox, Loader2, CheckCircle2, Archive, AlarmClockOff, User,
} from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/ui/PageHeader'
import { KpiCard, type Accent } from '@/components/ui/KpiCard'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog' import { Dialog } from '@/components/ui/Dialog'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
@ -23,6 +31,36 @@ function formatSlaDue(iso: string): string {
}) })
} }
// ── Presentation-only filter chips (PURO KpiCard row) ─────────────────────────
// 'all' = mặc định hiện mọi cột; statusKey = chỉ hiện 1 trạng thái; 'breached' =
// chỉ hiện ticket quá hạn SLA. Đây là LỌC HIỂN THỊ client-side trên items đã fetch
// — KHÔNG đổi query, KHÔNG gọi BE. Accent map mỗi trạng thái 1 tone Hồ sơ NS.
type StatusChip = 1 | 2 | 3 | 4
type FilterKey = 'all' | StatusChip | 'breached'
const STATUS_CHIPS: { key: StatusChip; icon: typeof Ticket; accent: Accent }[] = [
{ key: 1, icon: Inbox, accent: 'violet' }, // Mới
{ key: 2, icon: Loader2, accent: 'brand' }, // Đang xử lý
{ key: 3, icon: CheckCircle2, accent: 'greenx' }, // Đã giải quyết
{ key: 4, icon: Archive, accent: 'teal' }, // Đã đóng
]
// Kanban column order (giữ nguyên thứ tự gốc 1,2,3,5,4) + tone cột để tô header.
const COLUMN_ACCENT: Record<number, Accent> = {
1: 'violet', 2: 'brand', 3: 'greenx', 5: 'amberx', 4: 'teal',
}
const COLUMN_RAIL: Record<Accent, string> = {
brand: 'var(--color-brand-500)',
teal: 'var(--color-teal-500)',
violet: 'var(--color-violet-500)',
amberx: 'var(--color-amberx-500)',
greenx: 'var(--color-greenx-500)',
}
const COLUMN_HEAD: Record<Accent, string> = {
brand: 'text-brand-700', teal: 'text-teal-700', violet: 'text-violet-700',
amberx: 'text-amberx-700', greenx: 'text-greenx-700',
}
export function ItTicketsPage() { export function ItTicketsPage() {
const qc = useQueryClient() const qc = useQueryClient()
const list = useQuery({ const list = useQuery({
@ -34,6 +72,9 @@ export function ItTicketsPage() {
const [target, setTarget] = useState<ItTicketDto | null>(null) const [target, setTarget] = useState<ItTicketDto | null>(null)
const [pick, setPick] = useState('') const [pick, setPick] = useState('')
// Presentation-only: chip lọc hiển thị (KHÔNG ảnh hưởng fetch).
const [filter, setFilter] = useState<FilterKey>('all')
// BE capability: /assignable-staff trả { canReassign, staff } — canReassign quyết hiện nút // BE capability: /assignable-staff trả { canReassign, staff } — canReassign quyết hiện nút
// trên MỌI card, nên fetch on mount (KHÔNG gate enabled theo dialog). User thường → canReassign=false, staff=[]. // trên MỌI card, nên fetch on mount (KHÔNG gate enabled theo dialog). User thường → canReassign=false, staff=[].
const staffQ = useQuery({ const staffQ = useQuery({
@ -72,73 +113,136 @@ export function ItTicketsPage() {
if (grouped[t.status]) grouped[t.status].push(t) if (grouped[t.status]) grouped[t.status].push(t)
}) })
return ( // Presentation-only derive: số ticket quá hạn SLA (cho KpiCard "Quá hạn SLA").
<div className="space-y-4"> const breachedCount = items.filter(t => t.slaBreached).length
<PageHeader title="Ticket CNTT" description="Helpdesk — báo lỗi và yêu cầu hỗ trợ kỹ thuật" />
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3"> // Cột nào hiển thị theo chip lọc (presentation). 'all' = mọi cột; statusKey = 1 cột;
{[1, 2, 3, 5, 4].map((statusKey) => ( // 'breached' = mọi cột nhưng chỉ giữ ticket quá hạn (cardMatch lọc bên trong).
<div key={statusKey} className="rounded-lg border bg-card p-3"> const ORDER = [1, 2, 3, 5, 4]
<h3 className="font-medium text-sm mb-2"> const visibleColumns =
{IT_TICKET_STATUS_LABELS[statusKey]} <span className="text-xs text-muted-foreground">({grouped[statusKey].length})</span> filter === 'all' || filter === 'breached'
</h3> ? ORDER
<div className="space-y-2"> : ORDER.filter(s => s === filter)
{list.isLoading && <div className="text-xs text-muted-foreground">Đang tải...</div>} const cardMatch = (t: ItTicketDto) => (filter === 'breached' ? t.slaBreached : true)
{!list.isLoading && grouped[statusKey].length === 0 && (
<div className="text-xs text-muted-foreground italic">Trống</div> return (
)} <div className="space-y-5">
{grouped[statusKey].map((t) => ( <PageHeader
<div key={t.id} className="rounded border p-2 text-xs space-y-1 bg-background"> eyebrow="Văn phòng số"
<div className="flex items-center justify-between"> title="Ticket CNTT"
<span className="font-mono text-[10px] text-muted-foreground">{t.maTicket ?? '—'}</span> subtitle="Helpdesk — báo lỗi và yêu cầu hỗ trợ kỹ thuật"
<span className={cn('rounded px-1.5 py-0.5 text-[10px]', IT_TICKET_PRIORITY_BADGE[t.priority])}> icon={<Ticket className="h-5 w-5" />}
{IT_TICKET_PRIORITY_LABELS[t.priority]} accent="violet"
</span> />
</div>
<div className="font-medium truncate">{t.title}</div> {/* ── KpiCard filter-row (PURO) — thay status tabs. value = count, onClick =
<div className="text-muted-foreground"> set filter hiển thị. Bấm lại chip đang chọn → bỏ lọc (về 'all'). ── */}
{IT_TICKET_CATEGORY_LABELS[t.category]} · {t.requesterFullName} <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
</div> {STATUS_CHIPS.map(({ key, icon: Icon, accent }) => (
<div className="flex items-center justify-between gap-1 pt-0.5"> <KpiCard
<span className="flex items-center gap-1 min-w-0 text-muted-foreground"> key={key}
<span className="truncate" title={t.assignedToFullName ?? undefined}> label={IT_TICKET_STATUS_LABELS[key]}
👤 {t.assignedToFullName ?? <span className="italic">Chưa giao</span>} value={grouped[key].length}
</span> icon={<Icon className="h-4 w-4" />}
{/* Reassign Admin OR tổ IT (BE capability canReassign). Nút nhỏ cạnh người xử lý → mở Dialog gán lại. */} accent={accent}
{canReassign && ( active={filter === key}
<button onClick={() => setFilter(prev => (prev === key ? 'all' : key))}
type="button" />
onClick={() => openDialog(t)}
className="shrink-0 rounded p-0.5 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
title="Đổi người xử lý"
>
<Pencil className="h-3 w-3" />
</button>
)}
</span>
{t.slaDueAt && (
<span
className={cn(
'rounded px-1.5 py-0.5 text-[10px] whitespace-nowrap',
t.slaBreached ? 'bg-red-100 text-red-700 font-medium' : 'bg-slate-100 text-slate-600',
)}
title={`Hạn xử lý SLA: ${formatSlaDue(t.slaDueAt)}`}
>
{t.slaBreached ? 'Quá hạn SLA' : `SLA ${formatSlaDue(t.slaDueAt)}`}
</span>
)}
</div>
</div>
))}
</div>
</div>
))} ))}
<KpiCard
label="Quá hạn SLA"
value={breachedCount}
icon={<AlarmClockOff className="h-4 w-4" />}
accent="amberx"
active={filter === 'breached'}
onClick={() => setFilter(prev => (prev === 'breached' ? 'all' : 'breached'))}
/>
</div>
{/* ── Kanban columns — Hồ sơ NS chrome: card-accent rail + header tinted ── */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-5">
{visibleColumns.map((statusKey) => {
const accent = COLUMN_ACCENT[statusKey]
const cards = grouped[statusKey].filter(cardMatch)
return (
<section
key={statusKey}
className="card-accent flex min-w-0 flex-col overflow-hidden"
style={{ ['--accent' as string]: COLUMN_RAIL[accent] }}
>
<header className="flex items-center justify-between gap-2 border-b border-slate-100 px-3.5 py-2.5 pl-4">
<h3 className={cn('text-sm font-semibold tracking-tight', COLUMN_HEAD[accent])}>
{IT_TICKET_STATUS_LABELS[statusKey]}
</h3>
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
{cards.length}
</span>
</header>
<div className="space-y-2 p-3 pl-4">
{list.isLoading && <div className="text-xs text-slate-400">Đang tải...</div>}
{!list.isLoading && cards.length === 0 && (
<div className="py-4 text-center text-xs italic text-slate-400">Trống</div>
)}
{cards.map((t) => (
<div key={t.id} className="space-y-1 rounded-lg border border-slate-200 bg-white p-2.5 text-xs shadow-sm transition hover:border-slate-300 hover:shadow">
<div className="flex items-center justify-between">
<span className="font-mono text-[10px] text-slate-400">{t.maTicket ?? '—'}</span>
<span className={cn('rounded px-1.5 py-0.5 text-[10px] font-medium', IT_TICKET_PRIORITY_BADGE[t.priority])}>
{IT_TICKET_PRIORITY_LABELS[t.priority]}
</span>
</div>
<div className="truncate font-semibold text-slate-900">{t.title}</div>
<div className="text-slate-500">
{IT_TICKET_CATEGORY_LABELS[t.category]} · {t.requesterFullName}
</div>
<div className="flex items-center justify-between gap-1 pt-0.5">
<span className="flex min-w-0 items-center gap-1 text-slate-500">
<span className="flex min-w-0 items-center gap-1 truncate" title={t.assignedToFullName ?? undefined}>
<User className="h-3 w-3 shrink-0 text-slate-400" />
{t.assignedToFullName ?? <span className="italic">Chưa giao</span>}
</span>
{/* Reassign Admin OR tổ IT (BE capability canReassign). Nút nhỏ cạnh người xử lý → mở Dialog gán lại. */}
{canReassign && (
<button
type="button"
onClick={() => openDialog(t)}
className="shrink-0 rounded p-0.5 text-slate-400 transition hover:bg-violet-50 hover:text-violet-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500"
title="Đổi người xử lý"
>
<Pencil className="h-3 w-3" />
</button>
)}
</span>
{t.slaDueAt && (
<span
className={cn(
'whitespace-nowrap rounded px-1.5 py-0.5 text-[10px]',
t.slaBreached ? 'bg-red-100 font-medium text-red-700' : 'bg-slate-100 text-slate-600',
)}
title={`Hạn xử lý SLA: ${formatSlaDue(t.slaDueAt)}`}
>
{t.slaBreached ? 'Quá hạn SLA' : `SLA ${formatSlaDue(t.slaDueAt)}`}
</span>
)}
</div>
</div>
))}
</div>
</section>
)
})}
</div> </div>
{!list.isLoading && items.length === 0 && ( {!list.isLoading && items.length === 0 && (
<div className="rounded-lg border bg-card p-8 text-center text-muted-foreground"> <div className="card-accent p-8 text-center text-slate-500" style={{ ['--accent' as string]: COLUMN_RAIL.violet }}>
<Ticket className="mx-auto h-10 w-10 mb-3 opacity-50" /> <span
Chưa ticket nào. className="icon-chip mx-auto mb-3"
style={{ ['--chip-bg' as string]: 'var(--color-violet-50)', ['--chip-fg' as string]: 'var(--color-violet-700)' }}
aria-hidden
>
<Ticket className="h-5 w-5" />
</span>
<p className="text-sm">Chưa ticket nào.</p>
</div> </div>
)} )}
@ -162,18 +266,18 @@ export function ItTicketsPage() {
> >
{target && ( {target && (
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
<div className="text-xs text-muted-foreground"> <div className="rounded-lg bg-slate-50 px-3 py-2 text-xs text-slate-500">
Ticket <span className="font-mono">{target.maTicket ?? '—'}</span> · {target.title} Ticket <span className="font-mono text-slate-700">{target.maTicket ?? '—'}</span> · {target.title}
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-xs font-medium text-slate-700">Người xử </label> <label className="text-[11px] font-semibold uppercase tracking-wide text-violet-700">Người xử </label>
<Select value={pick} onChange={e => setPick(e.target.value)} disabled={staffQ.isLoading}> <Select value={pick} onChange={e => setPick(e.target.value)} disabled={staffQ.isLoading}>
<option value=""> Chọn người xử </option> <option value=""> Chọn người xử </option>
{staff.map(u => ( {staff.map(u => (
<option key={u.id} value={u.id}>{u.fullName}</option> <option key={u.id} value={u.id}>{u.fullName}</option>
))} ))}
</Select> </Select>
{staffQ.isLoading && <div className="text-xs text-muted-foreground">Đang tải danh sách</div>} {staffQ.isLoading && <div className="text-xs text-slate-400">Đang tải danh sách</div>}
</div> </div>
</div> </div>
)} )}

View File

@ -6,9 +6,9 @@
// Pattern 16-bis 4-place mirror foundation (7× cumulative). // Pattern 16-bis 4-place mirror foundation (7× cumulative).
import { useMemo, useState, type FormEvent } from 'react' import { useMemo, useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Clock, MapPin, Plus, Trash2, Users as UsersIcon, X } from 'lucide-react' import { ChevronLeft, ChevronRight, CalendarDays, Clock, MapPin, Plus, Trash2, Users as UsersIcon, X } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/ui/PageHeader'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
@ -259,13 +259,11 @@ export function MeetingCalendarPage() {
return ( return (
<div className="p-6"> <div className="p-6">
<PageHeader <PageHeader
title={ eyebrow="Văn phòng số"
<span className="flex items-center gap-2"> title="Lịch phòng họp"
<CalendarIcon className="h-5 w-5" /> subtitle={bookings.isLoading ? 'Đang tải…' : `${totalThisWeek} booking tuần này`}
Đt phòng họp icon={<CalendarDays className="h-5 w-5" />}
</span> accent="amberx"
}
description={bookings.isLoading ? 'Đang tải…' : `${totalThisWeek} booking tuần này`}
actions={ actions={
<Button onClick={openCreateBlank}> <Button onClick={openCreateBlank}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
@ -304,7 +302,10 @@ export function MeetingCalendarPage() {
</div> </div>
{/* Calendar grid */} {/* Calendar grid */}
<div className="overflow-x-auto rounded-lg border border-slate-200 bg-white shadow-sm"> <div
className="card-accent overflow-x-auto"
style={{ ['--accent' as string]: 'var(--color-amberx-500)' }}
>
<div className="min-w-[900px]"> <div className="min-w-[900px]">
{/* Header row: 7 days */} {/* Header row: 7 days */}
<div className="sticky top-0 z-10 grid grid-cols-[60px_repeat(7,minmax(0,1fr))] border-b border-slate-200 bg-slate-50"> <div className="sticky top-0 z-10 grid grid-cols-[60px_repeat(7,minmax(0,1fr))] border-b border-slate-200 bg-slate-50">
@ -526,52 +527,69 @@ export function MeetingCalendarPage() {
> >
{detailBooking && ( {detailBooking && (
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
<div> <div className="flex items-start gap-3">
<h3 className="text-base font-semibold text-slate-900">{detailBooking.title}</h3> <span
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs"> className="icon-chip mt-0.5 shrink-0"
<span className={cn('rounded-full px-2 py-0.5', statusBadgeClass(detailBooking.status))}> style={{ ['--chip-bg' as string]: 'var(--color-amberx-50)', ['--chip-fg' as string]: 'var(--color-amberx-700)' }}
{statusLabel(detailBooking.status)} aria-hidden
</span> >
<span className="text-slate-500"> <CalendarDays className="h-4 w-4" />
Người tạo: <span className="font-medium text-slate-700">{detailBooking.bookedByFullName}</span> </span>
</span> <div className="min-w-0">
<h3 className="text-base font-semibold tracking-tight text-brand-800">{detailBooking.title}</h3>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
<span className={cn('rounded-full px-2 py-0.5', statusBadgeClass(detailBooking.status))}>
{statusLabel(detailBooking.status)}
</span>
<span className="text-slate-500">
Người tạo: <span className="font-medium text-brand-800">{detailBooking.bookedByFullName}</span>
</span>
</div>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-2 rounded-md bg-slate-50 p-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 rounded-xl border border-slate-200 bg-slate-50/70 p-3.5 sm:grid-cols-2">
<div className="flex items-center gap-2 text-slate-700"> <div className="min-w-0">
<MapPin className="h-4 w-4 text-slate-400" /> <div className="label-eyebrow flex items-center gap-1">
<span>{detailBooking.roomCode} {detailBooking.roomName}</span> <MapPin className="h-3 w-3" />
Phòng họp
</div>
<div className="mt-0.5 break-words font-medium text-brand-800">
{detailBooking.roomCode} {detailBooking.roomName}
</div>
</div> </div>
<div className="flex items-center gap-2 text-slate-700"> <div className="min-w-0">
<Clock className="h-4 w-4 text-slate-400" /> <div className="label-eyebrow flex items-center gap-1">
<span> <Clock className="h-3 w-3" />
Thời gian
</div>
<div className="mt-0.5 break-words font-medium text-brand-800">
{new Date(detailBooking.startAt).toLocaleString('vi-VN', { dateStyle: 'short', timeStyle: 'short' })} {new Date(detailBooking.startAt).toLocaleString('vi-VN', { dateStyle: 'short', timeStyle: 'short' })}
{' '} {' '}
{new Date(detailBooking.endAt).toLocaleString('vi-VN', { dateStyle: 'short', timeStyle: 'short' })} {new Date(detailBooking.endAt).toLocaleString('vi-VN', { dateStyle: 'short', timeStyle: 'short' })}
</span> </div>
</div> </div>
</div> </div>
{detailBooking.description && ( {detailBooking.description && (
<div> <div>
<Label className="text-xs text-slate-500"> tả</Label> <div className="label-eyebrow"> tả</div>
<p className="mt-1 whitespace-pre-wrap text-sm text-slate-700">{detailBooking.description}</p> <p className="mt-1 whitespace-pre-wrap text-sm text-slate-700">{detailBooking.description}</p>
</div> </div>
)} )}
{detailBooking.note && ( {detailBooking.note && (
<div> <div>
<Label className="text-xs text-slate-500">Ghi chú</Label> <div className="label-eyebrow">Ghi chú</div>
<p className="mt-1 text-sm text-slate-700">{detailBooking.note}</p> <p className="mt-1 text-sm text-slate-700">{detailBooking.note}</p>
</div> </div>
)} )}
<div> <div>
<Label className="flex items-center gap-1 text-xs text-slate-500"> <div className="label-eyebrow flex items-center gap-1">
<UsersIcon className="h-3.5 w-3.5" /> <UsersIcon className="h-3 w-3" />
Người tham dự ({detailBooking.attendees.length}) Người tham dự ({detailBooking.attendees.length})
</Label> </div>
{detailBooking.attendees.length === 0 ? ( {detailBooking.attendees.length === 0 ? (
<p className="mt-1 text-xs text-slate-400"> Không người tham dự </p> <p className="mt-1 text-xs text-slate-400"> Không người tham dự </p>
) : ( ) : (

View File

@ -4,9 +4,9 @@
// Pattern 16-bis 4-place mirror foundation (7× cumulative). // Pattern 16-bis 4-place mirror foundation (7× cumulative).
import { useMemo, useState, type FormEvent } from 'react' import { useMemo, useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Building2, Pencil, Plus, Search, Trash2 } from 'lucide-react' import { CalendarDays, MapPin, Pencil, Plus, Search, Trash2 } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/ui/PageHeader'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
@ -126,13 +126,11 @@ export function MeetingRoomsPage() {
return ( return (
<div className="p-6"> <div className="p-6">
<PageHeader <PageHeader
title={ eyebrow="Văn phòng số"
<span className="flex items-center gap-2"> title="Phòng họp"
<Building2 className="h-5 w-5" /> subtitle={list.isLoading ? 'Đang tải…' : `${total} phòng họp`}
Phòng họp icon={<CalendarDays className="h-5 w-5" />}
</span> accent="amberx"
}
description={list.isLoading ? 'Đang tải…' : `${total} phòng họp`}
actions={ actions={
<Button onClick={openCreate}> <Button onClick={openCreate}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
@ -164,17 +162,20 @@ export function MeetingRoomsPage() {
</div> </div>
{/* Table */} {/* Table */}
<div className="overflow-x-auto rounded-lg border border-slate-200 bg-white"> <div
className="card-accent overflow-x-auto"
style={{ ['--accent' as string]: 'var(--color-amberx-500)' }}
>
<table className="min-w-full text-sm"> <table className="min-w-full text-sm">
<thead className="bg-slate-50 text-[11px] uppercase tracking-wider text-slate-500"> <thead className="border-b border-slate-100 bg-slate-50/70">
<tr> <tr className="label-eyebrow">
<th className="px-3 py-2 text-left"></th> <th className="px-3 py-2.5 pl-5 text-left"></th>
<th className="px-3 py-2 text-left">Tên</th> <th className="px-3 py-2.5 text-left">Tên</th>
<th className="px-3 py-2 text-left">Sức chứa</th> <th className="px-3 py-2.5 text-left">Sức chứa</th>
<th className="px-3 py-2 text-left">Vị trí</th> <th className="px-3 py-2.5 text-left">Vị trí</th>
<th className="px-3 py-2 text-left">Thiết bị</th> <th className="px-3 py-2.5 text-left">Thiết bị</th>
<th className="px-3 py-2 text-left">Trạng thái</th> <th className="px-3 py-2.5 text-left">Trạng thái</th>
<th className="w-20 px-3 py-2"></th> <th className="w-20 px-3 py-2.5"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-slate-100">
@ -185,25 +186,45 @@ export function MeetingRoomsPage() {
<tr><td colSpan={7} className="p-6 text-center text-slate-400">Chưa phòng họp bấm Thêm đ tạo mới.</td></tr> <tr><td colSpan={7} className="p-6 text-center text-slate-400">Chưa phòng họp bấm Thêm đ tạo mới.</td></tr>
)} )}
{filtered.map(row => ( {filtered.map(row => (
<tr key={row.id} className={cn('hover:bg-slate-50', !row.isActive && 'opacity-60')}> <tr key={row.id} className={cn('transition hover:bg-amberx-50/40', !row.isActive && 'opacity-60')}>
<td className="px-3 py-2 font-mono text-xs">{row.code}</td> <td className="px-3 py-2.5 pl-5 font-mono text-xs text-slate-500">{row.code}</td>
<td className="px-3 py-2 font-medium text-slate-800">{row.name}</td> <td className="px-3 py-2.5">
<td className="px-3 py-2 text-xs text-slate-600">{row.capacity} chỗ</td> <div className="flex items-center gap-2.5">
<td className="px-3 py-2 text-xs text-slate-600">{row.location ?? '—'}</td> <span
<td className="px-3 py-2 text-xs text-slate-600">{row.equipment ?? '—'}</td> className="icon-chip shrink-0"
<td className="px-3 py-2"> style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-amberx-50)', ['--chip-fg' as string]: 'var(--color-amberx-700)' }}
{row.isActive ? ( aria-hidden
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] text-emerald-700">Đang dùng</span> >
<CalendarDays className="h-4 w-4" />
</span>
<span className="font-medium text-brand-800">{row.name}</span>
</div>
</td>
<td className="px-3 py-2.5 text-xs font-medium tabular-nums text-brand-800">{row.capacity} chỗ</td>
<td className="px-3 py-2.5 text-xs text-slate-600">
{row.location ? (
<span className="inline-flex items-center gap-1">
<MapPin className="h-3 w-3 text-slate-400" />
{row.location}
</span>
) : ( ) : (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-500">Đã tắt</span> <span className="text-slate-300"></span>
)} )}
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2.5 text-xs text-slate-600">{row.equipment ?? <span className="text-slate-300"></span>}</td>
<td className="px-3 py-2.5">
{row.isActive ? (
<span className="rounded-full bg-greenx-50 px-2 py-0.5 text-[10px] font-medium text-greenx-700">Đang dùng</span>
) : (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-500">Đã tắt</span>
)}
</td>
<td className="px-3 py-2.5">
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
<button <button
onClick={() => openEdit(row)} onClick={() => openEdit(row)}
title="Sửa" title="Sửa"
className="rounded p-1 text-slate-500 hover:bg-slate-100 hover:text-brand-600" className="rounded-lg p-1.5 text-slate-500 transition hover:bg-brand-50 hover:text-brand-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-1"
> >
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</button> </button>
@ -215,7 +236,7 @@ export function MeetingRoomsPage() {
} }
}} }}
title="Tắt phòng" title="Tắt phòng"
className="rounded p-1 text-slate-500 hover:bg-slate-100 hover:text-red-600" className="rounded-lg p-1.5 text-slate-500 transition hover:bg-red-50 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1 disabled:opacity-50"
disabled={remove.isPending} disabled={remove.isPending}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />

View File

@ -1,12 +1,16 @@
// Tạo Đề xuất mới — Phase 10.3 G-O3 (S37 2026-05-28). // Tạo Đề xuất mới — Phase 10.3 G-O3 (S37 2026-05-28).
// Form Header card: Title + Description + AmountEstimate + Department + ApprovalWorkflow. // Form Header card: Title + Description + AmountEstimate + Department + ApprovalWorkflow.
// File MIRROR SHA256 identical với fe-user counterpart. // File MIRROR SHA256 identical với fe-user counterpart.
//
// Re-skin (S69): PURO/Hồ sơ-NS visual language — ui/PageHeader on top + form
// grouped into .card-accent sections with .icon-chip headers. Logic (state,
// queries, mutation, validation, bindings, submit) UNTOUCHED.
import { useState, type FormEvent } from 'react' import { useState, type FormEvent } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Save, X } from 'lucide-react' import { FileText, Info, Save, Settings2, X } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/ui/PageHeader'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
@ -90,10 +94,13 @@ export function ProposalCreatePage() {
} }
return ( return (
<div className="space-y-4"> <div className="space-y-5">
<PageHeader <PageHeader
eyebrow="Văn phòng số"
title="Tạo Đề xuất mới" title="Tạo Đề xuất mới"
description="Soạn đề xuất → gửi duyệt theo Quy trình admin config" subtitle="Soạn đề xuất → gửi duyệt theo Quy trình admin config"
icon={<FileText className="h-5 w-5" />}
accent="brand"
actions={ actions={
<Button variant="outline" onClick={() => navigate('/proposals')}> <Button variant="outline" onClick={() => navigate('/proposals')}>
<X className="mr-2 h-4 w-4" /> <X className="mr-2 h-4 w-4" />
@ -102,36 +109,62 @@ export function ProposalCreatePage() {
} }
/> />
<form onSubmit={onSubmit} className="space-y-4"> <form onSubmit={onSubmit} className="space-y-5">
<div className="rounded-lg border bg-card p-6 space-y-4"> {/* Section 1: Nội dung đề xuất */}
<div> <section className="card-accent" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
<Label htmlFor="title"> <header className="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
Tiêu đ <span className="text-red-500">*</span> <span
</Label> className="icon-chip"
<Input style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
id="title" aria-hidden
value={title} >
onChange={(e) => setTitle(e.target.value)} <FileText className="h-4 w-4" />
maxLength={300} </span>
placeholder="vd. Đề xuất mua sắm máy tính cho Phòng IT" <h3 className="text-sm font-semibold tracking-tight text-brand-800">Nội dung đ xuất</h3>
required </header>
/> <div className="space-y-4 p-5">
</div> <div>
<Label htmlFor="title">
Tiêu đ <span className="text-red-500">*</span>
</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={300}
placeholder="vd. Đề xuất mua sắm máy tính cho Phòng IT"
required
/>
</div>
<div> <div>
<Label htmlFor="description">Nội dung chi tiết</Label> <Label htmlFor="description">Nội dung chi tiết</Label>
<Textarea <Textarea
id="description" id="description"
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
maxLength={5000} maxLength={5000}
rows={6} rows={6}
placeholder="Mô tả lý do, nội dung, kết quả mong đợi..." placeholder="Mô tả lý do, nội dung, kết quả mong đợi..."
/> />
<div className="text-xs text-muted-foreground mt-1">{description.length}/5000</div> <div className="text-xs text-muted-foreground mt-1">{description.length}/5000</div>
</div>
</div> </div>
</section>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> {/* Section 2: Thông tin liên quan */}
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-teal-500)' }}>
<header className="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
<span
className="icon-chip"
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-teal-50)', ['--chip-fg' as string]: 'var(--color-teal-700)' }}
aria-hidden
>
<Info className="h-4 w-4" />
</span>
<h3 className="text-sm font-semibold tracking-tight text-teal-700">Thông tin liên quan</h3>
</header>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-5">
<div> <div>
<Label htmlFor="amount">Số tiền dự kiến (VND)</Label> <Label htmlFor="amount">Số tiền dự kiến (VND)</Label>
<Input <Input
@ -160,8 +193,21 @@ export function ProposalCreatePage() {
</select> </select>
</div> </div>
</div> </div>
</section>
<div> {/* Section 3: Quy trình duyệt */}
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-violet-500)' }}>
<header className="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
<span
className="icon-chip"
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-violet-50)', ['--chip-fg' as string]: 'var(--color-violet-700)' }}
aria-hidden
>
<Settings2 className="h-4 w-4" />
</span>
<h3 className="text-sm font-semibold tracking-tight text-violet-700">Quy trình duyệt</h3>
</header>
<div className="space-y-2 p-5">
<Label htmlFor="workflow"> <Label htmlFor="workflow">
Quy trình duyệt <span className="text-red-500">*</span> Quy trình duyệt <span className="text-red-500">*</span>
</Label> </Label>
@ -182,7 +228,7 @@ export function ProposalCreatePage() {
Chỉ hiển thị quy trình loại "Đề xuất" admin đã ghim cho user pick Chỉ hiển thị quy trình loại "Đề xuất" admin đã ghim cho user pick
</div> </div>
</div> </div>
</div> </section>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => navigate('/proposals')}> <Button type="button" variant="outline" onClick={() => navigate('/proposals')}>

View File

@ -1,14 +1,19 @@
// Đề xuất chi tiết — Phase 10.3 G-O3 (S37 2026-05-28). // Đề xuất chi tiết — Phase 10.3 G-O3 (S37 2026-05-28).
// 3 Section + WorkflowActions buttons + Ý kiến cấp duyệt V2 dynamic. // 3 Section + WorkflowActions buttons + Ý kiến cấp duyệt V2 dynamic.
// File MIRROR SHA256 identical với fe-user counterpart. // File MIRROR SHA256 identical với fe-user counterpart.
import { useState } from 'react' //
// Re-skin (S69): PURO/Hồ sơ-NS visual language — ui/PageHeader (subject title +
// status badge) on top, info sections wrapped in .card-accent cards with
// .icon-chip headers, field rows in the Hồ sơ-NS Field idiom (label .label-eyebrow
// + value text-brand-800). Logic (queries, mutations, handlers, dialog) UNTOUCHED.
import { useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { import {
ArrowLeft, Ban, CheckCircle2, RotateCcw, Send, ArrowLeft, Ban, CheckCircle2, FileText, MessageSquare, Paperclip, RotateCcw, Send,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/ui/PageHeader'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog' import { Dialog } from '@/components/ui/Dialog'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
@ -39,6 +44,62 @@ const ACTION_LABEL: Record<ActionKind, { text: string; tone: string }> = {
return: { text: 'Trả lại', tone: 'bg-orange-600 hover:bg-orange-700' }, return: { text: 'Trả lại', tone: 'bg-orange-600 hover:bg-orange-700' },
} }
// Section card — .card-accent shell + .icon-chip header (Hồ sơ-NS idiom).
function SectionCard({
title, icon, accent, head, chipBg, chipFg, count, children,
}: {
title: string
icon: ReactNode
accent: string
head: string
chipBg: string
chipFg: string
count?: number
children: ReactNode
}) {
return (
<section className="card-accent" style={{ ['--accent' as string]: accent }}>
<header className="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
<span
className="icon-chip"
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: chipBg, ['--chip-fg' as string]: chipFg }}
aria-hidden
>
{icon}
</span>
<h3 className={cn('text-sm font-semibold tracking-tight', head)}>{title}</h3>
{count != null && (
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
{count}
</span>
)}
</header>
<div className="p-5">{children}</div>
</section>
)
}
// Field — label .label-eyebrow style + value text-brand-800 (Hồ sơ-NS idiom).
function Field({
label, value, mono, full, children,
}: {
label: string
value?: ReactNode
mono?: boolean
full?: boolean
children?: ReactNode
}) {
const empty = value == null || value === ''
return (
<div className={cn('min-w-0 text-sm', full && 'sm:col-span-2')}>
<div className="text-[11px] font-semibold uppercase tracking-wide text-brand-600">{label}</div>
<div className={cn('mt-0.5 break-words text-sm', empty && !children ? 'text-slate-300' : 'font-medium text-brand-800', mono && !empty && 'font-mono')}>
{children ?? (empty ? '—' : value)}
</div>
</div>
)
}
export function ProposalDetailPage() { export function ProposalDetailPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
@ -84,17 +145,19 @@ export function ProposalDetailPage() {
if (proposal.isLoading) { if (proposal.isLoading) {
return ( return (
<div className="space-y-4"> <div className="space-y-5">
<PageHeader title="Đang tải..." /> <PageHeader eyebrow="Văn phòng số" title="Đang tải..." icon={<FileText className="h-5 w-5" />} />
</div> </div>
) )
} }
if (proposal.isError || !proposal.data) { if (proposal.isError || !proposal.data) {
return ( return (
<div className="space-y-4"> <div className="space-y-5">
<PageHeader <PageHeader
eyebrow="Văn phòng số"
title="Lỗi" title="Lỗi"
icon={<FileText className="h-5 w-5" />}
actions={ actions={
<Button variant="outline" onClick={() => navigate('/proposals')}> <Button variant="outline" onClick={() => navigate('/proposals')}>
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
@ -115,15 +178,27 @@ export function ProposalDetailPage() {
const isInWorkflow = status === ProposalStatus.DaGuiDuyet const isInWorkflow = status === ProposalStatus.DaGuiDuyet
return ( return (
<div className="space-y-4"> <div className="space-y-5">
<PageHeader <PageHeader
title={p.maDeXuat ?? '(Chưa có mã)'} eyebrow={p.maDeXuat ?? 'Đề xuất'}
description={p.title} title={p.title}
icon={<FileText className="h-5 w-5" />}
accent="brand"
actions={ actions={
<Button variant="outline" onClick={() => navigate('/proposals')}> <>
<ArrowLeft className="mr-2 h-4 w-4" /> <span
Danh sách className={cn(
</Button> 'inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-medium',
PROPOSAL_STATUS_BADGE[status],
)}
>
{PROPOSAL_STATUS_LABELS[status]}
</span>
<Button variant="outline" onClick={() => navigate('/proposals')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Danh sách
</Button>
</>
} }
/> />
@ -176,61 +251,55 @@ export function ProposalDetailPage() {
</div> </div>
{/* Section 1: Thông tin */} {/* Section 1: Thông tin */}
<div className="rounded-lg border bg-card p-6 space-y-3"> <SectionCard
<h3 className="font-semibold text-base">1. Thông tin đ xuất</h3> title="Thông tin đề xuất"
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm"> icon={<FileText className="h-4 w-4" />}
<div> accent="var(--color-brand-500)"
<Label className="text-muted-foreground">Tiêu đ</Label> head="text-brand-800"
<div className="mt-1 font-medium">{p.title}</div> chipBg="var(--color-brand-50)"
</div> chipFg="var(--color-brand-600)"
<div> >
<Label className="text-muted-foreground">Số tiền dự kiến</Label> <div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
<div className="mt-1 font-medium tabular-nums">{formatVnd(p.amountEstimate)}</div> <Field label="Tiêu đề" value={p.title} />
</div> <Field label="Số tiền dự kiến" value={formatVnd(p.amountEstimate)} mono />
<div> <Field label="Phòng ban" value={p.departmentName ?? '—'} />
<Label className="text-muted-foreground">Phòng ban</Label> <Field label="Người soạn" value={p.drafterFullName ?? '—'} />
<div className="mt-1">{p.departmentName ?? '—'}</div> <Field label="Quy trình">
</div> {p.workflowCode ? (
<div> <span>
<Label className="text-muted-foreground">Người soạn</Label> <span className="font-mono">{p.workflowCode}</span> - {p.workflowName}
<div className="mt-1">{p.drafterFullName ?? '—'}</div> </span>
</div> ) : (
<div> <span className="text-slate-300"> Chưa chọn </span>
<Label className="text-muted-foreground">Quy trình</Label> )}
<div className="mt-1 text-xs"> </Field>
{p.workflowCode ? ( <Field label="Ngày tạo" value={formatDateTime(p.createdAt)} />
<> {p.description && (
<span className="font-mono">{p.workflowCode}</span> - {p.workflowName} <Field label="Nội dung chi tiết" full>
</> <span className="whitespace-pre-wrap font-normal text-slate-700">{p.description}</span>
) : '— Chưa chọn —'} </Field>
</div> )}
</div>
<div>
<Label className="text-muted-foreground">Ngày tạo</Label>
<div className="mt-1 text-xs">{formatDateTime(p.createdAt)}</div>
</div>
</div> </div>
{p.description && ( </SectionCard>
<div className="pt-3 border-t">
<Label className="text-muted-foreground">Nội dung chi tiết</Label>
<div className="mt-1 whitespace-pre-wrap text-sm">{p.description}</div>
</div>
)}
</div>
{/* Section 2: Attachments */} {/* Section 2: Attachments */}
<div className="rounded-lg border bg-card p-6 space-y-3"> <SectionCard
<h3 className="font-semibold text-base"> title="File đính kèm"
2. File đính kèm <span className="text-muted-foreground text-sm">({p.attachments.length})</span> icon={<Paperclip className="h-4 w-4" />}
</h3> accent="var(--color-teal-500)"
head="text-teal-700"
chipBg="var(--color-teal-50)"
chipFg="var(--color-teal-700)"
count={p.attachments.length}
>
{p.attachments.length === 0 ? ( {p.attachments.length === 0 ? (
<div className="text-sm text-muted-foreground">Chưa file đính kèm.</div> <div className="text-sm text-muted-foreground">Chưa file đính kèm.</div>
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2">
{p.attachments.map((a) => ( {p.attachments.map((a) => (
<li key={a.id} className="flex items-center justify-between rounded border p-2 text-sm"> <li key={a.id} className="flex items-center justify-between rounded-lg border border-slate-200 p-2.5 text-sm">
<div> <div>
<div className="font-medium">{a.fileName}</div> <div className="font-medium text-brand-800">{a.fileName}</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{a.uploadedByFullName} · {(a.fileSize / 1024).toFixed(1)} KB {a.uploadedByFullName} · {(a.fileSize / 1024).toFixed(1)} KB
</div> </div>
@ -242,11 +311,18 @@ export function ProposalDetailPage() {
))} ))}
</ul> </ul>
)} )}
</div> </SectionCard>
{/* Section 3: Ý kiến cấp duyệt V2 dynamic */} {/* Section 3: Ý kiến cấp duyệt V2 dynamic */}
<div className="rounded-lg border bg-card p-6 space-y-3"> <SectionCard
<h3 className="font-semibold text-base">3. Ý kiến cấp duyệt</h3> title="Ý kiến cấp duyệt"
icon={<MessageSquare className="h-4 w-4" />}
accent="var(--color-violet-500)"
head="text-violet-700"
chipBg="var(--color-violet-50)"
chipFg="var(--color-violet-700)"
count={p.levelOpinions.length}
>
{p.levelOpinions.length === 0 ? ( {p.levelOpinions.length === 0 ? (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{status === ProposalStatus.Nhap {status === ProposalStatus.Nhap
@ -256,20 +332,20 @@ export function ProposalDetailPage() {
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{p.levelOpinions.map((o) => ( {p.levelOpinions.map((o) => (
<div key={o.id} className="rounded border-l-4 border-emerald-400 bg-emerald-50/40 p-3"> <div key={o.id} className="rounded-lg border-l-4 border-emerald-400 bg-emerald-50/40 p-3">
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="flex items-center justify-between text-xs text-muted-foreground">
<span> <span>
Bước {o.stepOrder} - {o.stepName} · Cấp {o.levelOrder} Bước {o.stepOrder} - {o.stepName} · Cấp {o.levelOrder}
</span> </span>
<span>{formatDateTime(o.signedAt)}</span> <span>{formatDateTime(o.signedAt)}</span>
</div> </div>
<div className="mt-1 font-medium">{o.signedByFullName}</div> <div className="mt-1 font-medium text-brand-800">{o.signedByFullName}</div>
<div className="mt-1 whitespace-pre-wrap text-sm">{o.comment ?? '(duyệt — không ý kiến)'}</div> <div className="mt-1 whitespace-pre-wrap text-sm">{o.comment ?? '(duyệt — không ý kiến)'}</div>
</div> </div>
))} ))}
</div> </div>
)} )}
</div> </SectionCard>
{/* Action confirm dialog */} {/* Action confirm dialog */}
<Dialog <Dialog

View File

@ -1,11 +1,18 @@
// Đề xuất danh sách — Phase 10.3 G-O3 (S37 2026-05-28). // Đề xuất danh sách — Phase 10.3 G-O3 (S37 2026-05-28).
// Table 6 cột với Status badge color + filter status + search. // Re-skin S69 (2026-06-17): PURO layout + Hồ sơ Nhân sự visual language, REUSING
// the shared ui components (PageHeader + KpiCard). The status filter is rendered
// as a ROW of KpiCards (each = one status, value = count, onClick = the EXISTING
// setStatus/setInboxOnly setter). Table + pagination + every data hook unchanged.
// File MIRROR SHA256 identical với fe-user counterpart. // File MIRROR SHA256 identical với fe-user counterpart.
import { useMemo, useState } from 'react' import { useMemo, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { Plus, Search } from 'lucide-react' import {
import { PageHeader } from '@/components/PageHeader' Plus, Search, FileSignature, FileEdit, SendHorizontal,
CheckCircle2, Undo2, XCircle, Layers, Inbox,
} from 'lucide-react'
import { PageHeader } from '@/components/ui/PageHeader'
import { KpiCard } from '@/components/ui/KpiCard'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { api } from '@/lib/api' import { api } from '@/lib/api'
@ -59,23 +66,46 @@ export function ProposalsListPage() {
const total = list.data?.total ?? 0 const total = list.data?.total ?? 0
const totalPages = list.data?.totalPages ?? 1 const totalPages = list.data?.totalPages ?? 1
const statusOptions: Array<{ value: number | null; label: string }> = useMemo( // Presentation-only: counts shown on the KpiCard filter chips. Derived from the
() => [ // currently loaded page (no extra fetch). The active filter's own card shows the
{ value: null, label: 'Tất cả' }, // authoritative server `total`; the others show how many of the loaded rows match
{ value: 1, label: PROPOSAL_STATUS_LABELS[1] }, // — an at-a-glance hint, not a query change.
{ value: 2, label: PROPOSAL_STATUS_LABELS[2] }, const countByStatus = useMemo(() => {
{ value: 3, label: PROPOSAL_STATUS_LABELS[3] }, const acc: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }
{ value: 4, label: PROPOSAL_STATUS_LABELS[4] }, for (const p of items) acc[p.status] = (acc[p.status] ?? 0) + 1
{ value: 5, label: PROPOSAL_STATUS_LABELS[5] }, return acc
], }, [items])
[],
) // Status filter chips (presentation). Mirrors the OLD button row 1:1 — value=null
// is "Tất cả" + the five ProposalStatus values, so every filter stays reachable.
// Accent per status (playbook): amberx = đã gửi/pending, greenx = đã duyệt, violet
// = trả lại/returned, brand = tất cả; nháp + từ chối reuse the nearest tone. The
// inbox toggle below is the teal chip.
// Count is a presentation hint only: the ACTIVE status card shows the server
// `total`; the others show how many of the currently-loaded rows match.
const statusCards: Array<{
value: number | null
label: string
icon: ReactNode
accent: 'brand' | 'teal' | 'violet' | 'amberx' | 'greenx'
count: number
}> = [
{ value: null, label: 'Tất cả', icon: <Layers className="h-4 w-4" />, accent: 'brand', count: status === null ? total : items.length },
{ value: 1, label: PROPOSAL_STATUS_LABELS[1], icon: <FileEdit className="h-4 w-4" />, accent: 'violet', count: status === 1 ? total : countByStatus[1] },
{ value: 2, label: PROPOSAL_STATUS_LABELS[2], icon: <SendHorizontal className="h-4 w-4" />, accent: 'amberx', count: status === 2 ? total : countByStatus[2] },
{ value: 5, label: PROPOSAL_STATUS_LABELS[5], icon: <CheckCircle2 className="h-4 w-4" />, accent: 'greenx', count: status === 5 ? total : countByStatus[5] },
{ value: 3, label: PROPOSAL_STATUS_LABELS[3], icon: <Undo2 className="h-4 w-4" />, accent: 'violet', count: status === 3 ? total : countByStatus[3] },
{ value: 4, label: PROPOSAL_STATUS_LABELS[4], icon: <XCircle className="h-4 w-4" />, accent: 'amberx', count: status === 4 ? total : countByStatus[4] },
]
return ( return (
<div className="space-y-4"> <div className="space-y-5">
<PageHeader <PageHeader
eyebrow="Văn phòng số"
title="Đề xuất" title="Đề xuất"
description="Quản lý đề xuất nội bộ — Workflow V2 dynamic theo Quy trình admin config" subtitle="Quản lý đề xuất nội bộ — Workflow V2 dynamic theo Quy trình admin config"
icon={<FileSignature className="h-5 w-5" />}
accent="brand"
actions={ actions={
<Button onClick={() => navigate('/proposals/new')}> <Button onClick={() => navigate('/proposals/new')}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
@ -84,78 +114,80 @@ export function ProposalsListPage() {
} }
/> />
<div className="rounded-lg border bg-card p-4"> {/* Status filter — row of KpiCards (PURO). Each wires the EXISTING setter,
<div className="flex flex-wrap items-center gap-3"> same semantics as the old button row + inbox checkbox (status and inbox
<div className="flex items-center gap-1"> stay independent filter dimensions). */}
{statusOptions.map((opt) => ( <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-7">
<button {statusCards.map((c) => (
key={opt.value ?? 'all'} <KpiCard
type="button" key={c.value ?? 'all'}
onClick={() => { label={c.label}
setStatus(opt.value) value={c.count}
setPage(1) icon={c.icon}
}} accent={c.accent}
className={cn( active={status === c.value}
'rounded-md border px-3 py-1.5 text-sm transition', onClick={() => {
status === opt.value setStatus(c.value)
? 'border-primary bg-primary/10 text-primary font-medium' setPage(1)
: 'border-input bg-background hover:bg-accent', }}
)} />
> ))}
{opt.label} <KpiCard
</button> label="Cần tôi duyệt"
))} value={items.length}
</div> icon={<Inbox className="h-4 w-4" />}
<label className="flex items-center gap-2 text-sm"> accent="teal"
<input active={inboxOnly}
type="checkbox" onClick={() => {
checked={inboxOnly} setInboxOnly((v) => !v)
onChange={(e) => { setPage(1)
setInboxOnly(e.target.checked) }}
setPage(1) />
}}
className="h-4 w-4"
/>
Inbox duyệt
</label>
<div className="ml-auto flex items-center gap-2">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
placeholder="Tìm mã hoặc tiêu đề..."
className="w-64"
/>
</div>
</div>
</div> </div>
<div className="rounded-lg border bg-card"> <div className="card-accent flex items-center gap-3 px-4 py-3" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
<Search className="h-4 w-4 shrink-0 text-slate-400" />
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
placeholder="Tìm mã hoặc tiêu đề..."
className="max-w-md border-0 bg-transparent px-0 shadow-none focus-visible:ring-0"
/>
</div>
<div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="border-b bg-muted/50"> <thead className="border-b border-slate-200 bg-slate-50/70">
<tr> <tr>
<th className="px-4 py-2 text-left font-medium"></th> <th className="label-eyebrow px-4 py-2.5 text-left"></th>
<th className="px-4 py-2 text-left font-medium">Tiêu đ</th> <th className="label-eyebrow px-4 py-2.5 text-left">Tiêu đ</th>
<th className="px-4 py-2 text-left font-medium">Trạng thái</th> <th className="label-eyebrow px-4 py-2.5 text-left">Trạng thái</th>
<th className="px-4 py-2 text-right font-medium">Số tiền dự kiến</th> <th className="label-eyebrow px-4 py-2.5 text-right">Số tiền dự kiến</th>
<th className="px-4 py-2 text-left font-medium">Người soạn</th> <th className="label-eyebrow px-4 py-2.5 text-left">Người soạn</th>
<th className="px-4 py-2 text-left font-medium">Ngày tạo</th> <th className="label-eyebrow px-4 py-2.5 text-left">Ngày tạo</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{list.isLoading && ( {list.isLoading && (
<tr> <tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground"> <td colSpan={6} className="px-4 py-8 text-center text-slate-500">
Đang tải... Đang tải...
</td> </td>
</tr> </tr>
)} )}
{!list.isLoading && items.length === 0 && ( {!list.isLoading && items.length === 0 && (
<tr> <tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground"> <td colSpan={6} className="px-4 py-10 text-center text-slate-500">
<span
className="icon-chip mx-auto mb-2 flex"
style={{ ['--chip-bg' as string]: '#f1f5f9', ['--chip-fg' as string]: '#94a3b8' }}
aria-hidden
>
<Inbox className="h-4 w-4" />
</span>
Chưa đ xuất nào. Chưa đ xuất nào.
</td> </td>
</tr> </tr>
@ -164,11 +196,22 @@ export function ProposalsListPage() {
<tr <tr
key={p.id} key={p.id}
onClick={() => navigate(`/proposals/${p.id}`)} onClick={() => navigate(`/proposals/${p.id}`)}
className="cursor-pointer border-b transition hover:bg-accent/50" className="cursor-pointer border-b border-slate-100 transition last:border-0 hover:bg-brand-50/50"
> >
<td className="px-4 py-2 font-mono text-xs">{p.maDeXuat ?? '—'}</td> <td className="px-4 py-2.5">
<td className="px-4 py-2 max-w-md truncate">{p.title}</td> <span className="inline-flex items-center gap-2">
<td className="px-4 py-2"> <span
className="icon-chip h-7! w-7!"
style={{ ['--chip-bg' as string]: 'var(--color-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
aria-hidden
>
<FileSignature className="h-3.5 w-3.5" />
</span>
<span className="font-mono text-xs text-brand-800">{p.maDeXuat ?? '—'}</span>
</span>
</td>
<td className="max-w-md truncate px-4 py-2.5 font-medium text-brand-800">{p.title}</td>
<td className="px-4 py-2.5">
<span <span
className={cn( className={cn(
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium', 'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium',
@ -178,19 +221,19 @@ export function ProposalsListPage() {
{PROPOSAL_STATUS_LABELS[p.status as ProposalStatusValue]} {PROPOSAL_STATUS_LABELS[p.status as ProposalStatusValue]}
</span> </span>
{p.currentApprovalLevelOrder && ( {p.currentApprovalLevelOrder && (
<span className="ml-2 text-xs text-muted-foreground">Cấp {p.currentApprovalLevelOrder}</span> <span className="ml-2 text-xs text-slate-500">Cấp {p.currentApprovalLevelOrder}</span>
)} )}
</td> </td>
<td className="px-4 py-2 text-right tabular-nums">{formatVnd(p.amountEstimate)}</td> <td className="px-4 py-2.5 text-right tabular-nums text-brand-800">{formatVnd(p.amountEstimate)}</td>
<td className="px-4 py-2 text-xs">{p.drafterFullName ?? '—'}</td> <td className="px-4 py-2.5 text-xs text-slate-600">{p.drafterFullName ?? '—'}</td>
<td className="px-4 py-2 text-xs">{formatDate(p.createdAt)}</td> <td className="px-4 py-2.5 text-xs text-slate-600">{formatDate(p.createdAt)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between border-t px-4 py-2 text-sm"> <div className="flex items-center justify-between border-t border-slate-200 px-4 py-2.5 text-sm">
<div className="text-muted-foreground"> <div className="text-slate-500">
{total} đ xuất Trang {page} / {totalPages} {total} đ xuất Trang {page} / {totalPages}
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">

View File

@ -2,15 +2,19 @@
// Declarative KIND_CONFIG Record<Kind> mirror WorkflowAppsListPage — 4 module // Declarative KIND_CONFIG Record<Kind> mirror WorkflowAppsListPage — 4 module
// leave / ot / travel / vehicle. Workflow status + Ý kiến cấp duyệt timeline + // leave / ot / travel / vehicle. Workflow status + Ý kiến cấp duyệt timeline +
// Submit/Approve/Reject/Return actions (mirror ProposalDetailPage cấu trúc). // Submit/Approve/Reject/Return actions (mirror ProposalDetailPage cấu trúc).
// Re-skin S69 (2026-06-17): PURO chrome (ui/PageHeader teal) + Hồ sơ Nhân sự visual
// language — accent-rail Card sections + Field idiom + status badge. ALL data logic
// (queries / mutations / state / handlers / endpoints) preserved verbatim.
// File MIRROR SHA256 identical với fe-user counterpart. // File MIRROR SHA256 identical với fe-user counterpart.
import { useState } from 'react' import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { import {
ArrowLeft, Ban, CalendarOff, Car, CheckCircle2, Clock, Plane, RotateCcw, Send, ArrowLeft, Ban, CalendarOff, Car, CheckCircle2, Clock, GitBranch, Info,
MessageSquareText, Plane, RotateCcw, Send, Wallet,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/ui/PageHeader'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog' import { Dialog } from '@/components/ui/Dialog'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
@ -49,6 +53,71 @@ const ACTION_LABEL: Record<ActionKind, { text: string; tone: string }> = {
return: { text: 'Trả lại', tone: 'bg-orange-600 hover:bg-orange-700' }, return: { text: 'Trả lại', tone: 'bg-orange-600 hover:bg-orange-700' },
} }
// ===== Visual language (Hồ sơ Nhân sự idiom): accent map + Card (rail) + Field =====
// Accent palettes (teal/violet/amberx/greenx) ship stops 50/100/500/600/700 ONLY — no
// -800 — so headings/labels use -700 (brand uses -700 here too; brand-800 reserved for
// values). A non-existent stop silently emits no class in Tailwind v4.
type Accent = 'brand' | 'teal' | 'violet' | 'amberx' | 'greenx'
const ACCENT: Record<Accent, { chipBg: string; chipFg: string; head: string; rail: string; labelText: string }> = {
brand: { chipBg: 'var(--color-brand-50)', chipFg: 'var(--color-brand-600)', head: 'text-brand-700', rail: 'before:bg-brand-500', labelText: 'text-brand-700' },
teal: { chipBg: 'var(--color-teal-50)', chipFg: 'var(--color-teal-700)', head: 'text-teal-700', rail: 'before:bg-teal-500', labelText: 'text-teal-700' },
violet: { chipBg: 'var(--color-violet-50)', chipFg: 'var(--color-violet-700)', head: 'text-violet-700', rail: 'before:bg-violet-500', labelText: 'text-violet-700' },
amberx: { chipBg: 'var(--color-amberx-50)', chipFg: 'var(--color-amberx-700)', head: 'text-amberx-700', rail: 'before:bg-amberx-500', labelText: 'text-amberx-700' },
greenx: { chipBg: 'var(--color-greenx-50)', chipFg: 'var(--color-greenx-700)', head: 'text-greenx-700', rail: 'before:bg-greenx-500', labelText: 'text-greenx-700' },
}
function Card({ title, icon: Icon, action, accent = 'brand', children }: {
title: string
icon: typeof Info
action?: React.ReactNode
accent?: Accent
children: React.ReactNode
}) {
const a = ACCENT[accent]
return (
<section
className={cn(
'relative overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm',
"before:absolute before:inset-y-0 before:left-0 before:w-1 before:content-['']", a.rail,
)}
>
<header className="flex items-center justify-between gap-2 border-b border-slate-100 px-4 py-2.5 pl-5">
<div className="flex items-center gap-2">
<span
className="icon-chip"
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: a.chipBg, ['--chip-fg' as string]: a.chipFg }}
>
<Icon className="h-4 w-4" />
</span>
<h3 className={cn('text-sm font-semibold tracking-tight', a.head)}>{title}</h3>
</div>
{action}
</header>
<div className="p-4 pl-5">{children}</div>
</section>
)
}
// Field — nhãn uppercase accent-tint, value đậm rõ. Empty = dấu —.
function Field({ label, value, mono, full, accent = 'brand' }: {
label: string
value: React.ReactNode
mono?: boolean
full?: boolean
accent?: Accent
}) {
const empty = value == null || value === '' || value === '—'
return (
<div className={cn('min-w-0 text-sm', full && 'sm:col-span-2')}>
<div className={cn('text-[11px] font-semibold uppercase tracking-wide', ACCENT[accent].labelText)}>{label}</div>
<div className={cn('mt-0.5 whitespace-pre-wrap break-words text-sm', empty ? 'text-slate-300' : 'font-medium text-brand-800', mono && !empty && 'font-mono')}>
{empty ? '—' : value}
</div>
</div>
)
}
const KIND_CONFIG: Record<Kind, { const KIND_CONFIG: Record<Kind, {
title: string title: string
endpoint: string endpoint: string
@ -204,17 +273,19 @@ export function WorkflowAppDetailPage() {
if (detail.isLoading) { if (detail.isLoading) {
return ( return (
<div className="space-y-4"> <div className="space-y-5">
<PageHeader title="Đang tải..." /> <PageHeader eyebrow="Văn phòng số · Đơn từ" title="Đang tải..." accent="teal" />
</div> </div>
) )
} }
if (detail.isError || !d) { if (detail.isError || !d) {
return ( return (
<div className="space-y-4"> <div className="space-y-5">
<PageHeader <PageHeader
eyebrow="Văn phòng số · Đơn từ"
title="Lỗi" title="Lỗi"
accent="teal"
actions={ actions={
<Button variant="outline" onClick={() => navigate(`/workflow-apps/${kind}`)}> <Button variant="outline" onClick={() => navigate(`/workflow-apps/${kind}`)}>
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
@ -222,7 +293,7 @@ export function WorkflowAppDetailPage() {
</Button> </Button>
} }
/> />
<div className="rounded-lg border bg-red-50 p-4 text-sm text-red-800"> <div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
Không tải đưc dữ liệu đơn từ. Không tải đưc dữ liệu đơn từ.
</div> </div>
</div> </div>
@ -232,10 +303,13 @@ export function WorkflowAppDetailPage() {
const Icon = config.icon const Icon = config.icon
return ( return (
<div className="space-y-4"> <div className="space-y-5">
<PageHeader <PageHeader
eyebrow="Văn phòng số · Đơn từ"
title={d.maDonTu ?? '(Chưa có mã)'} title={d.maDonTu ?? '(Chưa có mã)'}
description={config.title} subtitle={config.title}
icon={<Icon className="h-5 w-5" />}
accent="teal"
actions={ actions={
<Button variant="outline" onClick={() => navigate(`/workflow-apps/${kind}`)}> <Button variant="outline" onClick={() => navigate(`/workflow-apps/${kind}`)}>
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
@ -245,8 +319,8 @@ export function WorkflowAppDetailPage() {
/> />
{/* Status row + action buttons */} {/* Status row + action buttons */}
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-card p-4"> <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<span <span
className={cn( className={cn(
'inline-flex items-center rounded-md border px-3 py-1 text-sm font-medium', 'inline-flex items-center rounded-md border px-3 py-1 text-sm font-medium',
@ -256,13 +330,13 @@ export function WorkflowAppDetailPage() {
{WORKFLOW_APP_STATUS_LABELS[d.status]} {WORKFLOW_APP_STATUS_LABELS[d.status]}
</span> </span>
{d.currentApprovalLevelOrder != null && ( {d.currentApprovalLevelOrder != null && (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-slate-500">
Cấp hiện tại: <span className="font-semibold">{d.currentApprovalLevelOrder}</span> Cấp hiện tại: <span className="font-semibold text-brand-800">{d.currentApprovalLevelOrder}</span>
</span> </span>
)} )}
{d.workflowCode && ( {d.workflowCode && (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-slate-500">
Quy trình: <span className="font-mono">{d.workflowCode}</span> Quy trình: <span className="font-mono text-brand-800">{d.workflowCode}</span>
</span> </span>
)} )}
</div> </div>
@ -270,7 +344,7 @@ export function WorkflowAppDetailPage() {
{isDraft && !hasWorkflow && ( {isDraft && !hasWorkflow && (
<> <>
<select <select
className="h-9 rounded-md border bg-background px-2 text-sm" className="h-9 rounded-md border border-slate-300 bg-white px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-500"
value={pickedWorkflowId} value={pickedWorkflowId}
onChange={(e) => setPickedWorkflowId(e.target.value)} onChange={(e) => setPickedWorkflowId(e.target.value)}
> >
@ -320,30 +394,20 @@ export function WorkflowAppDetailPage() {
</div> </div>
{isDraft && !hasWorkflow && ( {isDraft && !hasWorkflow && (
<div className="rounded-lg border bg-amber-50/50 p-3 text-sm text-amber-900"> <div className="rounded-xl border border-amber-200 bg-amberx-50 p-3 text-sm text-amberx-700">
Đơn chưa gắn quy trình duyệt. Vui lòng chọn quy trình rồi bấm <strong>Lưu quy trình</strong> trước khi gửi duyệt. Đơn chưa gắn quy trình duyệt. Vui lòng chọn quy trình rồi bấm <strong>Lưu quy trình</strong> trước khi gửi duyệt.
</div> </div>
)} )}
{/* Section 1: Thông tin */} {/* Section 1: Thông tin */}
<div className="rounded-lg border bg-card p-6 space-y-3"> <Card title="Thông tin đơn từ" icon={Icon} accent="teal">
<h3 className="flex items-center gap-2 font-semibold text-base"> <div className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
<Icon className="h-4 w-4 opacity-70" />
1. Thông tin
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
{config.detailFields.map((f) => ( {config.detailFields.map((f) => (
<div key={f.label}> <Field key={f.label} label={f.label} value={f.render(d)} accent="teal" />
<Label className="text-muted-foreground">{f.label}</Label>
<div className="mt-1 font-medium whitespace-pre-wrap">{f.render(d)}</div>
</div>
))} ))}
<div> <Field label="Ngày tạo" value={formatDateTime(d.createdAt)} accent="teal" />
<Label className="text-muted-foreground">Ngày tạo</Label>
<div className="mt-1 text-xs">{formatDateTime(d.createdAt)}</div>
</div>
</div> </div>
</div> </Card>
{/* Số dư phép (chỉ kind=leave) — Wave 2 hiển thị balance đã embed trong detail */} {/* Số dư phép (chỉ kind=leave) — Wave 2 hiển thị balance đã embed trong detail */}
{kind === 'leave' && d.leaveBalanceRemaining != null && (() => { {kind === 'leave' && d.leaveBalanceRemaining != null && (() => {
@ -353,51 +417,39 @@ export function WorkflowAppDetailPage() {
const isApproved = d.status === WorkflowAppStatus.DaDuyet const isApproved = d.status === WorkflowAppStatus.DaDuyet
const overBudget = remaining < 0 || (!isApproved && remaining < numDays) const overBudget = remaining < 0 || (!isApproved && remaining < numDays)
return ( return (
<div className="rounded-lg border bg-card p-6 space-y-3"> <Card title="Số dư phép" icon={Wallet} accent="greenx">
<h3 className="font-semibold text-base">Số phép</h3> <div className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-3">
<div className="text-sm"> <Field label={`Được hưởng (${year})`} value={d.leaveBalanceEntitled ?? '—'} accent="greenx" />
Số phép năm <span className="font-semibold">{year}</span>:{' '} <Field label="Đã dùng" value={d.leaveBalanceUsed ?? '—'} accent="greenx" />
Đưc hưởng <span className="font-medium">{d.leaveBalanceEntitled ?? '—'}</span> ·{' '} <Field label="Còn lại" value={`${remaining} ngày`} accent="greenx" />
Đã dùng <span className="font-medium">{d.leaveBalanceUsed ?? '—'}</span> ·{' '}
<span className="font-semibold">Còn {remaining}</span> ngày
</div> </div>
{overBudget && ( {overBudget && (
<div className="rounded-lg border border-red-300 bg-amber-50/50 p-3 text-sm font-medium text-amber-900"> <div className="mt-3 rounded-lg border border-red-300 bg-amberx-50 p-3 text-sm font-medium text-amberx-700">
{remaining < 0 {remaining < 0
? '⚠️ Đã âm số dư phép' ? 'Đã âm số dư phép'
: `⚠️ Đơn ${numDays} ngày vượt số dư còn lại (${remaining} ngày)`} : `Đơn ${numDays} ngày vượt số dư còn lại (${remaining} ngày)`}
</div> </div>
)} )}
</div> </Card>
) )
})()} })()}
{/* Section 2: Quy trình duyệt */} {/* Section 2: Quy trình duyệt */}
<div className="rounded-lg border bg-card p-6 space-y-3"> <Card title="Quy trình duyệt" icon={GitBranch} accent="violet">
<h3 className="font-semibold text-base">2. Quy trình duyệt</h3> <div className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm"> <Field
<div> label="Quy trình"
<Label className="text-muted-foreground">Quy trình</Label> value={d.workflowCode ? `${d.workflowCode} - ${d.workflowName}` : '— Chưa chọn —'}
<div className="mt-1 text-xs"> accent="violet"
{d.workflowCode ? ( />
<> <Field label="Cấp hiện tại" value={d.currentApprovalLevelOrder ?? '—'} accent="violet" />
<span className="font-mono">{d.workflowCode}</span> - {d.workflowName}
</>
) : '— Chưa chọn —'}
</div>
</div>
<div>
<Label className="text-muted-foreground">Cấp hiện tại</Label>
<div className="mt-1 font-medium">{d.currentApprovalLevelOrder ?? '—'}</div>
</div>
</div> </div>
</div> </Card>
{/* Section 3: Ý kiến cấp duyệt V2 dynamic */} {/* Section 3: Ý kiến cấp duyệt V2 dynamic */}
<div className="rounded-lg border bg-card p-6 space-y-3"> <Card title="Ý kiến cấp duyệt" icon={MessageSquareText} accent="brand">
<h3 className="font-semibold text-base">3. Ý kiến cấp duyệt</h3>
{d.levelOpinions.length === 0 ? ( {d.levelOpinions.length === 0 ? (
<div className="text-sm text-muted-foreground">Chưa ý kiến.</div> <div className="text-sm text-slate-400">Chưa ý kiến.</div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{[...d.levelOpinions] {[...d.levelOpinions]
@ -405,20 +457,20 @@ export function WorkflowAppDetailPage() {
(a.stepOrder ?? 0) - (b.stepOrder ?? 0) || (a.stepOrder ?? 0) - (b.stepOrder ?? 0) ||
(a.levelOrder ?? 0) - (b.levelOrder ?? 0)) (a.levelOrder ?? 0) - (b.levelOrder ?? 0))
.map((o) => ( .map((o) => (
<div key={o.id} className="rounded border-l-4 border-emerald-400 bg-emerald-50/40 p-3"> <div key={o.id} className="rounded-lg border border-slate-200 border-l-4 border-l-greenx-500 bg-greenx-50/40 p-3">
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="flex items-center justify-between text-xs text-slate-500">
<span> <span>
Bước {o.stepOrder} {o.stepName} · Cấp {o.levelOrder} Bước {o.stepOrder} {o.stepName} · Cấp {o.levelOrder}
</span> </span>
<span>{formatDateTime(o.signedAt)}</span> <span>{formatDateTime(o.signedAt)}</span>
</div> </div>
<div className="mt-1 font-medium">{o.signedByFullName}</div> <div className="mt-1 font-semibold text-brand-800">{o.signedByFullName}</div>
<div className="mt-1 whitespace-pre-wrap text-sm">{o.comment ?? '(duyệt — không ý kiến)'}</div> <div className="mt-1 whitespace-pre-wrap text-sm text-slate-700">{o.comment ?? '(duyệt — không ý kiến)'}</div>
</div> </div>
))} ))}
</div> </div>
)} )}
</div> </Card>
{/* Action confirm dialog */} {/* Action confirm dialog */}
<Dialog <Dialog
@ -439,7 +491,7 @@ export function WorkflowAppDetailPage() {
placeholder="Để trống nếu không có ý kiến..." placeholder="Để trống nếu không có ý kiến..."
maxLength={2000} maxLength={2000}
/> />
<div className="text-xs text-muted-foreground">{comment.length}/2000</div> <div className="text-xs text-slate-400">{comment.length}/2000</div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setActionDialog(null)}> <Button variant="outline" onClick={() => setActionDialog(null)}>
Huỷ Huỷ

View File

@ -1,15 +1,20 @@
// Generic Workflow Apps List Page — Phase 10.3 G-O4+G-O5+G-O6 (S38 2026-05-28). // Generic Workflow Apps List Page — Phase 10.3 G-O4+G-O5+G-O6 (S38 2026-05-28).
// Wave 3a (S42 2026-05-30): row click → Detail page (workflow actions + opinion timeline). // Wave 3a (S42 2026-05-30): row click → Detail page (workflow actions + opinion timeline).
// Re-skin S69 (2026-06-17): PURO layout (ui/PageHeader teal + KpiCard status-filter row)
// + Hồ sơ Nhân sự visual language (accent rail card, slate table chrome). Status filter is
// a CLIENT-SIDE view over the already-fetched items (no query/endpoint/navigation change).
// Handles 4 module via URL `:kind` param: leave / ot / travel / vehicle. // Handles 4 module via URL `:kind` param: leave / ot / travel / vehicle.
// File MIRROR SHA256 identical fe-user counterpart. // File MIRROR SHA256 identical fe-user counterpart.
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { CalendarOff, Clock, Plane, Car, FileSignature } from 'lucide-react' import { CalendarOff, Clock, Plane, Car, FileSignature, Layers, Send, RotateCcw, CheckCircle2 } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/ui/PageHeader'
import { KpiCard } from '@/components/ui/KpiCard'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import { import {
WORKFLOW_APP_STATUS_BADGE, WORKFLOW_APP_STATUS_LABELS, WORKFLOW_APP_STATUS_BADGE, WORKFLOW_APP_STATUS_LABELS, WorkflowAppStatus,
type PagedResult, type PagedResult,
} from '@/types/workflowApps' } from '@/types/workflowApps'
@ -78,12 +83,18 @@ const ICON_MAP: Record<Kind, any> = {
leave: CalendarOff, ot: Clock, travel: Plane, vehicle: Car, leave: CalendarOff, ot: Clock, travel: Plane, vehicle: Car,
} }
// Status filter chips (presentation): null = Tất cả. Each maps to a KpiCard accent.
type StatusFilter = number | null
export function WorkflowAppsListPage() { export function WorkflowAppsListPage() {
const { kind = 'leave' } = useParams<{ kind: Kind }>() const { kind = 'leave' } = useParams<{ kind: Kind }>()
const navigate = useNavigate() const navigate = useNavigate()
const config = KIND_CONFIG[kind as Kind] const config = KIND_CONFIG[kind as Kind]
const Icon = ICON_MAP[kind as Kind] ?? FileSignature const Icon = ICON_MAP[kind as Kind] ?? FileSignature
// Client-side status filter — a view over the fetched list (no extra query / endpoint).
const [statusFilter, setStatusFilter] = useState<StatusFilter>(null)
const list = useQuery({ const list = useQuery({
queryKey: [config.endpoint, { page: 1 }], queryKey: [config.endpoint, { page: 1 }],
queryFn: async () => (await api.get<PagedResult<any>>(config.endpoint, { params: { page: 1, pageSize: 50 } })).data, queryFn: async () => (await api.get<PagedResult<any>>(config.endpoint, { params: { page: 1, pageSize: 50 } })).data,
@ -92,50 +103,108 @@ export function WorkflowAppsListPage() {
const items = list.data?.items ?? [] const items = list.data?.items ?? []
// Counts per status + the visible (filtered) rows — derived presentation only.
const counts = useMemo(() => {
const c = { all: items.length, submitted: 0, returned: 0, approved: 0 }
for (const it of items) {
if (it.status === WorkflowAppStatus.DaGuiDuyet) c.submitted++
else if (it.status === WorkflowAppStatus.TraLai) c.returned++
else if (it.status === WorkflowAppStatus.DaDuyet) c.approved++
}
return c
}, [items])
const visibleItems = useMemo(
() => (statusFilter == null ? items : items.filter((it: any) => it.status === statusFilter)),
[items, statusFilter],
)
if (!config) { if (!config) {
return <div className="text-red-600">Module không tồn tại: {kind}</div> return <div className="text-red-600">Module không tồn tại: {kind}</div>
} }
return ( return (
<div className="space-y-4"> <div className="space-y-5">
<PageHeader title={config.title} description={config.description} /> <PageHeader
eyebrow="Văn phòng số · Đơn từ"
title={config.title}
subtitle={config.description}
icon={<Icon className="h-5 w-5" />}
accent="teal"
/>
<div className="rounded-lg border bg-card"> {/* Status filter — row of KpiCards (PURO pattern, replaces tabs) */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<KpiCard
label="Tất cả"
value={counts.all}
icon={<Layers className="h-4 w-4" />}
accent="teal"
active={statusFilter == null}
onClick={() => setStatusFilter(null)}
/>
<KpiCard
label="Đã gửi duyệt"
value={counts.submitted}
icon={<Send className="h-4 w-4" />}
accent="amberx"
active={statusFilter === WorkflowAppStatus.DaGuiDuyet}
onClick={() => setStatusFilter(WorkflowAppStatus.DaGuiDuyet)}
/>
<KpiCard
label="Trả lại"
value={counts.returned}
icon={<RotateCcw className="h-4 w-4" />}
accent="violet"
active={statusFilter === WorkflowAppStatus.TraLai}
onClick={() => setStatusFilter(WorkflowAppStatus.TraLai)}
/>
<KpiCard
label="Đã duyệt"
value={counts.approved}
icon={<CheckCircle2 className="h-4 w-4" />}
accent="greenx"
active={statusFilter === WorkflowAppStatus.DaDuyet}
onClick={() => setStatusFilter(WorkflowAppStatus.DaDuyet)}
/>
</div>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="border-b bg-muted/50"> <thead className="border-b border-slate-200 bg-slate-50">
<tr> <tr>
{config.columns.map((c) => ( {config.columns.map((c) => (
<th key={c.key} className="px-4 py-2 text-left font-medium">{c.label}</th> <th key={c.key} className="px-4 py-2.5 text-left text-[11px] font-semibold uppercase tracking-wide text-slate-500">{c.label}</th>
))} ))}
<th className="px-4 py-2 text-left font-medium">Trạng thái</th> <th className="px-4 py-2.5 text-left text-[11px] font-semibold uppercase tracking-wide text-slate-500">Trạng thái</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{list.isLoading && ( {list.isLoading && (
<tr> <tr>
<td colSpan={config.columns.length + 1} className="px-4 py-8 text-center text-muted-foreground"> <td colSpan={config.columns.length + 1} className="px-4 py-10 text-center text-slate-400">
Đang tải... Đang tải...
</td> </td>
</tr> </tr>
)} )}
{!list.isLoading && items.length === 0 && ( {!list.isLoading && visibleItems.length === 0 && (
<tr> <tr>
<td colSpan={config.columns.length + 1} className="px-4 py-8 text-center text-muted-foreground"> <td colSpan={config.columns.length + 1} className="px-4 py-12 text-center text-slate-400">
<Icon className="mx-auto h-8 w-8 mb-2 opacity-50" /> <Icon className="mx-auto mb-2 h-8 w-8 opacity-40" />
Chưa dữ liệu. {items.length === 0 ? 'Chưa có dữ liệu.' : 'Không có đơn nào ở trạng thái này.'}
</td> </td>
</tr> </tr>
)} )}
{items.map((item: any) => ( {visibleItems.map((item: any) => (
<tr <tr
key={item.id} key={item.id}
className="border-b cursor-pointer hover:bg-muted/40" className="cursor-pointer border-b border-slate-100 transition hover:bg-teal-50/50"
onClick={() => navigate(`/workflow-apps/${kind}/${item.id}`)} onClick={() => navigate(`/workflow-apps/${kind}/${item.id}`)}
> >
{config.columns.map((c) => ( {config.columns.map((c) => (
<td key={c.key} className="px-4 py-2">{c.render(item)}</td> <td key={c.key} className="px-4 py-2.5 text-slate-700">{c.render(item)}</td>
))} ))}
<td className="px-4 py-2"> <td className="px-4 py-2.5">
<span <span
className={cn( className={cn(
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium', 'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium',

View File

@ -1,12 +1,19 @@
// Danh bạ nội bộ (Internal Directory) — Phase 10.2 G-O1 (S34 2026-05-27). // Danh bạ nội bộ (Internal Directory) — Phase 10.2 G-O1 (S34 2026-05-27).
// Card grid responsive, filter search + department, avatar fallback gradient theo // Re-skin S69 (2026-06-17): PURO layout + ngôn ngữ thị giác Hồ sơ Nhân sự —
// userId hash stable. File này MIRROR SHA256 identical với fe-admin counterpart. // ui/PageHeader (eyebrow "Văn phòng số" + icon Contact + accent brand, search/
// Reuse BE GET /api/directory readonly (DirectoryFeatures.cs). // filter dồn vào actions slot) · KpiCard hàng tóm tắt (tổng người / số phòng ban,
// inert vì không phải filter-status) · card người dùng .icon-chip cho avatar +
// tên text-brand-800 + .label-eyebrow cho phòng ban + viền card sạch.
// GIỮ brand #1F7DC1 + Be Vietnam Pro.
// KHÔNG đổi logic — 100% chức năng giữ: 2 query (departments-all-directory /
// directory) NGUYÊN, search box + Select phòng ban, URL params (q, deptId),
// avatar gradient hash theo userId, mọi mailto/tel/handler giữ nguyên.
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { Mail, Phone, Search, UserCircle2, Users } from 'lucide-react' import { Building2, Contact, Mail, Phone, Search, UserCircle2, Users } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/ui/PageHeader'
import { KpiCard } from '@/components/ui/KpiCard'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
@ -87,43 +94,64 @@ export function InternalDirectoryPage() {
} }
const total = list.data?.length ?? 0 const total = list.data?.length ?? 0
const deptCount = departments.data?.length ?? 0
return ( return (
<div className="p-6"> <div className="p-6">
{/* PURO header: eyebrow + icon-chip brand + search/filter trong actions slot */}
<PageHeader <PageHeader
eyebrow="Văn phòng số"
title="Danh bạ nội bộ" title="Danh bạ nội bộ"
description={list.isLoading ? 'Đang tải...' : `${total} nhân viên`} subtitle={list.isLoading ? 'Đang tải...' : `${total} nhân viên`}
icon={<Contact className="h-5 w-5" />}
accent="brand"
actions={
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<form onSubmit={applySearch} className="relative">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input
value={localSearch}
onChange={e => setLocalSearch(e.target.value)}
onBlur={() => setParam('q', localSearch.trim() || null)}
placeholder="Tìm tên / email / SĐT / mã NV..."
className="pl-8 sm:w-72"
/>
</form>
<Select
value={departmentId}
onChange={e => setParam('deptId', e.target.value || null)}
className="sm:w-56"
>
<option value="">Tất cả phòng ban</option>
{(departments.data ?? []).map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</Select>
</div>
}
/> />
{/* Filter bar sticky top */} {/* Hàng tóm tắt — counts có sẵn từ data (inert, KHÔNG phải filter trạng thái) */}
<div className="sticky top-0 z-10 mb-4 flex flex-col gap-2 rounded-lg border border-slate-200 bg-white/95 p-3 shadow-sm backdrop-blur sm:flex-row sm:items-center"> <div className="mb-5 grid grid-cols-2 gap-3 sm:max-w-md">
<form onSubmit={applySearch} className="relative flex-1"> <KpiCard
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" /> label="Tổng nhân viên"
<Input value={total}
value={localSearch} icon={<Users className="h-4 w-4" />}
onChange={e => setLocalSearch(e.target.value)} accent="brand"
onBlur={() => setParam('q', localSearch.trim() || null)} />
placeholder="Tìm tên / email / SĐT / mã NV..." <KpiCard
className="pl-8" label="Số phòng ban"
/> value={deptCount}
</form> icon={<Building2 className="h-4 w-4" />}
<Select accent="teal"
value={departmentId} />
onChange={e => setParam('deptId', e.target.value || null)}
className="sm:w-64"
>
<option value="">Tất cả phòng ban</option>
{(departments.data ?? []).map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</Select>
</div> </div>
{/* Card grid */} {/* Card grid */}
{list.isLoading ? ( {list.isLoading ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => ( {Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-44 animate-pulse rounded-lg border border-slate-200 bg-slate-100" /> <div key={i} className="h-44 animate-pulse rounded-xl border border-slate-200 bg-slate-100" />
))} ))}
</div> </div>
) : total === 0 ? ( ) : total === 0 ? (
@ -145,7 +173,7 @@ export function InternalDirectoryPage() {
function DirectoryCard({ item }: { item: DirectoryItem }) { function DirectoryCard({ item }: { item: DirectoryItem }) {
return ( return (
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm transition hover:shadow-md"> <div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md motion-reduce:transform-none">
{/* Top row: avatar + name + code */} {/* Top row: avatar + name + code */}
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{item.photoUrl ? ( {item.photoUrl ? (
@ -166,7 +194,7 @@ function DirectoryCard({ item }: { item: DirectoryItem }) {
)} )}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1.5"> <div className="flex items-baseline gap-1.5">
<h3 className="truncate text-sm font-semibold text-slate-900" title={item.fullName}> <h3 className="truncate text-sm font-semibold text-brand-800" title={item.fullName}>
{item.fullName} {item.fullName}
</h3> </h3>
{item.employeeCode && ( {item.employeeCode && (
@ -181,8 +209,9 @@ function DirectoryCard({ item }: { item: DirectoryItem }) {
</p> </p>
)} )}
{item.departmentName && ( {item.departmentName && (
<span className="mt-1 inline-flex rounded bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-900"> <span className="label-eyebrow mt-1.5 inline-flex max-w-full items-center gap-1 truncate">
{item.departmentName} <Building2 className="h-3 w-3 shrink-0" />
<span className="truncate">{item.departmentName}</span>
</span> </span>
)} )}
</div> </div>
@ -225,7 +254,7 @@ function DirectoryCard({ item }: { item: DirectoryItem }) {
{item.internalPhone && ( {item.internalPhone && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<UserCircle2 className="h-3.5 w-3.5 shrink-0 text-slate-400" /> <UserCircle2 className="h-3.5 w-3.5 shrink-0 text-slate-400" />
<span className="inline-flex rounded bg-amber-100 px-1.5 py-0.5 font-mono text-[10px] font-medium text-amber-900"> <span className="inline-flex rounded bg-amberx-50 px-1.5 py-0.5 font-mono text-[10px] font-medium text-amberx-700">
Ext: {item.internalPhone} Ext: {item.internalPhone}
</span> </span>
</div> </div>

View File

@ -1,11 +1,19 @@
// Ticket CNTT — Phase 10.3 G-O6 (S38) + P11-D auto-assign round-robin + SLA timer (S52). // Ticket CNTT — Phase 10.3 G-O6 (S38) + P11-D auto-assign round-robin + SLA timer (S52).
// Read-only kanban list + MaTicket + người xử lý (auto-assign dept IT) + SLA badge (đỏ khi quá hạn). // Read-only kanban list + MaTicket + người xử lý (auto-assign dept IT) + SLA badge (đỏ khi quá hạn).
// S54: reassign cho Admin HOẶC tổ IT (BE capability /assignable-staff). fe-user MIRROR cùng logic. // S54: reassign cho Admin HOẶC tổ IT (BE capability /assignable-staff). fe-user MIRROR cùng logic.
// S69 re-skin: PURO chrome + Hồ sơ NS visual language (PageHeader ui + KpiCard filter-row + card-accent).
// KHÔNG đổi logic — mọi query/mutation/endpoint/handler/state giữ NGUYÊN:
// list ['it-tickets'] · staffQ ['it-tickets','assignable-staff'] · reassign PUT /it-tickets/{id}/assign
// · canReassign · staff · grouped · formatSlaDue · Dialog. statusKey/breached chỉ LỌC HIỂN THỊ
// client-side (presentation), KHÔNG gọi API mới, KHÔNG sửa cách fetch.
import { useState } from 'react' import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Pencil, Ticket } from 'lucide-react' import {
Pencil, Ticket, Inbox, Loader2, CheckCircle2, Archive, AlarmClockOff, User,
} from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/ui/PageHeader'
import { KpiCard, type Accent } from '@/components/ui/KpiCard'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog' import { Dialog } from '@/components/ui/Dialog'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
@ -23,6 +31,36 @@ function formatSlaDue(iso: string): string {
}) })
} }
// ── Presentation-only filter chips (PURO KpiCard row) ─────────────────────────
// 'all' = mặc định hiện mọi cột; statusKey = chỉ hiện 1 trạng thái; 'breached' =
// chỉ hiện ticket quá hạn SLA. Đây là LỌC HIỂN THỊ client-side trên items đã fetch
// — KHÔNG đổi query, KHÔNG gọi BE. Accent map mỗi trạng thái 1 tone Hồ sơ NS.
type StatusChip = 1 | 2 | 3 | 4
type FilterKey = 'all' | StatusChip | 'breached'
const STATUS_CHIPS: { key: StatusChip; icon: typeof Ticket; accent: Accent }[] = [
{ key: 1, icon: Inbox, accent: 'violet' }, // Mới
{ key: 2, icon: Loader2, accent: 'brand' }, // Đang xử lý
{ key: 3, icon: CheckCircle2, accent: 'greenx' }, // Đã giải quyết
{ key: 4, icon: Archive, accent: 'teal' }, // Đã đóng
]
// Kanban column order (giữ nguyên thứ tự gốc 1,2,3,5,4) + tone cột để tô header.
const COLUMN_ACCENT: Record<number, Accent> = {
1: 'violet', 2: 'brand', 3: 'greenx', 5: 'amberx', 4: 'teal',
}
const COLUMN_RAIL: Record<Accent, string> = {
brand: 'var(--color-brand-500)',
teal: 'var(--color-teal-500)',
violet: 'var(--color-violet-500)',
amberx: 'var(--color-amberx-500)',
greenx: 'var(--color-greenx-500)',
}
const COLUMN_HEAD: Record<Accent, string> = {
brand: 'text-brand-700', teal: 'text-teal-700', violet: 'text-violet-700',
amberx: 'text-amberx-700', greenx: 'text-greenx-700',
}
export function ItTicketsPage() { export function ItTicketsPage() {
const qc = useQueryClient() const qc = useQueryClient()
const list = useQuery({ const list = useQuery({
@ -34,6 +72,9 @@ export function ItTicketsPage() {
const [target, setTarget] = useState<ItTicketDto | null>(null) const [target, setTarget] = useState<ItTicketDto | null>(null)
const [pick, setPick] = useState('') const [pick, setPick] = useState('')
// Presentation-only: chip lọc hiển thị (KHÔNG ảnh hưởng fetch).
const [filter, setFilter] = useState<FilterKey>('all')
// BE capability: /assignable-staff trả { canReassign, staff } — canReassign quyết hiện nút // BE capability: /assignable-staff trả { canReassign, staff } — canReassign quyết hiện nút
// trên MỌI card, nên fetch on mount (KHÔNG gate enabled theo dialog). User thường → canReassign=false, staff=[]. // trên MỌI card, nên fetch on mount (KHÔNG gate enabled theo dialog). User thường → canReassign=false, staff=[].
const staffQ = useQuery({ const staffQ = useQuery({
@ -72,73 +113,136 @@ export function ItTicketsPage() {
if (grouped[t.status]) grouped[t.status].push(t) if (grouped[t.status]) grouped[t.status].push(t)
}) })
return ( // Presentation-only derive: số ticket quá hạn SLA (cho KpiCard "Quá hạn SLA").
<div className="space-y-4"> const breachedCount = items.filter(t => t.slaBreached).length
<PageHeader title="Ticket CNTT" description="Helpdesk — báo lỗi và yêu cầu hỗ trợ kỹ thuật" />
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3"> // Cột nào hiển thị theo chip lọc (presentation). 'all' = mọi cột; statusKey = 1 cột;
{[1, 2, 3, 5, 4].map((statusKey) => ( // 'breached' = mọi cột nhưng chỉ giữ ticket quá hạn (cardMatch lọc bên trong).
<div key={statusKey} className="rounded-lg border bg-card p-3"> const ORDER = [1, 2, 3, 5, 4]
<h3 className="font-medium text-sm mb-2"> const visibleColumns =
{IT_TICKET_STATUS_LABELS[statusKey]} <span className="text-xs text-muted-foreground">({grouped[statusKey].length})</span> filter === 'all' || filter === 'breached'
</h3> ? ORDER
<div className="space-y-2"> : ORDER.filter(s => s === filter)
{list.isLoading && <div className="text-xs text-muted-foreground">Đang tải...</div>} const cardMatch = (t: ItTicketDto) => (filter === 'breached' ? t.slaBreached : true)
{!list.isLoading && grouped[statusKey].length === 0 && (
<div className="text-xs text-muted-foreground italic">Trống</div> return (
)} <div className="space-y-5">
{grouped[statusKey].map((t) => ( <PageHeader
<div key={t.id} className="rounded border p-2 text-xs space-y-1 bg-background"> eyebrow="Văn phòng số"
<div className="flex items-center justify-between"> title="Ticket CNTT"
<span className="font-mono text-[10px] text-muted-foreground">{t.maTicket ?? '—'}</span> subtitle="Helpdesk — báo lỗi và yêu cầu hỗ trợ kỹ thuật"
<span className={cn('rounded px-1.5 py-0.5 text-[10px]', IT_TICKET_PRIORITY_BADGE[t.priority])}> icon={<Ticket className="h-5 w-5" />}
{IT_TICKET_PRIORITY_LABELS[t.priority]} accent="violet"
</span> />
</div>
<div className="font-medium truncate">{t.title}</div> {/* ── KpiCard filter-row (PURO) — thay status tabs. value = count, onClick =
<div className="text-muted-foreground"> set filter hiển thị. Bấm lại chip đang chọn → bỏ lọc (về 'all'). ── */}
{IT_TICKET_CATEGORY_LABELS[t.category]} · {t.requesterFullName} <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
</div> {STATUS_CHIPS.map(({ key, icon: Icon, accent }) => (
<div className="flex items-center justify-between gap-1 pt-0.5"> <KpiCard
<span className="flex items-center gap-1 min-w-0 text-muted-foreground"> key={key}
<span className="truncate" title={t.assignedToFullName ?? undefined}> label={IT_TICKET_STATUS_LABELS[key]}
👤 {t.assignedToFullName ?? <span className="italic">Chưa giao</span>} value={grouped[key].length}
</span> icon={<Icon className="h-4 w-4" />}
{/* Reassign Admin OR tổ IT (BE capability canReassign). Nút nhỏ cạnh người xử lý → mở Dialog gán lại. */} accent={accent}
{canReassign && ( active={filter === key}
<button onClick={() => setFilter(prev => (prev === key ? 'all' : key))}
type="button" />
onClick={() => openDialog(t)}
className="shrink-0 rounded p-0.5 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
title="Đổi người xử lý"
>
<Pencil className="h-3 w-3" />
</button>
)}
</span>
{t.slaDueAt && (
<span
className={cn(
'rounded px-1.5 py-0.5 text-[10px] whitespace-nowrap',
t.slaBreached ? 'bg-red-100 text-red-700 font-medium' : 'bg-slate-100 text-slate-600',
)}
title={`Hạn xử lý SLA: ${formatSlaDue(t.slaDueAt)}`}
>
{t.slaBreached ? 'Quá hạn SLA' : `SLA ${formatSlaDue(t.slaDueAt)}`}
</span>
)}
</div>
</div>
))}
</div>
</div>
))} ))}
<KpiCard
label="Quá hạn SLA"
value={breachedCount}
icon={<AlarmClockOff className="h-4 w-4" />}
accent="amberx"
active={filter === 'breached'}
onClick={() => setFilter(prev => (prev === 'breached' ? 'all' : 'breached'))}
/>
</div>
{/* ── Kanban columns — Hồ sơ NS chrome: card-accent rail + header tinted ── */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-5">
{visibleColumns.map((statusKey) => {
const accent = COLUMN_ACCENT[statusKey]
const cards = grouped[statusKey].filter(cardMatch)
return (
<section
key={statusKey}
className="card-accent flex min-w-0 flex-col overflow-hidden"
style={{ ['--accent' as string]: COLUMN_RAIL[accent] }}
>
<header className="flex items-center justify-between gap-2 border-b border-slate-100 px-3.5 py-2.5 pl-4">
<h3 className={cn('text-sm font-semibold tracking-tight', COLUMN_HEAD[accent])}>
{IT_TICKET_STATUS_LABELS[statusKey]}
</h3>
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
{cards.length}
</span>
</header>
<div className="space-y-2 p-3 pl-4">
{list.isLoading && <div className="text-xs text-slate-400">Đang tải...</div>}
{!list.isLoading && cards.length === 0 && (
<div className="py-4 text-center text-xs italic text-slate-400">Trống</div>
)}
{cards.map((t) => (
<div key={t.id} className="space-y-1 rounded-lg border border-slate-200 bg-white p-2.5 text-xs shadow-sm transition hover:border-slate-300 hover:shadow">
<div className="flex items-center justify-between">
<span className="font-mono text-[10px] text-slate-400">{t.maTicket ?? '—'}</span>
<span className={cn('rounded px-1.5 py-0.5 text-[10px] font-medium', IT_TICKET_PRIORITY_BADGE[t.priority])}>
{IT_TICKET_PRIORITY_LABELS[t.priority]}
</span>
</div>
<div className="truncate font-semibold text-slate-900">{t.title}</div>
<div className="text-slate-500">
{IT_TICKET_CATEGORY_LABELS[t.category]} · {t.requesterFullName}
</div>
<div className="flex items-center justify-between gap-1 pt-0.5">
<span className="flex min-w-0 items-center gap-1 text-slate-500">
<span className="flex min-w-0 items-center gap-1 truncate" title={t.assignedToFullName ?? undefined}>
<User className="h-3 w-3 shrink-0 text-slate-400" />
{t.assignedToFullName ?? <span className="italic">Chưa giao</span>}
</span>
{/* Reassign Admin OR tổ IT (BE capability canReassign). Nút nhỏ cạnh người xử lý → mở Dialog gán lại. */}
{canReassign && (
<button
type="button"
onClick={() => openDialog(t)}
className="shrink-0 rounded p-0.5 text-slate-400 transition hover:bg-violet-50 hover:text-violet-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500"
title="Đổi người xử lý"
>
<Pencil className="h-3 w-3" />
</button>
)}
</span>
{t.slaDueAt && (
<span
className={cn(
'whitespace-nowrap rounded px-1.5 py-0.5 text-[10px]',
t.slaBreached ? 'bg-red-100 font-medium text-red-700' : 'bg-slate-100 text-slate-600',
)}
title={`Hạn xử lý SLA: ${formatSlaDue(t.slaDueAt)}`}
>
{t.slaBreached ? 'Quá hạn SLA' : `SLA ${formatSlaDue(t.slaDueAt)}`}
</span>
)}
</div>
</div>
))}
</div>
</section>
)
})}
</div> </div>
{!list.isLoading && items.length === 0 && ( {!list.isLoading && items.length === 0 && (
<div className="rounded-lg border bg-card p-8 text-center text-muted-foreground"> <div className="card-accent p-8 text-center text-slate-500" style={{ ['--accent' as string]: COLUMN_RAIL.violet }}>
<Ticket className="mx-auto h-10 w-10 mb-3 opacity-50" /> <span
Chưa ticket nào. className="icon-chip mx-auto mb-3"
style={{ ['--chip-bg' as string]: 'var(--color-violet-50)', ['--chip-fg' as string]: 'var(--color-violet-700)' }}
aria-hidden
>
<Ticket className="h-5 w-5" />
</span>
<p className="text-sm">Chưa ticket nào.</p>
</div> </div>
)} )}
@ -162,18 +266,18 @@ export function ItTicketsPage() {
> >
{target && ( {target && (
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
<div className="text-xs text-muted-foreground"> <div className="rounded-lg bg-slate-50 px-3 py-2 text-xs text-slate-500">
Ticket <span className="font-mono">{target.maTicket ?? '—'}</span> · {target.title} Ticket <span className="font-mono text-slate-700">{target.maTicket ?? '—'}</span> · {target.title}
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-xs font-medium text-slate-700">Người xử </label> <label className="text-[11px] font-semibold uppercase tracking-wide text-violet-700">Người xử </label>
<Select value={pick} onChange={e => setPick(e.target.value)} disabled={staffQ.isLoading}> <Select value={pick} onChange={e => setPick(e.target.value)} disabled={staffQ.isLoading}>
<option value=""> Chọn người xử </option> <option value=""> Chọn người xử </option>
{staff.map(u => ( {staff.map(u => (
<option key={u.id} value={u.id}>{u.fullName}</option> <option key={u.id} value={u.id}>{u.fullName}</option>
))} ))}
</Select> </Select>
{staffQ.isLoading && <div className="text-xs text-muted-foreground">Đang tải danh sách</div>} {staffQ.isLoading && <div className="text-xs text-slate-400">Đang tải danh sách</div>}
</div> </div>
</div> </div>
)} )}

View File

@ -6,9 +6,9 @@
// Pattern 16-bis 4-place mirror foundation (7× cumulative). // Pattern 16-bis 4-place mirror foundation (7× cumulative).
import { useMemo, useState, type FormEvent } from 'react' import { useMemo, useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Clock, MapPin, Plus, Trash2, Users as UsersIcon, X } from 'lucide-react' import { ChevronLeft, ChevronRight, CalendarDays, Clock, MapPin, Plus, Trash2, Users as UsersIcon, X } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/ui/PageHeader'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
@ -259,13 +259,11 @@ export function MeetingCalendarPage() {
return ( return (
<div className="p-6"> <div className="p-6">
<PageHeader <PageHeader
title={ eyebrow="Văn phòng số"
<span className="flex items-center gap-2"> title="Lịch phòng họp"
<CalendarIcon className="h-5 w-5" /> subtitle={bookings.isLoading ? 'Đang tải…' : `${totalThisWeek} booking tuần này`}
Đt phòng họp icon={<CalendarDays className="h-5 w-5" />}
</span> accent="amberx"
}
description={bookings.isLoading ? 'Đang tải…' : `${totalThisWeek} booking tuần này`}
actions={ actions={
<Button onClick={openCreateBlank}> <Button onClick={openCreateBlank}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
@ -304,7 +302,10 @@ export function MeetingCalendarPage() {
</div> </div>
{/* Calendar grid */} {/* Calendar grid */}
<div className="overflow-x-auto rounded-lg border border-slate-200 bg-white shadow-sm"> <div
className="card-accent overflow-x-auto"
style={{ ['--accent' as string]: 'var(--color-amberx-500)' }}
>
<div className="min-w-[900px]"> <div className="min-w-[900px]">
{/* Header row: 7 days */} {/* Header row: 7 days */}
<div className="sticky top-0 z-10 grid grid-cols-[60px_repeat(7,minmax(0,1fr))] border-b border-slate-200 bg-slate-50"> <div className="sticky top-0 z-10 grid grid-cols-[60px_repeat(7,minmax(0,1fr))] border-b border-slate-200 bg-slate-50">
@ -526,52 +527,69 @@ export function MeetingCalendarPage() {
> >
{detailBooking && ( {detailBooking && (
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
<div> <div className="flex items-start gap-3">
<h3 className="text-base font-semibold text-slate-900">{detailBooking.title}</h3> <span
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs"> className="icon-chip mt-0.5 shrink-0"
<span className={cn('rounded-full px-2 py-0.5', statusBadgeClass(detailBooking.status))}> style={{ ['--chip-bg' as string]: 'var(--color-amberx-50)', ['--chip-fg' as string]: 'var(--color-amberx-700)' }}
{statusLabel(detailBooking.status)} aria-hidden
</span> >
<span className="text-slate-500"> <CalendarDays className="h-4 w-4" />
Người tạo: <span className="font-medium text-slate-700">{detailBooking.bookedByFullName}</span> </span>
</span> <div className="min-w-0">
<h3 className="text-base font-semibold tracking-tight text-brand-800">{detailBooking.title}</h3>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
<span className={cn('rounded-full px-2 py-0.5', statusBadgeClass(detailBooking.status))}>
{statusLabel(detailBooking.status)}
</span>
<span className="text-slate-500">
Người tạo: <span className="font-medium text-brand-800">{detailBooking.bookedByFullName}</span>
</span>
</div>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-2 rounded-md bg-slate-50 p-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 rounded-xl border border-slate-200 bg-slate-50/70 p-3.5 sm:grid-cols-2">
<div className="flex items-center gap-2 text-slate-700"> <div className="min-w-0">
<MapPin className="h-4 w-4 text-slate-400" /> <div className="label-eyebrow flex items-center gap-1">
<span>{detailBooking.roomCode} {detailBooking.roomName}</span> <MapPin className="h-3 w-3" />
Phòng họp
</div>
<div className="mt-0.5 break-words font-medium text-brand-800">
{detailBooking.roomCode} {detailBooking.roomName}
</div>
</div> </div>
<div className="flex items-center gap-2 text-slate-700"> <div className="min-w-0">
<Clock className="h-4 w-4 text-slate-400" /> <div className="label-eyebrow flex items-center gap-1">
<span> <Clock className="h-3 w-3" />
Thời gian
</div>
<div className="mt-0.5 break-words font-medium text-brand-800">
{new Date(detailBooking.startAt).toLocaleString('vi-VN', { dateStyle: 'short', timeStyle: 'short' })} {new Date(detailBooking.startAt).toLocaleString('vi-VN', { dateStyle: 'short', timeStyle: 'short' })}
{' '} {' '}
{new Date(detailBooking.endAt).toLocaleString('vi-VN', { dateStyle: 'short', timeStyle: 'short' })} {new Date(detailBooking.endAt).toLocaleString('vi-VN', { dateStyle: 'short', timeStyle: 'short' })}
</span> </div>
</div> </div>
</div> </div>
{detailBooking.description && ( {detailBooking.description && (
<div> <div>
<Label className="text-xs text-slate-500"> tả</Label> <div className="label-eyebrow"> tả</div>
<p className="mt-1 whitespace-pre-wrap text-sm text-slate-700">{detailBooking.description}</p> <p className="mt-1 whitespace-pre-wrap text-sm text-slate-700">{detailBooking.description}</p>
</div> </div>
)} )}
{detailBooking.note && ( {detailBooking.note && (
<div> <div>
<Label className="text-xs text-slate-500">Ghi chú</Label> <div className="label-eyebrow">Ghi chú</div>
<p className="mt-1 text-sm text-slate-700">{detailBooking.note}</p> <p className="mt-1 text-sm text-slate-700">{detailBooking.note}</p>
</div> </div>
)} )}
<div> <div>
<Label className="flex items-center gap-1 text-xs text-slate-500"> <div className="label-eyebrow flex items-center gap-1">
<UsersIcon className="h-3.5 w-3.5" /> <UsersIcon className="h-3 w-3" />
Người tham dự ({detailBooking.attendees.length}) Người tham dự ({detailBooking.attendees.length})
</Label> </div>
{detailBooking.attendees.length === 0 ? ( {detailBooking.attendees.length === 0 ? (
<p className="mt-1 text-xs text-slate-400"> Không người tham dự </p> <p className="mt-1 text-xs text-slate-400"> Không người tham dự </p>
) : ( ) : (

View File

@ -4,9 +4,9 @@
// Pattern 16-bis 4-place mirror foundation (7× cumulative). // Pattern 16-bis 4-place mirror foundation (7× cumulative).
import { useMemo, useState, type FormEvent } from 'react' import { useMemo, useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Building2, Pencil, Plus, Search, Trash2 } from 'lucide-react' import { CalendarDays, MapPin, Pencil, Plus, Search, Trash2 } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/ui/PageHeader'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
@ -126,13 +126,11 @@ export function MeetingRoomsPage() {
return ( return (
<div className="p-6"> <div className="p-6">
<PageHeader <PageHeader
title={ eyebrow="Văn phòng số"
<span className="flex items-center gap-2"> title="Phòng họp"
<Building2 className="h-5 w-5" /> subtitle={list.isLoading ? 'Đang tải…' : `${total} phòng họp`}
Phòng họp icon={<CalendarDays className="h-5 w-5" />}
</span> accent="amberx"
}
description={list.isLoading ? 'Đang tải…' : `${total} phòng họp`}
actions={ actions={
<Button onClick={openCreate}> <Button onClick={openCreate}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
@ -164,17 +162,20 @@ export function MeetingRoomsPage() {
</div> </div>
{/* Table */} {/* Table */}
<div className="overflow-x-auto rounded-lg border border-slate-200 bg-white"> <div
className="card-accent overflow-x-auto"
style={{ ['--accent' as string]: 'var(--color-amberx-500)' }}
>
<table className="min-w-full text-sm"> <table className="min-w-full text-sm">
<thead className="bg-slate-50 text-[11px] uppercase tracking-wider text-slate-500"> <thead className="border-b border-slate-100 bg-slate-50/70">
<tr> <tr className="label-eyebrow">
<th className="px-3 py-2 text-left"></th> <th className="px-3 py-2.5 pl-5 text-left"></th>
<th className="px-3 py-2 text-left">Tên</th> <th className="px-3 py-2.5 text-left">Tên</th>
<th className="px-3 py-2 text-left">Sức chứa</th> <th className="px-3 py-2.5 text-left">Sức chứa</th>
<th className="px-3 py-2 text-left">Vị trí</th> <th className="px-3 py-2.5 text-left">Vị trí</th>
<th className="px-3 py-2 text-left">Thiết bị</th> <th className="px-3 py-2.5 text-left">Thiết bị</th>
<th className="px-3 py-2 text-left">Trạng thái</th> <th className="px-3 py-2.5 text-left">Trạng thái</th>
<th className="w-20 px-3 py-2"></th> <th className="w-20 px-3 py-2.5"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-slate-100">
@ -185,25 +186,45 @@ export function MeetingRoomsPage() {
<tr><td colSpan={7} className="p-6 text-center text-slate-400">Chưa phòng họp bấm Thêm đ tạo mới.</td></tr> <tr><td colSpan={7} className="p-6 text-center text-slate-400">Chưa phòng họp bấm Thêm đ tạo mới.</td></tr>
)} )}
{filtered.map(row => ( {filtered.map(row => (
<tr key={row.id} className={cn('hover:bg-slate-50', !row.isActive && 'opacity-60')}> <tr key={row.id} className={cn('transition hover:bg-amberx-50/40', !row.isActive && 'opacity-60')}>
<td className="px-3 py-2 font-mono text-xs">{row.code}</td> <td className="px-3 py-2.5 pl-5 font-mono text-xs text-slate-500">{row.code}</td>
<td className="px-3 py-2 font-medium text-slate-800">{row.name}</td> <td className="px-3 py-2.5">
<td className="px-3 py-2 text-xs text-slate-600">{row.capacity} chỗ</td> <div className="flex items-center gap-2.5">
<td className="px-3 py-2 text-xs text-slate-600">{row.location ?? '—'}</td> <span
<td className="px-3 py-2 text-xs text-slate-600">{row.equipment ?? '—'}</td> className="icon-chip shrink-0"
<td className="px-3 py-2"> style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-amberx-50)', ['--chip-fg' as string]: 'var(--color-amberx-700)' }}
{row.isActive ? ( aria-hidden
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] text-emerald-700">Đang dùng</span> >
<CalendarDays className="h-4 w-4" />
</span>
<span className="font-medium text-brand-800">{row.name}</span>
</div>
</td>
<td className="px-3 py-2.5 text-xs font-medium tabular-nums text-brand-800">{row.capacity} chỗ</td>
<td className="px-3 py-2.5 text-xs text-slate-600">
{row.location ? (
<span className="inline-flex items-center gap-1">
<MapPin className="h-3 w-3 text-slate-400" />
{row.location}
</span>
) : ( ) : (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-500">Đã tắt</span> <span className="text-slate-300"></span>
)} )}
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2.5 text-xs text-slate-600">{row.equipment ?? <span className="text-slate-300"></span>}</td>
<td className="px-3 py-2.5">
{row.isActive ? (
<span className="rounded-full bg-greenx-50 px-2 py-0.5 text-[10px] font-medium text-greenx-700">Đang dùng</span>
) : (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-500">Đã tắt</span>
)}
</td>
<td className="px-3 py-2.5">
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
<button <button
onClick={() => openEdit(row)} onClick={() => openEdit(row)}
title="Sửa" title="Sửa"
className="rounded p-1 text-slate-500 hover:bg-slate-100 hover:text-brand-600" className="rounded-lg p-1.5 text-slate-500 transition hover:bg-brand-50 hover:text-brand-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-1"
> >
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</button> </button>
@ -215,7 +236,7 @@ export function MeetingRoomsPage() {
} }
}} }}
title="Tắt phòng" title="Tắt phòng"
className="rounded p-1 text-slate-500 hover:bg-slate-100 hover:text-red-600" className="rounded-lg p-1.5 text-slate-500 transition hover:bg-red-50 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1 disabled:opacity-50"
disabled={remove.isPending} disabled={remove.isPending}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />

View File

@ -1,12 +1,16 @@
// Tạo Đề xuất mới — Phase 10.3 G-O3 (S37 2026-05-28). // Tạo Đề xuất mới — Phase 10.3 G-O3 (S37 2026-05-28).
// Form Header card: Title + Description + AmountEstimate + Department + ApprovalWorkflow. // Form Header card: Title + Description + AmountEstimate + Department + ApprovalWorkflow.
// File MIRROR SHA256 identical với fe-user counterpart. // File MIRROR SHA256 identical với fe-user counterpart.
//
// Re-skin (S69): PURO/Hồ sơ-NS visual language — ui/PageHeader on top + form
// grouped into .card-accent sections with .icon-chip headers. Logic (state,
// queries, mutation, validation, bindings, submit) UNTOUCHED.
import { useState, type FormEvent } from 'react' import { useState, type FormEvent } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Save, X } from 'lucide-react' import { FileText, Info, Save, Settings2, X } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/ui/PageHeader'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
@ -90,10 +94,13 @@ export function ProposalCreatePage() {
} }
return ( return (
<div className="space-y-4"> <div className="space-y-5">
<PageHeader <PageHeader
eyebrow="Văn phòng số"
title="Tạo Đề xuất mới" title="Tạo Đề xuất mới"
description="Soạn đề xuất → gửi duyệt theo Quy trình admin config" subtitle="Soạn đề xuất → gửi duyệt theo Quy trình admin config"
icon={<FileText className="h-5 w-5" />}
accent="brand"
actions={ actions={
<Button variant="outline" onClick={() => navigate('/proposals')}> <Button variant="outline" onClick={() => navigate('/proposals')}>
<X className="mr-2 h-4 w-4" /> <X className="mr-2 h-4 w-4" />
@ -102,36 +109,62 @@ export function ProposalCreatePage() {
} }
/> />
<form onSubmit={onSubmit} className="space-y-4"> <form onSubmit={onSubmit} className="space-y-5">
<div className="rounded-lg border bg-card p-6 space-y-4"> {/* Section 1: Nội dung đề xuất */}
<div> <section className="card-accent" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
<Label htmlFor="title"> <header className="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
Tiêu đ <span className="text-red-500">*</span> <span
</Label> className="icon-chip"
<Input style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
id="title" aria-hidden
value={title} >
onChange={(e) => setTitle(e.target.value)} <FileText className="h-4 w-4" />
maxLength={300} </span>
placeholder="vd. Đề xuất mua sắm máy tính cho Phòng IT" <h3 className="text-sm font-semibold tracking-tight text-brand-800">Nội dung đ xuất</h3>
required </header>
/> <div className="space-y-4 p-5">
</div> <div>
<Label htmlFor="title">
Tiêu đ <span className="text-red-500">*</span>
</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={300}
placeholder="vd. Đề xuất mua sắm máy tính cho Phòng IT"
required
/>
</div>
<div> <div>
<Label htmlFor="description">Nội dung chi tiết</Label> <Label htmlFor="description">Nội dung chi tiết</Label>
<Textarea <Textarea
id="description" id="description"
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
maxLength={5000} maxLength={5000}
rows={6} rows={6}
placeholder="Mô tả lý do, nội dung, kết quả mong đợi..." placeholder="Mô tả lý do, nội dung, kết quả mong đợi..."
/> />
<div className="text-xs text-muted-foreground mt-1">{description.length}/5000</div> <div className="text-xs text-muted-foreground mt-1">{description.length}/5000</div>
</div>
</div> </div>
</section>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> {/* Section 2: Thông tin liên quan */}
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-teal-500)' }}>
<header className="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
<span
className="icon-chip"
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-teal-50)', ['--chip-fg' as string]: 'var(--color-teal-700)' }}
aria-hidden
>
<Info className="h-4 w-4" />
</span>
<h3 className="text-sm font-semibold tracking-tight text-teal-700">Thông tin liên quan</h3>
</header>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-5">
<div> <div>
<Label htmlFor="amount">Số tiền dự kiến (VND)</Label> <Label htmlFor="amount">Số tiền dự kiến (VND)</Label>
<Input <Input
@ -160,8 +193,21 @@ export function ProposalCreatePage() {
</select> </select>
</div> </div>
</div> </div>
</section>
<div> {/* Section 3: Quy trình duyệt */}
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-violet-500)' }}>
<header className="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
<span
className="icon-chip"
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-violet-50)', ['--chip-fg' as string]: 'var(--color-violet-700)' }}
aria-hidden
>
<Settings2 className="h-4 w-4" />
</span>
<h3 className="text-sm font-semibold tracking-tight text-violet-700">Quy trình duyệt</h3>
</header>
<div className="space-y-2 p-5">
<Label htmlFor="workflow"> <Label htmlFor="workflow">
Quy trình duyệt <span className="text-red-500">*</span> Quy trình duyệt <span className="text-red-500">*</span>
</Label> </Label>
@ -182,7 +228,7 @@ export function ProposalCreatePage() {
Chỉ hiển thị quy trình loại "Đề xuất" admin đã ghim cho user pick Chỉ hiển thị quy trình loại "Đề xuất" admin đã ghim cho user pick
</div> </div>
</div> </div>
</div> </section>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => navigate('/proposals')}> <Button type="button" variant="outline" onClick={() => navigate('/proposals')}>

View File

@ -1,14 +1,19 @@
// Đề xuất chi tiết — Phase 10.3 G-O3 (S37 2026-05-28). // Đề xuất chi tiết — Phase 10.3 G-O3 (S37 2026-05-28).
// 3 Section + WorkflowActions buttons + Ý kiến cấp duyệt V2 dynamic. // 3 Section + WorkflowActions buttons + Ý kiến cấp duyệt V2 dynamic.
// File MIRROR SHA256 identical với fe-user counterpart. // File MIRROR SHA256 identical với fe-user counterpart.
import { useState } from 'react' //
// Re-skin (S69): PURO/Hồ sơ-NS visual language — ui/PageHeader (subject title +
// status badge) on top, info sections wrapped in .card-accent cards with
// .icon-chip headers, field rows in the Hồ sơ-NS Field idiom (label .label-eyebrow
// + value text-brand-800). Logic (queries, mutations, handlers, dialog) UNTOUCHED.
import { useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { import {
ArrowLeft, Ban, CheckCircle2, RotateCcw, Send, ArrowLeft, Ban, CheckCircle2, FileText, MessageSquare, Paperclip, RotateCcw, Send,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/ui/PageHeader'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog' import { Dialog } from '@/components/ui/Dialog'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
@ -39,6 +44,62 @@ const ACTION_LABEL: Record<ActionKind, { text: string; tone: string }> = {
return: { text: 'Trả lại', tone: 'bg-orange-600 hover:bg-orange-700' }, return: { text: 'Trả lại', tone: 'bg-orange-600 hover:bg-orange-700' },
} }
// Section card — .card-accent shell + .icon-chip header (Hồ sơ-NS idiom).
function SectionCard({
title, icon, accent, head, chipBg, chipFg, count, children,
}: {
title: string
icon: ReactNode
accent: string
head: string
chipBg: string
chipFg: string
count?: number
children: ReactNode
}) {
return (
<section className="card-accent" style={{ ['--accent' as string]: accent }}>
<header className="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
<span
className="icon-chip"
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: chipBg, ['--chip-fg' as string]: chipFg }}
aria-hidden
>
{icon}
</span>
<h3 className={cn('text-sm font-semibold tracking-tight', head)}>{title}</h3>
{count != null && (
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
{count}
</span>
)}
</header>
<div className="p-5">{children}</div>
</section>
)
}
// Field — label .label-eyebrow style + value text-brand-800 (Hồ sơ-NS idiom).
function Field({
label, value, mono, full, children,
}: {
label: string
value?: ReactNode
mono?: boolean
full?: boolean
children?: ReactNode
}) {
const empty = value == null || value === ''
return (
<div className={cn('min-w-0 text-sm', full && 'sm:col-span-2')}>
<div className="text-[11px] font-semibold uppercase tracking-wide text-brand-600">{label}</div>
<div className={cn('mt-0.5 break-words text-sm', empty && !children ? 'text-slate-300' : 'font-medium text-brand-800', mono && !empty && 'font-mono')}>
{children ?? (empty ? '—' : value)}
</div>
</div>
)
}
export function ProposalDetailPage() { export function ProposalDetailPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
@ -84,17 +145,19 @@ export function ProposalDetailPage() {
if (proposal.isLoading) { if (proposal.isLoading) {
return ( return (
<div className="space-y-4"> <div className="space-y-5">
<PageHeader title="Đang tải..." /> <PageHeader eyebrow="Văn phòng số" title="Đang tải..." icon={<FileText className="h-5 w-5" />} />
</div> </div>
) )
} }
if (proposal.isError || !proposal.data) { if (proposal.isError || !proposal.data) {
return ( return (
<div className="space-y-4"> <div className="space-y-5">
<PageHeader <PageHeader
eyebrow="Văn phòng số"
title="Lỗi" title="Lỗi"
icon={<FileText className="h-5 w-5" />}
actions={ actions={
<Button variant="outline" onClick={() => navigate('/proposals')}> <Button variant="outline" onClick={() => navigate('/proposals')}>
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
@ -115,15 +178,27 @@ export function ProposalDetailPage() {
const isInWorkflow = status === ProposalStatus.DaGuiDuyet const isInWorkflow = status === ProposalStatus.DaGuiDuyet
return ( return (
<div className="space-y-4"> <div className="space-y-5">
<PageHeader <PageHeader
title={p.maDeXuat ?? '(Chưa có mã)'} eyebrow={p.maDeXuat ?? 'Đề xuất'}
description={p.title} title={p.title}
icon={<FileText className="h-5 w-5" />}
accent="brand"
actions={ actions={
<Button variant="outline" onClick={() => navigate('/proposals')}> <>
<ArrowLeft className="mr-2 h-4 w-4" /> <span
Danh sách className={cn(
</Button> 'inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-medium',
PROPOSAL_STATUS_BADGE[status],
)}
>
{PROPOSAL_STATUS_LABELS[status]}
</span>
<Button variant="outline" onClick={() => navigate('/proposals')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Danh sách
</Button>
</>
} }
/> />
@ -176,61 +251,55 @@ export function ProposalDetailPage() {
</div> </div>
{/* Section 1: Thông tin */} {/* Section 1: Thông tin */}
<div className="rounded-lg border bg-card p-6 space-y-3"> <SectionCard
<h3 className="font-semibold text-base">1. Thông tin đ xuất</h3> title="Thông tin đề xuất"
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm"> icon={<FileText className="h-4 w-4" />}
<div> accent="var(--color-brand-500)"
<Label className="text-muted-foreground">Tiêu đ</Label> head="text-brand-800"
<div className="mt-1 font-medium">{p.title}</div> chipBg="var(--color-brand-50)"
</div> chipFg="var(--color-brand-600)"
<div> >
<Label className="text-muted-foreground">Số tiền dự kiến</Label> <div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
<div className="mt-1 font-medium tabular-nums">{formatVnd(p.amountEstimate)}</div> <Field label="Tiêu đề" value={p.title} />
</div> <Field label="Số tiền dự kiến" value={formatVnd(p.amountEstimate)} mono />
<div> <Field label="Phòng ban" value={p.departmentName ?? '—'} />
<Label className="text-muted-foreground">Phòng ban</Label> <Field label="Người soạn" value={p.drafterFullName ?? '—'} />
<div className="mt-1">{p.departmentName ?? '—'}</div> <Field label="Quy trình">
</div> {p.workflowCode ? (
<div> <span>
<Label className="text-muted-foreground">Người soạn</Label> <span className="font-mono">{p.workflowCode}</span> - {p.workflowName}
<div className="mt-1">{p.drafterFullName ?? '—'}</div> </span>
</div> ) : (
<div> <span className="text-slate-300"> Chưa chọn </span>
<Label className="text-muted-foreground">Quy trình</Label> )}
<div className="mt-1 text-xs"> </Field>
{p.workflowCode ? ( <Field label="Ngày tạo" value={formatDateTime(p.createdAt)} />
<> {p.description && (
<span className="font-mono">{p.workflowCode}</span> - {p.workflowName} <Field label="Nội dung chi tiết" full>
</> <span className="whitespace-pre-wrap font-normal text-slate-700">{p.description}</span>
) : '— Chưa chọn —'} </Field>
</div> )}
</div>
<div>
<Label className="text-muted-foreground">Ngày tạo</Label>
<div className="mt-1 text-xs">{formatDateTime(p.createdAt)}</div>
</div>
</div> </div>
{p.description && ( </SectionCard>
<div className="pt-3 border-t">
<Label className="text-muted-foreground">Nội dung chi tiết</Label>
<div className="mt-1 whitespace-pre-wrap text-sm">{p.description}</div>
</div>
)}
</div>
{/* Section 2: Attachments */} {/* Section 2: Attachments */}
<div className="rounded-lg border bg-card p-6 space-y-3"> <SectionCard
<h3 className="font-semibold text-base"> title="File đính kèm"
2. File đính kèm <span className="text-muted-foreground text-sm">({p.attachments.length})</span> icon={<Paperclip className="h-4 w-4" />}
</h3> accent="var(--color-teal-500)"
head="text-teal-700"
chipBg="var(--color-teal-50)"
chipFg="var(--color-teal-700)"
count={p.attachments.length}
>
{p.attachments.length === 0 ? ( {p.attachments.length === 0 ? (
<div className="text-sm text-muted-foreground">Chưa file đính kèm.</div> <div className="text-sm text-muted-foreground">Chưa file đính kèm.</div>
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2">
{p.attachments.map((a) => ( {p.attachments.map((a) => (
<li key={a.id} className="flex items-center justify-between rounded border p-2 text-sm"> <li key={a.id} className="flex items-center justify-between rounded-lg border border-slate-200 p-2.5 text-sm">
<div> <div>
<div className="font-medium">{a.fileName}</div> <div className="font-medium text-brand-800">{a.fileName}</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{a.uploadedByFullName} · {(a.fileSize / 1024).toFixed(1)} KB {a.uploadedByFullName} · {(a.fileSize / 1024).toFixed(1)} KB
</div> </div>
@ -242,11 +311,18 @@ export function ProposalDetailPage() {
))} ))}
</ul> </ul>
)} )}
</div> </SectionCard>
{/* Section 3: Ý kiến cấp duyệt V2 dynamic */} {/* Section 3: Ý kiến cấp duyệt V2 dynamic */}
<div className="rounded-lg border bg-card p-6 space-y-3"> <SectionCard
<h3 className="font-semibold text-base">3. Ý kiến cấp duyệt</h3> title="Ý kiến cấp duyệt"
icon={<MessageSquare className="h-4 w-4" />}
accent="var(--color-violet-500)"
head="text-violet-700"
chipBg="var(--color-violet-50)"
chipFg="var(--color-violet-700)"
count={p.levelOpinions.length}
>
{p.levelOpinions.length === 0 ? ( {p.levelOpinions.length === 0 ? (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{status === ProposalStatus.Nhap {status === ProposalStatus.Nhap
@ -256,20 +332,20 @@ export function ProposalDetailPage() {
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{p.levelOpinions.map((o) => ( {p.levelOpinions.map((o) => (
<div key={o.id} className="rounded border-l-4 border-emerald-400 bg-emerald-50/40 p-3"> <div key={o.id} className="rounded-lg border-l-4 border-emerald-400 bg-emerald-50/40 p-3">
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="flex items-center justify-between text-xs text-muted-foreground">
<span> <span>
Bước {o.stepOrder} - {o.stepName} · Cấp {o.levelOrder} Bước {o.stepOrder} - {o.stepName} · Cấp {o.levelOrder}
</span> </span>
<span>{formatDateTime(o.signedAt)}</span> <span>{formatDateTime(o.signedAt)}</span>
</div> </div>
<div className="mt-1 font-medium">{o.signedByFullName}</div> <div className="mt-1 font-medium text-brand-800">{o.signedByFullName}</div>
<div className="mt-1 whitespace-pre-wrap text-sm">{o.comment ?? '(duyệt — không ý kiến)'}</div> <div className="mt-1 whitespace-pre-wrap text-sm">{o.comment ?? '(duyệt — không ý kiến)'}</div>
</div> </div>
))} ))}
</div> </div>
)} )}
</div> </SectionCard>
{/* Action confirm dialog */} {/* Action confirm dialog */}
<Dialog <Dialog

View File

@ -1,11 +1,18 @@
// Đề xuất danh sách — Phase 10.3 G-O3 (S37 2026-05-28). // Đề xuất danh sách — Phase 10.3 G-O3 (S37 2026-05-28).
// Table 6 cột với Status badge color + filter status + search. // Re-skin S69 (2026-06-17): PURO layout + Hồ sơ Nhân sự visual language, REUSING
// the shared ui components (PageHeader + KpiCard). The status filter is rendered
// as a ROW of KpiCards (each = one status, value = count, onClick = the EXISTING
// setStatus/setInboxOnly setter). Table + pagination + every data hook unchanged.
// File MIRROR SHA256 identical với fe-user counterpart. // File MIRROR SHA256 identical với fe-user counterpart.
import { useMemo, useState } from 'react' import { useMemo, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { Plus, Search } from 'lucide-react' import {
import { PageHeader } from '@/components/PageHeader' Plus, Search, FileSignature, FileEdit, SendHorizontal,
CheckCircle2, Undo2, XCircle, Layers, Inbox,
} from 'lucide-react'
import { PageHeader } from '@/components/ui/PageHeader'
import { KpiCard } from '@/components/ui/KpiCard'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { api } from '@/lib/api' import { api } from '@/lib/api'
@ -59,23 +66,46 @@ export function ProposalsListPage() {
const total = list.data?.total ?? 0 const total = list.data?.total ?? 0
const totalPages = list.data?.totalPages ?? 1 const totalPages = list.data?.totalPages ?? 1
const statusOptions: Array<{ value: number | null; label: string }> = useMemo( // Presentation-only: counts shown on the KpiCard filter chips. Derived from the
() => [ // currently loaded page (no extra fetch). The active filter's own card shows the
{ value: null, label: 'Tất cả' }, // authoritative server `total`; the others show how many of the loaded rows match
{ value: 1, label: PROPOSAL_STATUS_LABELS[1] }, // — an at-a-glance hint, not a query change.
{ value: 2, label: PROPOSAL_STATUS_LABELS[2] }, const countByStatus = useMemo(() => {
{ value: 3, label: PROPOSAL_STATUS_LABELS[3] }, const acc: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }
{ value: 4, label: PROPOSAL_STATUS_LABELS[4] }, for (const p of items) acc[p.status] = (acc[p.status] ?? 0) + 1
{ value: 5, label: PROPOSAL_STATUS_LABELS[5] }, return acc
], }, [items])
[],
) // Status filter chips (presentation). Mirrors the OLD button row 1:1 — value=null
// is "Tất cả" + the five ProposalStatus values, so every filter stays reachable.
// Accent per status (playbook): amberx = đã gửi/pending, greenx = đã duyệt, violet
// = trả lại/returned, brand = tất cả; nháp + từ chối reuse the nearest tone. The
// inbox toggle below is the teal chip.
// Count is a presentation hint only: the ACTIVE status card shows the server
// `total`; the others show how many of the currently-loaded rows match.
const statusCards: Array<{
value: number | null
label: string
icon: ReactNode
accent: 'brand' | 'teal' | 'violet' | 'amberx' | 'greenx'
count: number
}> = [
{ value: null, label: 'Tất cả', icon: <Layers className="h-4 w-4" />, accent: 'brand', count: status === null ? total : items.length },
{ value: 1, label: PROPOSAL_STATUS_LABELS[1], icon: <FileEdit className="h-4 w-4" />, accent: 'violet', count: status === 1 ? total : countByStatus[1] },
{ value: 2, label: PROPOSAL_STATUS_LABELS[2], icon: <SendHorizontal className="h-4 w-4" />, accent: 'amberx', count: status === 2 ? total : countByStatus[2] },
{ value: 5, label: PROPOSAL_STATUS_LABELS[5], icon: <CheckCircle2 className="h-4 w-4" />, accent: 'greenx', count: status === 5 ? total : countByStatus[5] },
{ value: 3, label: PROPOSAL_STATUS_LABELS[3], icon: <Undo2 className="h-4 w-4" />, accent: 'violet', count: status === 3 ? total : countByStatus[3] },
{ value: 4, label: PROPOSAL_STATUS_LABELS[4], icon: <XCircle className="h-4 w-4" />, accent: 'amberx', count: status === 4 ? total : countByStatus[4] },
]
return ( return (
<div className="space-y-4"> <div className="space-y-5">
<PageHeader <PageHeader
eyebrow="Văn phòng số"
title="Đề xuất" title="Đề xuất"
description="Quản lý đề xuất nội bộ — Workflow V2 dynamic theo Quy trình admin config" subtitle="Quản lý đề xuất nội bộ — Workflow V2 dynamic theo Quy trình admin config"
icon={<FileSignature className="h-5 w-5" />}
accent="brand"
actions={ actions={
<Button onClick={() => navigate('/proposals/new')}> <Button onClick={() => navigate('/proposals/new')}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
@ -84,78 +114,80 @@ export function ProposalsListPage() {
} }
/> />
<div className="rounded-lg border bg-card p-4"> {/* Status filter — row of KpiCards (PURO). Each wires the EXISTING setter,
<div className="flex flex-wrap items-center gap-3"> same semantics as the old button row + inbox checkbox (status and inbox
<div className="flex items-center gap-1"> stay independent filter dimensions). */}
{statusOptions.map((opt) => ( <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-7">
<button {statusCards.map((c) => (
key={opt.value ?? 'all'} <KpiCard
type="button" key={c.value ?? 'all'}
onClick={() => { label={c.label}
setStatus(opt.value) value={c.count}
setPage(1) icon={c.icon}
}} accent={c.accent}
className={cn( active={status === c.value}
'rounded-md border px-3 py-1.5 text-sm transition', onClick={() => {
status === opt.value setStatus(c.value)
? 'border-primary bg-primary/10 text-primary font-medium' setPage(1)
: 'border-input bg-background hover:bg-accent', }}
)} />
> ))}
{opt.label} <KpiCard
</button> label="Cần tôi duyệt"
))} value={items.length}
</div> icon={<Inbox className="h-4 w-4" />}
<label className="flex items-center gap-2 text-sm"> accent="teal"
<input active={inboxOnly}
type="checkbox" onClick={() => {
checked={inboxOnly} setInboxOnly((v) => !v)
onChange={(e) => { setPage(1)
setInboxOnly(e.target.checked) }}
setPage(1) />
}}
className="h-4 w-4"
/>
Inbox duyệt
</label>
<div className="ml-auto flex items-center gap-2">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
placeholder="Tìm mã hoặc tiêu đề..."
className="w-64"
/>
</div>
</div>
</div> </div>
<div className="rounded-lg border bg-card"> <div className="card-accent flex items-center gap-3 px-4 py-3" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
<Search className="h-4 w-4 shrink-0 text-slate-400" />
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
placeholder="Tìm mã hoặc tiêu đề..."
className="max-w-md border-0 bg-transparent px-0 shadow-none focus-visible:ring-0"
/>
</div>
<div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="border-b bg-muted/50"> <thead className="border-b border-slate-200 bg-slate-50/70">
<tr> <tr>
<th className="px-4 py-2 text-left font-medium"></th> <th className="label-eyebrow px-4 py-2.5 text-left"></th>
<th className="px-4 py-2 text-left font-medium">Tiêu đ</th> <th className="label-eyebrow px-4 py-2.5 text-left">Tiêu đ</th>
<th className="px-4 py-2 text-left font-medium">Trạng thái</th> <th className="label-eyebrow px-4 py-2.5 text-left">Trạng thái</th>
<th className="px-4 py-2 text-right font-medium">Số tiền dự kiến</th> <th className="label-eyebrow px-4 py-2.5 text-right">Số tiền dự kiến</th>
<th className="px-4 py-2 text-left font-medium">Người soạn</th> <th className="label-eyebrow px-4 py-2.5 text-left">Người soạn</th>
<th className="px-4 py-2 text-left font-medium">Ngày tạo</th> <th className="label-eyebrow px-4 py-2.5 text-left">Ngày tạo</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{list.isLoading && ( {list.isLoading && (
<tr> <tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground"> <td colSpan={6} className="px-4 py-8 text-center text-slate-500">
Đang tải... Đang tải...
</td> </td>
</tr> </tr>
)} )}
{!list.isLoading && items.length === 0 && ( {!list.isLoading && items.length === 0 && (
<tr> <tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground"> <td colSpan={6} className="px-4 py-10 text-center text-slate-500">
<span
className="icon-chip mx-auto mb-2 flex"
style={{ ['--chip-bg' as string]: '#f1f5f9', ['--chip-fg' as string]: '#94a3b8' }}
aria-hidden
>
<Inbox className="h-4 w-4" />
</span>
Chưa đ xuất nào. Chưa đ xuất nào.
</td> </td>
</tr> </tr>
@ -164,11 +196,22 @@ export function ProposalsListPage() {
<tr <tr
key={p.id} key={p.id}
onClick={() => navigate(`/proposals/${p.id}`)} onClick={() => navigate(`/proposals/${p.id}`)}
className="cursor-pointer border-b transition hover:bg-accent/50" className="cursor-pointer border-b border-slate-100 transition last:border-0 hover:bg-brand-50/50"
> >
<td className="px-4 py-2 font-mono text-xs">{p.maDeXuat ?? '—'}</td> <td className="px-4 py-2.5">
<td className="px-4 py-2 max-w-md truncate">{p.title}</td> <span className="inline-flex items-center gap-2">
<td className="px-4 py-2"> <span
className="icon-chip h-7! w-7!"
style={{ ['--chip-bg' as string]: 'var(--color-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
aria-hidden
>
<FileSignature className="h-3.5 w-3.5" />
</span>
<span className="font-mono text-xs text-brand-800">{p.maDeXuat ?? '—'}</span>
</span>
</td>
<td className="max-w-md truncate px-4 py-2.5 font-medium text-brand-800">{p.title}</td>
<td className="px-4 py-2.5">
<span <span
className={cn( className={cn(
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium', 'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium',
@ -178,19 +221,19 @@ export function ProposalsListPage() {
{PROPOSAL_STATUS_LABELS[p.status as ProposalStatusValue]} {PROPOSAL_STATUS_LABELS[p.status as ProposalStatusValue]}
</span> </span>
{p.currentApprovalLevelOrder && ( {p.currentApprovalLevelOrder && (
<span className="ml-2 text-xs text-muted-foreground">Cấp {p.currentApprovalLevelOrder}</span> <span className="ml-2 text-xs text-slate-500">Cấp {p.currentApprovalLevelOrder}</span>
)} )}
</td> </td>
<td className="px-4 py-2 text-right tabular-nums">{formatVnd(p.amountEstimate)}</td> <td className="px-4 py-2.5 text-right tabular-nums text-brand-800">{formatVnd(p.amountEstimate)}</td>
<td className="px-4 py-2 text-xs">{p.drafterFullName ?? '—'}</td> <td className="px-4 py-2.5 text-xs text-slate-600">{p.drafterFullName ?? '—'}</td>
<td className="px-4 py-2 text-xs">{formatDate(p.createdAt)}</td> <td className="px-4 py-2.5 text-xs text-slate-600">{formatDate(p.createdAt)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between border-t px-4 py-2 text-sm"> <div className="flex items-center justify-between border-t border-slate-200 px-4 py-2.5 text-sm">
<div className="text-muted-foreground"> <div className="text-slate-500">
{total} đ xuất Trang {page} / {totalPages} {total} đ xuất Trang {page} / {totalPages}
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">

View File

@ -2,15 +2,19 @@
// Declarative KIND_CONFIG Record<Kind> mirror WorkflowAppsListPage — 4 module // Declarative KIND_CONFIG Record<Kind> mirror WorkflowAppsListPage — 4 module
// leave / ot / travel / vehicle. Workflow status + Ý kiến cấp duyệt timeline + // leave / ot / travel / vehicle. Workflow status + Ý kiến cấp duyệt timeline +
// Submit/Approve/Reject/Return actions (mirror ProposalDetailPage cấu trúc). // Submit/Approve/Reject/Return actions (mirror ProposalDetailPage cấu trúc).
// Re-skin S69 (2026-06-17): PURO chrome (ui/PageHeader teal) + Hồ sơ Nhân sự visual
// language — accent-rail Card sections + Field idiom + status badge. ALL data logic
// (queries / mutations / state / handlers / endpoints) preserved verbatim.
// File MIRROR SHA256 identical với fe-user counterpart. // File MIRROR SHA256 identical với fe-user counterpart.
import { useState } from 'react' import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { import {
ArrowLeft, Ban, CalendarOff, Car, CheckCircle2, Clock, Plane, RotateCcw, Send, ArrowLeft, Ban, CalendarOff, Car, CheckCircle2, Clock, GitBranch, Info,
MessageSquareText, Plane, RotateCcw, Send, Wallet,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/ui/PageHeader'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog' import { Dialog } from '@/components/ui/Dialog'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
@ -49,6 +53,71 @@ const ACTION_LABEL: Record<ActionKind, { text: string; tone: string }> = {
return: { text: 'Trả lại', tone: 'bg-orange-600 hover:bg-orange-700' }, return: { text: 'Trả lại', tone: 'bg-orange-600 hover:bg-orange-700' },
} }
// ===== Visual language (Hồ sơ Nhân sự idiom): accent map + Card (rail) + Field =====
// Accent palettes (teal/violet/amberx/greenx) ship stops 50/100/500/600/700 ONLY — no
// -800 — so headings/labels use -700 (brand uses -700 here too; brand-800 reserved for
// values). A non-existent stop silently emits no class in Tailwind v4.
type Accent = 'brand' | 'teal' | 'violet' | 'amberx' | 'greenx'
const ACCENT: Record<Accent, { chipBg: string; chipFg: string; head: string; rail: string; labelText: string }> = {
brand: { chipBg: 'var(--color-brand-50)', chipFg: 'var(--color-brand-600)', head: 'text-brand-700', rail: 'before:bg-brand-500', labelText: 'text-brand-700' },
teal: { chipBg: 'var(--color-teal-50)', chipFg: 'var(--color-teal-700)', head: 'text-teal-700', rail: 'before:bg-teal-500', labelText: 'text-teal-700' },
violet: { chipBg: 'var(--color-violet-50)', chipFg: 'var(--color-violet-700)', head: 'text-violet-700', rail: 'before:bg-violet-500', labelText: 'text-violet-700' },
amberx: { chipBg: 'var(--color-amberx-50)', chipFg: 'var(--color-amberx-700)', head: 'text-amberx-700', rail: 'before:bg-amberx-500', labelText: 'text-amberx-700' },
greenx: { chipBg: 'var(--color-greenx-50)', chipFg: 'var(--color-greenx-700)', head: 'text-greenx-700', rail: 'before:bg-greenx-500', labelText: 'text-greenx-700' },
}
function Card({ title, icon: Icon, action, accent = 'brand', children }: {
title: string
icon: typeof Info
action?: React.ReactNode
accent?: Accent
children: React.ReactNode
}) {
const a = ACCENT[accent]
return (
<section
className={cn(
'relative overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm',
"before:absolute before:inset-y-0 before:left-0 before:w-1 before:content-['']", a.rail,
)}
>
<header className="flex items-center justify-between gap-2 border-b border-slate-100 px-4 py-2.5 pl-5">
<div className="flex items-center gap-2">
<span
className="icon-chip"
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: a.chipBg, ['--chip-fg' as string]: a.chipFg }}
>
<Icon className="h-4 w-4" />
</span>
<h3 className={cn('text-sm font-semibold tracking-tight', a.head)}>{title}</h3>
</div>
{action}
</header>
<div className="p-4 pl-5">{children}</div>
</section>
)
}
// Field — nhãn uppercase accent-tint, value đậm rõ. Empty = dấu —.
function Field({ label, value, mono, full, accent = 'brand' }: {
label: string
value: React.ReactNode
mono?: boolean
full?: boolean
accent?: Accent
}) {
const empty = value == null || value === '' || value === '—'
return (
<div className={cn('min-w-0 text-sm', full && 'sm:col-span-2')}>
<div className={cn('text-[11px] font-semibold uppercase tracking-wide', ACCENT[accent].labelText)}>{label}</div>
<div className={cn('mt-0.5 whitespace-pre-wrap break-words text-sm', empty ? 'text-slate-300' : 'font-medium text-brand-800', mono && !empty && 'font-mono')}>
{empty ? '—' : value}
</div>
</div>
)
}
const KIND_CONFIG: Record<Kind, { const KIND_CONFIG: Record<Kind, {
title: string title: string
endpoint: string endpoint: string
@ -204,17 +273,19 @@ export function WorkflowAppDetailPage() {
if (detail.isLoading) { if (detail.isLoading) {
return ( return (
<div className="space-y-4"> <div className="space-y-5">
<PageHeader title="Đang tải..." /> <PageHeader eyebrow="Văn phòng số · Đơn từ" title="Đang tải..." accent="teal" />
</div> </div>
) )
} }
if (detail.isError || !d) { if (detail.isError || !d) {
return ( return (
<div className="space-y-4"> <div className="space-y-5">
<PageHeader <PageHeader
eyebrow="Văn phòng số · Đơn từ"
title="Lỗi" title="Lỗi"
accent="teal"
actions={ actions={
<Button variant="outline" onClick={() => navigate(`/workflow-apps/${kind}`)}> <Button variant="outline" onClick={() => navigate(`/workflow-apps/${kind}`)}>
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
@ -222,7 +293,7 @@ export function WorkflowAppDetailPage() {
</Button> </Button>
} }
/> />
<div className="rounded-lg border bg-red-50 p-4 text-sm text-red-800"> <div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
Không tải đưc dữ liệu đơn từ. Không tải đưc dữ liệu đơn từ.
</div> </div>
</div> </div>
@ -232,10 +303,13 @@ export function WorkflowAppDetailPage() {
const Icon = config.icon const Icon = config.icon
return ( return (
<div className="space-y-4"> <div className="space-y-5">
<PageHeader <PageHeader
eyebrow="Văn phòng số · Đơn từ"
title={d.maDonTu ?? '(Chưa có mã)'} title={d.maDonTu ?? '(Chưa có mã)'}
description={config.title} subtitle={config.title}
icon={<Icon className="h-5 w-5" />}
accent="teal"
actions={ actions={
<Button variant="outline" onClick={() => navigate(`/workflow-apps/${kind}`)}> <Button variant="outline" onClick={() => navigate(`/workflow-apps/${kind}`)}>
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
@ -245,8 +319,8 @@ export function WorkflowAppDetailPage() {
/> />
{/* Status row + action buttons */} {/* Status row + action buttons */}
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-card p-4"> <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<span <span
className={cn( className={cn(
'inline-flex items-center rounded-md border px-3 py-1 text-sm font-medium', 'inline-flex items-center rounded-md border px-3 py-1 text-sm font-medium',
@ -256,13 +330,13 @@ export function WorkflowAppDetailPage() {
{WORKFLOW_APP_STATUS_LABELS[d.status]} {WORKFLOW_APP_STATUS_LABELS[d.status]}
</span> </span>
{d.currentApprovalLevelOrder != null && ( {d.currentApprovalLevelOrder != null && (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-slate-500">
Cấp hiện tại: <span className="font-semibold">{d.currentApprovalLevelOrder}</span> Cấp hiện tại: <span className="font-semibold text-brand-800">{d.currentApprovalLevelOrder}</span>
</span> </span>
)} )}
{d.workflowCode && ( {d.workflowCode && (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-slate-500">
Quy trình: <span className="font-mono">{d.workflowCode}</span> Quy trình: <span className="font-mono text-brand-800">{d.workflowCode}</span>
</span> </span>
)} )}
</div> </div>
@ -270,7 +344,7 @@ export function WorkflowAppDetailPage() {
{isDraft && !hasWorkflow && ( {isDraft && !hasWorkflow && (
<> <>
<select <select
className="h-9 rounded-md border bg-background px-2 text-sm" className="h-9 rounded-md border border-slate-300 bg-white px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-500"
value={pickedWorkflowId} value={pickedWorkflowId}
onChange={(e) => setPickedWorkflowId(e.target.value)} onChange={(e) => setPickedWorkflowId(e.target.value)}
> >
@ -320,30 +394,20 @@ export function WorkflowAppDetailPage() {
</div> </div>
{isDraft && !hasWorkflow && ( {isDraft && !hasWorkflow && (
<div className="rounded-lg border bg-amber-50/50 p-3 text-sm text-amber-900"> <div className="rounded-xl border border-amber-200 bg-amberx-50 p-3 text-sm text-amberx-700">
Đơn chưa gắn quy trình duyệt. Vui lòng chọn quy trình rồi bấm <strong>Lưu quy trình</strong> trước khi gửi duyệt. Đơn chưa gắn quy trình duyệt. Vui lòng chọn quy trình rồi bấm <strong>Lưu quy trình</strong> trước khi gửi duyệt.
</div> </div>
)} )}
{/* Section 1: Thông tin */} {/* Section 1: Thông tin */}
<div className="rounded-lg border bg-card p-6 space-y-3"> <Card title="Thông tin đơn từ" icon={Icon} accent="teal">
<h3 className="flex items-center gap-2 font-semibold text-base"> <div className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
<Icon className="h-4 w-4 opacity-70" />
1. Thông tin
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
{config.detailFields.map((f) => ( {config.detailFields.map((f) => (
<div key={f.label}> <Field key={f.label} label={f.label} value={f.render(d)} accent="teal" />
<Label className="text-muted-foreground">{f.label}</Label>
<div className="mt-1 font-medium whitespace-pre-wrap">{f.render(d)}</div>
</div>
))} ))}
<div> <Field label="Ngày tạo" value={formatDateTime(d.createdAt)} accent="teal" />
<Label className="text-muted-foreground">Ngày tạo</Label>
<div className="mt-1 text-xs">{formatDateTime(d.createdAt)}</div>
</div>
</div> </div>
</div> </Card>
{/* Số dư phép (chỉ kind=leave) — Wave 2 hiển thị balance đã embed trong detail */} {/* Số dư phép (chỉ kind=leave) — Wave 2 hiển thị balance đã embed trong detail */}
{kind === 'leave' && d.leaveBalanceRemaining != null && (() => { {kind === 'leave' && d.leaveBalanceRemaining != null && (() => {
@ -353,51 +417,39 @@ export function WorkflowAppDetailPage() {
const isApproved = d.status === WorkflowAppStatus.DaDuyet const isApproved = d.status === WorkflowAppStatus.DaDuyet
const overBudget = remaining < 0 || (!isApproved && remaining < numDays) const overBudget = remaining < 0 || (!isApproved && remaining < numDays)
return ( return (
<div className="rounded-lg border bg-card p-6 space-y-3"> <Card title="Số dư phép" icon={Wallet} accent="greenx">
<h3 className="font-semibold text-base">Số phép</h3> <div className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-3">
<div className="text-sm"> <Field label={`Được hưởng (${year})`} value={d.leaveBalanceEntitled ?? '—'} accent="greenx" />
Số phép năm <span className="font-semibold">{year}</span>:{' '} <Field label="Đã dùng" value={d.leaveBalanceUsed ?? '—'} accent="greenx" />
Đưc hưởng <span className="font-medium">{d.leaveBalanceEntitled ?? '—'}</span> ·{' '} <Field label="Còn lại" value={`${remaining} ngày`} accent="greenx" />
Đã dùng <span className="font-medium">{d.leaveBalanceUsed ?? '—'}</span> ·{' '}
<span className="font-semibold">Còn {remaining}</span> ngày
</div> </div>
{overBudget && ( {overBudget && (
<div className="rounded-lg border border-red-300 bg-amber-50/50 p-3 text-sm font-medium text-amber-900"> <div className="mt-3 rounded-lg border border-red-300 bg-amberx-50 p-3 text-sm font-medium text-amberx-700">
{remaining < 0 {remaining < 0
? '⚠️ Đã âm số dư phép' ? 'Đã âm số dư phép'
: `⚠️ Đơn ${numDays} ngày vượt số dư còn lại (${remaining} ngày)`} : `Đơn ${numDays} ngày vượt số dư còn lại (${remaining} ngày)`}
</div> </div>
)} )}
</div> </Card>
) )
})()} })()}
{/* Section 2: Quy trình duyệt */} {/* Section 2: Quy trình duyệt */}
<div className="rounded-lg border bg-card p-6 space-y-3"> <Card title="Quy trình duyệt" icon={GitBranch} accent="violet">
<h3 className="font-semibold text-base">2. Quy trình duyệt</h3> <div className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm"> <Field
<div> label="Quy trình"
<Label className="text-muted-foreground">Quy trình</Label> value={d.workflowCode ? `${d.workflowCode} - ${d.workflowName}` : '— Chưa chọn —'}
<div className="mt-1 text-xs"> accent="violet"
{d.workflowCode ? ( />
<> <Field label="Cấp hiện tại" value={d.currentApprovalLevelOrder ?? '—'} accent="violet" />
<span className="font-mono">{d.workflowCode}</span> - {d.workflowName}
</>
) : '— Chưa chọn —'}
</div>
</div>
<div>
<Label className="text-muted-foreground">Cấp hiện tại</Label>
<div className="mt-1 font-medium">{d.currentApprovalLevelOrder ?? '—'}</div>
</div>
</div> </div>
</div> </Card>
{/* Section 3: Ý kiến cấp duyệt V2 dynamic */} {/* Section 3: Ý kiến cấp duyệt V2 dynamic */}
<div className="rounded-lg border bg-card p-6 space-y-3"> <Card title="Ý kiến cấp duyệt" icon={MessageSquareText} accent="brand">
<h3 className="font-semibold text-base">3. Ý kiến cấp duyệt</h3>
{d.levelOpinions.length === 0 ? ( {d.levelOpinions.length === 0 ? (
<div className="text-sm text-muted-foreground">Chưa ý kiến.</div> <div className="text-sm text-slate-400">Chưa ý kiến.</div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{[...d.levelOpinions] {[...d.levelOpinions]
@ -405,20 +457,20 @@ export function WorkflowAppDetailPage() {
(a.stepOrder ?? 0) - (b.stepOrder ?? 0) || (a.stepOrder ?? 0) - (b.stepOrder ?? 0) ||
(a.levelOrder ?? 0) - (b.levelOrder ?? 0)) (a.levelOrder ?? 0) - (b.levelOrder ?? 0))
.map((o) => ( .map((o) => (
<div key={o.id} className="rounded border-l-4 border-emerald-400 bg-emerald-50/40 p-3"> <div key={o.id} className="rounded-lg border border-slate-200 border-l-4 border-l-greenx-500 bg-greenx-50/40 p-3">
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="flex items-center justify-between text-xs text-slate-500">
<span> <span>
Bước {o.stepOrder} {o.stepName} · Cấp {o.levelOrder} Bước {o.stepOrder} {o.stepName} · Cấp {o.levelOrder}
</span> </span>
<span>{formatDateTime(o.signedAt)}</span> <span>{formatDateTime(o.signedAt)}</span>
</div> </div>
<div className="mt-1 font-medium">{o.signedByFullName}</div> <div className="mt-1 font-semibold text-brand-800">{o.signedByFullName}</div>
<div className="mt-1 whitespace-pre-wrap text-sm">{o.comment ?? '(duyệt — không ý kiến)'}</div> <div className="mt-1 whitespace-pre-wrap text-sm text-slate-700">{o.comment ?? '(duyệt — không ý kiến)'}</div>
</div> </div>
))} ))}
</div> </div>
)} )}
</div> </Card>
{/* Action confirm dialog */} {/* Action confirm dialog */}
<Dialog <Dialog
@ -439,7 +491,7 @@ export function WorkflowAppDetailPage() {
placeholder="Để trống nếu không có ý kiến..." placeholder="Để trống nếu không có ý kiến..."
maxLength={2000} maxLength={2000}
/> />
<div className="text-xs text-muted-foreground">{comment.length}/2000</div> <div className="text-xs text-slate-400">{comment.length}/2000</div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setActionDialog(null)}> <Button variant="outline" onClick={() => setActionDialog(null)}>
Huỷ Huỷ

View File

@ -1,15 +1,20 @@
// Generic Workflow Apps List Page — Phase 10.3 G-O4+G-O5+G-O6 (S38 2026-05-28). // Generic Workflow Apps List Page — Phase 10.3 G-O4+G-O5+G-O6 (S38 2026-05-28).
// Wave 3a (S42 2026-05-30): row click → Detail page (workflow actions + opinion timeline). // Wave 3a (S42 2026-05-30): row click → Detail page (workflow actions + opinion timeline).
// Re-skin S69 (2026-06-17): PURO layout (ui/PageHeader teal + KpiCard status-filter row)
// + Hồ sơ Nhân sự visual language (accent rail card, slate table chrome). Status filter is
// a CLIENT-SIDE view over the already-fetched items (no query/endpoint/navigation change).
// Handles 4 module via URL `:kind` param: leave / ot / travel / vehicle. // Handles 4 module via URL `:kind` param: leave / ot / travel / vehicle.
// File MIRROR SHA256 identical fe-user counterpart. // File MIRROR SHA256 identical fe-user counterpart.
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { CalendarOff, Clock, Plane, Car, FileSignature } from 'lucide-react' import { CalendarOff, Clock, Plane, Car, FileSignature, Layers, Send, RotateCcw, CheckCircle2 } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/ui/PageHeader'
import { KpiCard } from '@/components/ui/KpiCard'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import { import {
WORKFLOW_APP_STATUS_BADGE, WORKFLOW_APP_STATUS_LABELS, WORKFLOW_APP_STATUS_BADGE, WORKFLOW_APP_STATUS_LABELS, WorkflowAppStatus,
type PagedResult, type PagedResult,
} from '@/types/workflowApps' } from '@/types/workflowApps'
@ -78,12 +83,18 @@ const ICON_MAP: Record<Kind, any> = {
leave: CalendarOff, ot: Clock, travel: Plane, vehicle: Car, leave: CalendarOff, ot: Clock, travel: Plane, vehicle: Car,
} }
// Status filter chips (presentation): null = Tất cả. Each maps to a KpiCard accent.
type StatusFilter = number | null
export function WorkflowAppsListPage() { export function WorkflowAppsListPage() {
const { kind = 'leave' } = useParams<{ kind: Kind }>() const { kind = 'leave' } = useParams<{ kind: Kind }>()
const navigate = useNavigate() const navigate = useNavigate()
const config = KIND_CONFIG[kind as Kind] const config = KIND_CONFIG[kind as Kind]
const Icon = ICON_MAP[kind as Kind] ?? FileSignature const Icon = ICON_MAP[kind as Kind] ?? FileSignature
// Client-side status filter — a view over the fetched list (no extra query / endpoint).
const [statusFilter, setStatusFilter] = useState<StatusFilter>(null)
const list = useQuery({ const list = useQuery({
queryKey: [config.endpoint, { page: 1 }], queryKey: [config.endpoint, { page: 1 }],
queryFn: async () => (await api.get<PagedResult<any>>(config.endpoint, { params: { page: 1, pageSize: 50 } })).data, queryFn: async () => (await api.get<PagedResult<any>>(config.endpoint, { params: { page: 1, pageSize: 50 } })).data,
@ -92,50 +103,108 @@ export function WorkflowAppsListPage() {
const items = list.data?.items ?? [] const items = list.data?.items ?? []
// Counts per status + the visible (filtered) rows — derived presentation only.
const counts = useMemo(() => {
const c = { all: items.length, submitted: 0, returned: 0, approved: 0 }
for (const it of items) {
if (it.status === WorkflowAppStatus.DaGuiDuyet) c.submitted++
else if (it.status === WorkflowAppStatus.TraLai) c.returned++
else if (it.status === WorkflowAppStatus.DaDuyet) c.approved++
}
return c
}, [items])
const visibleItems = useMemo(
() => (statusFilter == null ? items : items.filter((it: any) => it.status === statusFilter)),
[items, statusFilter],
)
if (!config) { if (!config) {
return <div className="text-red-600">Module không tồn tại: {kind}</div> return <div className="text-red-600">Module không tồn tại: {kind}</div>
} }
return ( return (
<div className="space-y-4"> <div className="space-y-5">
<PageHeader title={config.title} description={config.description} /> <PageHeader
eyebrow="Văn phòng số · Đơn từ"
title={config.title}
subtitle={config.description}
icon={<Icon className="h-5 w-5" />}
accent="teal"
/>
<div className="rounded-lg border bg-card"> {/* Status filter — row of KpiCards (PURO pattern, replaces tabs) */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<KpiCard
label="Tất cả"
value={counts.all}
icon={<Layers className="h-4 w-4" />}
accent="teal"
active={statusFilter == null}
onClick={() => setStatusFilter(null)}
/>
<KpiCard
label="Đã gửi duyệt"
value={counts.submitted}
icon={<Send className="h-4 w-4" />}
accent="amberx"
active={statusFilter === WorkflowAppStatus.DaGuiDuyet}
onClick={() => setStatusFilter(WorkflowAppStatus.DaGuiDuyet)}
/>
<KpiCard
label="Trả lại"
value={counts.returned}
icon={<RotateCcw className="h-4 w-4" />}
accent="violet"
active={statusFilter === WorkflowAppStatus.TraLai}
onClick={() => setStatusFilter(WorkflowAppStatus.TraLai)}
/>
<KpiCard
label="Đã duyệt"
value={counts.approved}
icon={<CheckCircle2 className="h-4 w-4" />}
accent="greenx"
active={statusFilter === WorkflowAppStatus.DaDuyet}
onClick={() => setStatusFilter(WorkflowAppStatus.DaDuyet)}
/>
</div>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="border-b bg-muted/50"> <thead className="border-b border-slate-200 bg-slate-50">
<tr> <tr>
{config.columns.map((c) => ( {config.columns.map((c) => (
<th key={c.key} className="px-4 py-2 text-left font-medium">{c.label}</th> <th key={c.key} className="px-4 py-2.5 text-left text-[11px] font-semibold uppercase tracking-wide text-slate-500">{c.label}</th>
))} ))}
<th className="px-4 py-2 text-left font-medium">Trạng thái</th> <th className="px-4 py-2.5 text-left text-[11px] font-semibold uppercase tracking-wide text-slate-500">Trạng thái</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{list.isLoading && ( {list.isLoading && (
<tr> <tr>
<td colSpan={config.columns.length + 1} className="px-4 py-8 text-center text-muted-foreground"> <td colSpan={config.columns.length + 1} className="px-4 py-10 text-center text-slate-400">
Đang tải... Đang tải...
</td> </td>
</tr> </tr>
)} )}
{!list.isLoading && items.length === 0 && ( {!list.isLoading && visibleItems.length === 0 && (
<tr> <tr>
<td colSpan={config.columns.length + 1} className="px-4 py-8 text-center text-muted-foreground"> <td colSpan={config.columns.length + 1} className="px-4 py-12 text-center text-slate-400">
<Icon className="mx-auto h-8 w-8 mb-2 opacity-50" /> <Icon className="mx-auto mb-2 h-8 w-8 opacity-40" />
Chưa dữ liệu. {items.length === 0 ? 'Chưa có dữ liệu.' : 'Không có đơn nào ở trạng thái này.'}
</td> </td>
</tr> </tr>
)} )}
{items.map((item: any) => ( {visibleItems.map((item: any) => (
<tr <tr
key={item.id} key={item.id}
className="border-b cursor-pointer hover:bg-muted/40" className="cursor-pointer border-b border-slate-100 transition hover:bg-teal-50/50"
onClick={() => navigate(`/workflow-apps/${kind}/${item.id}`)} onClick={() => navigate(`/workflow-apps/${kind}/${item.id}`)}
> >
{config.columns.map((c) => ( {config.columns.map((c) => (
<td key={c.key} className="px-4 py-2">{c.render(item)}</td> <td key={c.key} className="px-4 py-2.5 text-slate-700">{c.render(item)}</td>
))} ))}
<td className="px-4 py-2"> <td className="px-4 py-2.5">
<span <span
className={cn( className={cn(
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium', 'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium',