From b20caef1f10c75367fe1846eab5bb00466aa7edd Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 23 Oct 2026 18:42:19 +0700 Subject: [PATCH] feat(web): align tables, KPI strip and tabs with shared mockup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Таблицы ResourcePage и FrameDataGrid переведены на раскладку MikrotikManager: сегменты, фильтры и поиск в тулбаре, подложка шапки, ячейки py-3. KPI стали полосой в одном Frame, переключатели разделов — линейными вкладками. Старая оболочка (app-shell, nav-user, монитор, палитра) удалена: её заменил PortalShell. Тесты kit-раскладки обновлены под новый контракт. Co-Authored-By: Claude Sonnet 5.5 --- apps/web/src/components/layout/app-shell.tsx | 233 ------------------ apps/web/src/components/layout/apps-menu.tsx | 104 -------- .../src/components/layout/command-palette.tsx | 147 ----------- apps/web/src/components/layout/nav-user.tsx | 229 ----------------- .../layout/system-monitor-popover.tsx | 213 ---------------- apps/web/src/components/portal/bits.tsx | 2 +- .../reui-kit/data-grid-kit-defaults.test.ts | 12 +- .../components/reui-kit/frame-data-grid.tsx | 14 +- .../src/components/reui-kit/kpi-stat-grid.tsx | 82 +++--- .../src/components/reui-kit/resource-page.tsx | 132 +++++----- apps/web/src/components/segmented-tabs.tsx | 17 +- apps/web/src/portal-theme.css | 13 + 12 files changed, 144 insertions(+), 1054 deletions(-) delete mode 100644 apps/web/src/components/layout/app-shell.tsx delete mode 100644 apps/web/src/components/layout/apps-menu.tsx delete mode 100644 apps/web/src/components/layout/command-palette.tsx delete mode 100644 apps/web/src/components/layout/nav-user.tsx delete mode 100644 apps/web/src/components/layout/system-monitor-popover.tsx diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx deleted file mode 100644 index 57a1bef..0000000 --- a/apps/web/src/components/layout/app-shell.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import { - LayoutDashboard, - Boxes, - Network, - Cog, - ListChecks, - Activity, - Settings, - BookText, - KeyRound, - Search, -} from 'lucide-react' - -import { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarHeader, - SidebarInset, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarProvider, - SidebarTrigger, -} from '@evobgp/ui/components/sidebar' -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from '@evobgp/ui/components/breadcrumb' -import { Separator } from '@evobgp/ui/components/separator' -import { TooltipProvider } from '@evobgp/ui/components/tooltip' - -import { Link, useRouterState } from '@tanstack/react-router' -import type { ComponentType, CSSProperties, ReactNode } from 'react' - -import { AppSwitcher } from '@/components/app-switcher' -import { AppsMenu } from '@/components/layout/apps-menu' -import { CommandPalette, type CommandPaletteItem } from '@/components/layout/command-palette' -import { NavUser } from '@/components/layout/nav-user' -import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover' -import { can, isAuthEnabled, permissionForPath } from '@/lib/auth' -import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface' - -interface NavItem { - to: string - label: string - icon: ComponentType<{ className?: string }> - description?: string - search?: Record - /** Explicit permission override; when omitted derived from `to`. */ - permission?: string -} - -interface NavGroup { - label: string - items: NavItem[] -} - -const NAV_GROUPS: NavGroup[] = [ - { - label: 'Обзор', - items: [ - { - to: '/dashboard', - label: 'Панель', - icon: LayoutDashboard, - description: 'Метрики, модули и активность', - }, - ], - }, - { - label: 'Маршрутизация', - items: [ - { to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' }, - { to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' }, - { to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } }, - { to: '/directories', label: 'Справочники', icon: BookText, description: 'BGP community и DoH' }, - ], - }, - { - label: 'Операции', - items: [ - { to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и применение', search: { tab: 'revisions' } }, - { to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание обновления' }, - { to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD' }, - ], - }, - { - label: 'Система', - items: [ - { to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' }, - { to: '/settings', label: 'Настройки', icon: Settings, description: 'UI и BIRD', search: { tab: 'ui' } }, - ], - }, -] - -const ALL_NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items) - -/** Nav visible for the current claim (or all items when portal auth is off). */ -function useVisibleNavGroups(): NavGroup[] { - if (!isAuthEnabled()) return NAV_GROUPS - return NAV_GROUPS.map((group) => ({ - ...group, - items: group.items.filter((item) => { - const perm = item.permission ?? permissionForPath(item.to) - return !perm || can(perm) - }), - })).filter((group) => group.items.length > 0) -} - -const ROUTE_LABELS: Record = Object.fromEntries( - ALL_NAV_ITEMS.map((i) => [i.to, i.label]), -) - -const PARENT_ROUTE: Record = { - '/modules/new': '/modules', -} - -const COMMAND_ITEMS: CommandPaletteItem[] = ALL_NAV_ITEMS.map((item) => ({ - id: item.to, - label: item.label, - description: item.description, - to: item.to, - search: item.search, - keywords: [item.to.replace(/^\//, '')], -})) - -/** - * Shared ops chrome etalon for CFDM / vps-tracker. - * @see https://reui.io/preview/base/app-shell-12 - * @see docs/ui-design-contract.md — Shared App Shell chrome - */ -export function AppShell({ children }: { children: ReactNode }) { - const pathname = useRouterState({ select: (s) => s.location.pathname }) - const visibleGroups = useVisibleNavGroups() - const activeItem = - ALL_NAV_ITEMS.find((i) => pathname === i.to || (i.to !== '/' && pathname.startsWith(`${i.to}/`))) ?? - ALL_NAV_ITEMS[0] - const parentTo = PARENT_ROUTE[pathname] ?? PARENT_ROUTE[activeItem.to] - const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null - - const pageTitle = - pathname.startsWith('/modules/') && pathname !== '/modules/new' - ? 'Модуль' - : (ROUTE_LABELS[activeItem.to] ?? activeItem.label) - - return ( - - - - К содержимому - - - - - - - {visibleGroups.map((group) => ( - - {group.label} - - - {group.items.map((item) => { - const Icon = item.icon - const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`) - return ( - - } - isActive={isActive} - tooltip={item.label} - > - - {item.label} - - - ) - })} - - - - ))} - - - - - - -
- - - - - {parentLabel && parentTo ? ( - <> - - }>{parentLabel} - - - - ) : null} - - {pageTitle} - - - -
- - -
-
-
- {children} -
-
- -
-
- ) -} diff --git a/apps/web/src/components/layout/apps-menu.tsx b/apps/web/src/components/layout/apps-menu.tsx deleted file mode 100644 index 2d66389..0000000 --- a/apps/web/src/components/layout/apps-menu.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { Link } from '@tanstack/react-router' -import { LayoutGridIcon } from 'lucide-react' -import { Button } from '@evobgp/ui/components/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@evobgp/ui/components/dropdown-menu' -import { - APP_SWITCHER_ICONS, - CURRENT_APP_ID, -} from '@/lib/app-switcher-config' -import { useAppSwitcherConfig } from '@/hooks/use-app-switcher' -import { authPortalUrl, isAuthEnabled } from '@/lib/auth' - -/** Header apps grid — app-shell-12 AppsMenu. @see https://reui.io/preview/base/app-shell-12 */ -export function AppsMenu() { - const { config, isLoading } = useAppSwitcherConfig() - - return ( - - - } - > - - - - - - {isLoading ? 'Загрузка…' : config.menuLabel} - -
- {config.apps.map((app) => { - const Icon = APP_SWITCHER_ICONS[app.icon] - const isCurrent = app.id === CURRENT_APP_ID - - if (isCurrent) { - return ( - - - - {app.name} - - ) - } - - return ( - } - className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5" - > - - - {app.name} - - ) - })} -
- - {isAuthEnabled() ? ( - } - className="justify-center text-sm font-medium" - > - Настроить на портале - - ) : ( - } - className="justify-center text-sm font-medium" - > - Настройки - - )} -
-
-
- ) -} diff --git a/apps/web/src/components/layout/command-palette.tsx b/apps/web/src/components/layout/command-palette.tsx deleted file mode 100644 index 9a94bc9..0000000 --- a/apps/web/src/components/layout/command-palette.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { useEffect, useId, useMemo, useState } from 'react' -import { useNavigate } from '@tanstack/react-router' -import { Search } from 'lucide-react' - -import { Button } from '@evobgp/ui/components/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@evobgp/ui/components/dialog' -import { Input } from '@evobgp/ui/components/input' -import { Kbd } from '@evobgp/ui/components/kbd' -import { - SidebarGroup, - SidebarGroupContent, -} from '@evobgp/ui/components/sidebar' - -export type CommandPaletteItem = { - id: string - label: string - description?: string - to: string - search?: Record - keywords?: string[] -} - -interface CommandPaletteProps { - items: CommandPaletteItem[] - className?: string - /** Hotkey-only: no sidebar search trigger (chrome parity with CFDM). */ - hotkeyOnly?: boolean -} - -export function CommandPalette({ items, hotkeyOnly = false }: CommandPaletteProps) { - const [open, setOpen] = useState(false) - const [query, setQuery] = useState('') - const searchInputId = useId() - const navigate = useNavigate() - - useEffect(() => { - function onKeyDown(event: KeyboardEvent) { - if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) { - event.preventDefault() - setOpen(true) - } - } - window.addEventListener('keydown', onKeyDown) - return () => window.removeEventListener('keydown', onKeyDown) - }, []) - - const filtered = useMemo(() => { - const q = query.trim().toLowerCase() - if (!q) return items - return items.filter((item) => { - const haystack = [item.label, item.description, ...(item.keywords ?? [])] - .filter(Boolean) - .join(' ') - .toLowerCase() - return haystack.includes(q) - }) - }, [items, query]) - - function go(item: CommandPaletteItem) { - setOpen(false) - setQuery('') - void navigate({ to: item.to, search: item.search }) - } - - return ( - <> - {!hotkeyOnly ? ( - - - - - - ⌘K - - - - ) : null} - - { - setOpen(next) - if (!next) setQuery('') - }} - > - - Быстрый переход - Навигация по разделам EvoBGP - - -
- - setQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && filtered[0]) go(filtered[0]) - }} - /> -
-
    - {filtered.length === 0 ? ( -
  • Ничего не найдено
  • - ) : ( - filtered.map((item) => ( -
  • - -
  • - )) - )} -
-
-
- - ) -} diff --git a/apps/web/src/components/layout/nav-user.tsx b/apps/web/src/components/layout/nav-user.tsx deleted file mode 100644 index ee8c22c..0000000 --- a/apps/web/src/components/layout/nav-user.tsx +++ /dev/null @@ -1,229 +0,0 @@ -import { Link } from '@tanstack/react-router' -import { useEffect, useState } from 'react' -import { useTheme } from 'next-themes' -import { - ChevronsUpDownIcon, - ExternalLinkIcon, - LogOutIcon, - MonitorIcon, - MoonIcon, - PaletteIcon, - SettingsIcon, - SunIcon, -} from 'lucide-react' - -import { cn } from '@evobgp/ui/lib/utils' -import { Avatar, AvatarFallback } from '@evobgp/ui/components/avatar' -import { Button } from '@evobgp/ui/components/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@evobgp/ui/components/dropdown-menu' -import { - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from '@evobgp/ui/components/sidebar' - -import { setToken as setApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client' -import { - authPortalUrl, - clearPortalToken, - getClaims, - isAuthEnabled, - redirectToPortalLogout, - resetPortalHandoff, -} from '@/lib/auth' - -/** Sidebar footer account menu — ReUI app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */ - -const THEMES = [ - { - value: 'light', - label: 'Светлая', - icon: , - }, - { - value: 'dark', - label: 'Тёмная', - icon: , - }, - { - value: 'system', - label: 'Системная', - icon: , - }, -] as const - -function ThemeSegmentedToggle() { - const { theme, setTheme } = useTheme() - const [mounted, setMounted] = useState(false) - - useEffect(() => { - setMounted(true) - }, []) - - const currentTheme = mounted ? (theme ?? 'system') : 'system' - - return ( -
e.stopPropagation()} - onPointerDown={(e) => e.stopPropagation()} - > - {THEMES.map(({ value, label, icon }) => { - const isActive = currentTheme === value - return ( - - ) - })} -
- ) -} - -function initials(source: string): string { - const base = source.trim() - if (!base) return '?' - const parts = base.split(/\s+/).filter(Boolean) - if (parts.length >= 2) { - return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase() - } - return base.slice(0, 2).toUpperCase() -} - -export function NavUser() { - const { isMobile } = useSidebar() - const authOn = isAuthEnabled() - const claims = getClaims() - - const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость') - const email = - claims?.email?.trim() || (authOn ? '' : 'локальный API-токен') - const fallback = initials(name || email) - - function handleSignOut() { - if (authOn) { - clearPortalToken() - resetPortalHandoff() - redirectToPortalLogout() - return - } - setApiToken(null) - if (typeof window !== 'undefined') { - window.localStorage.removeItem(TOKEN_STORAGE_KEY) - window.location.assign('/settings?tab=ui&reason=token-required') - } - } - - return ( - - - - - } - > - - {fallback} - -
- {name} - - {email || '—'} - -
- -
- - - - - - {fallback} - - -
- {name} - - {email || '—'} - -
-
-
- - - - - } - > - - Настройки - - {authOn ? ( - } - > - - Открыть Auth Portal - - ) : null} - - - Тема -
- -
-
-
- - - - - - - {authOn ? 'Выйти' : 'Сбросить токен'} - - -
-
-
-
- ) -} diff --git a/apps/web/src/components/layout/system-monitor-popover.tsx b/apps/web/src/components/layout/system-monitor-popover.tsx deleted file mode 100644 index fe79458..0000000 --- a/apps/web/src/components/layout/system-monitor-popover.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { useMemo, type CSSProperties, type ReactNode } from 'react' -import { useQuery } from '@tanstack/react-query' -import { Activity, Bird, HeartPulse, ListChecks, Network } from 'lucide-react' - -import { Badge } from '@/components/reui/badge' -import { cn } from '@evobgp/ui/lib/utils' -import { Item, ItemMedia } from '@evobgp/ui/components/item' -import { Popover, PopoverContent, PopoverTrigger } from '@evobgp/ui/components/popover' -import { Progress } from '@evobgp/ui/components/progress' - -import { monitoringHealthQueryOptions } from '@/queries/monitoring' -import { networkBirdQueryOptions } from '@/queries/network' -import { aggregateNetworkMetrics, overviewJobsQueryOptions, overviewPeersQueryOptions } from '@/queries/overview' - -type MonitorMetric = { - id: string - label: string - value: string - unit: string - percent: number - icon: ReactNode - tone: 'success' | 'warning' | 'destructive' | 'info' - alert: boolean -} - -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 ( -
-
-
- - - {metric.icon} - - - {metric.label} -
- - {metric.value} - {metric.unit} - -
- -
- ) -} - -/** Live system monitor popover (app-shell-7 pattern, EvoBGP API data). */ -export function SystemMonitorPopover() { - const healthQ = useQuery({ ...monitoringHealthQueryOptions(), refetchInterval: 30_000 }) - const peersQ = useQuery({ ...overviewPeersQueryOptions(), refetchInterval: 30_000 }) - const jobsQ = useQuery({ ...overviewJobsQueryOptions(), refetchInterval: 30_000 }) - const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 }) - - const peers = peersQ.data?.items ?? [] - const jobs = jobsQ.data?.items ?? [] - const network = aggregateNetworkMetrics(peers, []) - const bgpPct = - network.peersEnabled > 0 - ? Math.round((network.peersEstablished / network.peersEnabled) * 100) - : 0 - const failedJobs = jobs.filter((j) => - ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()), - ).length - const runningJobs = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length - const healthOk = healthQ.data?.ok ?? false - const birdRatio = - birdQ.data && birdQ.data.bgp_sessions_total > 0 - ? Math.round((birdQ.data.bgp_established / birdQ.data.bgp_sessions_total) * 100) - : null - - const metrics = useMemo( - () => [ - { - id: 'api', - label: 'Состояние API', - value: healthOk ? 'норма' : '—', - unit: '', - percent: healthOk ? 100 : 0, - icon: , - tone: healthOk ? 'success' : 'destructive', - alert: !healthOk, - }, - { - id: 'bgp', - label: 'BGP пиры', - value: String(bgpPct), - unit: '%', - percent: bgpPct, - icon: , - tone: bgpPct >= 90 ? 'success' : bgpPct >= 50 ? 'warning' : 'destructive', - alert: bgpPct < 70 && network.peersEnabled > 0, - }, - { - id: 'bird', - label: 'BIRD сессии', - value: birdRatio !== null ? String(birdRatio) : '—', - unit: birdRatio !== null ? '%' : '', - percent: birdRatio ?? 0, - icon: , - tone: - birdRatio === null - ? 'info' - : birdRatio >= 100 - ? 'success' - : birdRatio >= 50 - ? 'warning' - : 'destructive', - alert: birdRatio !== null && birdRatio < 100, - }, - { - id: 'jobs', - label: 'Ошибки задач', - value: String(failedJobs), - unit: 'шт.', - percent: Math.min(100, failedJobs * 10), - icon: , - tone: failedJobs > 0 ? 'warning' : 'success', - alert: failedJobs > 0, - }, - ], - [bgpPct, birdRatio, failedJobs, healthOk, network.peersEnabled], - ) - - const spiking = metrics.some((m) => m.alert) || runningJobs > 5 - - return ( - - - } - > - - - {spiking ? ( - - ) : null} - - Система - - {spiking ? 'Внимание' : 'Норма'} - - - - -
- Монитор EvoBGP - - {new Date().toLocaleTimeString('ru-RU')} - -
-
- {metrics.map((metric, i) => ( -
= 2 && 'border-border border-t', - )} - > - -
- ))} -
-
- Активных задач: {runningJobs} - {' · '} - Пиров в каталоге:{' '} - {network.peersTotal} -
-
-
- ) -} diff --git a/apps/web/src/components/portal/bits.tsx b/apps/web/src/components/portal/bits.tsx index 03b1a72..784d826 100644 --- a/apps/web/src/components/portal/bits.tsx +++ b/apps/web/src/components/portal/bits.tsx @@ -432,7 +432,7 @@ export function PageTabsList({ children }: { children: ReactNode }) { export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) { return ( - + {label} {count !== undefined ? {count} : null} diff --git a/apps/web/src/components/reui-kit/data-grid-kit-defaults.test.ts b/apps/web/src/components/reui-kit/data-grid-kit-defaults.test.ts index 356b356..c6fbc89 100644 --- a/apps/web/src/components/reui-kit/data-grid-kit-defaults.test.ts +++ b/apps/web/src/components/reui-kit/data-grid-kit-defaults.test.ts @@ -9,18 +9,18 @@ import { } from './frame-data-grid' describe('kitDataGridTableLayout', () => { - it('filtering-2 defaults: dense, headerBackground false, width fixed', () => { + it('defaults макета: не dense, headerBackground true, width fixed', () => { const layout = kitDataGridTableLayout() - expect(layout.dense).toBe(true) - expect(layout.headerBackground).toBe(false) + expect(layout.dense).toBe(false) + expect(layout.headerBackground).toBe(true) expect(layout.width).toBe('fixed') expect(layout.headerSticky).toBe(false) expect('stripped' in layout ? layout.stripped : undefined).toBeUndefined() expect(layout.columnsPinnable).toBe(false) }) - it('kit tableClassNames совпадает с filtering-2 edgeCell', () => { - expect(kitDataGridTableClassNames.edgeCell).toBe('first:ps-3 last:pe-3') + it('kit tableClassNames: поля краёв как в MikrotikManager', () => { + expect(kitDataGridTableClassNames.edgeCell).toBe('first:ps-5 last:pe-4') expect(kitDataGridTableClassNames.base).toBe( '[&_[data-pinned]]:bg-(--frame-panel-bg)', ) @@ -33,7 +33,7 @@ describe('kitDataGridTableLayout', () => { }) expect(layout.width).toBe('auto') expect(layout.columnsPinnable).toBe(true) - expect(layout.headerBackground).toBe(false) + expect(layout.headerBackground).toBe(true) }) }) diff --git a/apps/web/src/components/reui-kit/frame-data-grid.tsx b/apps/web/src/components/reui-kit/frame-data-grid.tsx index 11320e4..2ae656e 100644 --- a/apps/web/src/components/reui-kit/frame-data-grid.tsx +++ b/apps/web/src/components/reui-kit/frame-data-grid.tsx @@ -19,7 +19,6 @@ import { import { Columns3Icon } from 'lucide-react' import { Button } from '@evobgp/ui/components/button' -import { Separator } from '@evobgp/ui/components/separator' import { cn } from '@evobgp/ui/lib/utils' import { DataGrid, @@ -41,7 +40,6 @@ import { DataGridTableVirtual } from '@/components/reui/data-grid/data-grid-tabl import { Frame, FrameDescription, - FrameFooter, FrameHeader, FramePanel, FrameTitle, @@ -66,10 +64,10 @@ export function kitDataGridTableLayout( } = {}, ) { return { - dense: opts.dense ?? true, + dense: opts.dense ?? false, rowBorder: true, headerSticky: false, - headerBackground: false, + headerBackground: true, headerBorder: true, width: opts.width ?? ('fixed' as const), columnsVisibility: opts.columnsVisibility ?? false, @@ -84,7 +82,8 @@ export function kitDataGridTableLayout( /** filtering-2 edge inset + Frame-surface pinned cells (not page --background). */ export const kitDataGridTableClassNames = { base: '[&_[data-pinned]]:bg-(--frame-panel-bg)', - edgeCell: 'first:ps-3 last:pe-3', + edgeCell: 'first:ps-5 last:pe-4', + headerRow: 'border-b border-border', } as const /** @@ -316,10 +315,7 @@ function FrameDataGridBody({ )} {showPagination ? ( <> - - - - + ) : null} diff --git a/apps/web/src/components/reui-kit/kpi-stat-grid.tsx b/apps/web/src/components/reui-kit/kpi-stat-grid.tsx index 7b24091..8c149a7 100644 --- a/apps/web/src/components/reui-kit/kpi-stat-grid.tsx +++ b/apps/web/src/components/reui-kit/kpi-stat-grid.tsx @@ -260,39 +260,61 @@ interface KpiStatGridProps { 'aria-label'?: string } -function KpiStatCardItem({ item }: { item: KpiStatItem }) { - const onActivate = resolveActivate(item) - const panelClass = panelClassName(item) +/** Колонки полосы по ширине контейнера: 4 → 1/2/4, 3 → 1/3. */ +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' + return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3' +} +/** Ячейка полосы показателей как на досках макета: плитка-иконка, подпись, значение и плашка рядом. */ +function KpiStripCell({ item }: { item: KpiStatItem }) { + const onActivate = resolveActivate(item) + const footer = resolveFooter(item) + const valueVariant = item.variant ?? 'default' + const clickable = Boolean(item.to || onActivate) + const body = ( +
+ {item.icon ? ( + + ) : null} +
+
{item.label}
+
+ + {item.value} + + {footer ? {footer} : null} +
+
+
+ ) + const cellClass = cn( + 'bg-card relative min-w-0', + clickable && 'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2', + isSelected(item) && 'bg-muted/30', + ) if (item.to) { return ( - - - +
+ + {body} - +
) } - if (onActivate) { return ( - handleCardKeyDown(onActivate, e)} - > - - +
handleCardKeyDown(onActivate, e)}> + {body} +
) } - - return ( - - - - ) + return
{body}
} /** @@ -342,12 +364,14 @@ export function KpiStatGrid({ } return ( - -
- {list.map((item, index) => ( - - ))} -
+ + +
+ {list.map((item, index) => ( + + ))} +
+
) } diff --git a/apps/web/src/components/reui-kit/resource-page.tsx b/apps/web/src/components/reui-kit/resource-page.tsx index dedfa14..48c241b 100644 --- a/apps/web/src/components/reui-kit/resource-page.tsx +++ b/apps/web/src/components/reui-kit/resource-page.tsx @@ -11,7 +11,6 @@ import { CircleAlertIcon, FilterIcon, FilterXIcon, SearchIcon } from 'lucide-rea import { EmptyState } from '@/components/empty-state' import { - StatusToggleGroup, type StatusToggleTone, } from '@/components/status-toggle-group' import { Badge } from '@/components/reui/badge' @@ -24,12 +23,12 @@ import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' import { DataGridTableRowExpand } from '@/components/reui/data-grid/data-grid-table' import { Filters } from '@/components/reui/filters/filters' +import { SegmentedControl } from '@/components/portal/data-page' import { flattenFilterConditions } from '@/components/reui/filters/filters-query' import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types' import { Frame, FrameDescription, - FrameFooter, FrameHeader, FramePanel, FrameTitle, @@ -41,7 +40,6 @@ import { InputGroupAddon, InputGroupInput, } from '@evobgp/ui/components/input-group' -import { Separator } from '@evobgp/ui/components/separator' import { Skeleton } from '@evobgp/ui/components/skeleton' import { DATA_GRID_I18N_RU } from '@/lib/reui-i18n-ru' import { FILTERS_LABELS_RU, FILTERS_OPERATOR_LABELS_RU } from '@/lib/filters-i18n' @@ -567,74 +565,66 @@ function ResourcePageFiltered({ ) : null} -
-
- {showSearch && primaryTextField ? ( - - - - handleSearchChange(event.target.value)} - placeholder={primaryTextField.placeholder ?? 'Поиск…'} - aria-label={primaryTextField.label || 'Поиск'} - /> - - ) : null} - {showFiltersPopover ? ( - -