feat(design-preview): иконки в карточках KPI

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-18 16:54:54 +07:00
1 parent c8d69bbdfa
commit a07cb0e55a
9 files changed
+70 -59

No files matched your search

+28 -11
View File
@@ -1,4 +1,5 @@
import { Badge } from '@/components/reui/badge'
import { IconTile } from '@/components/reui/icon-tile'
import { Frame, FramePanel } from '@/components/reui/frame'
import { cn } from '@authportal/ui/lib/utils'
import type { KpiItem, KpiTone } from './kpi'
@@ -19,6 +20,14 @@ const COLS: Record<number, string> = {
4: 'grid-cols-1 min-[640px]:grid-cols-2 min-[1240px]:grid-cols-4',
}
const TILE: Record<KpiTone, string> = {
neutral: 'text-muted-foreground',
ok: 'text-success',
in: 'text-info',
wn: 'text-warning',
er: 'text-destructive',
}
const GLYPH: Record<KpiTone, string> = { neutral: '', ok: '●', in: '', wn: '▲', er: '✕' }
const BADGE = {
@@ -38,18 +47,26 @@ export function KpiStrip({ items, 'aria-label': ariaLabel }: { items: KpiItem[];
<Frame spacing="sm" className="w-full min-w-0" aria-label={ariaLabel}>
<FramePanel className="overflow-hidden p-0">
<div className={cn('bg-border grid gap-px', COLS[Math.min(Math.max(items.length, 1), 4)])}>
{items.map((k) => (
<div key={k.id} className="bg-card flex min-w-0 flex-col gap-1.5 px-[18px] py-3.5">
<div className="text-muted-foreground text-[13px]">{k.label}</div>
<div className="flex items-baseline gap-2">
<span className={cn('text-2xl font-semibold tracking-tight tabular-nums', VALUE[k.tone])}>{k.value}</span>
<Badge variant={BADGE[k.tone]} size="sm" className="whitespace-nowrap">
{GLYPH[k.tone] ? <span aria-hidden className="text-[0.55rem]">{GLYPH[k.tone]}</span> : null}
{k.hint}
</Badge>
{items.map((k) => {
const Icon = k.icon
return (
<div key={k.id} className="bg-card flex min-w-0 items-center gap-3 px-[18px] py-3.5">
<IconTile variant="outline" aria-hidden className={cn('shrink-0', TILE[k.tone])}>
<Icon />
</IconTile>
<div className="flex min-w-0 flex-col gap-1">
<div className="text-muted-foreground text-[13px]">{k.label}</div>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className={cn('text-2xl font-semibold tracking-tight tabular-nums', VALUE[k.tone])}>{k.value}</span>
<Badge variant={BADGE[k.tone]} size="sm" className="whitespace-nowrap">
{GLYPH[k.tone] ? <span aria-hidden className="text-[0.55rem]">{GLYPH[k.tone]}</span> : null}
{k.hint}
</Badge>
</div>
</div>
</div>
</div>
))}
)
})}
</div>
</FramePanel>
</Frame>
+7 -3
View File
@@ -1,3 +1,5 @@
import type { LucideIcon } from 'lucide-react'
export type KpiTone = 'neutral' | 'ok' | 'wn' | 'er' | 'in'
export type KpiItem = {
@@ -7,14 +9,16 @@ export type KpiItem = {
/** Плашка-подсказка рядом со значением. */
hint: string
tone: KpiTone
/** Иконка показателя: плитка слева от подписи. */
icon: LucideIcon
}
/** Плитка KPI доски: подпись, значение (цветом при wn/er) и плашка-подсказка. */
/** Плитка KPI: иконка, подпись, значение (цветом при wn/er) и плашка-подсказка. */
export function kpi(
id: string,
label: string,
value: string,
opts: { icon?: unknown; tone?: KpiTone; badge: string; sub?: string },
opts: { icon: LucideIcon; tone?: KpiTone; badge: string },
): KpiItem {
return { id, label, value, hint: opts.badge, tone: opts.tone ?? 'neutral' }
return { id, label, value, hint: opts.badge, tone: opts.tone ?? 'neutral', icon: opts.icon }
}
@@ -1,12 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ChartColumnIcon,
CloudIcon,
LayoutDashboardIcon,
NetworkIcon,
ServerIcon,
type LucideIcon
} from 'lucide-react'
import { ChartColumnIcon, CloudIcon, EyeIcon, KeyRoundIcon, LayoutDashboardIcon, LayoutGridIcon, LinkIcon, NetworkIcon, ServerIcon, type LucideIcon } from 'lucide-react'
import { toast } from 'sonner'
import type { AppId, AppSwitcherIconName } from '@authportal/shared'
import { Button } from '@authportal/ui/components/button'
@@ -175,10 +168,10 @@ export function AppLinksPage() {
<KpiStrip
aria-label="Показатели"
items={[
kpi('n', 'Приложений', String(rows.length), { badge: 'в каталоге' }),
kpi('sw', 'В переключателе', String(shown.length), { tone: 'ok', badge: 'видны всем' }),
kpi('jwt', 'JWT SSO', String(rows.filter((r) => r.mode === 'JWT').length), { badge: 'fragment-токен' }),
kpi('oidc', 'OIDC', String(rows.filter((r) => r.mode === 'OIDC').length), { badge: 'Technitium DNS' }),
kpi('n', 'Приложений', String(rows.length), { icon: LayoutGridIcon, badge: 'в каталоге' }),
kpi('sw', 'В переключателе', String(shown.length), { icon: EyeIcon, tone: 'ok', badge: 'видны всем' }),
kpi('jwt', 'JWT SSO', String(rows.filter((r) => r.mode === 'JWT').length), { icon: KeyRoundIcon, badge: 'fragment-токен' }),
kpi('oidc', 'OIDC', String(rows.filter((r) => r.mode === 'OIDC').length), { icon: LinkIcon, badge: 'Technitium DNS' }),
]}
/>
<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 { BadgeCheckIcon, CloudIcon, GlobeIcon, KeyRoundIcon, LayoutDashboardIcon, LayoutGridIcon, ServerIcon, ShieldIcon } from 'lucide-react'
import { toast } from 'sonner'
import { APPS, type AppId } from '@authportal/shared'
import { KpiStrip } from '../kpi-strip'
@@ -63,10 +57,10 @@ export function AppsPage() {
<KpiStrip
aria-label="Показатели"
items={[
kpi('avail', 'Доступно приложений', String(allowed.length), { tone: 'ok', badge: `из ${APPS.length}` }),
kpi('jwt', 'JWT SSO', String(allowed.filter((a) => a.authMode === 'jwt').length), { badge: 'fragment-токен' }),
kpi('oidc', 'OIDC', String(allowed.filter((a) => a.authMode === 'oidc').length), { badge: 'Technitium DNS' }),
kpi('role', 'Роль', 'Админ', { badge: 'is_admin' }),
kpi('avail', 'Доступно приложений', String(allowed.length), { icon: LayoutGridIcon, tone: 'ok', badge: `из ${APPS.length}` }),
kpi('jwt', 'JWT SSO', String(allowed.filter((a) => a.authMode === 'jwt').length), { icon: KeyRoundIcon, badge: 'fragment-токен' }),
kpi('oidc', 'OIDC', String(allowed.filter((a) => a.authMode === 'oidc').length), { icon: BadgeCheckIcon, badge: 'Technitium DNS' }),
kpi('role', 'Роль', 'Админ', { icon: ShieldIcon, badge: 'is_admin' }),
]}
/>
<Tabs defaultValue="all" className="min-w-0">
@@ -1,3 +1,4 @@
import { HistoryIcon, NetworkIcon, OctagonAlertIcon, UserCogIcon } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
import { KpiStrip } from '../kpi-strip'
@@ -48,10 +49,10 @@ export function AuditPage() {
<KpiStrip
aria-label="Показатели"
items={[
kpi('all', 'Событий', '148', { badge: 'за 7 дней' }),
kpi('acc', 'Изменений прав', '22', { tone: 'ok', badge: 'user.access_update' }),
kpi('crit', 'Критичных', '3', { tone: 'er', badge: 'critical' }),
kpi('src', 'Источников', '6', { badge: 'портал +5 приложений' }),
kpi('all', 'Событий', '148', { icon: HistoryIcon, badge: 'за 7 дней' }),
kpi('acc', 'Изменений прав', '22', { icon: UserCogIcon, tone: 'ok', badge: 'user.access_update' }),
kpi('crit', 'Критичных', '3', { icon: OctagonAlertIcon, tone: 'er', badge: 'critical' }),
kpi('src', 'Источников', '6', { icon: NetworkIcon, badge: 'портал +5 приложений' }),
]}
/>
<Tabs value={tab} onValueChange={setTab} className="min-w-0">
@@ -1,3 +1,4 @@
import { CircleCheckIcon, GlobeIcon, MonitorSmartphoneIcon, ShieldAlertIcon } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { toast } from 'sonner'
@@ -63,10 +64,10 @@ export function LoginsPage() {
<KpiStrip
aria-label="Показатели"
items={[
kpi('ok', 'Успешные (24ч)', '38', { tone: 'ok', badge: 'входы и SSO' }),
kpi('fail', 'Неудачи (24ч)', '6', { tone: 'er', badge: 'неверный пароль' }),
kpi('ip', 'Уникальные IP (24ч)', '9', { badge: '2 новых' }),
kpi('sess', 'Активные сессии', String(sessions.length), { badge: '3 пользователя' }),
kpi('ok', 'Успешные (24ч)', '38', { icon: CircleCheckIcon, tone: 'ok', badge: 'входы и SSO' }),
kpi('fail', 'Неудачи (24ч)', '6', { icon: ShieldAlertIcon, tone: 'er', badge: 'неверный пароль' }),
kpi('ip', 'Уникальные IP (24ч)', '9', { icon: GlobeIcon, badge: '2 новых' }),
kpi('sess', 'Активные сессии', String(sessions.length), { icon: MonitorSmartphoneIcon, badge: '3 пользователя' }),
]}
/>
<Tabs defaultValue="hist" className="min-w-0">
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { CopyIcon } from 'lucide-react'
import { CircleCheckIcon, CopyIcon, KeyRoundIcon, LockKeyholeIcon, TicketIcon } from 'lucide-react'
import { toast } from 'sonner'
import type { CreateOidcClientValues } from '@/components/reui-kit/create-oidc-client-sheet'
import { CreateOidcPreviewSheet } from '../create-sheets'
@@ -99,10 +99,10 @@ export function OidcPage() {
<KpiStrip
aria-label="Показатели"
items={[
kpi('n', 'Клиентов', String(clients.length), { badge: 'зарегистрировано' }),
kpi('on', 'Включено', String(clients.filter((c) => c.enabled).length), { tone: 'ok', badge: 'принимают вход' }),
kpi('tok', 'Токенов за 24 ч', '41', { badge: 'authorization_code' }),
kpi('key', 'Ключ подписи', 'RS256', { badge: 'ротация вручную' }),
kpi('n', 'Клиентов', String(clients.length), { icon: KeyRoundIcon, badge: 'зарегистрировано' }),
kpi('on', 'Включено', String(clients.filter((c) => c.enabled).length), { icon: CircleCheckIcon, tone: 'ok', badge: 'принимают вход' }),
kpi('tok', 'Токенов за 24 ч', '41', { icon: TicketIcon, badge: 'authorization_code' }),
kpi('key', 'Ключ подписи', 'RS256', { icon: LockKeyholeIcon, badge: 'ротация вручную' }),
]}
/>
<Tabs defaultValue="clients" className="min-w-0">
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { KeyRoundIcon, TrashIcon } from 'lucide-react'
import { ClockIcon, FingerprintIcon, KeyRoundIcon, LockIcon, MonitorSmartphoneIcon, TrashIcon } from 'lucide-react'
import { toast } from 'sonner'
import { KpiStrip } from '../kpi-strip'
import { Button } from '@authportal/ui/components/button'
@@ -59,10 +59,10 @@ export function SecurityPage() {
<KpiStrip
aria-label="Показатели"
items={[
kpi('keys', 'Passkeys', String(keys.length), { tone: 'ok', badge: 'активны' }),
kpi('last', 'Последний вход', 'сегодня', { badge: 'MacBook Touch ID' }),
kpi('pw', 'Пароль', 'Задан', { badge: 'запасной вход' }),
kpi('sess', 'Сессий', '3', { badge: 'устройства' }),
kpi('keys', 'Passkeys', String(keys.length), { icon: FingerprintIcon, tone: 'ok', badge: 'активны' }),
kpi('last', 'Последний вход', 'сегодня', { icon: ClockIcon, badge: 'MacBook Touch ID' }),
kpi('pw', 'Пароль', 'Задан', { icon: LockIcon, badge: 'запасной вход' }),
kpi('sess', 'Сессий', '3', { icon: MonitorSmartphoneIcon, badge: 'устройства' }),
]}
/>
<Tabs defaultValue="keys" className="min-w-0">
@@ -1,3 +1,4 @@
import { FingerprintIcon, ShieldIcon, UserXIcon, UsersIcon } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { AdminUser, CreateUserRequest } from '@authportal/shared'
import { CreateUserPreviewSheet } from '../create-sheets'
@@ -111,10 +112,10 @@ export function UsersPage() {
<KpiStrip
aria-label="Показатели"
items={[
kpi('total', 'Пользователей', String(users.length), { badge: 'в каталоге' }),
kpi('admins', 'Администраторов', String(admins), { tone: 'wn', badge: 'is_admin' }),
kpi('nopk', 'Без passkey', String(noPasskey), { tone: 'wn', badge: 'только пароль' }),
kpi('off', 'Отключённых', String(disabled), { badge: 'вход закрыт' }),
kpi('total', 'Пользователей', String(users.length), { icon: UsersIcon, badge: 'в каталоге' }),
kpi('admins', 'Администраторов', String(admins), { icon: ShieldIcon, tone: 'wn', badge: 'is_admin' }),
kpi('nopk', 'Без passkey', String(noPasskey), { icon: FingerprintIcon, tone: 'wn', badge: 'только пароль' }),
kpi('off', 'Отключённых', String(disabled), { icon: UserXIcon, badge: 'вход закрыт' }),
]}
/>
<Tabs defaultValue="users" className="min-w-0">