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

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

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

View File

@ -62,6 +62,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.

View File

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

View File

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

View File

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