feat(design-preview): иконки в карточках KPI
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
c8d69bbdfa
commit
a07cb0e55a
9 files changed
+63
-52
No files matched your search
@@ -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,10 +47,16 @@ 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">
|
||||
{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 items-baseline gap-2">
|
||||
<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}
|
||||
@@ -49,7 +64,9 @@ export function KpiStrip({ items, 'aria-label': ariaLabel }: { items: KpiItem[];
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user