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 |