feat(web): обновить компоненты KpiStatGrid и SectionCards для улучшения интерфейса
Docker / build (push) Failing after 19s

- Добавлен новый тип KpiStatVariant для управления визуальными состояниями карточек KPI.
- Обновлены компоненты SectionCards и KpiStatGrid для поддержки новых свойств, таких как iconClassName и footer с Badge.
- Изменены стили и логика отображения для улучшения визуального представления и взаимодействия с пользователем.
- Обновлена документация для отражения изменений в интерфейсе.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
DenozordecandCursor committed 2026-07-17 17:14:55 +07:00
1 parent 35e5d8430d
commit 52665843d6
6 files changed
+146 -55

No files matched your search

+6 -1
View File
@@ -1,6 +1,11 @@
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
export { KpiStatGrid, type KpiStatCard, type OpsKpiCard } from './kpi-stat-grid'
export {
KpiStatGrid,
type KpiStatCard,
type KpiStatVariant,
type OpsKpiCard,
} from './kpi-stat-grid'
export { OpsDashboard } from './ops-dashboard'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
@@ -1,10 +1,13 @@
import type { ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import { Frame, FramePanel } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { Item, ItemMedia } from '@cfdm/ui/components/item'
import { cn } from '@cfdm/ui/lib/utils'
import { Skeleton } from '@cfdm/ui/components/skeleton'
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
export interface KpiStatCard {
id: string
label: string
@@ -16,6 +19,8 @@ export interface KpiStatCard {
selected?: boolean
icon?: ReactNode
iconClassName?: string
/** Semantic color for the primary value */
variant?: KpiStatVariant
footer?: ReactNode
}
@@ -31,8 +36,13 @@ interface KpiStatGridProps {
skeletonCount?: number
}
const DEFAULT_ICON_CLASS =
'text-muted-foreground [&_svg]:text-current'
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
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'
@@ -44,9 +54,24 @@ function kpiCols(count: number): string {
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
}
function resolveFooter(card: KpiStatCard): ReactNode {
if (card.footer) return card.footer
if (card.hint) {
return (
<Badge variant="outline" size="sm">
{card.hint}
</Badge>
)
}
return null
}
function KpiStatCardBody({ card }: { card: KpiStatCard }) {
const footer = resolveFooter(card)
const valueVariant = card.variant ?? 'default'
return (
<div className="relative z-10 flex h-full flex-col items-start gap-4">
<div className="relative z-10 flex h-full flex-col items-start gap-3">
{card.icon ? (
<Item
className={cn(
@@ -59,16 +84,20 @@ function KpiStatCardBody({ card }: { card: KpiStatCard }) {
</ItemMedia>
</Item>
) : null}
<div className="flex flex-col gap-0.5">
<span className="text-foreground text-2xl leading-none font-bold tabular-nums">
<span
className={cn(
'text-2xl leading-none font-bold tabular-nums',
VALUE_VARIANT_CLASS[valueVariant],
)}
>
{card.value}
</span>
<span className="text-muted-foreground text-sm font-medium">{card.label}</span>
{card.hint ? (
<span className="text-muted-foreground text-xs leading-snug">{card.hint}</span>
) : null}
</div>
{card.footer ? <div className="mt-auto w-full">{card.footer}</div> : null}
{footer ? <div className="mt-auto w-full">{footer}</div> : null}
</div>
)
}
@@ -76,7 +105,7 @@ function KpiStatCardBody({ card }: { card: KpiStatCard }) {
function KpiStatCardItem({ card }: { card: KpiStatCard }) {
const interactive = Boolean(card.to || card.onSelect)
const panelClass = cn(
'relative isolate flex h-full flex-col items-start gap-4',
'relative isolate flex h-full flex-col',
card.selected && 'ring-primary/30 bg-muted/30 ring-1',
interactive &&
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
@@ -116,12 +145,13 @@ function KpiStatCardItem({ card }: { card: KpiStatCard }) {
function KpiStatGridSkeleton({ count }: { count: number }) {
return (
<Frame className="@container w-full">
<div className={cn('grid gap-1', kpiCols(count))}>
<div className={cn('grid gap-2', kpiCols(count))}>
{Array.from({ length: count }).map((_, index) => (
<FramePanel key={index} className="flex flex-col gap-4">
<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>
@@ -129,7 +159,10 @@ function KpiStatGridSkeleton({ count }: { count: number }) {
)
}
/** KPI grid — ReUI stats-12. Preview: https://reui.io/preview/base/stats-12 */
/**
* Hybrid KPI grid — compact stats-12 Frame strip.
* Preview: https://reui.io/preview/base/stats-12
*/
export function KpiStatGrid({
cards,
isLoading = false,
@@ -157,7 +190,7 @@ export function KpiStatGrid({
return (
<Frame className={cn('@container w-full', className)}>
<div className={cn('grid gap-1', kpiCols(cards.length))}>
<div className={cn('grid gap-2', kpiCols(cards.length))}>
{cards.map((card) => (
<KpiStatCardItem key={card.id} card={card} />
))}
+35 -29
View File
@@ -1,58 +1,64 @@
import type { ReactElement, ReactNode } from 'react'
import { cn } from '@cfdm/ui/lib/utils'
import { KpiStatGrid, type KpiStatCard } from '@/components/reui-kit/kpi-stat-grid'
import { Badge } from '@cfdm/ui/components/badge'
import { KpiStatGrid, type KpiStatCard, type KpiStatVariant } from '@/components/reui-kit/kpi-stat-grid'
import { Badge } from '@/components/reui/badge'
export interface SectionCardItem {
label: ReactNode
value: string | number | ReactElement
hint?: ReactNode
icon?: ReactNode
iconClassName?: string
badge?: ReactNode
variant?: 'default' | 'warning' | 'destructive'
variant?: KpiStatVariant
active?: boolean
onClick?: () => void
}
const VARIANT_ICON_CLASS: Record<KpiStatVariant, string> = {
default: 'text-muted-foreground',
warning: 'text-warning',
destructive: 'text-destructive',
}
/**
* @deprecated Prefer `KpiStatGrid` directly.
* Thin adapter → stats-12 Frame KPI. Preview: https://reui.io/preview/base/stats-12
* Thin adapter → hybrid stats-12 Frame KPI. Preview: https://reui.io/preview/base/stats-12
*/
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
const cards: KpiStatCard[] = items.map((item, idx) => {
const label = typeof item.label === 'string' ? item.label : `kpi-${idx}`
const footer =
item.badge || item.hint ? (
<div className="flex flex-wrap items-center gap-2">
{item.badge}
{item.hint && !item.badge ? (
<span className="text-muted-foreground text-xs">{item.hint}</span>
) : null}
</div>
) : undefined
const variant = item.variant ?? 'default'
let footer: ReactNode = item.badge
if (!footer && typeof item.hint === 'string') {
footer = (
<Badge variant="outline" size="sm">
{item.hint}
</Badge>
)
} else if (!footer && item.hint) {
footer = item.hint
} else if (!footer && variant !== 'default') {
footer = (
<Badge
variant={variant === 'warning' ? 'warning-light' : 'destructive-light'}
size="sm"
>
{variant === 'warning' ? 'Внимание' : 'Критично'}
</Badge>
)
}
return {
id: label,
label,
value: item.value as string | number,
hint: typeof item.hint === 'string' ? item.hint : undefined,
icon: item.icon,
iconClassName: item.iconClassName ?? VARIANT_ICON_CLASS[variant],
variant,
selected: item.active,
onSelect: item.onClick,
footer:
item.variant && item.variant !== 'default' && !item.badge ? (
<Badge
variant="outline"
className={cn(
item.variant === 'warning' && 'border-warning/50 text-warning-foreground',
item.variant === 'destructive' && 'border-destructive/50 text-destructive',
)}
>
{item.variant === 'warning' ? 'Внимание' : 'Критично'}
</Badge>
) : (
footer
),
footer,
}
})