refactor: enhance KPI components with variant support and improved layout
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.
This commit is contained in:
@@ -122,6 +122,7 @@ function buildKpis({
|
|||||||
iconClassName: 'text-destructive',
|
iconClassName: 'text-destructive',
|
||||||
value: loading ? '—' : String(riskCount),
|
value: loading ? '—' : String(riskCount),
|
||||||
label: 'Риски',
|
label: 'Риски',
|
||||||
|
variant: riskCount > 0 ? 'destructive' : 'default',
|
||||||
footer: (
|
footer: (
|
||||||
<Badge variant={riskCount > 0 ? 'destructive-light' : 'success-light'} size="sm">
|
<Badge variant={riskCount > 0 ? 'destructive-light' : 'success-light'} size="sm">
|
||||||
{loading
|
{loading
|
||||||
|
|||||||
@@ -9,5 +9,6 @@ export {
|
|||||||
kpiStatItemKey,
|
kpiStatItemKey,
|
||||||
type KpiStatItem,
|
type KpiStatItem,
|
||||||
type KpiStatCardData,
|
type KpiStatCardData,
|
||||||
|
type KpiStatVariant,
|
||||||
type OpsKpiCard,
|
type OpsKpiCard,
|
||||||
} from '@/components/reui-kit/kpi-stat-grid'
|
} from '@/components/reui-kit/kpi-stat-grid'
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export {
|
|||||||
type KpiStatItem,
|
type KpiStatItem,
|
||||||
type KpiStatCardData,
|
type KpiStatCardData,
|
||||||
type KpiStatCard as KpiStatCardType,
|
type KpiStatCard as KpiStatCardType,
|
||||||
|
type KpiStatVariant,
|
||||||
type OpsKpiCard,
|
type OpsKpiCard,
|
||||||
} from './kpi-stat-grid'
|
} from './kpi-stat-grid'
|
||||||
export { OpsDashboard } from './ops-dashboard'
|
export { OpsDashboard } from './ops-dashboard'
|
||||||
|
|||||||
@@ -2,15 +2,15 @@ import type { KeyboardEvent, ReactNode } from 'react'
|
|||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||||
|
|
||||||
|
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KPI tile data — stats-12 visual (icon tile + value + label + footer).
|
* KPI tile data — hybrid compact stats-12 (icon + value + label + Badge footer).
|
||||||
* CFDM-compatible: id, label, value, hint?, to?, search?, onSelect?, selected?
|
|
||||||
* EvoBGP: icon?, iconClassName?, footer?, active?, onClick?
|
|
||||||
*
|
|
||||||
* @see https://reui.io/preview/base/stats-12
|
* @see https://reui.io/preview/base/stats-12
|
||||||
*/
|
*/
|
||||||
export type KpiStatItem = {
|
export type KpiStatItem = {
|
||||||
@@ -26,6 +26,7 @@ export type KpiStatItem = {
|
|||||||
active?: boolean
|
active?: boolean
|
||||||
icon?: ReactNode
|
icon?: ReactNode
|
||||||
iconClassName?: string
|
iconClassName?: string
|
||||||
|
variant?: KpiStatVariant
|
||||||
footer?: ReactNode
|
footer?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +41,13 @@ export type KpiStatCard = KpiStatCardData
|
|||||||
|
|
||||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
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 <= 1) return 'grid-cols-1'
|
||||||
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||||
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
|
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)
|
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 }) {
|
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||||
const footer =
|
const footer = resolveFooter(item)
|
||||||
item.footer ??
|
const valueVariant = item.variant ?? 'default'
|
||||||
(item.hint ? (
|
|
||||||
<span className="text-muted-foreground text-xs leading-snug">{item.hint}</span>
|
|
||||||
) : null)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="relative z-10 flex h-full flex-col items-start gap-3">
|
||||||
{item.icon ? (
|
{item.icon ? (
|
||||||
<Item
|
<Item
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -87,19 +104,38 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
|||||||
</Item>
|
</Item>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="space-y-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<div className="text-foreground text-2xl leading-none font-bold tabular-nums">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'text-2xl leading-none font-bold tabular-nums',
|
||||||
|
VALUE_VARIANT_CLASS[valueVariant],
|
||||||
|
)}
|
||||||
|
>
|
||||||
{item.value}
|
{item.value}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
|
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{footer ? <div className="mt-auto w-full">{footer}</div> : null}
|
{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({
|
export function KpiStatCardTile({
|
||||||
item,
|
item,
|
||||||
embedded = false,
|
embedded = false,
|
||||||
@@ -110,26 +146,14 @@ export function KpiStatCardTile({
|
|||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const onActivate = resolveActivate(item)
|
const onActivate = resolveActivate(item)
|
||||||
const clickable = Boolean(item.to || onActivate)
|
const panelClass = panelClassName(item, className)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
let panel: ReactNode
|
let panel: ReactNode
|
||||||
|
|
||||||
if (item.to) {
|
if (item.to) {
|
||||||
panel = (
|
panel = (
|
||||||
<FramePanel className={panelClass}>
|
<FramePanel className={panelClass}>
|
||||||
<Link
|
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
||||||
to={item.to}
|
|
||||||
search={item.search}
|
|
||||||
className="flex h-full w-full flex-col items-start gap-6 focus-visible:outline-none"
|
|
||||||
>
|
|
||||||
<KpiStatCardBody item={item} />
|
<KpiStatCardBody item={item} />
|
||||||
</Link>
|
</Link>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
@@ -176,22 +200,18 @@ export function KpiStatCard({
|
|||||||
|
|
||||||
function KpiStatGridSkeleton({ count }: { count: number }) {
|
function KpiStatGridSkeleton({ count }: { count: number }) {
|
||||||
return (
|
return (
|
||||||
<section className="@container w-full" aria-label="Загрузка показателей">
|
<Frame className="@container w-full">
|
||||||
<div className={cn('grid gap-5', kpiStatGridClassName(count))}>
|
<div className={cn('grid gap-2', kpiCols(count))}>
|
||||||
{Array.from({ length: count }).map((_, index) => (
|
{Array.from({ length: count }).map((_, index) => (
|
||||||
<Frame key={index} className="h-full">
|
<FramePanel key={index} className="flex flex-col gap-3">
|
||||||
<FramePanel className="flex h-full flex-col items-start gap-6">
|
<Skeleton className="size-10.5 rounded-lg" />
|
||||||
<Skeleton className="size-10.5 rounded-md" />
|
<Skeleton className="h-7 w-14" />
|
||||||
<div className="flex flex-col gap-1">
|
<Skeleton className="h-4 w-20" />
|
||||||
<Skeleton className="h-8 w-16" />
|
<Skeleton className="mt-auto h-4.5 w-16 rounded-full" />
|
||||||
<Skeleton className="h-4 w-28" />
|
</FramePanel>
|
||||||
</div>
|
|
||||||
<Skeleton className="mt-auto h-5 w-32 rounded-full" />
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,10 +225,50 @@ interface KpiStatGridProps {
|
|||||||
emptyIcon?: ReactNode
|
emptyIcon?: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
skeletonCount?: number
|
skeletonCount?: number
|
||||||
|
/** Wrap each tile in its own Frame (analytics panels). */
|
||||||
embedded?: boolean
|
embedded?: boolean
|
||||||
'aria-label'?: string
|
'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({
|
export function KpiStatGrid({
|
||||||
items,
|
items,
|
||||||
cards,
|
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 (
|
return (
|
||||||
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
|
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
|
||||||
<div className={cn('grid gap-5', kpiStatGridClassName(list.length || 1))}>
|
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||||
{list.map((item, index) => (
|
{list.map((item, index) => (
|
||||||
<KpiStatCardTile
|
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||||
key={kpiStatItemKey(item, index)}
|
|
||||||
item={item}
|
|
||||||
embedded={embedded}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,15 +32,17 @@ function hintToFooter(hint: ReactNode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toKpiStatItem(item: SectionCardItem, index: number): KpiStatItem {
|
function toKpiStatItem(item: SectionCardItem, index: number): KpiStatItem {
|
||||||
|
const variant = item.variant ?? 'default'
|
||||||
const footer =
|
const footer =
|
||||||
item.badge ?? (item.hint ? hintToFooter(item.hint) : undefined)
|
item.badge ?? (item.hint ? hintToFooter(item.hint) : undefined)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: typeof item.label === 'string' ? item.label : `section-${index}`,
|
id: typeof item.label === 'string' ? item.label : `section-${index}`,
|
||||||
icon: item.icon,
|
icon: item.icon,
|
||||||
iconClassName: VARIANT_ICON_CLASS[item.variant ?? 'default'],
|
iconClassName: VARIANT_ICON_CLASS[variant],
|
||||||
value: item.value,
|
value: item.value,
|
||||||
label: item.label,
|
label: item.label,
|
||||||
|
variant,
|
||||||
footer,
|
footer,
|
||||||
active: item.active,
|
active: item.active,
|
||||||
onClick: item.onClick,
|
onClick: item.onClick,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Ops / list / dashboard / detail / settings — только **Frame**, не shad
|
|||||||
| Зона | Block | Preview |
|
| Зона | Block | Preview |
|
||||||
|------|-------|---------|
|
|------|-------|---------|
|
||||||
| Shell | `app-shell-12` (+ cmdk/monitor где нужно) | https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/app-shell-7 |
|
| 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 |
|
| Dashboard | `dashboard-1` | https://reui.io/preview/base/dashboard-1 |
|
||||||
| Lists | `data-grid-filtering-2` | https://reui.io/preview/base/data-grid-filtering-2 |
|
| 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 |
|
| 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 |
|
| Component | Role |
|
||||||
|-----------|------|
|
|-----------|------|
|
||||||
| `ResourcePage` | Frame + line tabs + Filters + DataGrid |
|
| `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 |
|
| `OpsDashboard` | KPI + charts + attention queue |
|
||||||
| `SettingsShell` | settings nav + Outlet |
|
| `SettingsShell` | settings nav + Outlet |
|
||||||
| `DetailPanel` | detail Frame sections |
|
| `DetailPanel` | detail Frame sections |
|
||||||
|
|||||||
Reference in New Issue
Block a user