Files
cloudflare-domain-manager/apps/web/src/components/reui-kit/kpi-stat-grid.tsx
T
DenozordecandCursor 2418344551
CD / update-wiki (push) Successful in 6s
quality / commitlint (push) Skipped
quality / changes (push) Successful in 11s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Failing after 52s
CD / quality (push) Failing after 1m5s
CD / publish (push) Skipped
feat(web): собрать экраны из блоков ReUI PRO
Подключить stats-12, card-12, settings-3 и empty-state-12 к оболочке, спискам, карточкам и настройкам, чтобы метрики и пустые состояния шли из одних блоков.

Co-authored-by: Cursor <[email protected]>
2026-11-02 19:40:18 +07:00

288 lines
7.8 KiB
TypeScript

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 '@cfdm/ui/lib/utils'
import { Item, ItemMedia } from '@cfdm/ui/components/item'
import { Skeleton } from '@cfdm/ui/components/skeleton'
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
/**
* 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
variant?: KpiStatVariant
footer?: ReactNode
}
/** CFDM-compatible card shape (id required). */
export type KpiStatCardData = KpiStatItem & { id: string }
/** @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'
const HINT_VARIANT: Record<KpiStatVariant, 'outline' | 'warning-light' | 'destructive-light'> = {
default: 'outline',
warning: 'warning-light',
destructive: 'destructive-light',
}
/** stats-12 tile: https://reui.io/preview/base/stats-12 */
const STAT_ICON_CLASS =
'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-5'
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onActivate()
}
}
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={HINT_VARIANT[item.variant ?? 'default']}
size="sm"
className="max-w-full truncate"
>
{item.hint}
</Badge>
)
}
if (item.hint) return item.hint
return null
}
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const footer = resolveFooter(item)
return (
<div className="relative z-10 flex h-full min-w-0 flex-col items-start gap-6">
{item.icon ? (
<Item
className={cn(STAT_ICON_CLASS, item.iconClassName ?? DEFAULT_ICON_CLASS)}
aria-hidden="true"
>
<ItemMedia variant="icon" className="size-auto">
{item.icon}
</ItemMedia>
</Item>
) : null}
<div className="flex min-w-0 flex-col gap-0.5">
<div className="text-foreground min-w-0 text-2xl leading-none font-bold break-all tabular-nums">
{item.value}
</div>
<div className="text-muted-foreground min-w-0 text-sm font-medium">
{item.label}
</div>
</div>
{footer}
</div>
)
}
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 min-w-0 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={item.to} search={item.search} className="focus-visible:outline-none">
<KpiStatCardBody item={item} />
</Link>
</FramePanel>
)
} 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}>
<KpiStatCardBody item={item} />
</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 statGridClass(count: number) {
if (count >= 4) return 'grid-cols-1 gap-5 @xl:grid-cols-2 @5xl:grid-cols-4'
if (count === 3) return 'grid-cols-1 gap-5 @3xl:grid-cols-3'
return 'grid-cols-1 gap-5 @3xl:grid-cols-2'
}
function KpiStatGridSkeleton({ count }: { count: number }) {
return (
<div className={cn('@container grid w-full', statGridClass(count))}>
{Array.from({ length: count }).map((_, index) => (
<Frame key={index}>
<FramePanel className="flex flex-col items-start gap-6">
<Skeleton className="size-10.5 rounded-lg" />
<div className="flex flex-col gap-1.5">
<Skeleton className="h-7 w-16" />
<Skeleton className="h-4 w-24" />
</div>
<Skeleton className="h-5 w-28 rounded-full" />
</FramePanel>
</Frame>
))}
</div>
)
}
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
}
/**
* KPI grid from stats-12: one Frame per metric, icon tile, value, label, badge.
* Preview: https://reui.io/preview/base/stats-12
* Docs: https://reui.io/blocks/application/stats/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} />
}
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">
{emptyIcon}
{emptyMessage ? (
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
) : null}
</FramePanel>
</Frame>
)
}
return (
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
<div className={cn('grid', statGridClass(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardTile
key={kpiStatItemKey(item, index)}
item={item}
embedded={embedded}
/>
))}
</div>
</section>
)
}
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}`
}