feat(web): привести «Внимание» и KPI к макету и EvoBGP
- монитор в шапке: пилюля «Внимание ●N ●M», в попапе сводка сбой / предупреждения / в норме, список проблем со ссылками, плитки метрик с полосами и время обновления - KPI одной полосой в Frame с разделителями 1px, как доски макета Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
06d767fde7
commit
148ef8efb7
2 files changed
+199
-203
No files matched your search
@@ -1,80 +1,32 @@
|
||||
import { useMemo, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Wallet,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Activity, ArrowRight } from 'lucide-react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@cfdm/ui/components/item'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@cfdm/ui/components/popover'
|
||||
import { Progress } from '@cfdm/ui/components/progress'
|
||||
|
||||
import { api } from '@/lib/api-client'
|
||||
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
|
||||
type MonitorMetric = {
|
||||
type Tone = 'er' | 'wn' | 'ok'
|
||||
|
||||
/** Одна проверка монитора: строка в списке проблем (если не ok) и плитка с полосой. */
|
||||
type MonitorCheck = {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
unit: string
|
||||
percent: number
|
||||
icon: ReactNode
|
||||
tone: 'success' | 'warning' | 'destructive' | 'info'
|
||||
alert: boolean
|
||||
tone: Tone
|
||||
title: string
|
||||
text: string
|
||||
to: '/sync-journal' | '/settings/notifications' | '/accounts' | '/vps' | '/dashboard'
|
||||
}
|
||||
|
||||
function toneColor(tone: MonitorMetric['tone']) {
|
||||
switch (tone) {
|
||||
case 'success':
|
||||
return 'var(--color-success)'
|
||||
case 'warning':
|
||||
return 'var(--color-warning)'
|
||||
case 'destructive':
|
||||
return 'var(--color-destructive)'
|
||||
default:
|
||||
return 'var(--color-info)'
|
||||
}
|
||||
}
|
||||
|
||||
function MetricCell({ metric }: { metric: MonitorMetric }) {
|
||||
const color = toneColor(metric.tone)
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Item
|
||||
className="flex size-5 shrink-0 items-center justify-center p-0"
|
||||
style={{ backgroundColor: `${color}18` }}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto" style={{ color }}>
|
||||
{metric.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-semibold tabular-nums" style={{ color }}>
|
||||
{metric.value}
|
||||
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={metric.percent}
|
||||
className="**:data-[slot=progress-indicator]:bg-(--bar-color) **:data-[slot=progress-track]:h-1"
|
||||
style={{ '--bar-color': color } as CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const TONE_TEXT: Record<Tone, string> = { er: 'text-destructive', wn: 'text-warning', ok: 'text-success' }
|
||||
const TONE_BG: Record<Tone, string> = { er: 'bg-destructive', wn: 'bg-warning', ok: 'bg-success' }
|
||||
|
||||
type SyncStatusRow = {
|
||||
accountId?: string
|
||||
@@ -127,7 +79,7 @@ export function countRecentFailedNotifications(
|
||||
}).length
|
||||
}
|
||||
|
||||
/** Live system monitor popover (app-shell pattern, VPS Tracker API data). */
|
||||
/** Центр состояния в шапке — та же разметка, что «Внимание» в макете и EvoBGP. */
|
||||
export function SystemMonitorPopover() {
|
||||
const statsQ = useQuery({ ...dashboardStatsQueryOptions(), refetchInterval: 30_000 })
|
||||
const snapQ = useQuery({ ...snapshotQueryOptions(), refetchInterval: 30_000 })
|
||||
@@ -144,84 +96,87 @@ export function SystemMonitorPopover() {
|
||||
|
||||
const stats = statsQ.data
|
||||
const vps = snapQ.data?.vps ?? []
|
||||
const downCount = vps.filter((v) => {
|
||||
const status = (v as { lastHealthStatus?: string }).lastHealthStatus
|
||||
return status === 'down'
|
||||
}).length
|
||||
const monitored = vps.filter((v) => (v as { monitoringEnabled?: boolean }).monitoringEnabled).length
|
||||
const downCount = vps.filter((v) => (v as { lastHealthStatus?: string }).lastHealthStatus === 'down').length
|
||||
const issuesCount = stats?.issuesCount ?? 0
|
||||
const runwayDays = stats?.minRunwayDays
|
||||
const runwayLow = runwayDays != null && runwayDays < 14
|
||||
const lowBalance = (stats?.lowBalanceAccountCount ?? 0) > 0
|
||||
const staleSync =
|
||||
(stats?.staleSyncAccountCount ?? 0) > 0 || isStaleSync(stats?.lastGlobalSyncAt)
|
||||
const staleSync = (stats?.staleSyncAccountCount ?? 0) > 0 || isStaleSync(stats?.lastGlobalSyncAt)
|
||||
const failedSyncCount = countCurrentSyncFailures(syncQ.data ?? [])
|
||||
const recentSyncFailed = failedSyncCount > 0
|
||||
const syncAlert = staleSync || recentSyncFailed
|
||||
const failedNotifications = countRecentFailedNotifications(notifyQ.data ?? [])
|
||||
const apiOk = Boolean(statsQ.data) || Boolean(snapQ.data)
|
||||
const updatedAt = Math.max(statsQ.dataUpdatedAt, snapQ.dataUpdatedAt)
|
||||
|
||||
const metrics = useMemo<MonitorMetric[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'sync',
|
||||
label: 'Синк',
|
||||
value: recentSyncFailed ? String(failedSyncCount) : syncAlert ? '!' : 'OK',
|
||||
unit: '',
|
||||
percent: syncAlert ? 35 : 100,
|
||||
icon: <RefreshCw aria-hidden />,
|
||||
tone: syncAlert ? (recentSyncFailed ? 'destructive' : 'warning') : 'success',
|
||||
alert: syncAlert,
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
label: 'Уведомления',
|
||||
value: String(failedNotifications),
|
||||
unit: 'шт.',
|
||||
percent: Math.min(100, failedNotifications * 15),
|
||||
icon: <Bell aria-hidden />,
|
||||
tone: failedNotifications > 0 ? 'warning' : 'success',
|
||||
alert: failedNotifications > 0,
|
||||
},
|
||||
{
|
||||
id: 'runway',
|
||||
label: 'Запас дней',
|
||||
value: runwayDays != null ? String(runwayDays) : '—',
|
||||
unit: runwayDays != null ? 'дн' : '',
|
||||
percent:
|
||||
runwayDays == null
|
||||
? 0
|
||||
: Math.min(100, Math.round((runwayDays / 30) * 100)),
|
||||
icon: <Wallet aria-hidden />,
|
||||
tone: runwayLow || lowBalance ? 'warning' : 'success',
|
||||
alert: runwayLow || lowBalance,
|
||||
},
|
||||
{
|
||||
id: 'vps-down',
|
||||
label: 'VPS down',
|
||||
value: String(downCount),
|
||||
unit: 'шт.',
|
||||
percent: Math.min(100, downCount * 25),
|
||||
icon: <Server aria-hidden />,
|
||||
tone: downCount > 0 ? 'destructive' : 'success',
|
||||
alert: downCount > 0,
|
||||
},
|
||||
],
|
||||
[
|
||||
downCount,
|
||||
failedNotifications,
|
||||
failedSyncCount,
|
||||
lowBalance,
|
||||
recentSyncFailed,
|
||||
runwayDays,
|
||||
runwayLow,
|
||||
syncAlert,
|
||||
],
|
||||
)
|
||||
|
||||
const errorCount = metrics.filter((m) => m.alert && m.tone === 'destructive').length + (apiOk ? 0 : 1)
|
||||
const warningCount = metrics.filter((m) => m.alert && m.tone === 'warning').length
|
||||
const spiking = errorCount + warningCount > 0
|
||||
const statusLabel = spiking ? 'Внимание' : 'Норма'
|
||||
const checks: MonitorCheck[] = [
|
||||
{
|
||||
id: 'sync',
|
||||
label: 'Синк',
|
||||
value: failedSyncCount > 0 ? String(failedSyncCount) : staleSync ? 'устарел' : 'OK',
|
||||
percent: failedSyncCount > 0 || staleSync ? 35 : 100,
|
||||
tone: failedSyncCount > 0 ? 'er' : staleSync ? 'wn' : 'ok',
|
||||
title: failedSyncCount > 0 ? 'Ошибка синхронизации' : 'Синк давно не выполнялся',
|
||||
text:
|
||||
failedSyncCount > 0
|
||||
? `${failedSyncCount} акк. с ошибкой последнего синка`
|
||||
: 'Нет успешного синка за 24 ч',
|
||||
to: '/sync-journal',
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
label: 'Уведомления',
|
||||
value: `${failedNotifications} шт.`,
|
||||
percent: Math.min(100, failedNotifications * 15),
|
||||
tone: failedNotifications > 0 ? 'wn' : 'ok',
|
||||
title: 'Уведомления не доставлены',
|
||||
text: `${failedNotifications} за 24 ч — проверьте Telegram / webhook`,
|
||||
to: '/settings/notifications',
|
||||
},
|
||||
{
|
||||
id: 'runway',
|
||||
label: 'Запас дней',
|
||||
value: runwayDays != null ? `${runwayDays} дн` : '—',
|
||||
percent: runwayDays == null ? 0 : Math.min(100, Math.round((runwayDays / 30) * 100)),
|
||||
tone: runwayLow || lowBalance ? 'wn' : 'ok',
|
||||
title: lowBalance ? 'Низкий баланс аккаунта' : 'Мало денег у хостера',
|
||||
text: runwayDays != null ? `Хватит примерно на ${runwayDays} дн` : 'Баланс ниже порога',
|
||||
to: '/accounts',
|
||||
},
|
||||
{
|
||||
id: 'vps-down',
|
||||
label: 'VPS down',
|
||||
value: monitored ? `${downCount} из ${monitored}` : String(downCount),
|
||||
percent: monitored ? Math.round(((monitored - downCount) / monitored) * 100) : 100,
|
||||
tone: downCount > 0 ? 'er' : 'ok',
|
||||
title: 'VPS недоступны',
|
||||
text: `${downCount} не отвечают на uptime-проверку`,
|
||||
to: '/vps',
|
||||
},
|
||||
]
|
||||
const problems: Omit<MonitorCheck, 'label' | 'value' | 'percent'>[] = [
|
||||
...(apiOk
|
||||
? []
|
||||
: [{ id: 'api', tone: 'er' as const, title: 'API недоступно', text: 'Нет ответа от сервера', to: '/dashboard' as const }]),
|
||||
...checks.filter((c) => c.tone !== 'ok'),
|
||||
...(issuesCount > 0
|
||||
? [
|
||||
{
|
||||
id: 'inventory',
|
||||
tone: 'wn' as const,
|
||||
title: 'Проблемы инвентаря',
|
||||
text: `${issuesCount} категор. на дашборде`,
|
||||
to: '/dashboard' as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
const errors = problems.filter((p) => p.tone === 'er').length
|
||||
const warnings = problems.length - errors
|
||||
const okCount = checks.filter((c) => c.tone === 'ok').length
|
||||
const status: Tone = errors > 0 ? 'er' : warnings > 0 ? 'wn' : 'ok'
|
||||
const statusLabel = status === 'er' ? 'Сбой' : status === 'wn' ? 'Внимание' : 'Норма'
|
||||
// Кнопка подсвечена, пока есть что смотреть; в норме — спокойный outline.
|
||||
const alert = status !== 'ok'
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
@@ -229,78 +184,106 @@ export function SystemMonitorPopover() {
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label={`Монитор VPS Tracker: ${statusLabel.toLowerCase()}, ошибок ${errorCount}, предупреждений ${warningCount}`}
|
||||
type="button"
|
||||
aria-label={`Монитор VPS Tracker: ${statusLabel.toLowerCase()}, ${errors} сбой, ${warnings} предупреждения`}
|
||||
className={cn(
|
||||
'relative h-8 gap-2 px-2 max-[760px]:w-8 max-[760px]:border-transparent max-[760px]:bg-transparent max-[760px]:px-0',
|
||||
alert && 'border-warning/40 bg-warning/10 hover:bg-warning/20 aria-expanded:bg-warning/20',
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Activity
|
||||
aria-hidden
|
||||
className={cn(
|
||||
errorCount > 0 ? 'text-destructive' : warningCount > 0 ? 'text-warning' : 'text-muted-foreground',
|
||||
)}
|
||||
/>
|
||||
<span className="hidden sm:inline">{statusLabel}</span>
|
||||
{errorCount > 0 ? (
|
||||
<Badge variant="destructive-light" size="sm" className="tabular-nums">
|
||||
{errorCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
{warningCount > 0 ? (
|
||||
<Badge variant="warning-light" size="sm" className="tabular-nums">
|
||||
{warningCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
{!spiking ? (
|
||||
<Badge variant="success-light" size="sm">
|
||||
OK
|
||||
</Badge>
|
||||
<Activity className={cn('size-4', alert ? 'text-warning' : 'text-success')} aria-hidden />
|
||||
<span
|
||||
className={cn('hidden text-sm font-medium min-[1000px]:inline', alert ? 'text-warning' : 'text-success')}
|
||||
>
|
||||
{alert ? 'Внимание' : 'Норма'}
|
||||
</span>
|
||||
{alert ? (
|
||||
<>
|
||||
<span className="hidden items-center gap-1.5 text-sm tabular-nums min-[1240px]:inline-flex">
|
||||
<span className="bg-destructive size-1.5 rounded-full" aria-hidden />
|
||||
{errors}
|
||||
<span className="bg-warning size-1.5 rounded-full" aria-hidden />
|
||||
{warnings}
|
||||
</span>
|
||||
<span className="bg-destructive hidden size-4.5 min-w-4.5 items-center justify-center rounded-full px-1 text-[11px] font-semibold text-white min-[760px]:inline-flex min-[1240px]:hidden">
|
||||
{problems.length}
|
||||
</span>
|
||||
<span
|
||||
className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[760px]:hidden"
|
||||
aria-hidden
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
|
||||
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
|
||||
<span className="text-foreground text-xs font-medium">Монитор VPS Tracker</span>
|
||||
<span className="text-muted-foreground text-[11px] tabular-nums">
|
||||
{new Date().toLocaleTimeString('ru-RU')}
|
||||
</span>
|
||||
<PopoverContent align="end" className="flex w-[24rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2">
|
||||
<div className="flex items-start justify-between gap-2 px-1.5 pt-1.5">
|
||||
<div className="grid leading-tight">
|
||||
<b className="text-sm font-semibold">Монитор VPS Tracker</b>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{updatedAt ? `обновлено ${new Date(updatedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}` : 'загрузка…'}
|
||||
</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant={status === 'er' ? 'destructive-light' : status === 'wn' ? 'warning-light' : 'success-light'}
|
||||
size="sm"
|
||||
>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2">
|
||||
{metrics.map((metric, i) => (
|
||||
<div
|
||||
key={metric.id}
|
||||
className={cn(
|
||||
i % 2 === 1 && 'border-border border-l',
|
||||
i >= 2 && 'border-border border-t',
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{[
|
||||
{ n: errors, l: 'сбой', t: 'er' as const },
|
||||
{ n: warnings, l: 'предупреждения', t: 'wn' as const },
|
||||
{ n: okCount, l: 'в норме', t: 'ok' as const },
|
||||
].map((x) => (
|
||||
<span
|
||||
key={x.l}
|
||||
className="bg-muted/55 text-muted-foreground flex flex-col rounded-lg px-2.5 py-2 text-[11.5px]"
|
||||
>
|
||||
<MetricCell metric={metric} />
|
||||
<b className={cn('text-lg leading-tight font-semibold tabular-nums', TONE_TEXT[x.t])}>{x.n}</b>
|
||||
{x.l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{problems.length > 0 ? (
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{problems.map((p) => (
|
||||
<li key={p.id}>
|
||||
<Link
|
||||
to={p.to}
|
||||
className="hover:bg-muted/55 focus-visible:bg-muted/55 flex items-center gap-2.5 rounded-md px-1.5 py-2 outline-none"
|
||||
>
|
||||
<span aria-hidden className={cn('size-2 shrink-0 rounded-full', TONE_BG[p.tone])} />
|
||||
<span className="grid min-w-0 flex-1 leading-tight">
|
||||
<b className="truncate text-[13px] font-medium">{p.title}</b>
|
||||
<span className="text-muted-foreground truncate text-xs">{p.text}</span>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{checks.map((c) => (
|
||||
<div key={c.id} className="bg-muted/55 flex min-w-0 flex-col gap-1.5 rounded-lg px-3 py-2.5">
|
||||
<span className="text-muted-foreground text-xs leading-snug">{c.label}</span>
|
||||
<b className={cn('text-base font-semibold whitespace-nowrap tabular-nums', TONE_TEXT[c.tone])}>
|
||||
{c.value}
|
||||
</b>
|
||||
<div className="bg-muted-foreground/25 mt-auto h-[3px] overflow-hidden rounded-full">
|
||||
<i className={cn('block h-full rounded-full', TONE_BG[c.tone])} style={{ width: `${c.percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
|
||||
API:{' '}
|
||||
<span className={cn('font-medium', apiOk ? 'text-foreground' : 'text-destructive')}>
|
||||
{apiOk ? 'OK' : '—'}
|
||||
</span>
|
||||
{' · '}
|
||||
Инвентарь:{' '}
|
||||
<span className="text-foreground font-medium tabular-nums">{issuesCount}</span>
|
||||
{stats?.lastGlobalSyncAt ? (
|
||||
<>
|
||||
{' · '}
|
||||
Синк:{' '}
|
||||
<span className="text-foreground font-medium tabular-nums">
|
||||
{new Date(stats.lastGlobalSyncAt).toLocaleString('ru-RU')}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<Link
|
||||
to="/sync-journal"
|
||||
className="border-border text-muted-foreground hover:text-foreground flex items-center justify-between border-t px-3 py-2 text-xs transition-colors"
|
||||
className="text-muted-foreground hover:text-foreground flex items-center justify-between px-1.5 pt-1 pb-0.5 text-sm transition-colors"
|
||||
>
|
||||
Журнал синка
|
||||
<ChevronRight aria-hidden className="size-3.5" />
|
||||
<ArrowRight className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -231,38 +231,39 @@ interface KpiStatGridProps {
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
/** Ячейка полосы KPI: плоская (разделители даёт `gap-px` на фоне border), padding как в макете 14×18. */
|
||||
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
|
||||
const onActivate = resolveActivate(item)
|
||||
const panelClass = panelClassName(item)
|
||||
const cellClass = cn('bg-card min-w-0 px-[18px] py-3.5', panelClassName(item))
|
||||
|
||||
if (item.to) {
|
||||
return (
|
||||
<FramePanel className={panelClass}>
|
||||
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
||||
<div className={cellClass}>
|
||||
<Link to={item.to} search={item.search} className="block focus-visible:outline-none">
|
||||
<KpiStatCardBody item={item} />
|
||||
</Link>
|
||||
</FramePanel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (onActivate) {
|
||||
return (
|
||||
<FramePanel
|
||||
className={panelClass}
|
||||
<div
|
||||
className={cellClass}
|
||||
onClick={onActivate}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||
>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FramePanel className={panelClass}>
|
||||
<div className={cellClass}>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -312,17 +313,29 @@ export function KpiStatGrid({
|
||||
)
|
||||
}
|
||||
|
||||
// Макет (TplVps `cap3 knN`) и EvoBGP: одна панель, ячейки через 1px-разделитель, не больше 4 колонок.
|
||||
return (
|
||||
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
|
||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||
))}
|
||||
</div>
|
||||
<Frame spacing="sm" className={cn('@container w-full min-w-0', className)} aria-label={ariaLabel}>
|
||||
<FramePanel className="overflow-hidden p-0">
|
||||
<div className={cn('bg-border grid gap-px', stripCols(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function stripCols(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 @2xl:grid-cols-3'
|
||||
if (count === 4) return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-4'
|
||||
// 5+ — по 3 в ряд, чтобы не оставалась пустая серая ячейка (6 = 3+3).
|
||||
return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3'
|
||||
}
|
||||
|
||||
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
|
||||
if (item.id) return item.id
|
||||
if (typeof item.label === 'string') return item.label
|
||||
|
||||
Reference in new issue
Block a user