feat(design-preview): KPI-плитки с иконками, тонами и пояснениями

Плитки собираются хелпером kpi(): иконка по тону, значение с пояснением, бейдж статуса.
Для четырёх плиток сетка переходит на 2 колонки раньше (kpi-cols).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-17 22:14:30 +07:00
1 parent 7f9d69d27d
commit bd69b27e5c
9 files changed
+89 -41

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@ export 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'
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 4) return 'grid-cols-1 @xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
+46
View File
@@ -0,0 +1,46 @@
import { createElement } from 'react'
import type { LucideIcon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import type { KpiStatItem } from '@/components/reui-kit/kpi-stat-grid'
export type KpiTone = 'neutral' | 'ok' | 'wn' | 'er' | 'in'
const ICON_CLASS: Record<KpiTone, string> = {
neutral: 'text-muted-foreground [&_svg]:text-current',
ok: 'text-success [&_svg]:text-current',
wn: 'text-warning [&_svg]:text-current',
er: 'text-destructive [&_svg]:text-current',
in: 'text-info [&_svg]:text-current',
}
const BADGE: Record<KpiTone, 'outline' | 'success-light' | 'warning-light' | 'destructive-light' | 'info-light'> = {
neutral: 'outline',
ok: 'success-light',
wn: 'warning-light',
er: 'destructive-light',
in: 'info-light',
}
/** Плитка KPI: иконка по тону, значение с пояснением, бейдж статуса. */
export function kpi(
id: string,
label: string,
value: string,
opts: { icon: LucideIcon; tone?: KpiTone; badge: string; sub?: string },
): KpiStatItem {
const tone = opts.tone ?? 'neutral'
return {
id,
label,
icon: createElement(opts.icon),
iconClassName: ICON_CLASS[tone],
variant: tone === 'wn' ? 'warning' : tone === 'er' ? 'destructive' : 'default',
value: createElement(
'span',
{ className: 'flex flex-wrap items-baseline gap-x-2' },
value,
opts.sub ? createElement('span', { className: 'text-muted-foreground text-xs font-normal' }, opts.sub) : null,
),
footer: createElement(Badge, { variant: BADGE[tone], size: 'sm', className: 'whitespace-nowrap' }, opts.badge),
}
}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowDownIcon, ArrowUpIcon, PlusIcon } from 'lucide-react'
import { ArrowDownIcon, ArrowUpIcon, PlusIcon, LayoutGridIcon, EyeIcon, KeyRoundIcon, FingerprintIcon } from 'lucide-react'
import { toast } from 'sonner'
import { APPS, type AppId } from '@authportal/shared'
import { AppSwitcherAdminEditor } from '@/components/reui-kit/app-switcher-admin-editor'
@@ -10,6 +10,7 @@ import { Input } from '@authportal/ui/components/input'
import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { PageHead, PreviewSheet, StatusBadge } from '../bits'
import { kpi } from '../kpi'
import { MOCK_SWITCHER } from '../mock'
import { usePreviewStore } from '../preview-store'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
@@ -120,10 +121,10 @@ export function AppLinksPage() {
<KpiStatGrid
aria-label="Показатели"
items={[
{ id: 'n', label: 'Приложений', value: String(rows.length), hint: 'в каталоге' },
{ id: 'sw', label: 'В переключателе', value: String(shown), hint: 'видны всем' },
{ id: 'jwt', label: 'JWT SSO', value: String(rows.filter((r) => r.mode === 'JWT').length), hint: 'fragment-токен' },
{ id: 'oidc', label: 'OIDC', value: String(rows.filter((r) => r.mode === 'OIDC').length), hint: 'Technitium DNS' },
kpi('n', 'Приложений', String(rows.length), { icon: LayoutGridIcon, badge: 'в каталоге', sub: 'сервисов' }),
kpi('sw', 'В переключателе', String(shown), { icon: EyeIcon, tone: 'ok', badge: 'видны всем', sub: `из ${rows.length}` }),
kpi('jwt', 'JWT SSO', String(rows.filter((r) => r.mode === 'JWT').length), { icon: KeyRoundIcon, tone: 'in', badge: 'fragment-токен', sub: 'без пароля' }),
kpi('oidc', 'OIDC', String(rows.filter((r) => r.mode === 'OIDC').length), { icon: FingerprintIcon, tone: 'in', badge: 'Technitium DNS', sub: 'внешний RP' }),
]}
/>
<Tabs defaultValue="list" className="min-w-0">
@@ -1,11 +1,5 @@
import { Link } from '@tanstack/react-router'
import {
CloudIcon,
GlobeIcon,
LayoutDashboardIcon,
ServerIcon,
ShieldIcon,
} from 'lucide-react'
import { CloudIcon, GlobeIcon, LayoutDashboardIcon, ServerIcon, ShieldIcon, LayoutGridIcon, KeyRoundIcon, FingerprintIcon, ShieldCheckIcon } from 'lucide-react'
import { toast } from 'sonner'
import { APPS, type AppId } from '@authportal/shared'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
@@ -13,6 +7,7 @@ import { QuickActionGrid } from '@/components/reui-kit/quick-action-grid'
import { Button } from '@authportal/ui/components/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { PageHead } from '../bits'
import { kpi } from '../kpi'
const ICONS: Record<AppId, typeof CloudIcon> = {
cfdm: CloudIcon,
@@ -45,10 +40,10 @@ export function AppsPage() {
<KpiStatGrid
aria-label="Показатели"
items={[
{ id: 'avail', label: 'Доступно приложений', value: String(allowed.length), hint: `из ${APPS.length}` },
{ id: 'jwt', label: 'JWT SSO', value: String(allowed.filter((a) => a.authMode === 'jwt').length), hint: 'fragment-токен' },
{ id: 'oidc', label: 'OIDC', value: String(allowed.filter((a) => a.authMode === 'oidc').length), hint: 'Technitium DNS' },
{ id: 'role', label: 'Роль', value: 'Админ', hint: 'is_admin' },
kpi('avail', 'Доступно приложений', String(allowed.length), { icon: LayoutGridIcon, tone: 'ok', badge: 'доступ есть', sub: `из ${APPS.length}` }),
kpi('jwt', 'JWT SSO', String(allowed.filter((a) => a.authMode === 'jwt').length), { icon: KeyRoundIcon, tone: 'in', badge: 'fragment-токен', sub: 'вход без пароля' }),
kpi('oidc', 'OIDC', String(allowed.filter((a) => a.authMode === 'oidc').length), { icon: FingerprintIcon, tone: 'in', badge: 'Technitium DNS', sub: 'внешний RP' }),
kpi('role', 'Роль', 'Админ', { icon: ShieldCheckIcon, tone: 'wn', badge: 'is_admin', sub: 'полный доступ' }),
]}
/>
<Tabs defaultValue="all" className="min-w-0">
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { DownloadIcon, Trash2Icon } from 'lucide-react'
import { DownloadIcon, Trash2Icon, HistoryIcon, UserCogIcon, ShieldAlertIcon, NetworkIcon } from 'lucide-react'
import { toast } from 'sonner'
import type { AuditLogEntry, AuditSeverity } from '@authportal/shared'
import { AuditLogTimeline } from '@/components/reui-kit/audit-log-timeline'
@@ -8,6 +8,7 @@ import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
import { Button } from '@authportal/ui/components/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { Segmented, CodeBlock, KvList, PageHead, PreviewSheet, StatusBadge, TypedConfirm } from '../bits'
import { kpi } from '../kpi'
import { MOCK_AUDIT, APP_SHORT, type Tone } from '../mock'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
@@ -89,10 +90,10 @@ export function AuditPage() {
<KpiStatGrid
aria-label="Показатели"
items={[
{ id: 'all', label: 'Событий', value: '148', hint: 'за 7 дней' },
{ id: 'acc', label: 'Изменений прав', value: '22', hint: 'user.access_update' },
{ id: 'crit', label: 'Критичных', value: String(MOCK_AUDIT.filter((e) => e.severity === 'critical').length), hint: 'critical', variant: 'destructive' },
{ id: 'src', label: 'Источников', value: '6', hint: 'портал + 5 приложений' },
kpi('all', 'Событий', '148', { icon: HistoryIcon, badge: 'за 7 дней', sub: 'во всех источниках' }),
kpi('acc', 'Изменений прав', '22', { icon: UserCogIcon, tone: 'ok', badge: 'user.access_update', sub: 'в портале' }),
kpi('crit', 'Критичных', String(MOCK_AUDIT.filter((e) => e.severity === 'critical').length), { icon: ShieldAlertIcon, tone: 'er', badge: 'critical', sub: 'требуют внимания' }),
kpi('src', 'Источников', '6', { icon: NetworkIcon, tone: 'in', badge: 'ingest', sub: 'портал + 5 приложений' }),
]}
/>
<Tabs defaultValue="all" className="min-w-0">
@@ -1,11 +1,12 @@
import { useMemo, useState } from 'react'
import { Link, useNavigate } from '@tanstack/react-router'
import { DownloadIcon } from 'lucide-react'
import { DownloadIcon, LogInIcon, ShieldAlertIcon, GlobeIcon, MonitorSmartphoneIcon } from 'lucide-react'
import { toast } from 'sonner'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
import { Button } from '@authportal/ui/components/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { Segmented, CodeBlock, KvList, PageHead, PreviewSheet, SignalAlert, StatusBadge } from '../bits'
import { kpi } from '../kpi'
import { MOCK_LOGINS, MOCK_SESSIONS, type LoginRow, type SessionRow } from '../mock'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
@@ -87,10 +88,10 @@ export function LoginsPage() {
<KpiStatGrid
aria-label="Показатели"
items={[
{ id: 'ok', label: 'Успешные (24ч)', value: '38', hint: 'входы и SSO' },
{ id: 'fail', label: 'Неудачи (24ч)', value: '6', hint: 'неверный пароль', variant: 'destructive' },
{ id: 'ip', label: 'Уникальные IP (24ч)', value: '9', hint: '2 новых' },
{ id: 'sess', label: 'Активные сессии', value: String(sessions.length), hint: '3 пользователя' },
kpi('ok', 'Успешные (24ч)', '38', { icon: LogInIcon, tone: 'ok', badge: 'входы и SSO', sub: 'без ошибок' }),
kpi('fail', 'Неудачи (24ч)', '6', { icon: ShieldAlertIcon, tone: 'er', badge: 'неверный пароль', sub: 'с одного IP' }),
kpi('ip', 'Уникальные IP (24ч)', '9', { icon: GlobeIcon, badge: '2 новых', sub: 'адреса' }),
kpi('sess', 'Активные сессии', String(sessions.length), { icon: MonitorSmartphoneIcon, tone: 'in', badge: '3 пользователя', sub: 'токены' }),
]}
/>
<Tabs defaultValue="hist" className="min-w-0">
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { CopyIcon, PlusIcon, RefreshCwIcon } from 'lucide-react'
import { CopyIcon, PlusIcon, RefreshCwIcon, AppWindowIcon, PowerIcon, TicketIcon, KeyRoundIcon } from 'lucide-react'
import { toast } from 'sonner'
import { CreateOidcClientSheet, type CreateOidcClientValues } from '@/components/reui-kit/create-oidc-client-sheet'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
@@ -18,6 +18,7 @@ import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { Textarea } from '@authportal/ui/components/textarea'
import { CopyList, KvFrame, PageHead, PreviewSheet, SignalAlert, StatusBadge, TypedConfirm } from '../bits'
import { kpi } from '../kpi'
import {
MOCK_CLIENT_SECRET,
MOCK_OIDC_CLIENTS,
@@ -113,10 +114,10 @@ export function OidcPage() {
<KpiStatGrid
aria-label="Показатели"
items={[
{ id: 'n', label: 'Клиентов', value: String(clients.length), hint: 'зарегистрировано' },
{ id: 'on', label: 'Включено', value: String(clients.filter((c) => c.enabled).length), hint: 'принимают вход' },
{ id: 'tok', label: 'Токенов за 24 ч', value: '41', hint: 'authorization_code' },
{ id: 'key', label: 'Ключ подписи', value: 'RS256', hint: 'ротация вручную' },
kpi('n', 'Клиентов', String(clients.length), { icon: AppWindowIcon, badge: 'зарегистрировано', sub: 'внешних RP' }),
kpi('on', 'Включено', String(clients.filter((c) => c.enabled).length), { icon: PowerIcon, tone: 'ok', badge: 'принимают вход', sub: 'активны' }),
kpi('tok', 'Токенов за 24 ч', '41', { icon: TicketIcon, tone: 'in', badge: 'authorization_code', sub: 'выдано' }),
kpi('key', 'Ключ подписи', 'RS256', { icon: KeyRoundIcon, tone: staleCount > 0 ? 'wn' : 'ok', badge: 'ротация вручную', sub: 'JWKS активен' }),
]}
/>
<Tabs defaultValue="clients" className="min-w-0">
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { KeyRoundIcon, PlusIcon, TrashIcon } from 'lucide-react'
import { KeyRoundIcon, PlusIcon, TrashIcon, FingerprintIcon, ClockIcon, LockKeyholeIcon, MonitorSmartphoneIcon } from 'lucide-react'
import { toast } from 'sonner'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
@@ -9,6 +9,7 @@ import { Input } from '@authportal/ui/components/input'
import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { KvFrame, KvList, PageHead, PreviewSheet, SignalAlert, StatusBadge, TypedConfirm } from '../bits'
import { kpi } from '../kpi'
import { MOCK_PASSKEYS, type PasskeyRow } from '../mock'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
@@ -52,10 +53,10 @@ export function SecurityPage() {
<KpiStatGrid
aria-label="Показатели"
items={[
{ id: 'keys', label: 'Passkeys', value: String(keys.length), hint: 'активны' },
{ id: 'last', label: 'Последний вход', value: 'сегодня', hint: 'MacBook Touch ID' },
{ id: 'pw', label: 'Пароль', value: 'Задан', hint: 'запасной вход' },
{ id: 'sess', label: 'Сессий', value: '3', hint: 'устройства' },
kpi('keys', 'Passkeys', String(keys.length), { icon: FingerprintIcon, tone: keys.length > 1 ? 'ok' : 'wn', badge: keys.length > 1 ? 'резерв есть' : 'одна копия', sub: 'активны' }),
kpi('last', 'Последний вход', 'сегодня', { icon: ClockIcon, badge: 'Touch ID', sub: '09:41' }),
kpi('pw', 'Пароль', 'Задан', { icon: LockKeyholeIcon, tone: 'ok', badge: 'запасной вход', sub: 'сменён 11.03' }),
kpi('sess', 'Сессий', '3', { icon: MonitorSmartphoneIcon, badge: 'устройства', sub: 'сейчас активны' }),
]}
/>
<Tabs defaultValue="keys" className="min-w-0">
@@ -1,3 +1,4 @@
import { UsersIcon, ShieldCheckIcon, FingerprintIcon, UserXIcon } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link } from '@tanstack/react-router'
import { toast } from 'sonner'
@@ -9,6 +10,7 @@ import { Button } from '@authportal/ui/components/button'
import { AccessDialog } from '../access-dialog'
import { diffAccess, type AccessValue } from '../access-model'
import { CodeBlock, KvList, PageHead, PreviewSheet, SignalAlert, TypedConfirm } from '../bits'
import { kpi } from '../kpi'
import { MOCK_USERS, USER_JOURNAL } from '../mock'
import { usePreviewStore } from '../preview-store'
@@ -126,10 +128,10 @@ export function UsersPage() {
<KpiStatGrid
aria-label="Показатели"
items={[
{ id: 'total', label: 'Пользователей', value: String(users.length), hint: 'в каталоге' },
{ id: 'admins', label: 'Администраторов', value: String(admins), hint: 'is_admin', variant: 'warning' },
{ id: 'nopk', label: 'Без passkey', value: String(noPasskey), hint: 'только пароль', variant: 'warning' },
{ id: 'off', label: 'Отключённых', value: String(disabled), hint: 'вход закрыт' },
kpi('total', 'Пользователей', String(users.length), { icon: UsersIcon, badge: 'в каталоге', sub: 'учётных записей' }),
kpi('admins', 'Администраторов', String(admins), { icon: ShieldCheckIcon, tone: 'wn', badge: 'is_admin', sub: 'полный доступ' }),
kpi('nopk', 'Без passkey', String(noPasskey), { icon: FingerprintIcon, tone: noPasskey > 0 ? 'wn' : 'ok', badge: 'только пароль', sub: 'нет резервного входа' }),
kpi('off', 'Отключённых', String(disabled), { icon: UserXIcon, badge: 'вход закрыт', sub: 'не удаляются' }),
]}
/>
<AdminUsersGrid