|
|
|
@@ -2,15 +2,15 @@ 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 { cn } from '@evobgp/ui/lib/utils'
|
|
|
|
|
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
|
|
|
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
|
|
|
|
|
|
|
|
|
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* KPI tile data — stats-12 visual (icon tile + value + label + footer).
|
|
|
|
|
* CFDM-compatible: id, label, value, hint?, to?, search?, onSelect?, selected?
|
|
|
|
|
* EvoBGP: icon?, iconClassName?, footer?, active?, onClick?
|
|
|
|
|
*
|
|
|
|
|
* KPI tile data — horizontal compact hybrid (icon left + label/Badge + value).
|
|
|
|
|
* @see https://reui.io/preview/base/stats-12
|
|
|
|
|
*/
|
|
|
|
|
export type KpiStatItem = {
|
|
|
|
@@ -26,6 +26,7 @@ export type KpiStatItem = {
|
|
|
|
|
active?: boolean
|
|
|
|
|
icon?: ReactNode
|
|
|
|
|
iconClassName?: string
|
|
|
|
|
variant?: KpiStatVariant
|
|
|
|
|
footer?: ReactNode
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -40,7 +41,13 @@ export type KpiStatCard = KpiStatCardData
|
|
|
|
|
|
|
|
|
|
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
|
|
|
|
|
|
|
|
|
function kpiStatGridClassName(count: number): string {
|
|
|
|
|
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
|
|
|
|
|
default: 'text-foreground',
|
|
|
|
|
warning: 'text-warning',
|
|
|
|
|
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'
|
|
|
|
@@ -65,19 +72,29 @@ 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">
|
|
|
|
|
{item.hint}
|
|
|
|
|
</Badge>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
if (item.hint) return item.hint
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
|
|
|
|
const footer =
|
|
|
|
|
item.footer ??
|
|
|
|
|
(item.hint ? (
|
|
|
|
|
<span className="text-muted-foreground text-xs leading-snug">{item.hint}</span>
|
|
|
|
|
) : null)
|
|
|
|
|
const footer = resolveFooter(item)
|
|
|
|
|
const valueVariant = item.variant ?? 'default'
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<div className="relative z-10 flex h-full items-start gap-3">
|
|
|
|
|
{item.icon ? (
|
|
|
|
|
<Item
|
|
|
|
|
className={cn(
|
|
|
|
|
'border-background bg-muted flex size-10.5 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
|
|
|
|
'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',
|
|
|
|
|
item.iconClassName ?? DEFAULT_ICON_CLASS,
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
@@ -87,19 +104,39 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
|
|
|
|
</Item>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<div className="space-y-0.5">
|
|
|
|
|
<div className="text-foreground text-2xl leading-none font-bold tabular-nums">
|
|
|
|
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
|
|
|
<div className="flex items-start justify-between gap-2">
|
|
|
|
|
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
|
|
|
|
|
{footer ? <div className="shrink-0">{footer}</div> : null}
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'text-2xl leading-none font-bold tabular-nums',
|
|
|
|
|
VALUE_VARIANT_CLASS[valueVariant],
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{item.value}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{footer ? <div className="mt-auto w-full">{footer}</div> : null}
|
|
|
|
|
</>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Single KPI tile (ReUI stats-12 / dashboard PRO pattern). */
|
|
|
|
|
function panelClassName(item: KpiStatItem, className?: string) {
|
|
|
|
|
const onActivate = resolveActivate(item)
|
|
|
|
|
const clickable = Boolean(item.to || onActivate)
|
|
|
|
|
const selected = isSelected(item)
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
@@ -110,26 +147,14 @@ export function KpiStatCardTile({
|
|
|
|
|
className?: string
|
|
|
|
|
}) {
|
|
|
|
|
const onActivate = resolveActivate(item)
|
|
|
|
|
const clickable = Boolean(item.to || onActivate)
|
|
|
|
|
const selected = isSelected(item)
|
|
|
|
|
|
|
|
|
|
const panelClass = cn(
|
|
|
|
|
'flex h-full flex-col items-start gap-6',
|
|
|
|
|
clickable && 'cursor-pointer transition-colors hover:bg-muted/30',
|
|
|
|
|
selected && 'ring-1 ring-primary/30',
|
|
|
|
|
className,
|
|
|
|
|
)
|
|
|
|
|
const panelClass = panelClassName(item, className)
|
|
|
|
|
|
|
|
|
|
let panel: ReactNode
|
|
|
|
|
|
|
|
|
|
if (item.to) {
|
|
|
|
|
panel = (
|
|
|
|
|
<FramePanel className={panelClass}>
|
|
|
|
|
<Link
|
|
|
|
|
to={item.to}
|
|
|
|
|
search={item.search}
|
|
|
|
|
className="flex h-full w-full flex-col items-start gap-6 focus-visible:outline-none"
|
|
|
|
|
>
|
|
|
|
|
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
|
|
|
|
<KpiStatCardBody item={item} />
|
|
|
|
|
</Link>
|
|
|
|
|
</FramePanel>
|
|
|
|
@@ -176,22 +201,22 @@ export function KpiStatCard({
|
|
|
|
|
|
|
|
|
|
function KpiStatGridSkeleton({ count }: { count: number }) {
|
|
|
|
|
return (
|
|
|
|
|
<section className="@container w-full" aria-label="Загрузка показателей">
|
|
|
|
|
<div className={cn('grid gap-5', kpiStatGridClassName(count))}>
|
|
|
|
|
<Frame className="@container w-full">
|
|
|
|
|
<div className={cn('grid gap-2', kpiCols(count))}>
|
|
|
|
|
{Array.from({ length: count }).map((_, index) => (
|
|
|
|
|
<Frame key={index} className="h-full">
|
|
|
|
|
<FramePanel className="flex h-full flex-col items-start gap-6">
|
|
|
|
|
<Skeleton className="size-10.5 rounded-md" />
|
|
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
<Skeleton className="h-8 w-16" />
|
|
|
|
|
<Skeleton className="h-4 w-28" />
|
|
|
|
|
<FramePanel key={index} className="flex items-start gap-3">
|
|
|
|
|
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
|
|
|
|
|
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
|
|
|
<Skeleton className="h-4 w-20" />
|
|
|
|
|
<Skeleton className="h-4.5 w-14 rounded-full" />
|
|
|
|
|
</div>
|
|
|
|
|
<Skeleton className="mt-auto h-5 w-32 rounded-full" />
|
|
|
|
|
</FramePanel>
|
|
|
|
|
</Frame>
|
|
|
|
|
<Skeleton className="h-7 w-16" />
|
|
|
|
|
</div>
|
|
|
|
|
</FramePanel>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
</Frame>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -205,10 +230,50 @@ interface KpiStatGridProps {
|
|
|
|
|
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 + horizontal compact layout (icon left).
|
|
|
|
|
* Preview: https://reui.io/preview/base/stats-12
|
|
|
|
|
*/
|
|
|
|
|
export function KpiStatGrid({
|
|
|
|
|
items,
|
|
|
|
|
cards,
|
|
|
|
@@ -239,18 +304,26 @@ 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 (
|
|
|
|
|
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
|
|
|
|
|
<div className={cn('grid gap-5', kpiStatGridClassName(list.length || 1))}>
|
|
|
|
|
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
|
|
|
|
|
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
|
|
|
|
{list.map((item, index) => (
|
|
|
|
|
<KpiStatCardTile
|
|
|
|
|
key={kpiStatItemKey(item, index)}
|
|
|
|
|
item={item}
|
|
|
|
|
embedded={embedded}
|
|
|
|
|
/>
|
|
|
|
|
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
</Frame>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|