Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26a96bc824 | ||
|
|
4fc5c96e63 |
@@ -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 — horizontal compact hybrid (icon left + label/Badge + value).
|
||||||
* 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,19 +72,29 @@ 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 items-start gap-3">
|
||||||
{item.icon ? (
|
{item.icon ? (
|
||||||
<Item
|
<Item
|
||||||
className={cn(
|
className={cn(
|
||||||
'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-4',
|
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||||
item.iconClassName ?? DEFAULT_ICON_CLASS,
|
item.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -87,19 +104,39 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
|||||||
</Item>
|
</Item>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="space-y-0.5">
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
<div className="text-foreground text-2xl leading-none font-bold tabular-nums">
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
|
||||||
|
{footer ? <div className="shrink-0">{footer}</div> : null}
|
||||||
|
</div>
|
||||||
|
<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>
|
</div>
|
||||||
|
</div>
|
||||||
{footer ? <div className="mt-auto w-full">{footer}</div> : 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({
|
export function KpiStatCardTile({
|
||||||
item,
|
item,
|
||||||
embedded = false,
|
embedded = false,
|
||||||
@@ -110,26 +147,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 +201,22 @@ 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 items-start gap-3">
|
||||||
<FramePanel className="flex h-full flex-col items-start gap-6">
|
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
|
||||||
<Skeleton className="size-10.5 rounded-md" />
|
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<Skeleton className="h-8 w-16" />
|
<Skeleton className="h-4 w-20" />
|
||||||
<Skeleton className="h-4 w-28" />
|
<Skeleton className="h-4.5 w-14 rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="mt-auto h-5 w-32 rounded-full" />
|
<Skeleton className="h-7 w-16" />
|
||||||
</FramePanel>
|
</div>
|
||||||
</Frame>
|
</FramePanel>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,10 +230,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 — EvoBGP visual + horizontal compact layout (icon left).
|
||||||
|
* Preview: https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
export function KpiStatGrid({
|
export function KpiStatGrid({
|
||||||
items,
|
items,
|
||||||
cards,
|
cards,
|
||||||
@@ -239,18 +304,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 | horizontal compact hybrid (icon left + label/Badge + value ± variant; EvoBGP visual) | 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` | horizontal compact hybrid KPI tiles (`variant`, Badge) |
|
||||||
| `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