From 4fc5c96e63c731f6e8e20bca8294ebff5bac8ce3 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 17 Jul 2026 17:15:14 +0700 Subject: [PATCH] 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. --- .../dashboard/dashboard-kpi-grid.tsx | 1 + apps/web/src/components/kpi-stat-grid.tsx | 1 + apps/web/src/components/reui-kit/index.ts | 1 + .../src/components/reui-kit/kpi-stat-grid.tsx | 168 ++++++++++++------ apps/web/src/components/section-cards.tsx | 4 +- docs/ui-design-contract.md | 4 +- 6 files changed, 126 insertions(+), 53 deletions(-) diff --git a/apps/web/src/components/dashboard/dashboard-kpi-grid.tsx b/apps/web/src/components/dashboard/dashboard-kpi-grid.tsx index e6964b0..13ff830 100644 --- a/apps/web/src/components/dashboard/dashboard-kpi-grid.tsx +++ b/apps/web/src/components/dashboard/dashboard-kpi-grid.tsx @@ -122,6 +122,7 @@ function buildKpis({ iconClassName: 'text-destructive', value: loading ? '—' : String(riskCount), label: 'Риски', + variant: riskCount > 0 ? 'destructive' : 'default', footer: ( 0 ? 'destructive-light' : 'success-light'} size="sm"> {loading diff --git a/apps/web/src/components/kpi-stat-grid.tsx b/apps/web/src/components/kpi-stat-grid.tsx index f7f1249..5da4d38 100644 --- a/apps/web/src/components/kpi-stat-grid.tsx +++ b/apps/web/src/components/kpi-stat-grid.tsx @@ -9,5 +9,6 @@ export { kpiStatItemKey, type KpiStatItem, type KpiStatCardData, + type KpiStatVariant, type OpsKpiCard, } from '@/components/reui-kit/kpi-stat-grid' diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts index 6d8c935..c5c7b13 100644 --- a/apps/web/src/components/reui-kit/index.ts +++ b/apps/web/src/components/reui-kit/index.ts @@ -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' diff --git a/apps/web/src/components/reui-kit/kpi-stat-grid.tsx b/apps/web/src/components/reui-kit/kpi-stat-grid.tsx index f04196f..1ddc4d2 100644 --- a/apps/web/src/components/reui-kit/kpi-stat-grid.tsx +++ b/apps/web/src/components/reui-kit/kpi-stat-grid.tsx @@ -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 = { + 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 ( + + {item.hint} + + ) + } + if (item.hint) return item.hint + return null +} + function KpiStatCardBody({ item }: { item: KpiStatItem }) { - const footer = - item.footer ?? - (item.hint ? ( - {item.hint} - ) : null) + const footer = resolveFooter(item) + const valueVariant = item.variant ?? 'default' return ( - <> +
{item.icon ? ( ) : null} -
-
+
+
{item.value}
{item.label}
{footer ?
{footer}
: null} - +
) } -/** 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 = ( - + @@ -176,22 +200,18 @@ export function KpiStatCard({ function KpiStatGridSkeleton({ count }: { count: number }) { return ( -
-
+ +
{Array.from({ length: count }).map((_, index) => ( - - - -
- - -
- -
- + + + + + + ))}
-
+ ) } @@ -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 ( + + + + + + ) + } + + if (onActivate) { + return ( + handleCardKeyDown(onActivate, e)} + > + + + ) + } + + return ( + + + + ) +} + +/** + * 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 ( +
+
+ {list.map((item, index) => ( + + ))} +
+
+ ) + } + return ( -
-
+ +
{list.map((item, index) => ( - + ))}
-
+ ) } diff --git a/apps/web/src/components/section-cards.tsx b/apps/web/src/components/section-cards.tsx index f5e9358..c8a291f 100644 --- a/apps/web/src/components/section-cards.tsx +++ b/apps/web/src/components/section-cards.tsx @@ -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, diff --git a/docs/ui-design-contract.md b/docs/ui-design-contract.md index 24e77be..9deb1fc 100644 --- a/docs/ui-design-contract.md +++ b/docs/ui-design-contract.md @@ -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 |