fix(design-preview): «Приложения» и «Изменения» точно как на досках канваса
Плитки приложений и лента событий собраны по разметке доски: Frame с шапкой, сетка auto-fit от 260px, иконка, плашка, описание и адрес; лента — время, точка с линией, плашка и подпись. На «Приложениях» возвращена полоса KPI, «Весь журнал» открывает таблицу. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
60e0b886fc
commit
709dd93548
4 files changed
+224
-44
No files matched your search
@@ -0,0 +1,156 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import type { AuditLogEntry, AuditSeverity } from '@authportal/shared'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
|
||||||
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
|
import { cn } from '@authportal/ui/lib/utils'
|
||||||
|
import { APP_SHORT } from './mock'
|
||||||
|
import { StatusBadge } from './bits'
|
||||||
|
|
||||||
|
export type AppCardItem = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
desc: string
|
||||||
|
badge: string
|
||||||
|
badgeVariant?: 'info-light' | 'warning-light' | 'outline'
|
||||||
|
meta: string
|
||||||
|
icon: ReactNode
|
||||||
|
onSelect?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Плитки приложений как на доске «Приложения»: Frame с шапкой, внутри сетка auto-fit от 260px,
|
||||||
|
* в плитке иконка, название, плашка действия, описание и адрес моноширинным шрифтом.
|
||||||
|
*/
|
||||||
|
export function AppCards({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
items,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
items: AppCardItem[]
|
||||||
|
action?: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Frame spacing="sm" className="w-full min-w-0">
|
||||||
|
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<FrameTitle>{title}</FrameTitle>
|
||||||
|
<FrameDescription className="text-pretty">{description}</FrameDescription>
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="p-3">
|
||||||
|
<ul className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,16.25rem),1fr))] gap-3">
|
||||||
|
{items.map((c) => {
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="flex min-w-0 items-center gap-2.5">
|
||||||
|
<IconTile variant="elevated" size="sm" aria-hidden className="text-muted-foreground">
|
||||||
|
{c.icon}
|
||||||
|
</IconTile>
|
||||||
|
<b className="min-w-0 text-sm leading-tight font-semibold">{c.title}</b>
|
||||||
|
</span>
|
||||||
|
<Badge variant={c.badgeVariant ?? 'info-light'} size="sm" className="shrink-0">
|
||||||
|
{c.badge}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-1.5 text-[13px] text-pretty">{c.desc}</p>
|
||||||
|
<p className="text-muted-foreground mt-2 truncate font-mono text-[11px]">{c.meta}</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
const cls = 'bg-muted/25 w-full rounded-lg border p-3.5 text-left'
|
||||||
|
return (
|
||||||
|
<li key={c.id} className="min-w-0">
|
||||||
|
{c.onSelect ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={c.onSelect}
|
||||||
|
aria-label={`${c.title}: ${c.badge}`}
|
||||||
|
className={cn(cls, 'hover:bg-muted/50 focus-visible:ring-ring transition-colors outline-none focus-visible:ring-2')}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className={cls}>{body}</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TONE: Record<AuditSeverity, { color: string; label: string; tone: 'ok' | 'wn' | 'er' }> = {
|
||||||
|
info: { color: 'bg-success', label: 'Info', tone: 'ok' },
|
||||||
|
warning: { color: 'bg-warning', label: 'Warning', tone: 'wn' },
|
||||||
|
critical: { color: 'bg-destructive', label: 'Critical', tone: 'er' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function when(iso: string) {
|
||||||
|
const d = new Date(iso)
|
||||||
|
const ageH = (Date.now() - d.getTime()) / 3_600_000
|
||||||
|
if (ageH < 24) return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(d)
|
||||||
|
if (ageH < 48) return 'вчера'
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' }).format(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Лента событий как на доске «Изменения»: время, точка важности с линией, плашка, заголовок
|
||||||
|
* и строка «кто · источник · IP».
|
||||||
|
*/
|
||||||
|
export function ActivityFeed({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
entries,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
entries: AuditLogEntry[]
|
||||||
|
action?: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Frame spacing="sm" className="w-full min-w-0">
|
||||||
|
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<FrameTitle>{title}</FrameTitle>
|
||||||
|
<FrameDescription className="text-pretty">{description}</FrameDescription>
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="py-1.5">
|
||||||
|
<ol>
|
||||||
|
{entries.map((e) => {
|
||||||
|
const t = TONE[e.severity]
|
||||||
|
const source = e.source_app === 'portal' ? 'Auth Portal' : APP_SHORT[e.source_app]
|
||||||
|
return (
|
||||||
|
<li key={e.id} className="grid grid-cols-[3.9rem_1rem_minmax(0,1fr)] items-start gap-2.5 px-4 py-2 text-[13px]">
|
||||||
|
<span className="text-muted-foreground pt-px font-mono text-xs tabular-nums">{when(e.created_at)}</span>
|
||||||
|
<span aria-hidden className="flex h-full flex-col items-center">
|
||||||
|
<i className={cn('mt-1 size-[9px] rounded-full', t.color)} />
|
||||||
|
<u className="bg-border mt-0.5 min-h-4.5 w-px flex-1 no-underline" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="mr-2 inline-block align-middle">
|
||||||
|
<StatusBadge tone={t.tone}>{t.label}</StatusBadge>
|
||||||
|
</span>
|
||||||
|
<b className="font-medium">{e.summary}</b>
|
||||||
|
<span className="text-muted-foreground mt-0.5 block text-xs">
|
||||||
|
{e.actor_name ?? 'system'} · {source}
|
||||||
|
{e.ip ? ` · ${e.ip}` : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import type { AppId, AppSwitcherIconName } from '@authportal/shared'
|
import type { AppId, AppSwitcherIconName } from '@authportal/shared'
|
||||||
import { QuickActionGrid } from '@/components/reui-kit/quick-action-grid'
|
|
||||||
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
|
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
|
||||||
import { IconTile } from '@/components/reui/icon-tile'
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
import { Button } from '@authportal/ui/components/button'
|
import { Button } from '@authportal/ui/components/button'
|
||||||
@@ -42,6 +41,7 @@ import { Switch } from '@authportal/ui/components/switch'
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||||
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
|
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
|
||||||
import { PageHead, PreviewSheet, StatusBadge } from '../bits'
|
import { PageHead, PreviewSheet, StatusBadge } from '../bits'
|
||||||
|
import { AppCards } from '../canvas-blocks'
|
||||||
import { kpi } from '../kpi'
|
import { kpi } from '../kpi'
|
||||||
import { MOCK_SWITCHER } from '../mock'
|
import { MOCK_SWITCHER } from '../mock'
|
||||||
import { usePreviewStore } from '../preview-store'
|
import { usePreviewStore } from '../preview-store'
|
||||||
@@ -341,18 +341,18 @@ export function AppLinksPage() {
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
<QuickActionGrid
|
<AppCards
|
||||||
autoFit
|
|
||||||
title="Предпросмотр меню"
|
title="Предпросмотр меню"
|
||||||
description={`«${menuLabel}» · так увидят пользователи с доступом ко всем приложениям`}
|
description={`«${menuLabel}» · так увидят пользователи с доступом ко всем приложениям`}
|
||||||
actions={shown.slice(0, 4).map((r) => {
|
items={shown.slice(0, 4).map((r) => {
|
||||||
const { Icon } = ICONS[r.icon]
|
const { Icon } = ICONS[r.icon]
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
title: r.name,
|
title: r.name,
|
||||||
description: r.url.replace('https://', ''),
|
desc: r.url.replace('https://', ''),
|
||||||
badgeLabel: r.mode,
|
badge: r.mode,
|
||||||
badgeVariant: r.mode === 'OIDC' ? ('warning-light' as const) : ('info-light' as const),
|
badgeVariant: r.mode === 'OIDC' ? ('warning-light' as const) : ('info-light' as const),
|
||||||
|
meta: r.id,
|
||||||
icon: <Icon />,
|
icon: <Icon />,
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import { CloudIcon, GlobeIcon, LayoutDashboardIcon, ServerIcon, ShieldIcon } from 'lucide-react'
|
import {
|
||||||
|
CloudIcon,
|
||||||
|
FingerprintIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
KeyRoundIcon,
|
||||||
|
LayoutDashboardIcon,
|
||||||
|
LayoutGridIcon,
|
||||||
|
ServerIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
ShieldIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { APPS, type AppId } from '@authportal/shared'
|
import { APPS, type AppId } from '@authportal/shared'
|
||||||
import { QuickActionGrid } from '@/components/reui-kit/quick-action-grid'
|
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
|
||||||
import { Button } from '@authportal/ui/components/button'
|
import { Button } from '@authportal/ui/components/button'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||||
import { PageHead } from '../bits'
|
import { PageHead } from '../bits'
|
||||||
|
import { AppCards, type AppCardItem } from '../canvas-blocks'
|
||||||
|
import { kpi } from '../kpi'
|
||||||
|
|
||||||
const ICONS: Record<AppId, typeof CloudIcon> = {
|
const ICONS: Record<AppId, typeof CloudIcon> = {
|
||||||
cfdm: CloudIcon,
|
cfdm: CloudIcon,
|
||||||
@@ -23,59 +35,61 @@ export function AppsPage() {
|
|||||||
const allowed = APPS.filter((a) => !NO_ACCESS.includes(a.id))
|
const allowed = APPS.filter((a) => !NO_ACCESS.includes(a.id))
|
||||||
const denied = APPS.filter((a) => NO_ACCESS.includes(a.id))
|
const denied = APPS.filter((a) => NO_ACCESS.includes(a.id))
|
||||||
|
|
||||||
|
const toCard = (app: (typeof APPS)[number], open: boolean): AppCardItem => {
|
||||||
|
const Icon = ICONS[app.id]
|
||||||
|
const meta = `${app.url.replace('https://', '')} · ${app.authMode === 'oidc' ? 'OIDC' : 'JWT'}`
|
||||||
|
return {
|
||||||
|
id: app.id,
|
||||||
|
title: app.title,
|
||||||
|
desc: app.description,
|
||||||
|
badge: open ? 'Открыть' : 'Нет доступа',
|
||||||
|
badgeVariant: open ? 'info-light' : 'outline',
|
||||||
|
meta,
|
||||||
|
icon: <Icon />,
|
||||||
|
onSelect: () =>
|
||||||
|
open
|
||||||
|
? toast(`Открыть ${app.title}`, { description: `${meta} (макет)` })
|
||||||
|
: toast.warning(`Нет доступа к ${app.title}`, { description: 'Запросите права у администратора.' }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Приложения"
|
title="Приложения"
|
||||||
sub="Открыть приложение с текущей сессией · SSO handoff без повторного ввода пароля"
|
sub="Открыть приложение с текущей сессией · SSO handoff без повторного ввода пароля"
|
||||||
actions={
|
actions={
|
||||||
<Button variant="outline" size="sm" render={<Link to="/design-preview/app-links" />} nativeButton={false}>
|
<Button variant="outline" render={<Link to="/design-preview/app-links" />} nativeButton={false}>
|
||||||
Ссылки приложений
|
Ссылки приложений
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<KpiStatGrid
|
||||||
|
aria-label="Показатели"
|
||||||
|
items={[
|
||||||
|
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">
|
<Tabs defaultValue="all" className="min-w-0">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="all">Доступные · {allowed.length}</TabsTrigger>
|
<TabsTrigger value="all">Доступные · {allowed.length}</TabsTrigger>
|
||||||
<TabsTrigger value="no">Без доступа · {denied.length}</TabsTrigger>
|
<TabsTrigger value="no">Без доступа · {denied.length}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="all" className="flex flex-col gap-4">
|
<TabsContent value="all" className="min-w-0">
|
||||||
<QuickActionGrid
|
<AppCards
|
||||||
autoFit
|
|
||||||
title="Мои приложения"
|
title="Мои приложения"
|
||||||
description="Клик открывает приложение · портал заново выпускает токен с актуальными правами"
|
description="Клик открывает приложение в новой вкладке · портал заново выпускает токен с актуальными правами"
|
||||||
actions={allowed.map((app) => {
|
items={allowed.map((a) => toCard(a, true))}
|
||||||
const Icon = ICONS[app.id]
|
|
||||||
return {
|
|
||||||
id: app.id,
|
|
||||||
title: app.title,
|
|
||||||
description: app.description,
|
|
||||||
badgeLabel: 'Открыть',
|
|
||||||
badgeVariant: 'info-light' as const,
|
|
||||||
meta: `${app.url.replace('https://', '')} · ${app.authMode === 'oidc' ? 'OIDC' : 'JWT'}`,
|
|
||||||
icon: <Icon />,
|
|
||||||
onSelect: () => toast(`Открыть ${app.title}`, { description: `${app.url.replace('https://', '')} · ${app.authMode.toUpperCase()} (макет)` }),
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="no">
|
<TabsContent value="no" className="min-w-0">
|
||||||
<QuickActionGrid
|
<AppCards
|
||||||
autoFit
|
|
||||||
title="Недоступные приложения"
|
title="Недоступные приложения"
|
||||||
description="Права не выданы · запросите у администратора"
|
description="Права не выданы · запросите у администратора"
|
||||||
actions={denied.map((app) => {
|
items={denied.map((a) => toCard(a, false))}
|
||||||
const Icon = ICONS[app.id]
|
|
||||||
return {
|
|
||||||
id: app.id,
|
|
||||||
title: app.title,
|
|
||||||
description: app.description,
|
|
||||||
badgeLabel: 'Нет доступа',
|
|
||||||
meta: `${app.url.replace('https://', '')} · ${app.authMode === 'oidc' ? 'OIDC' : 'JWT'}`,
|
|
||||||
icon: <Icon />,
|
|
||||||
onSelect: () => toast.warning(`Нет доступа к ${app.title}`, { description: 'Запросите права у администратора.' }),
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { useState } from 'react'
|
|||||||
import { DownloadIcon, Trash2Icon, HistoryIcon, UserCogIcon, ShieldAlertIcon, NetworkIcon } from 'lucide-react'
|
import { DownloadIcon, Trash2Icon, HistoryIcon, UserCogIcon, ShieldAlertIcon, NetworkIcon } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import type { AuditLogEntry, AuditSeverity } from '@authportal/shared'
|
import type { AuditLogEntry, AuditSeverity } from '@authportal/shared'
|
||||||
import { AuditLogTimeline } from '@/components/reui-kit/audit-log-timeline'
|
|
||||||
import { AuditRetentionPanel } from '@/components/reui-kit/audit-retention-panel'
|
import { AuditRetentionPanel } from '@/components/reui-kit/audit-retention-panel'
|
||||||
|
import { ActivityFeed } from '../canvas-blocks'
|
||||||
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
|
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
|
||||||
import { Button } from '@authportal/ui/components/button'
|
import { Button } from '@authportal/ui/components/button'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||||
@@ -43,6 +43,7 @@ const VIEWS = [
|
|||||||
export function AuditPage() {
|
export function AuditPage() {
|
||||||
const [entry, setEntry] = useState<AuditLogEntry | null>(null)
|
const [entry, setEntry] = useState<AuditLogEntry | null>(null)
|
||||||
const [purgeOpen, setPurgeOpen] = useState(false)
|
const [purgeOpen, setPurgeOpen] = useState(false)
|
||||||
|
const [tab, setTab] = useState('feed')
|
||||||
const [retention, setRetention] = useState(90)
|
const [retention, setRetention] = useState(90)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -66,14 +67,23 @@ export function AuditPage() {
|
|||||||
kpi('src', 'Источников', '6', { icon: NetworkIcon, tone: 'in', badge: 'ingest', sub: 'портал + 5 приложений' }),
|
kpi('src', 'Источников', '6', { icon: NetworkIcon, tone: 'in', badge: 'ingest', sub: 'портал + 5 приложений' }),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Tabs defaultValue="feed" className="min-w-0">
|
<Tabs value={tab} onValueChange={setTab} className="min-w-0">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="feed">Лента</TabsTrigger>
|
<TabsTrigger value="feed">Лента</TabsTrigger>
|
||||||
<TabsTrigger value="table">Таблица · {MOCK_AUDIT.length}</TabsTrigger>
|
<TabsTrigger value="table">Таблица · {MOCK_AUDIT.length}</TabsTrigger>
|
||||||
<TabsTrigger value="retention">Ротация</TabsTrigger>
|
<TabsTrigger value="retention">Ротация</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="feed" className="min-w-0">
|
<TabsContent value="feed" className="min-w-0">
|
||||||
<AuditLogTimeline entries={MOCK_AUDIT} totalCount={MOCK_AUDIT.length} />
|
<ActivityFeed
|
||||||
|
title="Лента изменений"
|
||||||
|
description="Последние события · точка отражает важность, плашка — уровень"
|
||||||
|
entries={[...MOCK_AUDIT].sort((a, b) => b.created_at.localeCompare(a.created_at)).slice(0, 6)}
|
||||||
|
action={
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setTab('table')}>
|
||||||
|
Весь журнал
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="table" className="min-w-0">
|
<TabsContent value="table" className="min-w-0">
|
||||||
<PreviewSectionGrid
|
<PreviewSectionGrid
|
||||||
|
|||||||
Reference in new issue
Block a user