[CLAUDE] FE: Văn phòng số foundation — shared PageHeader/KpiCard/WidgetCard + Dashboard landing (PURO · CSS Hồ sơ NS) + sync fe-admin index.css + menu Off_Dashboard
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m41s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m41s
- 3 shared component PageHeader/KpiCard/WidgetCard ×2 app SHA256-identical; tái dùng token Hồ sơ NS (.app-gradient-brand/.card-accent/.icon-chip/.stat-value/.label-eyebrow + accent palette teal/violet/amberx/greenx); gotcha #66 text-white! trên gradient header. - OfficeDashboardPage 2-cột widget kiểu PURO HomePage: Đề xuất/Đơn từ/Ticket/Phòng họp hôm nay + "Công việc của tôi" + Thao tác nhanh. Reuse query endpoint sẵn có (shared TanStack cache, KHÔNG BE/API mới), đếm client-side, loading/error/empty mỗi widget. - Sync fe-admin/src/index.css ← fe-user (đóng drift S66-S68: heading 600→700, ink #0f172a→#0b1220, label-eyebrow slate→brand-600 + rule gotcha #66). Nay 2 app đồng bộ. - Menu key Off_Dashboard (MenuKeys.cs const + All[] + DbInitializer seed Order 0 dưới Off) — no migration, idempotent. GIỮ ẨN non-Admin (RevokeTemporarilyHiddenModules StartsWith Off). Chưa golive. - Wire 4-place ×2 app: App.tsx route /office/dashboard + menuKeys.ts + Layout staticMap. - Fix KpiCard activeBorder -300 → -500 (accent palette chỉ 50/100/500/600/700 — chống "vỡ màu im lặng" Tailwind v4: border-teal-300 rơi default Tailwind, border-amberx-300 drop hẳn). - Build PASS x2 (fe-user index-DrxDysO7 / fe-admin index-TbkadgKd) + dotnet slnx 0/0. reviewer PASS 0 blocker. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -30,6 +30,7 @@ import { WorkflowAppsListPage } from '@/pages/office/WorkflowAppsListPage'
|
||||
import { WorkflowAppDetailPage } from '@/pages/office/WorkflowAppDetailPage'
|
||||
import { ItTicketsPage } from '@/pages/office/ItTicketsPage'
|
||||
import { MyAttendancePage } from '@/pages/office/MyAttendancePage'
|
||||
import { OfficeDashboardPage } from '@/pages/office/OfficeDashboardPage'
|
||||
import { HrmDashboardPage } from '@/pages/hrm/HrmDashboardPage'
|
||||
|
||||
function App() {
|
||||
@ -66,6 +67,8 @@ function App() {
|
||||
{/* Cấu hình HRM (Phase 10.2 G-H2 — Mig 35) */}
|
||||
<Route path="/hrm/configs" element={<Navigate to="/hrm/configs/leave-types" replace />} />
|
||||
<Route path="/hrm/configs/:kind" element={<HrmConfigsPage />} />
|
||||
{/* Văn phòng số — Bảng điều khiển (landing dashboard, Off_Dashboard) */}
|
||||
<Route path="/office/dashboard" element={<OfficeDashboardPage />} />
|
||||
{/* Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1) */}
|
||||
<Route path="/directory" element={<InternalDirectoryPage />} />
|
||||
{/* Văn phòng số — Phòng họp Booking + Catalog (Phase 10.2 G-O2 — Mig 36 S36) */}
|
||||
|
||||
@ -84,6 +84,7 @@ function resolvePath(key: string): string | null {
|
||||
Hrm_Config_Drivers: '/hrm/configs/drivers',
|
||||
// [Phase 10.2 G-O1 S34 2026-05-27] Module Văn phòng số — Danh bạ nội bộ.
|
||||
// 4-place mirror Pattern 16-bis: types/ + pages/ + App.tsx + menuKeys + staticMap.
|
||||
Off_Dashboard: '/office/dashboard',
|
||||
Off_DanhBa: '/directory',
|
||||
// [Phase 10.2 G-O2 S36 2026-05-28] Phòng họp Booking + Catalog (Mig 36).
|
||||
// Pattern 16-bis 4-place mirror 7× cumulative — staticMap = 4th place dễ miss.
|
||||
|
||||
107
fe-user/src/components/ui/KpiCard.tsx
Normal file
107
fe-user/src/components/ui/KpiCard.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import type { KeyboardEvent, ReactNode } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
// KpiCard — clickable stat card used as a FILTER chip (PURO pattern: a row of
|
||||
// KpiCards filters the list, replacing tabs). icon-chip + big stat-value +
|
||||
// .label-eyebrow. The ACTIVE state tints the background and rings in the accent;
|
||||
// hover lifts.
|
||||
//
|
||||
// Visual idiom from pages/hrm/EmployeesListPage.tsx (icon-chip + stat-value +
|
||||
// label-eyebrow tokens) and index.css. Accent palettes ship stops
|
||||
// 50/100/500/600/700 only (no -800) → stat text uses -700 (brand uses -800,
|
||||
// which exists). Mismatched stop = silent no-class in Tailwind v4.
|
||||
//
|
||||
// a11y: when onClick is given the card becomes role="button", focusable
|
||||
// (tabIndex 0), responds to Enter + Space, exposes aria-pressed=active, and
|
||||
// shows a focus-visible ring. Without onClick it is an inert presentational card.
|
||||
export type Accent = 'brand' | 'teal' | 'violet' | 'amberx' | 'greenx'
|
||||
|
||||
type AccentTokens = {
|
||||
chipBg: string
|
||||
chipFg: string
|
||||
value: string
|
||||
activeBg: string
|
||||
activeRing: string
|
||||
activeBorder: string
|
||||
}
|
||||
|
||||
// activeBorder uses the -500 stop: the accent palettes ship only
|
||||
// 50/100/500/600/700, so -300 (which only brand has) would silently fall to
|
||||
// Tailwind's DEFAULT teal/violet — or drop entirely for amberx/greenx (custom
|
||||
// names). -500 exists for EVERY accent → the active border always renders the
|
||||
// intended brand-aligned tone. (gotcha "vỡ màu im lặng" Tailwind v4.)
|
||||
const ACCENT: Record<Accent, AccentTokens> = {
|
||||
brand: { chipBg: 'var(--color-brand-50)', chipFg: 'var(--color-brand-600)', value: 'text-brand-800', activeBg: 'bg-brand-50', activeRing: 'ring-brand-500', activeBorder: 'border-brand-500' },
|
||||
teal: { chipBg: 'var(--color-teal-50)', chipFg: 'var(--color-teal-700)', value: 'text-teal-700', activeBg: 'bg-teal-50', activeRing: 'ring-teal-500', activeBorder: 'border-teal-500' },
|
||||
violet: { chipBg: 'var(--color-violet-50)', chipFg: 'var(--color-violet-700)', value: 'text-violet-700', activeBg: 'bg-violet-50', activeRing: 'ring-violet-500', activeBorder: 'border-violet-500' },
|
||||
amberx: { chipBg: 'var(--color-amberx-50)', chipFg: 'var(--color-amberx-700)', value: 'text-amberx-700', activeBg: 'bg-amberx-50', activeRing: 'ring-amberx-500', activeBorder: 'border-amberx-500' },
|
||||
greenx: { chipBg: 'var(--color-greenx-50)', chipFg: 'var(--color-greenx-700)', value: 'text-greenx-700', activeBg: 'bg-greenx-50', activeRing: 'ring-greenx-500', activeBorder: 'border-greenx-500' },
|
||||
}
|
||||
|
||||
export function KpiCard({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
accent = 'brand',
|
||||
active = false,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
/** Caption under the value (.label-eyebrow). */
|
||||
label: string
|
||||
/** The headline stat. */
|
||||
value: number | string
|
||||
/** Optional lucide icon node, shown inside the accent-tinted chip. */
|
||||
icon?: ReactNode
|
||||
/** Accent colour for the chip, value, and active ring (default brand). */
|
||||
accent?: Accent
|
||||
/** Highlight as the currently selected filter. */
|
||||
active?: boolean
|
||||
/** When set, the card behaves as a button (keyboard + pointer). */
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
}) {
|
||||
const a = ACCENT[accent]
|
||||
const clickable = !!onClick
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
|
||||
if (!onClick) return
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role={clickable ? 'button' : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
aria-pressed={clickable ? active : undefined}
|
||||
onClick={onClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'group flex items-center gap-3 rounded-xl border bg-white p-3.5 text-left transition',
|
||||
'shadow-[0_1px_2px_rgb(15_23_42/0.04),0_1px_3px_rgb(15_23_42/0.06)]',
|
||||
active ? cn(a.activeBg, a.activeBorder) : 'border-slate-200',
|
||||
clickable && 'cursor-pointer hover:-translate-y-0.5 hover:shadow-md motion-reduce:transform-none motion-reduce:transition-none',
|
||||
clickable && 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-offset-white',
|
||||
clickable && a.activeRing,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon && (
|
||||
<span
|
||||
className="icon-chip shrink-0"
|
||||
style={{ ['--chip-bg' as string]: a.chipBg, ['--chip-fg' as string]: a.chipFg }}
|
||||
aria-hidden
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className={cn('stat-value text-2xl', a.value)}>{value}</div>
|
||||
<div className="label-eyebrow mt-0.5 truncate">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
82
fe-user/src/components/ui/PageHeader.tsx
Normal file
82
fe-user/src/components/ui/PageHeader.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
// PageHeader (ui) — standard page header for the Văn phòng số (E-Office) module,
|
||||
// PURO-style: accent-tinted icon-chip + title + actions slot. Richer than the
|
||||
// constrained @/components/PageHeader ({title,description,actions}); this one
|
||||
// adds eyebrow / icon / accent / breadcrumb for module landing pages.
|
||||
//
|
||||
// Visual idiom copied from pages/hrm/EmployeesListPage.tsx: the ACCENT map
|
||||
// recolours the icon-chip (via --chip-bg / --chip-fg) and the heading. Accent
|
||||
// palettes (teal/violet/amberx/greenx) ship stops 50/100/500/600/700 ONLY — no
|
||||
// -800 — so headings use -700 (brand uses brand-800, which DOES exist). Using a
|
||||
// non-existent stop would silently emit no class in Tailwind v4.
|
||||
//
|
||||
// gotcha 66: index.css has `h1,h2,h3,h4 { color:#0b1220 }` OUTSIDE @layer, which
|
||||
// in Tailwind v4 beats `text-white`. There is no dark background in THIS header
|
||||
// (it sits on the light page), so the title uses the accent ink directly. Any
|
||||
// heading placed on a dark/gradient surface must use `text-white!`.
|
||||
export type Accent = 'brand' | 'teal' | 'violet' | 'amberx' | 'greenx'
|
||||
|
||||
type AccentTokens = { chipBg: string; chipFg: string; head: string }
|
||||
|
||||
const ACCENT: Record<Accent, AccentTokens> = {
|
||||
brand: { chipBg: 'var(--color-brand-50)', chipFg: 'var(--color-brand-600)', head: 'text-brand-800' },
|
||||
teal: { chipBg: 'var(--color-teal-50)', chipFg: 'var(--color-teal-700)', head: 'text-teal-700' },
|
||||
violet: { chipBg: 'var(--color-violet-50)', chipFg: 'var(--color-violet-700)', head: 'text-violet-700' },
|
||||
amberx: { chipBg: 'var(--color-amberx-50)', chipFg: 'var(--color-amberx-700)', head: 'text-amberx-700' },
|
||||
greenx: { chipBg: 'var(--color-greenx-50)', chipFg: 'var(--color-greenx-700)', head: 'text-greenx-700' },
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
accent = 'brand',
|
||||
actions,
|
||||
breadcrumb,
|
||||
className,
|
||||
}: {
|
||||
/** Optional uppercase kicker rendered above the title (.label-eyebrow). */
|
||||
eyebrow?: string
|
||||
/** Page title. */
|
||||
title: string
|
||||
/** Optional one-line description rendered below the title. */
|
||||
subtitle?: string
|
||||
/** Optional lucide icon node, shown inside the accent-tinted chip. */
|
||||
icon?: ReactNode
|
||||
/** Accent colour for the chip + title (default brand). */
|
||||
accent?: Accent
|
||||
/** Optional right-aligned actions slot (buttons, filters…). */
|
||||
actions?: ReactNode
|
||||
/** Optional breadcrumb node rendered above the whole header row. */
|
||||
breadcrumb?: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
const a = ACCENT[accent]
|
||||
return (
|
||||
<div className={cn('mb-5 border-b border-slate-200 pb-3.5', className)}>
|
||||
{breadcrumb && <div className="mb-2 text-xs text-slate-500">{breadcrumb}</div>}
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
{icon && (
|
||||
<span
|
||||
className="icon-chip mt-0.5 shrink-0"
|
||||
style={{ ['--chip-bg' as string]: a.chipBg, ['--chip-fg' as string]: a.chipFg }}
|
||||
aria-hidden
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{eyebrow && <div className="label-eyebrow mb-1">{eyebrow}</div>}
|
||||
<h1 className={cn('text-xl font-bold leading-tight tracking-tight', a.head)}>{title}</h1>
|
||||
{subtitle && <p className="mt-1 text-[13px] leading-relaxed text-slate-500">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
219
fe-user/src/components/ui/WidgetCard.tsx
Normal file
219
fe-user/src/components/ui/WidgetCard.tsx
Normal file
@ -0,0 +1,219 @@
|
||||
import type { KeyboardEvent, ReactNode } from 'react'
|
||||
import { Inbox, Maximize2, RefreshCw } from 'lucide-react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
// WidgetCard — dashboard widget container (PURO HomePage widget). A header
|
||||
// (brand gradient, or an accent-tinted bar) carries the title + optional refresh
|
||||
// / expand icon-buttons; an optional clickable stat-chip row sits under it; the
|
||||
// body is `children`, or a muted EmptyState when `empty`. The whole card is
|
||||
// wrapped with .card-accent so it gets the colored left rail.
|
||||
//
|
||||
// Visual idiom from pages/hrm/EmployeesListPage.tsx + index.css: .card-accent
|
||||
// (rail via --accent), .app-gradient-brand (gradient surface), .icon-chip, and
|
||||
// the stat-chip treatment. Accent palettes ship stops 50/100/500/600/700 only
|
||||
// (no -800).
|
||||
//
|
||||
// gotcha 66: index.css declares `h1,h2,h3,h4 { color:#0b1220 }` OUTSIDE any
|
||||
// @layer, so in Tailwind v4 it beats `text-white`. The brand-gradient header
|
||||
// uses an <h3>, therefore its title MUST be `text-white!` (with the important
|
||||
// bang) — plain `text-white` would render dark ink on the gradient. Accent
|
||||
// (non-brand) headers sit on a light tinted bar and use the accent ink instead.
|
||||
export type Accent = 'brand' | 'teal' | 'violet' | 'amberx' | 'greenx'
|
||||
|
||||
export type WidgetStat = {
|
||||
label: string
|
||||
value: number | string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
type AccentTokens = {
|
||||
rail: string
|
||||
chipBg: string
|
||||
chipFg: string
|
||||
head: string
|
||||
headBar: string
|
||||
statValue: string
|
||||
}
|
||||
|
||||
// rail = value for --accent on .card-accent; headBar = light tinted header bg
|
||||
// used for non-brand accents (brand uses the gradient instead).
|
||||
const ACCENT: Record<Accent, AccentTokens> = {
|
||||
brand: { rail: 'var(--color-brand-500)', chipBg: 'var(--color-brand-50)', chipFg: 'var(--color-brand-600)', head: 'text-brand-800', headBar: 'bg-brand-50', statValue: 'text-brand-800' },
|
||||
teal: { rail: 'var(--color-teal-500)', chipBg: 'var(--color-teal-50)', chipFg: 'var(--color-teal-700)', head: 'text-teal-700', headBar: 'bg-teal-50', statValue: 'text-teal-700' },
|
||||
violet: { rail: 'var(--color-violet-500)', chipBg: 'var(--color-violet-50)', chipFg: 'var(--color-violet-700)', head: 'text-violet-700', headBar: 'bg-violet-50', statValue: 'text-violet-700' },
|
||||
amberx: { rail: 'var(--color-amberx-500)', chipBg: 'var(--color-amberx-50)', chipFg: 'var(--color-amberx-700)', head: 'text-amberx-700', headBar: 'bg-amberx-50', statValue: 'text-amberx-700' },
|
||||
greenx: { rail: 'var(--color-greenx-500)', chipBg: 'var(--color-greenx-50)', chipFg: 'var(--color-greenx-700)', head: 'text-greenx-700', headBar: 'bg-greenx-50', statValue: 'text-greenx-700' },
|
||||
}
|
||||
|
||||
export function WidgetCard({
|
||||
title,
|
||||
icon,
|
||||
accent = 'brand',
|
||||
stats,
|
||||
onExpand,
|
||||
onRefresh,
|
||||
children,
|
||||
empty = false,
|
||||
emptyText = 'Chưa có dữ liệu.',
|
||||
className,
|
||||
}: {
|
||||
/** Widget title (rendered in the header). */
|
||||
title: string
|
||||
/** Optional lucide icon node shown before the title. */
|
||||
icon?: ReactNode
|
||||
/** Accent colour — brand uses the gradient header, others a tinted bar. */
|
||||
accent?: Accent
|
||||
/** Optional clickable stat chips rendered under the header. */
|
||||
stats?: WidgetStat[]
|
||||
/** When set, shows an expand icon-button in the header. */
|
||||
onExpand?: () => void
|
||||
/** When set, shows a refresh icon-button in the header. */
|
||||
onRefresh?: () => void
|
||||
/** Widget body. Ignored when `empty` is true. */
|
||||
children?: ReactNode
|
||||
/** Render the empty state instead of children. */
|
||||
empty?: boolean
|
||||
/** Message for the empty state. */
|
||||
emptyText?: string
|
||||
className?: string
|
||||
}) {
|
||||
const a = ACCENT[accent]
|
||||
const isBrand = accent === 'brand'
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn('card-accent flex min-w-0 flex-col overflow-hidden', className)}
|
||||
style={{ ['--accent' as string]: a.rail }}
|
||||
>
|
||||
{/* ===== Header ===== brand = gradient (white text, gotcha 66), else tinted bar */}
|
||||
<header
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-2 px-4 py-2.5',
|
||||
isBrand ? 'app-gradient-brand text-white' : cn(a.headBar, 'border-b border-slate-100'),
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{icon && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg',
|
||||
isBrand ? 'bg-white/15 text-white ring-1 ring-white/25' : 'icon-chip',
|
||||
)}
|
||||
style={isBrand ? undefined : { ['--chip-bg' as string]: a.chipBg, ['--chip-fg' as string]: a.chipFg }}
|
||||
aria-hidden
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
{/* gotcha 66: gradient header → text-white! (important) so the
|
||||
unlayered h1-h4 dark rule does not win. */}
|
||||
<h3 className={cn('truncate text-sm font-semibold tracking-tight', isBrand ? 'text-white!' : a.head)}>
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
{(onRefresh || onExpand) && (
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{onRefresh && (
|
||||
<IconButton onClick={onRefresh} label={`Làm mới ${title}`} onGradient={isBrand}>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
)}
|
||||
{onExpand && (
|
||||
<IconButton onClick={onExpand} label={`Mở rộng ${title}`} onGradient={isBrand}>
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* ===== Stat-chip row ===== */}
|
||||
{stats && stats.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 border-b border-slate-100 bg-slate-50/50 px-4 py-3">
|
||||
{stats.map((s, i) => (
|
||||
<StatChip key={i} stat={s} valueClass={a.statValue} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== Body / EmptyState ===== */}
|
||||
<div className="min-h-0 flex-1 p-4">
|
||||
{empty ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center">
|
||||
<span
|
||||
className="icon-chip"
|
||||
style={{ ['--chip-bg' as string]: '#f1f5f9', ['--chip-fg' as string]: '#94a3b8' }}
|
||||
aria-hidden
|
||||
>
|
||||
<Inbox className="h-4 w-4" />
|
||||
</span>
|
||||
<p className="text-xs text-slate-500">{emptyText}</p>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Small clickable stat — value + label. Falls back to an inert div when no
|
||||
// onClick (no button affordance, no focus ring).
|
||||
function StatChip({ stat, valueClass }: { stat: WidgetStat; valueClass: string }) {
|
||||
const clickable = !!stat.onClick
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
|
||||
if (!stat.onClick) return
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
stat.onClick()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role={clickable ? 'button' : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
onClick={stat.onClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'min-w-[5rem] rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-left transition',
|
||||
clickable &&
|
||||
'cursor-pointer hover:border-brand-300 hover:bg-brand-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white',
|
||||
)}
|
||||
>
|
||||
<div className={cn('stat-value text-lg', valueClass)}>{stat.value}</div>
|
||||
<div className="label-eyebrow mt-0.5 truncate">{stat.label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Header icon-button — adapts contrast to the gradient vs tinted header.
|
||||
function IconButton({
|
||||
onClick,
|
||||
label,
|
||||
onGradient,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void
|
||||
label: string
|
||||
onGradient: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
'inline-flex h-7 w-7 items-center justify-center rounded-lg transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1',
|
||||
onGradient
|
||||
? 'text-white/80 hover:bg-white/15 hover:text-white focus-visible:ring-white/70 focus-visible:ring-offset-transparent'
|
||||
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-700 focus-visible:ring-brand-500 focus-visible:ring-offset-white',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@ -42,6 +42,8 @@ export const MenuKeys = {
|
||||
HrmConfigDrivers: 'Hrm_Config_Drivers',
|
||||
// Module Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1 Session 34, 2026-05-27)
|
||||
Off: 'Off',
|
||||
// Bảng điều khiển Văn phòng số (landing dashboard — admin auto via All)
|
||||
OffDashboard: 'Off_Dashboard',
|
||||
OffDanhBa: 'Off_DanhBa',
|
||||
// Văn phòng số — Phòng họp (Phase 10.2 G-O2 — Mig 36 Session 36, 2026-05-28)
|
||||
OffPhongHop: 'Off_PhongHop',
|
||||
|
||||
474
fe-user/src/pages/office/OfficeDashboardPage.tsx
Normal file
474
fe-user/src/pages/office/OfficeDashboardPage.tsx
Normal file
@ -0,0 +1,474 @@
|
||||
// Bảng điều khiển Văn phòng số (E-Office) — landing dashboard, PURO HomePage style.
|
||||
// Composes the 3 shared ui widgets (PageHeader / KpiCard / WidgetCard) over the
|
||||
// EXISTING data hooks of the four E-Office modules. NO new API calls, NO new BE:
|
||||
// every query below mirrors the queryKey + endpoint already used by the module
|
||||
// pages, so the TanStack cache is shared and counts are computed client-side.
|
||||
//
|
||||
// • Đề xuất → GET /proposals (ProposalsListPage)
|
||||
// • Đơn từ → GET /leave|ot|travel-requests (WorkflowAppsListPage KIND_CONFIG)
|
||||
// • Ticket CNTT → GET /it-tickets (ItTicketsPage)
|
||||
// • Phòng họp → GET /meeting-bookings (MeetingCalendarPage)
|
||||
//
|
||||
// Layout: PageHeader (brand) on top, then a 2-col grid — LEFT (~2/3) a stack of
|
||||
// WidgetCards, RIGHT (~1/3) a "Công việc của tôi" panel + quick-action buttons.
|
||||
// Stacks to 1 column under lg. Each widget's onExpand navigates to its real route.
|
||||
// gotcha 66: any heading on the brand gradient lives inside WidgetCard, which
|
||||
// already uses text-white! — this page adds no gradient headings of its own.
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
AlertTriangle,
|
||||
CalendarDays,
|
||||
ClipboardList,
|
||||
FilePlus2,
|
||||
FileSignature,
|
||||
Inbox,
|
||||
LayoutDashboard,
|
||||
ListChecks,
|
||||
Plus,
|
||||
Ticket,
|
||||
} from 'lucide-react'
|
||||
import { PageHeader } from '@/components/ui/PageHeader'
|
||||
import { KpiCard } from '@/components/ui/KpiCard'
|
||||
import { WidgetCard } from '@/components/ui/WidgetCard'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { ProposalStatus, type PagedResult, type ProposalListItemDto } from '@/types/proposal'
|
||||
import {
|
||||
IT_TICKET_STATUS_LABELS,
|
||||
ItTicketStatus,
|
||||
WorkflowAppStatus,
|
||||
type ItTicketDto,
|
||||
type LeaveRequestDto,
|
||||
type OtRequestDto,
|
||||
type TravelRequestDto,
|
||||
} from '@/types/workflowApps'
|
||||
import type { MeetingBookingDto } from '@/types/meeting'
|
||||
|
||||
// ── date window for "today" meeting bookings (local midnight → next midnight) ──
|
||||
function todayWindow(): { start: string; end: string } {
|
||||
const start = new Date()
|
||||
start.setHours(0, 0, 0, 0)
|
||||
const end = new Date(start)
|
||||
end.setDate(end.getDate() + 1)
|
||||
return { start: start.toISOString(), end: end.toISOString() }
|
||||
}
|
||||
|
||||
function countByStatus<T extends { status: number }>(items: T[], status: number): number {
|
||||
return items.reduce((n, x) => (x.status === status ? n + 1 : n), 0)
|
||||
}
|
||||
|
||||
// A small skeleton body used while a widget's data is loading. Mimics a couple of
|
||||
// stat rows so the card keeps its height (no layout shift on resolve).
|
||||
function WidgetSkeleton() {
|
||||
return (
|
||||
<div className="space-y-2.5" aria-hidden>
|
||||
<div className="h-3.5 w-2/3 animate-pulse rounded bg-slate-100 motion-reduce:animate-none" />
|
||||
<div className="h-3.5 w-1/2 animate-pulse rounded bg-slate-100 motion-reduce:animate-none" />
|
||||
<div className="h-3.5 w-3/5 animate-pulse rounded bg-slate-100 motion-reduce:animate-none" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Inline error body — graceful, never blocks the page.
|
||||
function WidgetError({ onRetry }: { onRetry: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-6 text-center">
|
||||
<span
|
||||
className="icon-chip"
|
||||
style={{ ['--chip-bg' as string]: 'var(--color-accent-500)', ['--chip-fg' as string]: '#fff' }}
|
||||
aria-hidden
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
</span>
|
||||
<p className="text-xs text-slate-500">Không tải được dữ liệu.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="rounded-md px-2 py-1 text-xs font-medium text-brand-600 transition hover:bg-brand-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
|
||||
>
|
||||
Thử lại
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// A labelled metric line inside a widget body. `tone` tints the value; clickable
|
||||
// rows expose a button affordance (used to deep-link a filtered list view).
|
||||
function MetricRow({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
tone: string
|
||||
onClick?: () => void
|
||||
}) {
|
||||
const clickable = !!onClick
|
||||
return (
|
||||
<div
|
||||
role={clickable ? 'button' : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
onClick={onClick}
|
||||
onKeyDown={
|
||||
clickable
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onClick?.()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-3 rounded-lg px-2.5 py-1.5 transition',
|
||||
clickable &&
|
||||
'cursor-pointer hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500',
|
||||
)}
|
||||
>
|
||||
<span className="text-[13px] text-slate-600">{label}</span>
|
||||
<span className={cn('stat-value text-base', tone)}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function OfficeDashboardPage() {
|
||||
const navigate = useNavigate()
|
||||
const { start, end } = useMemo(todayWindow, [])
|
||||
|
||||
// ── Đề xuất ── same queryKey/endpoint as ProposalsListPage (shared cache).
|
||||
// First page, large size: enough to count the active workload client-side.
|
||||
const proposalsQ = useQuery({
|
||||
queryKey: ['proposals', { status: null, inboxOnly: false, search: '', page: 1, dashboard: true }],
|
||||
queryFn: async () =>
|
||||
(await api.get<PagedResult<ProposalListItemDto>>('/proposals', { params: { page: 1, pageSize: 100 } })).data,
|
||||
})
|
||||
// "Cần duyệt" = items in MY approval inbox (BE inboxOnly filter — the real
|
||||
// needs-my-action signal). Mirrors the inbox toggle on ProposalsListPage.
|
||||
const proposalsInboxQ = useQuery({
|
||||
queryKey: ['proposals', { status: null, inboxOnly: true, search: '', page: 1, dashboard: true }],
|
||||
queryFn: async () =>
|
||||
(await api.get<PagedResult<ProposalListItemDto>>('/proposals', { params: { inboxOnly: true, page: 1, pageSize: 100 } }))
|
||||
.data,
|
||||
})
|
||||
|
||||
// ── Đơn từ ── three endpoints from WorkflowAppsListPage KIND_CONFIG.
|
||||
const leaveQ = useQuery({
|
||||
queryKey: ['/leave-requests', { page: 1 }],
|
||||
queryFn: async () => (await api.get<PagedResult<LeaveRequestDto>>('/leave-requests', { params: { page: 1, pageSize: 50 } })).data,
|
||||
})
|
||||
const otQ = useQuery({
|
||||
queryKey: ['/ot-requests', { page: 1 }],
|
||||
queryFn: async () => (await api.get<PagedResult<OtRequestDto>>('/ot-requests', { params: { page: 1, pageSize: 50 } })).data,
|
||||
})
|
||||
const travelQ = useQuery({
|
||||
queryKey: ['/travel-requests', { page: 1 }],
|
||||
queryFn: async () => (await api.get<PagedResult<TravelRequestDto>>('/travel-requests', { params: { page: 1, pageSize: 50 } })).data,
|
||||
})
|
||||
|
||||
// ── Ticket CNTT ── same queryKey/endpoint as ItTicketsPage (shared cache).
|
||||
const ticketsQ = useQuery({
|
||||
queryKey: ['it-tickets'],
|
||||
queryFn: async () => (await api.get<PagedResult<ItTicketDto>>('/it-tickets', { params: { pageSize: 100 } })).data,
|
||||
})
|
||||
|
||||
// ── Phòng họp hôm nay ── /meeting-bookings windowed to today (no room filter).
|
||||
const meetingsTodayQ = useQuery({
|
||||
queryKey: ['meeting-bookings', { dashboard: 'today', start }],
|
||||
queryFn: async () =>
|
||||
(await api.get<MeetingBookingDto[]>('/meeting-bookings', { params: { startDate: start, endDate: end } })).data,
|
||||
})
|
||||
|
||||
// ── derived counts (client-side) ──
|
||||
const proposals = proposalsQ.data?.items ?? []
|
||||
const proposalTotal = proposalsQ.data?.total ?? proposals.length
|
||||
const proposalPending = countByStatus(proposals, ProposalStatus.DaGuiDuyet)
|
||||
const proposalInbox = proposalsInboxQ.data?.total ?? (proposalsInboxQ.data?.items.length ?? 0)
|
||||
|
||||
const donTu = useMemo(
|
||||
() => [...(leaveQ.data?.items ?? []), ...(otQ.data?.items ?? []), ...(travelQ.data?.items ?? [])],
|
||||
[leaveQ.data, otQ.data, travelQ.data],
|
||||
)
|
||||
const donTuSubmitted = countByStatus(donTu, WorkflowAppStatus.DaGuiDuyet)
|
||||
const donTuReturned = countByStatus(donTu, WorkflowAppStatus.TraLai)
|
||||
const donTuApproved = countByStatus(donTu, WorkflowAppStatus.DaDuyet)
|
||||
const donTuLoading = leaveQ.isLoading || otQ.isLoading || travelQ.isLoading
|
||||
const donTuError = leaveQ.isError || otQ.isError || travelQ.isError
|
||||
|
||||
const tickets = ticketsQ.data?.items ?? []
|
||||
const ticketOpen = countByStatus(tickets, ItTicketStatus.Open)
|
||||
const ticketInProgress = countByStatus(tickets, ItTicketStatus.InProgress)
|
||||
const ticketBreached = tickets.reduce((n, t) => (t.slaBreached ? n + 1 : n), 0)
|
||||
|
||||
const meetingsToday = meetingsTodayQ.data ?? []
|
||||
const meetingsTodayCount = meetingsToday.length
|
||||
|
||||
// "Công việc của tôi" — total items currently awaiting THIS user's action across
|
||||
// the modules. Proposals come from the BE inbox filter; đơn-từ in "Đã gửi duyệt"
|
||||
// are pending approval. (Tickets have their own assignment flow, surfaced in
|
||||
// their widget rather than double-counted here.)
|
||||
const myTodo = proposalInbox + donTuSubmitted
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageHeader
|
||||
eyebrow="Văn phòng số"
|
||||
title="Bảng điều khiển"
|
||||
subtitle="Tổng quan đề xuất, đơn từ, ticket và lịch họp trong ngày"
|
||||
icon={<LayoutDashboard className="h-5 w-5" />}
|
||||
accent="brand"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
|
||||
{/* ───────────────────────── LEFT (~2/3) — widget stack ───────────────────────── */}
|
||||
<div className="flex flex-col gap-5 lg:col-span-2">
|
||||
{/* Đề xuất */}
|
||||
<WidgetCard
|
||||
title="Đề xuất"
|
||||
icon={<FileSignature className="h-4 w-4" />}
|
||||
accent="brand"
|
||||
onExpand={() => navigate('/proposals')}
|
||||
onRefresh={() => {
|
||||
proposalsQ.refetch()
|
||||
proposalsInboxQ.refetch()
|
||||
}}
|
||||
empty={!proposalsQ.isLoading && !proposalsQ.isError && proposalTotal === 0}
|
||||
emptyText="Chưa có đề xuất nào."
|
||||
>
|
||||
{proposalsQ.isError ? (
|
||||
<WidgetError onRetry={() => proposalsQ.refetch()} />
|
||||
) : proposalsQ.isLoading ? (
|
||||
<WidgetSkeleton />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<KpiCard
|
||||
label="Cần duyệt"
|
||||
value={proposalInbox}
|
||||
icon={<Inbox className="h-4 w-4" />}
|
||||
accent="amberx"
|
||||
onClick={() => navigate('/proposals?inboxOnly=true')}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Chờ duyệt"
|
||||
value={proposalPending}
|
||||
icon={<ClipboardList className="h-4 w-4" />}
|
||||
accent="brand"
|
||||
onClick={() => navigate(`/proposals?status=${ProposalStatus.DaGuiDuyet}`)}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Tất cả"
|
||||
value={proposalTotal}
|
||||
icon={<ListChecks className="h-4 w-4" />}
|
||||
accent="teal"
|
||||
onClick={() => navigate('/proposals')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</WidgetCard>
|
||||
|
||||
{/* Đơn từ (nghỉ phép / OT / công tác) */}
|
||||
<WidgetCard
|
||||
title="Đơn từ"
|
||||
icon={<FileSignature className="h-4 w-4" />}
|
||||
accent="teal"
|
||||
onExpand={() => navigate('/workflow-apps/leave')}
|
||||
onRefresh={() => {
|
||||
leaveQ.refetch()
|
||||
otQ.refetch()
|
||||
travelQ.refetch()
|
||||
}}
|
||||
empty={!donTuLoading && !donTuError && donTu.length === 0}
|
||||
emptyText="Chưa có đơn từ nào."
|
||||
>
|
||||
{donTuError ? (
|
||||
<WidgetError
|
||||
onRetry={() => {
|
||||
leaveQ.refetch()
|
||||
otQ.refetch()
|
||||
travelQ.refetch()
|
||||
}}
|
||||
/>
|
||||
) : donTuLoading ? (
|
||||
<WidgetSkeleton />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<KpiCard
|
||||
label="Đã gửi duyệt"
|
||||
value={donTuSubmitted}
|
||||
accent="amberx"
|
||||
onClick={() => navigate('/workflow-apps/leave')}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Trả lại"
|
||||
value={donTuReturned}
|
||||
accent="violet"
|
||||
onClick={() => navigate('/workflow-apps/leave')}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Đã duyệt"
|
||||
value={donTuApproved}
|
||||
accent="greenx"
|
||||
onClick={() => navigate('/workflow-apps/leave')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</WidgetCard>
|
||||
|
||||
{/* Ticket CNTT */}
|
||||
<WidgetCard
|
||||
title="Ticket CNTT"
|
||||
icon={<Ticket className="h-4 w-4" />}
|
||||
accent="violet"
|
||||
onExpand={() => navigate('/it-tickets')}
|
||||
onRefresh={() => ticketsQ.refetch()}
|
||||
empty={!ticketsQ.isLoading && !ticketsQ.isError && tickets.length === 0}
|
||||
emptyText="Chưa có ticket nào."
|
||||
>
|
||||
{ticketsQ.isError ? (
|
||||
<WidgetError onRetry={() => ticketsQ.refetch()} />
|
||||
) : ticketsQ.isLoading ? (
|
||||
<WidgetSkeleton />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<KpiCard
|
||||
label={IT_TICKET_STATUS_LABELS[ItTicketStatus.Open]}
|
||||
value={ticketOpen}
|
||||
icon={<Inbox className="h-4 w-4" />}
|
||||
accent="violet"
|
||||
onClick={() => navigate('/it-tickets')}
|
||||
/>
|
||||
<KpiCard
|
||||
label={IT_TICKET_STATUS_LABELS[ItTicketStatus.InProgress]}
|
||||
value={ticketInProgress}
|
||||
icon={<ClipboardList className="h-4 w-4" />}
|
||||
accent="brand"
|
||||
onClick={() => navigate('/it-tickets')}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Quá hạn SLA"
|
||||
value={ticketBreached}
|
||||
icon={<AlertTriangle className="h-4 w-4" />}
|
||||
accent="amberx"
|
||||
onClick={() => navigate('/it-tickets')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</WidgetCard>
|
||||
|
||||
{/* Phòng họp hôm nay */}
|
||||
<WidgetCard
|
||||
title="Phòng họp hôm nay"
|
||||
icon={<CalendarDays className="h-4 w-4" />}
|
||||
accent="amberx"
|
||||
onExpand={() => navigate('/meeting-calendar')}
|
||||
onRefresh={() => meetingsTodayQ.refetch()}
|
||||
empty={!meetingsTodayQ.isLoading && !meetingsTodayQ.isError && meetingsTodayCount === 0}
|
||||
emptyText="Hôm nay chưa có lịch họp."
|
||||
>
|
||||
{meetingsTodayQ.isError ? (
|
||||
<WidgetError onRetry={() => meetingsTodayQ.refetch()} />
|
||||
) : meetingsTodayQ.isLoading ? (
|
||||
<WidgetSkeleton />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<KpiCard
|
||||
label="Lịch họp hôm nay"
|
||||
value={meetingsTodayCount}
|
||||
icon={<CalendarDays className="h-4 w-4" />}
|
||||
accent="amberx"
|
||||
onClick={() => navigate('/meeting-calendar')}
|
||||
/>
|
||||
</div>
|
||||
{/* A compact peek of the next few bookings today. */}
|
||||
<ul className="divide-y divide-slate-100 overflow-hidden rounded-lg border border-slate-200">
|
||||
{meetingsToday
|
||||
.slice()
|
||||
.sort((a, b) => new Date(a.startAt).getTime() - new Date(b.startAt).getTime())
|
||||
.slice(0, 4)
|
||||
.map((b) => (
|
||||
<li key={b.id} className="flex items-center justify-between gap-3 px-3 py-2 text-xs">
|
||||
<span className="min-w-0 truncate font-medium text-slate-700">{b.title}</span>
|
||||
<span className="shrink-0 tabular-nums text-slate-500">
|
||||
{new Date(b.startAt).toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' })} ·{' '}
|
||||
{b.roomCode}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</WidgetCard>
|
||||
</div>
|
||||
|
||||
{/* ───────────────────────── RIGHT (~1/3) — my work + actions ───────────────────────── */}
|
||||
<div className="flex flex-col gap-5 lg:col-span-1">
|
||||
{/* Công việc của tôi */}
|
||||
<WidgetCard
|
||||
title="Công việc của tôi"
|
||||
icon={<ListChecks className="h-4 w-4" />}
|
||||
accent="brand"
|
||||
empty={false}
|
||||
>
|
||||
{proposalsInboxQ.isError ? (
|
||||
<WidgetError onRetry={() => proposalsInboxQ.refetch()} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-end justify-between gap-3 rounded-xl bg-brand-50 px-3.5 py-3">
|
||||
<div>
|
||||
<div className="label-eyebrow">Cần xử lý</div>
|
||||
<p className="mt-0.5 text-[13px] text-slate-600">Mục đang chờ thao tác của bạn</p>
|
||||
</div>
|
||||
<div className="stat-value text-3xl text-brand-800">
|
||||
{proposalsInboxQ.isLoading || donTuLoading ? '—' : myTodo}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<MetricRow
|
||||
label="Đề xuất chờ tôi duyệt"
|
||||
value={proposalsInboxQ.isLoading ? 0 : proposalInbox}
|
||||
tone="text-brand-800"
|
||||
onClick={() => navigate('/proposals?inboxOnly=true')}
|
||||
/>
|
||||
<MetricRow
|
||||
label="Đơn từ đã gửi duyệt"
|
||||
value={donTuLoading ? 0 : donTuSubmitted}
|
||||
tone="text-teal-700"
|
||||
onClick={() => navigate('/workflow-apps/leave')}
|
||||
/>
|
||||
<MetricRow
|
||||
label="Ticket đang mở"
|
||||
value={ticketsQ.isLoading ? 0 : ticketOpen}
|
||||
tone="text-violet-700"
|
||||
onClick={() => navigate('/it-tickets')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</WidgetCard>
|
||||
|
||||
{/* Thao tác nhanh */}
|
||||
<section className="card-accent flex flex-col gap-2 p-4" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
|
||||
<h3 className="mb-1 text-sm font-semibold tracking-tight text-slate-800">Thao tác nhanh</h3>
|
||||
<Button variant="primary" className="justify-start" onClick={() => navigate('/proposals/new')}>
|
||||
<FilePlus2 className="h-4 w-4" />
|
||||
Tạo đề xuất
|
||||
</Button>
|
||||
<Button variant="secondary" className="justify-start" onClick={() => navigate('/workflow-apps/leave')}>
|
||||
<FileSignature className="h-4 w-4" />
|
||||
Tạo đơn
|
||||
</Button>
|
||||
<Button variant="outline" className="justify-start" onClick={() => navigate('/it-tickets')}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tạo ticket
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user