feat(web): NavUser chrome и унификация ReUI PRO kit
Тема в NavUser вместо ModeToggle в header; KpiStatGrid/QuickActionGrid по эталону EvoBGP; обновлены rules и contract. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
SettingsIcon,
|
||||
} from 'lucide-react'
|
||||
import { AppSwitcher } from '@/components/app-switcher'
|
||||
import { NavUser } from '@/components/layout/nav-user'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -93,7 +94,8 @@ export function AppSidebar() {
|
||||
<NavSection label="Операции" items={opsNav} pathname={pathname} />
|
||||
<NavSection label="Система" items={systemNav} pathname={pathname} />
|
||||
</SidebarContent>
|
||||
<SidebarFooter />
|
||||
</Sidebar>
|
||||
<SidebarFooter>
|
||||
<NavUser />
|
||||
</SidebarFooter> </Sidebar>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import {
|
||||
ChevronsUpDownIcon,
|
||||
LogOutIcon,
|
||||
MonitorIcon,
|
||||
MoonIcon,
|
||||
PaletteIcon,
|
||||
SettingsIcon,
|
||||
SunIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
import { Avatar, AvatarFallback } from '@evofw/ui/components/avatar'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evofw/ui/components/dropdown-menu'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@evofw/ui/components/sidebar'
|
||||
|
||||
import {
|
||||
authPortalUrl,
|
||||
getClaims,
|
||||
isAuthEnabled,
|
||||
logout,
|
||||
} from '@/lib/auth'
|
||||
|
||||
/** Sidebar footer NavUser — theme in footer, not header. @see https://reui.io/preview/base/app-shell-1 */
|
||||
|
||||
const THEMES = [
|
||||
{
|
||||
value: 'light',
|
||||
label: 'Светлая',
|
||||
icon: <SunIcon className="size-3.5" aria-hidden />,
|
||||
},
|
||||
{
|
||||
value: 'dark',
|
||||
label: 'Тёмная',
|
||||
icon: <MoonIcon className="size-3.5" aria-hidden />,
|
||||
},
|
||||
{
|
||||
value: 'system',
|
||||
label: 'Системная',
|
||||
icon: <MonitorIcon className="size-3.5" aria-hidden />,
|
||||
},
|
||||
] as const
|
||||
|
||||
function ThemeSegmentedToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const currentTheme = mounted ? (theme ?? 'system') : 'system'
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Тема"
|
||||
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{THEMES.map(({ value, label, icon }) => {
|
||||
const isActive = currentTheme === value
|
||||
return (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
aria-label={label}
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setTheme(value)}
|
||||
className={cn(
|
||||
'rounded-full',
|
||||
isActive
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function initials(source: string): string {
|
||||
const base = source.trim()
|
||||
if (!base) return '?'
|
||||
const parts = base.split(/\s+/).filter(Boolean)
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
|
||||
}
|
||||
return base.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
export function NavUser() {
|
||||
const { isMobile } = useSidebar()
|
||||
const authOn = isAuthEnabled()
|
||||
const claims = getClaims()
|
||||
|
||||
const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость')
|
||||
const email = claims?.email?.trim() || (authOn ? '' : 'auth выключен')
|
||||
const fallback = initials(name || email)
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
<AvatarFallback className="rounded-lg text-xs">{fallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{email || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--anchor-width) min-w-56 rounded-lg"
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
<AvatarFallback className="rounded-lg text-xs">
|
||||
{fallback}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{email || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<Link to="/settings" />}
|
||||
>
|
||||
<SettingsIcon aria-hidden />
|
||||
Настройки
|
||||
</DropdownMenuItem>
|
||||
{authOn ? (
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<a href={authPortalUrl()} />}
|
||||
>
|
||||
Auth Portal
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
||||
<PaletteIcon aria-hidden />
|
||||
Тема
|
||||
<div className="ml-auto">
|
||||
<ThemeSegmentedToggle />
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
{authOn ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => logout()}>
|
||||
<LogOutIcon aria-hidden />
|
||||
Выйти
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import { Separator } from '@evofw/ui/components/separator'
|
||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||
import { ModeToggle } from '@/components/mode-toggle'
|
||||
import { SidebarTrigger } from '@evofw/ui/components/sidebar'
|
||||
|
||||
export interface RouteBreadcrumbLoaderData {
|
||||
@@ -71,7 +70,7 @@ function useDynamicBreadcrumbLabels() {
|
||||
}, [matches])
|
||||
}
|
||||
|
||||
/** Header chrome — AppsMenu + SystemMonitor + ModeToggle (EvoBGP etalon). */
|
||||
/** Header chrome — AppsMenu + SystemMonitor; theme in NavUser. */
|
||||
export function SiteHeader() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const dynamicLabels = useDynamicBreadcrumbLabels()
|
||||
@@ -114,7 +113,6 @@ export function SiteHeader() {
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<AppsMenu />
|
||||
<SystemMonitorPopover />
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evofw/ui/components/dropdown-menu'
|
||||
|
||||
export function ModeToggle() {
|
||||
const { setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" />}>
|
||||
<Sun className="size-5 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
||||
<Moon className="absolute size-5 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
||||
<span className="sr-only">Сменить тему</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>Светлая</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>Тёмная</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('system')}>Системная</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -10,7 +10,12 @@ export {
|
||||
} from './resource-page'
|
||||
export {
|
||||
KpiStatGrid,
|
||||
type KpiStatCard,
|
||||
KpiStatCard,
|
||||
KpiStatCardTile,
|
||||
kpiStatItemKey,
|
||||
type KpiStatItem,
|
||||
type KpiStatCardData,
|
||||
type KpiStatCard as KpiStatCardType,
|
||||
type KpiStatVariant,
|
||||
type OpsKpiCard,
|
||||
} from './kpi-stat-grid'
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Shared grid column classes for hybrid KPI / Quick Actions tiles. */
|
||||
export function kpiCols(count: number): string {
|
||||
if (count <= 1) return 'grid-cols-1'
|
||||
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
|
||||
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
||||
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
|
||||
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
|
||||
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
|
||||
}
|
||||
@@ -1,40 +1,44 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { KeyboardEvent, ReactNode } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Item, ItemMedia } from '@evofw/ui/components/item'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
import { kpiCols } from './kpi-cols'
|
||||
import { Item, ItemMedia } from '@evofw/ui/components/item'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
|
||||
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
||||
|
||||
export interface KpiStatCard {
|
||||
id: string
|
||||
label: string
|
||||
value: string | number
|
||||
hint?: string
|
||||
/**
|
||||
* KPI tile data — horizontal compact hybrid (icon left + label/Badge + value).
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export type KpiStatItem = {
|
||||
id?: string
|
||||
label: ReactNode
|
||||
value: ReactNode
|
||||
hint?: ReactNode
|
||||
to?: string
|
||||
search?: Record<string, unknown>
|
||||
onSelect?: () => void
|
||||
onClick?: () => void
|
||||
selected?: boolean
|
||||
active?: boolean
|
||||
icon?: ReactNode
|
||||
iconClassName?: string
|
||||
/** Semantic color for the primary value */
|
||||
variant?: KpiStatVariant
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
/** @deprecated Use KpiStatCard */
|
||||
export type OpsKpiCard = KpiStatCard
|
||||
/** CFDM-compatible card shape (id required). */
|
||||
export type KpiStatCardData = KpiStatItem & { id: string }
|
||||
|
||||
interface KpiStatGridProps {
|
||||
cards: KpiStatCard[]
|
||||
isLoading?: boolean
|
||||
emptyMessage?: ReactNode
|
||||
emptyIcon?: ReactNode
|
||||
className?: string
|
||||
skeletonCount?: number
|
||||
}
|
||||
/** @deprecated Use KpiStatCardData */
|
||||
export type OpsKpiCard = KpiStatCardData
|
||||
|
||||
/** @deprecated Use KpiStatCardData — type alias for CFDM kit parity */
|
||||
export type KpiStatCard = KpiStatCardData
|
||||
|
||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||
|
||||
@@ -44,107 +48,146 @@ const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
|
||||
destructive: 'text-destructive',
|
||||
}
|
||||
|
||||
function kpiCols(count: number): string {
|
||||
if (count <= 1) return 'grid-cols-1'
|
||||
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
|
||||
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
||||
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
|
||||
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
|
||||
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
|
||||
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onActivate()
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFooter(card: KpiStatCard): ReactNode {
|
||||
if (card.footer) return card.footer
|
||||
if (card.hint) {
|
||||
function resolveActivate(item: KpiStatItem): (() => void) | undefined {
|
||||
return item.onClick ?? item.onSelect
|
||||
}
|
||||
|
||||
function isSelected(item: KpiStatItem): boolean {
|
||||
return Boolean(item.selected ?? item.active)
|
||||
}
|
||||
|
||||
function resolveFooter(item: KpiStatItem): ReactNode {
|
||||
if (item.footer) return item.footer
|
||||
if (typeof item.hint === 'string') {
|
||||
return (
|
||||
<Badge variant="outline" size="sm">
|
||||
{card.hint}
|
||||
{item.hint}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (item.hint) return item.hint
|
||||
return null
|
||||
}
|
||||
|
||||
function KpiStatCardBody({ card }: { card: KpiStatCard }) {
|
||||
const footer = resolveFooter(card)
|
||||
const valueVariant = card.variant ?? 'default'
|
||||
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||
const footer = resolveFooter(item)
|
||||
const valueVariant = item.variant ?? 'default'
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
{card.icon ? (
|
||||
{item.icon ? (
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||
card.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||
item.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{card.icon}
|
||||
{item.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-muted-foreground text-sm font-medium">{card.label}</span>
|
||||
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
|
||||
{footer ? <div className="shrink-0">{footer}</div> : null}
|
||||
</div>
|
||||
<span
|
||||
<div
|
||||
className={cn(
|
||||
'text-2xl leading-none font-bold tabular-nums',
|
||||
VALUE_VARIANT_CLASS[valueVariant],
|
||||
)}
|
||||
>
|
||||
{card.value}
|
||||
</span>
|
||||
{item.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KpiStatCardItem({ card }: { card: KpiStatCard }) {
|
||||
const interactive = Boolean(card.to || card.onSelect)
|
||||
const panelClass = cn(
|
||||
'relative isolate flex h-full flex-col',
|
||||
card.selected && 'ring-primary/30 bg-muted/30 ring-1',
|
||||
interactive &&
|
||||
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
|
||||
)
|
||||
function panelClassName(item: KpiStatItem, className?: string) {
|
||||
const onActivate = resolveActivate(item)
|
||||
const clickable = Boolean(item.to || onActivate)
|
||||
const selected = isSelected(item)
|
||||
|
||||
if (card.to) {
|
||||
return (
|
||||
return cn(
|
||||
'relative isolate flex h-full flex-col',
|
||||
clickable &&
|
||||
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
|
||||
selected && 'ring-primary/30 bg-muted/30 ring-1',
|
||||
className,
|
||||
)
|
||||
}
|
||||
|
||||
/** Single KPI tile — used for embedded / standalone contexts. */
|
||||
export function KpiStatCardTile({
|
||||
item,
|
||||
embedded = false,
|
||||
className,
|
||||
}: {
|
||||
item: KpiStatItem
|
||||
embedded?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const onActivate = resolveActivate(item)
|
||||
const panelClass = panelClassName(item, className)
|
||||
|
||||
let panel: ReactNode
|
||||
|
||||
if (item.to) {
|
||||
panel = (
|
||||
<FramePanel className={panelClass}>
|
||||
<Link
|
||||
to={card.to}
|
||||
{...(card.search ? { search: card.search } : {})}
|
||||
className="focus-visible:outline-none"
|
||||
>
|
||||
<KpiStatCardBody card={card} />
|
||||
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
||||
<KpiStatCardBody item={item} />
|
||||
</Link>
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
if (card.onSelect) {
|
||||
return (
|
||||
} else if (onActivate) {
|
||||
panel = (
|
||||
<FramePanel
|
||||
className={panelClass}
|
||||
onClick={onActivate}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||
>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
} else {
|
||||
panel = (
|
||||
<FramePanel className={panelClass}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={card.onSelect}
|
||||
className="w-full text-start focus-visible:outline-none"
|
||||
>
|
||||
<KpiStatCardBody card={card} />
|
||||
</button>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FramePanel className={panelClass}>
|
||||
<KpiStatCardBody card={card} />
|
||||
</FramePanel>
|
||||
)
|
||||
if (embedded) {
|
||||
return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
|
||||
}
|
||||
|
||||
return <Frame className="h-full">{panel}</Frame>
|
||||
}
|
||||
|
||||
/** @deprecated Prefer KpiStatCardTile — kept for existing imports */
|
||||
export function KpiStatCard({
|
||||
item,
|
||||
embedded = false,
|
||||
className,
|
||||
}: {
|
||||
item: KpiStatItem
|
||||
embedded?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
return <KpiStatCardTile item={item} embedded={embedded} className={className} />
|
||||
}
|
||||
|
||||
function KpiStatGridSkeleton({ count }: { count: number }) {
|
||||
@@ -168,23 +211,78 @@ function KpiStatGridSkeleton({ count }: { count: number }) {
|
||||
)
|
||||
}
|
||||
|
||||
interface KpiStatGridProps {
|
||||
/** EvoBGP primary API */
|
||||
items?: KpiStatItem[]
|
||||
/** CFDM-compatible API */
|
||||
cards?: KpiStatCardData[]
|
||||
isLoading?: boolean
|
||||
emptyMessage?: ReactNode
|
||||
emptyIcon?: ReactNode
|
||||
className?: string
|
||||
skeletonCount?: number
|
||||
/** Wrap each tile in its own Frame (analytics panels). */
|
||||
embedded?: boolean
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
|
||||
const onActivate = resolveActivate(item)
|
||||
const panelClass = panelClassName(item)
|
||||
|
||||
if (item.to) {
|
||||
return (
|
||||
<FramePanel className={panelClass}>
|
||||
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
||||
<KpiStatCardBody item={item} />
|
||||
</Link>
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
if (onActivate) {
|
||||
return (
|
||||
<FramePanel
|
||||
className={panelClass}
|
||||
onClick={onActivate}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||
>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FramePanel className={panelClass}>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hybrid KPI — EvoBGP visual (colored icon + Badge) + horizontal compact layout.
|
||||
* Hybrid KPI — EvoBGP visual + horizontal compact layout (icon left).
|
||||
* Preview: https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export function KpiStatGrid({
|
||||
items,
|
||||
cards,
|
||||
isLoading = false,
|
||||
emptyMessage,
|
||||
emptyIcon,
|
||||
className,
|
||||
skeletonCount = 4,
|
||||
embedded = false,
|
||||
'aria-label': ariaLabel,
|
||||
}: KpiStatGridProps) {
|
||||
if (isLoading) {
|
||||
return <KpiStatGridSkeleton count={skeletonCount} />
|
||||
}
|
||||
|
||||
if (cards.length === 0) {
|
||||
const list = items ?? cards ?? []
|
||||
|
||||
if (list.length === 0 && (emptyMessage || emptyIcon)) {
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn('w-full', className)}>
|
||||
<FramePanel className="flex items-center gap-3 p-4">
|
||||
@@ -197,13 +295,31 @@ export function KpiStatGrid({
|
||||
)
|
||||
}
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
|
||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame className={cn('@container w-full', className)}>
|
||||
<div className={cn('grid gap-2', kpiCols(cards.length))}>
|
||||
{cards.map((card) => (
|
||||
<KpiStatCardItem key={card.id} card={card} />
|
||||
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
|
||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
|
||||
if (item.id) return item.id
|
||||
if (typeof item.label === 'string') return item.label
|
||||
return `kpi-${index}`
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Item, ItemMedia } from '@evofw/ui/components/item'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
import { kpiCols } from './kpi-cols'
|
||||
|
||||
export interface QuickActionItem {
|
||||
id: string
|
||||
@@ -30,16 +31,6 @@ interface QuickActionGridProps {
|
||||
|
||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||
|
||||
function kpiCols(count: number): string {
|
||||
if (count <= 1) return 'grid-cols-1'
|
||||
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
|
||||
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
||||
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
|
||||
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
|
||||
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
|
||||
}
|
||||
|
||||
function QuickActionBody({ action }: { action: QuickActionItem }) {
|
||||
return (
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
@@ -99,7 +90,7 @@ export function QuickActionGrid({
|
||||
>
|
||||
<Link
|
||||
to={action.to}
|
||||
{...(action.search ? { search: action.search } : {})}
|
||||
search={action.search}
|
||||
className="focus-visible:outline-none"
|
||||
aria-label={`${action.title}: ${action.description}`}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user