Compare commits

..
1 Commits
Author SHA1 Message Date
Denozordec 4fc5c96e63 refactor: enhance KPI components with variant support and improved layout
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 49s
CI / go (push) Successful in 51s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m12s
Updated the KPI components to include a new KpiStatVariant type for better visual differentiation. Refactored the KpiStatCardBody and KpiStatGrid to utilize this variant, allowing for dynamic styling based on the KPI state. Improved the layout of the KpiStatGrid and its associated components for a more cohesive user experience, including adjustments to the Badge footer presentation.
2026-07-17 17:15:14 +07:00
6 changed files with 126 additions and 53 deletions
@@ -122,6 +122,7 @@ function buildKpis({
iconClassName: 'text-destructive',
value: loading ? '—' : String(riskCount),
label: 'Риски',
variant: riskCount > 0 ? 'destructive' : 'default',
footer: (
<Badge variant={riskCount > 0 ? 'destructive-light' : 'success-light'} size="sm">
{loading
@@ -9,5 +9,6 @@ export {
kpiStatItemKey,
type KpiStatItem,
type KpiStatCardData,
type KpiStatVariant,
type OpsKpiCard,
} from '@/components/reui-kit/kpi-stat-grid'
@@ -17,6 +17,7 @@ export {
type KpiStatItem,
type KpiStatCardData,
type KpiStatCard as KpiStatCardType,
type KpiStatVariant,
type OpsKpiCard,
} from './kpi-stat-grid'
export { OpsDashboard } from './ops-dashboard'
@@ -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 — hybrid compact stats-12 (icon + value + label + Badge footer).
* @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,15 +72,25 @@ 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 flex-col items-start gap-3">
{item.icon ? (
<Item
className={cn(
@@ -87,19 +104,38 @@ 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 flex-col gap-0.5">
<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 +146,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 +200,18 @@ 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" />
</div>
<Skeleton className="mt-auto h-5 w-32 rounded-full" />
</FramePanel>
</Frame>
<FramePanel key={index} className="flex flex-col gap-3">
<Skeleton className="size-10.5 rounded-lg" />
<Skeleton className="h-7 w-14" />
<Skeleton className="h-4 w-20" />
<Skeleton className="mt-auto h-4.5 w-16 rounded-full" />
</FramePanel>
))}
</div>
</section>
</Frame>
)
}
@@ -205,10 +225,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 grid — compact Frame strip.
* Preview: https://reui.io/preview/base/stats-12
*/
export function KpiStatGrid({
items,
cards,
@@ -239,18 +299,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>
)
}
+3 -1
View File
@@ -32,15 +32,17 @@ function hintToFooter(hint: ReactNode) {
}
function toKpiStatItem(item: SectionCardItem, index: number): KpiStatItem {
const variant = item.variant ?? 'default'
const footer =
item.badge ?? (item.hint ? hintToFooter(item.hint) : undefined)
return {
id: typeof item.label === 'string' ? item.label : `section-${index}`,
icon: item.icon,
iconClassName: VARIANT_ICON_CLASS[item.variant ?? 'default'],
iconClassName: VARIANT_ICON_CLASS[variant],
value: item.value,
label: item.label,
variant,
footer,
active: item.active,
onClick: item.onClick,
+2 -2
View File
@@ -18,7 +18,7 @@ Ops / list / dashboard / detail / settings — только **Frame**, не shad
| Зона | Block | Preview |
|------|-------|---------|
| Shell | `app-shell-12` (+ cmdk/monitor где нужно) | https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/app-shell-7 |
| KPI | `stats-12` (primary); `card-35` compact strip | https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-35 |
| KPI | hybrid `stats-12` (compact Frame strip: colored icon → value ± variant → label → Badge footer) | https://reui.io/preview/base/stats-12 |
| Dashboard | `dashboard-1` | https://reui.io/preview/base/dashboard-1 |
| Lists | `data-grid-filtering-2` | https://reui.io/preview/base/data-grid-filtering-2 |
| Settings | `settings-16` + SettingRow (`settings-7`) | https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-7 |
@@ -31,7 +31,7 @@ Ops / list / dashboard / detail / settings — только **Frame**, не shad
| Component | Role |
|-----------|------|
| `ResourcePage` | Frame + line tabs + Filters + DataGrid |
| `KpiStatGrid` | stats-12 KPI tiles |
| `KpiStatGrid` | hybrid compact stats-12 KPI tiles (`variant`, Badge footer) |
| `OpsDashboard` | KPI + charts + attention queue |
| `SettingsShell` | settings nav + Outlet |
| `DetailPanel` | detail Frame sections |