[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
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:
File diff suppressed because one or more lines are too long
@ -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ư cũ, không có filter-state sẵn nên thêm view-layer = thuần presentation; empty-state phân biệt "chưa có data" vs "không có đơ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ố dư phép=greenx · Quy trình=violet · Ý kiến=brand. Status badge giữ `WORKFLOW_APP_STATUS_BADGE`. Drop raw "⚠️" emoji trong over-budget banner→text 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ồ sơ Nhân sự fe-user REFINE từ eoffice LIVE (3 việc) — layout 3-cột→2-cột + 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 tree→list→detail + 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` tô 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 → có màu rõ 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 có 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ồ sơ Nhân sự fe-user REFINE từ eoffice LIVE (3 việc) — layout 3-cột→2-cột + 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 tree→list→detail + 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` tô 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 → có màu rõ 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 có 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ồ sơ 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+section→tab 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ồ sơ 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+section→tab 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].
|
||||||
|
|||||||
@ -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]`.
|
||||||
|
|||||||
@ -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,20 +155,36 @@ 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">
|
||||||
|
<header className="flex items-center gap-2 border-b border-slate-100 bg-brand-50 px-4 py-2.5">
|
||||||
|
<span
|
||||||
|
className="icon-chip"
|
||||||
|
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-brand-100)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<ClipboardList className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<h3 className="text-sm font-semibold tracking-tight text-brand-800">Bảng tổng hợp chấm công</h3>
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<span className="rounded-full bg-white px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-brand-700">
|
||||||
|
{rows.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<div className="overflow-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="border-b border-slate-200 bg-slate-50 text-xs uppercase text-slate-500">
|
<thead className="sticky top-0 border-b border-slate-200 bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2 text-left font-medium">STT</th>
|
<th className="px-3 py-2 text-left font-semibold">STT</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Họ tên</th>
|
<th className="px-3 py-2 text-left font-semibold">Họ tên</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Phòng ban</th>
|
<th className="px-3 py-2 text-left font-semibold">Phòng ban</th>
|
||||||
<th className="px-3 py-2 text-right font-medium">Ngày công</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-medium">Tổng giờ làm</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-medium">OT thường</th>
|
<th className="px-3 py-2 text-right font-semibold">OT thường</th>
|
||||||
<th className="px-3 py-2 text-right font-medium">OT cuối tuần</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-medium">OT lễ</th>
|
<th className="px-3 py-2 text-right font-semibold">OT lễ</th>
|
||||||
<th className="px-3 py-2 text-right font-medium">OT quy đổi</th>
|
<th className="px-3 py-2 text-right font-semibold">OT quy đổi</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -140,21 +198,21 @@ export function AttendanceReportPage() {
|
|||||||
</td></tr>
|
</td></tr>
|
||||||
)}
|
)}
|
||||||
{rows.map((r, i) => (
|
{rows.map((r, i) => (
|
||||||
<tr key={r.userId} className="border-b border-slate-100 hover:bg-slate-50">
|
<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 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 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-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 text-slate-700">{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-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.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.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 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>
|
<td className="px-3 py-2 text-right font-semibold tabular-nums text-brand-800">{fmtNum(r.otWeighted)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
{!report.isLoading && rows.length > 0 && report.data && (
|
{!report.isLoading && rows.length > 0 && report.data && (
|
||||||
<tfoot className="border-t-2 border-slate-300 bg-slate-50 font-semibold text-slate-800">
|
<tfoot className="border-t-2 border-brand-200 bg-brand-50 font-semibold text-brand-800">
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-3 py-2.5 text-right" colSpan={4}>Tổng</td>
|
<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 text-right tabular-nums">{fmtNum(report.data.grandTotalWorkHours)}</td>
|
||||||
@ -165,6 +223,7 @@ export function AttendanceReportPage() {
|
|||||||
)}
|
)}
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,30 +94,33 @@ 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"
|
||||||
{/* Filter bar sticky top */}
|
actions={
|
||||||
<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="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
<form onSubmit={applySearch} className="relative flex-1">
|
<form onSubmit={applySearch} className="relative">
|
||||||
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
|
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
|
||||||
<Input
|
<Input
|
||||||
value={localSearch}
|
value={localSearch}
|
||||||
onChange={e => setLocalSearch(e.target.value)}
|
onChange={e => setLocalSearch(e.target.value)}
|
||||||
onBlur={() => setParam('q', localSearch.trim() || null)}
|
onBlur={() => setParam('q', localSearch.trim() || null)}
|
||||||
placeholder="Tìm tên / email / SĐT / mã NV..."
|
placeholder="Tìm tên / email / SĐT / mã NV..."
|
||||||
className="pl-8"
|
className="pl-8 sm:w-72"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
<Select
|
<Select
|
||||||
value={departmentId}
|
value={departmentId}
|
||||||
onChange={e => setParam('deptId', e.target.value || null)}
|
onChange={e => setParam('deptId', e.target.value || null)}
|
||||||
className="sm:w-64"
|
className="sm:w-56"
|
||||||
>
|
>
|
||||||
<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 => (
|
||||||
@ -118,12 +128,30 @@ export function InternalDirectoryPage() {
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Hàng tóm tắt — counts có sẵn từ data (inert, KHÔNG phải filter trạng thái) */}
|
||||||
|
<div className="mb-5 grid grid-cols-2 gap-3 sm:max-w-md">
|
||||||
|
<KpiCard
|
||||||
|
label="Tổng nhân viên"
|
||||||
|
value={total}
|
||||||
|
icon={<Users className="h-4 w-4" />}
|
||||||
|
accent="brand"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Số phòng ban"
|
||||||
|
value={deptCount}
|
||||||
|
icon={<Building2 className="h-4 w-4" />}
|
||||||
|
accent="teal"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
|||||||
@ -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,44 +113,100 @@ 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'
|
||||||
|
? ORDER
|
||||||
|
: ORDER.filter(s => s === filter)
|
||||||
|
const cardMatch = (t: ItTicketDto) => (filter === 'breached' ? t.slaBreached : true)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Văn phòng số"
|
||||||
|
title="Ticket CNTT"
|
||||||
|
subtitle="Helpdesk — báo lỗi và yêu cầu hỗ trợ kỹ thuật"
|
||||||
|
icon={<Ticket className="h-5 w-5" />}
|
||||||
|
accent="violet"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── KpiCard filter-row (PURO) — thay status tabs. value = count, onClick =
|
||||||
|
set filter hiển thị. Bấm lại chip đang chọn → bỏ lọc (về 'all'). ── */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
|
{STATUS_CHIPS.map(({ key, icon: Icon, accent }) => (
|
||||||
|
<KpiCard
|
||||||
|
key={key}
|
||||||
|
label={IT_TICKET_STATUS_LABELS[key]}
|
||||||
|
value={grouped[key].length}
|
||||||
|
icon={<Icon className="h-4 w-4" />}
|
||||||
|
accent={accent}
|
||||||
|
active={filter === key}
|
||||||
|
onClick={() => setFilter(prev => (prev === key ? 'all' : key))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<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>
|
</h3>
|
||||||
<div className="space-y-2">
|
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
|
||||||
{list.isLoading && <div className="text-xs text-muted-foreground">Đang tải...</div>}
|
{cards.length}
|
||||||
{!list.isLoading && grouped[statusKey].length === 0 && (
|
</span>
|
||||||
<div className="text-xs text-muted-foreground italic">Trống</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
{grouped[statusKey].map((t) => (
|
{cards.map((t) => (
|
||||||
<div key={t.id} className="rounded border p-2 text-xs space-y-1 bg-background">
|
<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">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-mono text-[10px] text-muted-foreground">{t.maTicket ?? '—'}</span>
|
<span className="font-mono text-[10px] text-slate-400">{t.maTicket ?? '—'}</span>
|
||||||
<span className={cn('rounded px-1.5 py-0.5 text-[10px]', IT_TICKET_PRIORITY_BADGE[t.priority])}>
|
<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]}
|
{IT_TICKET_PRIORITY_LABELS[t.priority]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium truncate">{t.title}</div>
|
<div className="truncate font-semibold text-slate-900">{t.title}</div>
|
||||||
<div className="text-muted-foreground">
|
<div className="text-slate-500">
|
||||||
{IT_TICKET_CATEGORY_LABELS[t.category]} · {t.requesterFullName}
|
{IT_TICKET_CATEGORY_LABELS[t.category]} · {t.requesterFullName}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-1 pt-0.5">
|
<div className="flex items-center justify-between gap-1 pt-0.5">
|
||||||
<span className="flex items-center gap-1 min-w-0 text-muted-foreground">
|
<span className="flex min-w-0 items-center gap-1 text-slate-500">
|
||||||
<span className="truncate" title={t.assignedToFullName ?? undefined}>
|
<span className="flex min-w-0 items-center gap-1 truncate" title={t.assignedToFullName ?? undefined}>
|
||||||
👤 {t.assignedToFullName ?? <span className="italic">Chưa giao</span>}
|
<User className="h-3 w-3 shrink-0 text-slate-400" />
|
||||||
|
{t.assignedToFullName ?? <span className="italic">Chưa giao</span>}
|
||||||
</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. */}
|
{/* Reassign Admin OR tổ IT (BE capability canReassign). Nút nhỏ cạnh người xử lý → mở Dialog gán lại. */}
|
||||||
{canReassign && (
|
{canReassign && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openDialog(t)}
|
onClick={() => openDialog(t)}
|
||||||
className="shrink-0 rounded p-0.5 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
|
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ý"
|
title="Đổi người xử lý"
|
||||||
>
|
>
|
||||||
<Pencil className="h-3 w-3" />
|
<Pencil className="h-3 w-3" />
|
||||||
@ -119,8 +216,8 @@ export function ItTicketsPage() {
|
|||||||
{t.slaDueAt && (
|
{t.slaDueAt && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded px-1.5 py-0.5 text-[10px] whitespace-nowrap',
|
'whitespace-nowrap rounded px-1.5 py-0.5 text-[10px]',
|
||||||
t.slaBreached ? 'bg-red-100 text-red-700 font-medium' : 'bg-slate-100 text-slate-600',
|
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)}`}
|
title={`Hạn xử lý SLA: ${formatSlaDue(t.slaDueAt)}`}
|
||||||
>
|
>
|
||||||
@ -131,14 +228,21 @@ export function ItTicketsPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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 có 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 có 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ử lý</label>
|
<label className="text-[11px] font-semibold uppercase tracking-wide text-violet-700">Người xử lý</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ử lý —</option>
|
<option value="">— Chọn người xử lý —</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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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
|
||||||
|
className="icon-chip mt-0.5 shrink-0"
|
||||||
|
style={{ ['--chip-bg' as string]: 'var(--color-amberx-50)', ['--chip-fg' as string]: 'var(--color-amberx-700)' }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<CalendarDays className="h-4 w-4" />
|
||||||
|
</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">
|
<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))}>
|
<span className={cn('rounded-full px-2 py-0.5', statusBadgeClass(detailBooking.status))}>
|
||||||
{statusLabel(detailBooking.status)}
|
{statusLabel(detailBooking.status)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-slate-500">
|
<span className="text-slate-500">
|
||||||
Người tạo: <span className="font-medium text-slate-700">{detailBooking.bookedByFullName}</span>
|
Người tạo: <span className="font-medium text-brand-800">{detailBooking.bookedByFullName}</span>
|
||||||
</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>
|
||||||
<div className="flex items-center gap-2 text-slate-700">
|
<div className="mt-0.5 break-words font-medium text-brand-800">
|
||||||
<Clock className="h-4 w-4 text-slate-400" />
|
{detailBooking.roomCode} — {detailBooking.roomName}
|
||||||
<span>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="label-eyebrow flex items-center gap-1">
|
||||||
|
<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">Mô tả</Label>
|
<div className="label-eyebrow">Mô 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 có người tham dự —</p>
|
<p className="mt-1 text-xs text-slate-400">— Không có người tham dự —</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -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">Mã</th>
|
<th className="px-3 py-2.5 pl-5 text-left">Mã</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 có 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 có 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" />
|
||||||
|
|||||||
@ -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,8 +109,20 @@ 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 */}
|
||||||
|
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-brand-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-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<h3 className="text-sm font-semibold tracking-tight text-brand-800">Nội dung đề xuất</h3>
|
||||||
|
</header>
|
||||||
|
<div className="space-y-4 p-5">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="title">
|
<Label htmlFor="title">
|
||||||
Tiêu đề <span className="text-red-500">*</span>
|
Tiêu đề <span className="text-red-500">*</span>
|
||||||
@ -130,8 +149,22 @@ export function ProposalCreatePage() {
|
|||||||
/>
|
/>
|
||||||
<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')}>
|
||||||
|
|||||||
@ -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={
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'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')}>
|
<Button variant="outline" onClick={() => navigate('/proposals')}>
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Danh sách
|
Danh sách
|
||||||
</Button>
|
</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>
|
|
||||||
<div>
|
|
||||||
<Label className="text-muted-foreground">Người soạn</Label>
|
|
||||||
<div className="mt-1">{p.drafterFullName ?? '—'}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-muted-foreground">Quy trình</Label>
|
|
||||||
<div className="mt-1 text-xs">
|
|
||||||
{p.workflowCode ? (
|
{p.workflowCode ? (
|
||||||
<>
|
<span>
|
||||||
<span className="font-mono">{p.workflowCode}</span> - {p.workflowName}
|
<span className="font-mono">{p.workflowCode}</span> - {p.workflowName}
|
||||||
</>
|
</span>
|
||||||
) : '— Chưa chọn —'}
|
) : (
|
||||||
</div>
|
<span className="text-slate-300">— Chưa chọn —</span>
|
||||||
</div>
|
)}
|
||||||
<div>
|
</Field>
|
||||||
<Label className="text-muted-foreground">Ngày tạo</Label>
|
<Field label="Ngày tạo" value={formatDateTime(p.createdAt)} />
|
||||||
<div className="mt-1 text-xs">{formatDateTime(p.createdAt)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{p.description && (
|
{p.description && (
|
||||||
<div className="pt-3 border-t">
|
<Field label="Nội dung chi tiết" full>
|
||||||
<Label className="text-muted-foreground">Nội dung chi tiết</Label>
|
<span className="whitespace-pre-wrap font-normal text-slate-700">{p.description}</span>
|
||||||
<div className="mt-1 whitespace-pre-wrap text-sm">{p.description}</div>
|
</Field>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
{/* 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 có file đính kèm.</div>
|
<div className="text-sm text-muted-foreground">Chưa có 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
|
||||||
|
|||||||
@ -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,42 +114,39 @@ 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'}
|
||||||
|
label={c.label}
|
||||||
|
value={c.count}
|
||||||
|
icon={c.icon}
|
||||||
|
accent={c.accent}
|
||||||
|
active={status === c.value}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStatus(opt.value)
|
setStatus(c.value)
|
||||||
setPage(1)
|
setPage(1)
|
||||||
}}
|
}}
|
||||||
className={cn(
|
|
||||||
'rounded-md border px-3 py-1.5 text-sm transition',
|
|
||||||
status === opt.value
|
|
||||||
? 'border-primary bg-primary/10 text-primary font-medium'
|
|
||||||
: 'border-input bg-background hover:bg-accent',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center gap-2 text-sm">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={inboxOnly}
|
|
||||||
onChange={(e) => {
|
|
||||||
setInboxOnly(e.target.checked)
|
|
||||||
setPage(1)
|
|
||||||
}}
|
|
||||||
className="h-4 w-4"
|
|
||||||
/>
|
/>
|
||||||
Inbox duyệt
|
))}
|
||||||
</label>
|
<KpiCard
|
||||||
<div className="ml-auto flex items-center gap-2">
|
label="Cần tôi duyệt"
|
||||||
<Search className="h-4 w-4 text-muted-foreground" />
|
value={items.length}
|
||||||
|
icon={<Inbox className="h-4 w-4" />}
|
||||||
|
accent="teal"
|
||||||
|
active={inboxOnly}
|
||||||
|
onClick={() => {
|
||||||
|
setInboxOnly((v) => !v)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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
|
<Input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@ -127,35 +154,40 @@ export function ProposalsListPage() {
|
|||||||
setPage(1)
|
setPage(1)
|
||||||
}}
|
}}
|
||||||
placeholder="Tìm mã hoặc tiêu đề..."
|
placeholder="Tìm mã hoặc tiêu đề..."
|
||||||
className="w-64"
|
className="max-w-md border-0 bg-transparent px-0 shadow-none focus-visible:ring-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card">
|
<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">Mã</th>
|
<th className="label-eyebrow px-4 py-2.5 text-left">Mã</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 có đề xuất nào.
|
Chưa có đề 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">
|
||||||
|
|||||||
@ -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ố dư 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ố dư 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 có ý kiến.</div>
|
<div className="text-sm text-slate-400">Chưa có ý 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ỷ
|
||||||
|
|||||||
@ -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 có 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',
|
||||||
|
|||||||
@ -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,30 +94,33 @@ 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"
|
||||||
{/* Filter bar sticky top */}
|
actions={
|
||||||
<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="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
<form onSubmit={applySearch} className="relative flex-1">
|
<form onSubmit={applySearch} className="relative">
|
||||||
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
|
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
|
||||||
<Input
|
<Input
|
||||||
value={localSearch}
|
value={localSearch}
|
||||||
onChange={e => setLocalSearch(e.target.value)}
|
onChange={e => setLocalSearch(e.target.value)}
|
||||||
onBlur={() => setParam('q', localSearch.trim() || null)}
|
onBlur={() => setParam('q', localSearch.trim() || null)}
|
||||||
placeholder="Tìm tên / email / SĐT / mã NV..."
|
placeholder="Tìm tên / email / SĐT / mã NV..."
|
||||||
className="pl-8"
|
className="pl-8 sm:w-72"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
<Select
|
<Select
|
||||||
value={departmentId}
|
value={departmentId}
|
||||||
onChange={e => setParam('deptId', e.target.value || null)}
|
onChange={e => setParam('deptId', e.target.value || null)}
|
||||||
className="sm:w-64"
|
className="sm:w-56"
|
||||||
>
|
>
|
||||||
<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 => (
|
||||||
@ -118,12 +128,30 @@ export function InternalDirectoryPage() {
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Hàng tóm tắt — counts có sẵn từ data (inert, KHÔNG phải filter trạng thái) */}
|
||||||
|
<div className="mb-5 grid grid-cols-2 gap-3 sm:max-w-md">
|
||||||
|
<KpiCard
|
||||||
|
label="Tổng nhân viên"
|
||||||
|
value={total}
|
||||||
|
icon={<Users className="h-4 w-4" />}
|
||||||
|
accent="brand"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Số phòng ban"
|
||||||
|
value={deptCount}
|
||||||
|
icon={<Building2 className="h-4 w-4" />}
|
||||||
|
accent="teal"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
|||||||
@ -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,44 +113,100 @@ 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'
|
||||||
|
? ORDER
|
||||||
|
: ORDER.filter(s => s === filter)
|
||||||
|
const cardMatch = (t: ItTicketDto) => (filter === 'breached' ? t.slaBreached : true)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Văn phòng số"
|
||||||
|
title="Ticket CNTT"
|
||||||
|
subtitle="Helpdesk — báo lỗi và yêu cầu hỗ trợ kỹ thuật"
|
||||||
|
icon={<Ticket className="h-5 w-5" />}
|
||||||
|
accent="violet"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── KpiCard filter-row (PURO) — thay status tabs. value = count, onClick =
|
||||||
|
set filter hiển thị. Bấm lại chip đang chọn → bỏ lọc (về 'all'). ── */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
|
{STATUS_CHIPS.map(({ key, icon: Icon, accent }) => (
|
||||||
|
<KpiCard
|
||||||
|
key={key}
|
||||||
|
label={IT_TICKET_STATUS_LABELS[key]}
|
||||||
|
value={grouped[key].length}
|
||||||
|
icon={<Icon className="h-4 w-4" />}
|
||||||
|
accent={accent}
|
||||||
|
active={filter === key}
|
||||||
|
onClick={() => setFilter(prev => (prev === key ? 'all' : key))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<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>
|
</h3>
|
||||||
<div className="space-y-2">
|
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
|
||||||
{list.isLoading && <div className="text-xs text-muted-foreground">Đang tải...</div>}
|
{cards.length}
|
||||||
{!list.isLoading && grouped[statusKey].length === 0 && (
|
</span>
|
||||||
<div className="text-xs text-muted-foreground italic">Trống</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
{grouped[statusKey].map((t) => (
|
{cards.map((t) => (
|
||||||
<div key={t.id} className="rounded border p-2 text-xs space-y-1 bg-background">
|
<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">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-mono text-[10px] text-muted-foreground">{t.maTicket ?? '—'}</span>
|
<span className="font-mono text-[10px] text-slate-400">{t.maTicket ?? '—'}</span>
|
||||||
<span className={cn('rounded px-1.5 py-0.5 text-[10px]', IT_TICKET_PRIORITY_BADGE[t.priority])}>
|
<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]}
|
{IT_TICKET_PRIORITY_LABELS[t.priority]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium truncate">{t.title}</div>
|
<div className="truncate font-semibold text-slate-900">{t.title}</div>
|
||||||
<div className="text-muted-foreground">
|
<div className="text-slate-500">
|
||||||
{IT_TICKET_CATEGORY_LABELS[t.category]} · {t.requesterFullName}
|
{IT_TICKET_CATEGORY_LABELS[t.category]} · {t.requesterFullName}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-1 pt-0.5">
|
<div className="flex items-center justify-between gap-1 pt-0.5">
|
||||||
<span className="flex items-center gap-1 min-w-0 text-muted-foreground">
|
<span className="flex min-w-0 items-center gap-1 text-slate-500">
|
||||||
<span className="truncate" title={t.assignedToFullName ?? undefined}>
|
<span className="flex min-w-0 items-center gap-1 truncate" title={t.assignedToFullName ?? undefined}>
|
||||||
👤 {t.assignedToFullName ?? <span className="italic">Chưa giao</span>}
|
<User className="h-3 w-3 shrink-0 text-slate-400" />
|
||||||
|
{t.assignedToFullName ?? <span className="italic">Chưa giao</span>}
|
||||||
</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. */}
|
{/* Reassign Admin OR tổ IT (BE capability canReassign). Nút nhỏ cạnh người xử lý → mở Dialog gán lại. */}
|
||||||
{canReassign && (
|
{canReassign && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openDialog(t)}
|
onClick={() => openDialog(t)}
|
||||||
className="shrink-0 rounded p-0.5 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
|
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ý"
|
title="Đổi người xử lý"
|
||||||
>
|
>
|
||||||
<Pencil className="h-3 w-3" />
|
<Pencil className="h-3 w-3" />
|
||||||
@ -119,8 +216,8 @@ export function ItTicketsPage() {
|
|||||||
{t.slaDueAt && (
|
{t.slaDueAt && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded px-1.5 py-0.5 text-[10px] whitespace-nowrap',
|
'whitespace-nowrap rounded px-1.5 py-0.5 text-[10px]',
|
||||||
t.slaBreached ? 'bg-red-100 text-red-700 font-medium' : 'bg-slate-100 text-slate-600',
|
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)}`}
|
title={`Hạn xử lý SLA: ${formatSlaDue(t.slaDueAt)}`}
|
||||||
>
|
>
|
||||||
@ -131,14 +228,21 @@ export function ItTicketsPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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 có 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 có 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ử lý</label>
|
<label className="text-[11px] font-semibold uppercase tracking-wide text-violet-700">Người xử lý</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ử lý —</option>
|
<option value="">— Chọn người xử lý —</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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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
|
||||||
|
className="icon-chip mt-0.5 shrink-0"
|
||||||
|
style={{ ['--chip-bg' as string]: 'var(--color-amberx-50)', ['--chip-fg' as string]: 'var(--color-amberx-700)' }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<CalendarDays className="h-4 w-4" />
|
||||||
|
</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">
|
<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))}>
|
<span className={cn('rounded-full px-2 py-0.5', statusBadgeClass(detailBooking.status))}>
|
||||||
{statusLabel(detailBooking.status)}
|
{statusLabel(detailBooking.status)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-slate-500">
|
<span className="text-slate-500">
|
||||||
Người tạo: <span className="font-medium text-slate-700">{detailBooking.bookedByFullName}</span>
|
Người tạo: <span className="font-medium text-brand-800">{detailBooking.bookedByFullName}</span>
|
||||||
</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>
|
||||||
<div className="flex items-center gap-2 text-slate-700">
|
<div className="mt-0.5 break-words font-medium text-brand-800">
|
||||||
<Clock className="h-4 w-4 text-slate-400" />
|
{detailBooking.roomCode} — {detailBooking.roomName}
|
||||||
<span>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="label-eyebrow flex items-center gap-1">
|
||||||
|
<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">Mô tả</Label>
|
<div className="label-eyebrow">Mô 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 có người tham dự —</p>
|
<p className="mt-1 text-xs text-slate-400">— Không có người tham dự —</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -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">Mã</th>
|
<th className="px-3 py-2.5 pl-5 text-left">Mã</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 có 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 có 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" />
|
||||||
|
|||||||
@ -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,8 +109,20 @@ 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 */}
|
||||||
|
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-brand-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-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<h3 className="text-sm font-semibold tracking-tight text-brand-800">Nội dung đề xuất</h3>
|
||||||
|
</header>
|
||||||
|
<div className="space-y-4 p-5">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="title">
|
<Label htmlFor="title">
|
||||||
Tiêu đề <span className="text-red-500">*</span>
|
Tiêu đề <span className="text-red-500">*</span>
|
||||||
@ -130,8 +149,22 @@ export function ProposalCreatePage() {
|
|||||||
/>
|
/>
|
||||||
<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')}>
|
||||||
|
|||||||
@ -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={
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'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')}>
|
<Button variant="outline" onClick={() => navigate('/proposals')}>
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Danh sách
|
Danh sách
|
||||||
</Button>
|
</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>
|
|
||||||
<div>
|
|
||||||
<Label className="text-muted-foreground">Người soạn</Label>
|
|
||||||
<div className="mt-1">{p.drafterFullName ?? '—'}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-muted-foreground">Quy trình</Label>
|
|
||||||
<div className="mt-1 text-xs">
|
|
||||||
{p.workflowCode ? (
|
{p.workflowCode ? (
|
||||||
<>
|
<span>
|
||||||
<span className="font-mono">{p.workflowCode}</span> - {p.workflowName}
|
<span className="font-mono">{p.workflowCode}</span> - {p.workflowName}
|
||||||
</>
|
</span>
|
||||||
) : '— Chưa chọn —'}
|
) : (
|
||||||
</div>
|
<span className="text-slate-300">— Chưa chọn —</span>
|
||||||
</div>
|
)}
|
||||||
<div>
|
</Field>
|
||||||
<Label className="text-muted-foreground">Ngày tạo</Label>
|
<Field label="Ngày tạo" value={formatDateTime(p.createdAt)} />
|
||||||
<div className="mt-1 text-xs">{formatDateTime(p.createdAt)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{p.description && (
|
{p.description && (
|
||||||
<div className="pt-3 border-t">
|
<Field label="Nội dung chi tiết" full>
|
||||||
<Label className="text-muted-foreground">Nội dung chi tiết</Label>
|
<span className="whitespace-pre-wrap font-normal text-slate-700">{p.description}</span>
|
||||||
<div className="mt-1 whitespace-pre-wrap text-sm">{p.description}</div>
|
</Field>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
{/* 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 có file đính kèm.</div>
|
<div className="text-sm text-muted-foreground">Chưa có 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
|
||||||
|
|||||||
@ -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,42 +114,39 @@ 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'}
|
||||||
|
label={c.label}
|
||||||
|
value={c.count}
|
||||||
|
icon={c.icon}
|
||||||
|
accent={c.accent}
|
||||||
|
active={status === c.value}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStatus(opt.value)
|
setStatus(c.value)
|
||||||
setPage(1)
|
setPage(1)
|
||||||
}}
|
}}
|
||||||
className={cn(
|
|
||||||
'rounded-md border px-3 py-1.5 text-sm transition',
|
|
||||||
status === opt.value
|
|
||||||
? 'border-primary bg-primary/10 text-primary font-medium'
|
|
||||||
: 'border-input bg-background hover:bg-accent',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center gap-2 text-sm">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={inboxOnly}
|
|
||||||
onChange={(e) => {
|
|
||||||
setInboxOnly(e.target.checked)
|
|
||||||
setPage(1)
|
|
||||||
}}
|
|
||||||
className="h-4 w-4"
|
|
||||||
/>
|
/>
|
||||||
Inbox duyệt
|
))}
|
||||||
</label>
|
<KpiCard
|
||||||
<div className="ml-auto flex items-center gap-2">
|
label="Cần tôi duyệt"
|
||||||
<Search className="h-4 w-4 text-muted-foreground" />
|
value={items.length}
|
||||||
|
icon={<Inbox className="h-4 w-4" />}
|
||||||
|
accent="teal"
|
||||||
|
active={inboxOnly}
|
||||||
|
onClick={() => {
|
||||||
|
setInboxOnly((v) => !v)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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
|
<Input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@ -127,35 +154,40 @@ export function ProposalsListPage() {
|
|||||||
setPage(1)
|
setPage(1)
|
||||||
}}
|
}}
|
||||||
placeholder="Tìm mã hoặc tiêu đề..."
|
placeholder="Tìm mã hoặc tiêu đề..."
|
||||||
className="w-64"
|
className="max-w-md border-0 bg-transparent px-0 shadow-none focus-visible:ring-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card">
|
<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">Mã</th>
|
<th className="label-eyebrow px-4 py-2.5 text-left">Mã</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 có đề xuất nào.
|
Chưa có đề 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">
|
||||||
|
|||||||
@ -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ố dư 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ố dư 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 có ý kiến.</div>
|
<div className="text-sm text-slate-400">Chưa có ý 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ỷ
|
||||||
|
|||||||
@ -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 có 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',
|
||||||
|
|||||||
Reference in New Issue
Block a user