diff --git a/apps/web/src/components/layout/portal-shell.tsx b/apps/web/src/components/layout/portal-shell.tsx new file mode 100644 index 0000000..2f56b33 --- /dev/null +++ b/apps/web/src/components/layout/portal-shell.tsx @@ -0,0 +1,226 @@ +import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react' +import { useQuery } from '@tanstack/react-query' +import { + ActivityIcon, + BookTextIcon, + BoxesIcon, + CogIcon, + KeyRoundIcon, + LayoutDashboardIcon, + ListChecksIcon, + NetworkIcon, + PlusIcon, + SearchIcon, + SettingsIcon, +} from 'lucide-react' + +import { AppSwitcher } from '@/components/app-switcher' +import { CommandPalette, PortalHeader, PortalSidebar } from '@/components/portal/chrome' +import { ChromeConfigProvider, PortalUiProvider } from '@/components/portal/chrome-context' +import type { ChromeConfig, MonitorItem, MonitorMetric, NavItem, PaletteObject } from '@/components/portal/chrome-store' +import { can, getClaims, isAuthEnabled, permissionForPath, redirectToPortalLogout, clearPortalToken, resetPortalHandoff } from '@/lib/auth' +import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface' +import { monitoringHealthQueryOptions } from '@/queries/monitoring' +import { networkBirdQueryOptions } from '@/queries/network' +import { + aggregateNetworkMetrics, + overviewJobsQueryOptions, + overviewModulesQueryOptions, + overviewPeersQueryOptions, +} from '@/queries/overview' +import { Skeleton } from '@evobgp/ui/components/skeleton' +import { SidebarInset, SidebarProvider } from '@evobgp/ui/components/sidebar' +import { TooltipProvider } from '@evobgp/ui/components/tooltip' + +const FRESH: Record = { + '/dashboard': { badge: 'Live', text: 'GET /v1/modules · /v1/peers · /v1/jobs · данные обновляются при открытии страницы' }, + '/modules': { badge: 'Каталог', text: 'GET /v1/modules · списки префиксов и AS' }, + '/lookup': { badge: 'Проверка', text: 'GET /v1/lookup · IP или домен по спискам и community' }, + '/network': { badge: 'BGP', text: 'GET /v1/peers · /v1/speakers · состояние сессий опрашивается живьём' }, + '/directories': { badge: 'Справочники', text: 'BGP community и DoH-профили' }, + '/operations': { badge: 'Ревизии', text: 'GET /v1/revisions · /v1/jobs · применение и откат' }, + '/schedule': { badge: 'Задачи', text: 'Расписание обновления модулей' }, + '/monitoring': { badge: 'Live', text: 'GET /health · /ready · BIRD через birdc' }, + '/access': { badge: 'API', text: 'API-ключи и роли доступа' }, + '/settings': { badge: 'Настройки', text: 'Интерфейс и BIRD' }, +} + +function freshFor(pathname: string) { + const key = Object.keys(FRESH) + .sort((a, b) => b.length - a.length) + .find((k) => pathname === k || pathname.startsWith(`${k}/`)) + return FRESH[key ?? '/dashboard']! +} + +function initialsOf(name: string) { + const parts = name.trim().split(/[\s@._-]+/).filter(Boolean) + return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase() +} + +const NAV: Array> = [ + { id: 'dashboard', label: 'Панель', to: '/dashboard', icon: LayoutDashboardIcon, group: 'Обзор' }, + { id: 'modules', label: 'Модули', to: '/modules', icon: BoxesIcon, group: 'Маршрутизация' }, + { id: 'lookup', label: 'Проверка', to: '/lookup', icon: SearchIcon, group: 'Маршрутизация' }, + { id: 'network', label: 'Сеть', to: '/network', icon: NetworkIcon, group: 'Маршрутизация' }, + { id: 'directories', label: 'Справочники', to: '/directories', icon: BookTextIcon, group: 'Маршрутизация' }, + { id: 'operations', label: 'Операции', to: '/operations', icon: CogIcon, group: 'Операции' }, + { id: 'schedule', label: 'Задачи', to: '/schedule', icon: ListChecksIcon, group: 'Операции' }, + { id: 'monitoring', label: 'Мониторинг', to: '/monitoring', icon: ActivityIcon, group: 'Операции' }, + { id: 'access', label: 'Доступ', to: '/access', icon: KeyRoundIcon, group: 'Система' }, + { id: 'settings', label: 'Настройки', to: '/settings', icon: SettingsIcon, group: 'Система' }, +] + +function logout() { + if (isAuthEnabled()) { + clearPortalToken() + resetPortalHandoff() + redirectToPortalLogout() + } +} + +/** Меню и данные оболочки EvoBGP: пункты по правам, точки проблем и монитор из живых запросов. */ +function useChromeData(): ChromeConfig { + const healthQ = useQuery({ ...monitoringHealthQueryOptions(), refetchInterval: 30_000 }) + const peersQ = useQuery({ ...overviewPeersQueryOptions(), refetchInterval: 30_000 }) + const jobsQ = useQuery({ ...overviewJobsQueryOptions(), refetchInterval: 30_000 }) + const modulesQ = useQuery(overviewModulesQueryOptions()) + const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 }) + + return useMemo(() => { + const peers = peersQ.data?.items ?? [] + const jobs = jobsQ.data?.items ?? [] + const modules = modulesQ.data?.items ?? [] + const net = aggregateNetworkMetrics(peers, []) + const failedJobs = jobs.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase())).length + const peersDown = Math.max(0, net.peersEnabled - net.peersEstablished) + 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 bgpPct = net.peersEnabled > 0 ? Math.round((net.peersEstablished / net.peersEnabled) * 100) : 0 + + const visible = NAV.filter((item) => { + if (!isAuthEnabled()) return true + const perm = permissionForPath(item.to) + return !perm || can(perm) + }) + const problems: Record = { + network: peersDown > 0 ? { tone: 'er', label: `${peersDown} пиров не в Established` } : undefined, + schedule: failedJobs > 0 ? { tone: 'wn', label: `Ошибок задач: ${failedJobs}` } : undefined, + monitoring: healthQ.isSuccess && !healthOk ? { tone: 'er', label: 'API не отвечает как норма' } : undefined, + } + const navItems: NavItem[] = visible.map((i) => ({ ...i, problem: problems[i.id] })) + const groups = [...new Set(navItems.map((i) => i.group))] + + const monitorItems: MonitorItem[] = [] + if (healthQ.isSuccess && !healthOk) { + monitorItems.push({ id: 'health', tone: 'er', title: 'API EvoBGP не в норме', text: 'GET /health вернул ошибку', age: 'сейчас', to: '/monitoring' }) + } + if (peersDown > 0) { + monitorItems.push({ id: 'peers', tone: 'er', title: `Пиров не в Established: ${peersDown}`, text: `Включено ${net.peersEnabled}, поднято ${net.peersEstablished}`, age: 'сейчас', to: '/network' }) + } + if (failedJobs > 0) { + monitorItems.push({ id: 'jobs', tone: 'wn', title: `Ошибок задач: ${failedJobs}`, text: 'Проверьте расписание и журнал ревизий', age: 'недавно', to: '/schedule' }) + } + if (monitorItems.length === 0) { + monitorItems.push({ id: 'ok', tone: 'ok', title: 'Всё в норме', text: 'API, BGP-сессии и задачи без проблем', age: 'сейчас', to: '/monitoring' }) + } + + const metrics: MonitorMetric[] = [ + { l: 'Состояние API', v: healthOk ? 'норма' : '—', p: healthOk ? 100 : 0, tone: healthOk ? 'success' : 'warning' }, + { l: 'BGP пиры', v: `${bgpPct}%`, p: bgpPct, tone: bgpPct >= 90 ? 'success' : 'warning' }, + { l: 'BIRD сессии', v: birdRatio === null ? '—' : `${birdRatio}%`, p: birdRatio ?? 0, tone: birdRatio === 100 ? 'success' : 'warning' }, + { l: 'Ошибки задач', v: String(failedJobs), p: Math.min(100, failedJobs * 10), tone: failedJobs > 0 ? 'warning' : 'success' }, + ] + + const claims = getClaims() + const name = claims?.name?.trim() || (isAuthEnabled() ? 'Пользователь' : 'Гость') + const email = claims?.email?.trim() || (isAuthEnabled() ? '' : 'локальный API-токен') + + const objects: PaletteObject[] = [ + ...modules.map((m) => ({ + id: `module-${m.id}`, + group: 'Модули', + label: m.name, + hint: `${m.type}${m.enabled ? '' : ' · выключен'}`, + dot: m.enabled ? 'ok' : 'mu', + to: `/modules/${m.id}`, + })), + ...peers.map((p) => ({ + id: `peer-${p.id}`, + group: 'BGP-пиры', + label: p.name ?? p.neighbor, + hint: `${p.neighbor}${p.remote_asn ? ` · AS${p.remote_asn}` : ''}`, + dot: p.session_state === 'Established' ? 'ok' : 'er', + to: '/network', + })), + ] + + return { + navItems, + navGroups: groups, + brand: , + user: { name, email, initials: initialsOf(name || email) }, + securityTo: '/settings', + onLogout: logout, + fresh: freshFor, + monitor: { items: monitorItems, metrics, updated: 'обновлено сейчас', footerTo: '/monitoring', footerLabel: 'Мониторинг' }, + palette: { + actions: [ + { id: 'act-module', label: 'Создать модуль', hint: 'M', icon: PlusIcon, to: '/modules/new' }, + { id: 'act-lookup', label: 'Проверить IP или домен', hint: 'L', icon: SearchIcon, to: '/lookup' }, + { id: 'act-ops', label: 'Открыть ревизии', hint: 'R', icon: CogIcon, to: '/operations' }, + ], + objects, + assistantTo: '/lookup', + example: 'Подсказка: «пиры не в Established»', + }, + } + }, [healthQ.data, healthQ.isSuccess, peersQ.data, jobsQ.data, modulesQ.data, birdQ.data]) +} + +/** Оболочка EvoBGP: боковое меню, шапка, монитор, палитра ⌘K — блоки общего макета. */ +export function PortalShell({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(true) + const [mounted, setMounted] = useState(false) + useEffect(() => { + // До 1100px меню сворачивается в колонку иконок (на телефоне его заменяет Sheet). + const mq = window.matchMedia('(min-width: 1100px)') + const sync = () => setOpen(mq.matches) + sync() + mq.addEventListener('change', sync) + setMounted(true) + return () => mq.removeEventListener('change', sync) + }, []) + const config = useChromeData() + + if (!mounted) { + return ( +
+ + +
+ ) + } + + return ( + + + + + + К содержимому + + + + +
{children}
+
+ +
+
+
+
+ ) +} diff --git a/apps/web/src/components/portal/bits.tsx b/apps/web/src/components/portal/bits.tsx new file mode 100644 index 0000000..03b1a72 --- /dev/null +++ b/apps/web/src/components/portal/bits.tsx @@ -0,0 +1,481 @@ +import { useState, type ReactNode } from 'react' +import { CircleAlertIcon, InfoIcon, TriangleAlertIcon, XIcon } from 'lucide-react' +import { toast } from 'sonner' +import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/reui/alert' +import { Badge } from '@/components/reui/badge' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@evobgp/ui/components/alert-dialog' +import { Button } from '@evobgp/ui/components/button' +import { cn } from '@evobgp/ui/lib/utils' +import { Input } from '@evobgp/ui/components/input' +import { Label } from '@evobgp/ui/components/label' +import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@evobgp/ui/components/sheet' +import type { Tone } from './tone' +import { TONE_BADGE } from './tone' + +const TONE_GLYPH: Record = { ok: '●', wn: '▲', er: '✕', mu: '○', in: '' } + +/** Плашка статуса: слово + глиф (● ▲ ✕ ○), чтобы цвет не был единственным признаком. */ +export function StatusBadge({ tone, children }: { tone: Tone; children: ReactNode }) { + const glyph = TONE_GLYPH[tone] + return ( + + {glyph ? {glyph} : null} + {children} + + ) +} + +/** Заголовок страницы: название, описание, метка свежести и действия. */ +export function PageHead({ + title, + sub, + actions, +}: { + title: string + sub: string + actions?: ReactNode +}) { + return ( +
+
+

{title}

+

{sub}

+
+ {actions ?
{actions}
: null} +
+ ) +} + +const ALERT_ICON = { + er: , + wn: , + in: , +} as const + +const ALERT_VARIANT = { + er: 'destructive', + wn: 'warning', + in: 'info', +} as const + +export function SignalAlert({ + tone, + title, + children, + action, +}: { + tone: 'er' | 'wn' | 'in' + title: string + children: ReactNode + action?: ReactNode +}) { + return ( + + {ALERT_ICON[tone]} + {title} + {children} + {action ? {action} : null} + + ) +} + +/** Боковое окно (как CreateUserSheet): заголовок, прокручиваемое тело, подвал. */ +export function PortalSheet({ + open, + onOpenChange, + title, + description, + children, + footer, + size = 'md', + dirty, +}: { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description: string + children: ReactNode + footer?: ReactNode + /** Ширина панели: md — форма, lg — редактор с двумя колонками. */ + size?: 'md' | 'lg' + /** Явный признак изменений (иначе ловим ввод в полях и переключатели). */ + dirty?: boolean +}) { + const [touchedState, setTouched] = useState(false) + const touched = dirty ?? touchedState + const [guard, setGuard] = useState(false) + + function requestChange(next: boolean) { + if (!next && touched) { + setGuard(true) + return + } + if (!next) setTouched(false) + onOpenChange(next) + } + + return ( + <> + + + +
+ {title} + + } + > + +
+ + {description} + +
+
setTouched(true)} + onClick={(e) => { + if ((e.target as HTMLElement).closest('[role=switch]')) setTouched(true) + }} + > + {children} +
+ {footer ? ( + { + // «Закрыть» и «Отмена» при изменённой форме тоже спрашивают подтверждение. + const label = (e.target as HTMLElement).closest('button')?.textContent?.trim() + if (touched && (label === 'Закрыть' || label === 'Отмена')) { + e.stopPropagation() + setGuard(true) + } + }} + >{footer} + ) : null} +
+
+ + + + Закрыть без сохранения? + Изменения в форме будут потеряны. + + + Остаться + + + + + + ) +} + +/** Подтверждение опасного действия вводом слова (email, «сбросить», «очистить»…). */ +export function TypedConfirm({ + open, + onOpenChange, + title, + description, + expected, + confirmLabel, + onConfirm, +}: { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description: ReactNode + expected: string + confirmLabel: string + onConfirm: () => void +}) { + const [value, setValue] = useState('') + const matches = value.trim().toLowerCase() === expected.toLowerCase() + + return ( + { + if (!next) setValue('') + onOpenChange(next) + }} + > + + + {title} + {description} + +
+ + setValue(e.target.value)} + aria-invalid={value.length > 0 && !matches} + /> +
+ + Отмена + + +
+
+ ) +} + +export function KvFrame({ + title, + description, + items, +}: { + title: string + description: string + items: Array<{ label: string; value: ReactNode }> +}) { + return ( + + + {title} + {description} + + +
+ {items.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+
+ + ) +} + +/** Список «значение + подпись + копировать» (параметры issuer). */ +export function CopyList({ + title, + description, + items, +}: { + title: string + description: string + items: Array<{ label: string; value: string }> +}) { + return ( + + + {title} + {description} + + +
    + {items.map((item) => ( +
  • + {item.value} + {item.label} + +
  • + ))} +
+
+ + ) +} + +/** Компактный список «ключ — значение» для боковых окон. */ +export function KvList({ items }: { items: Array<[string, string]> }) { + return ( +
+ {items.map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+ ) +} + +export function CodeBlock({ label, children }: { label: string; children: string }) { + return ( +
+ {label} +
{children}
+
+ ) +} + +/** Сегментированный переключатель на Tabs (вид таблицы, период). */ +export function Segmented({ + label, + options, + value, + onChange, +}: { + label: string + options: string[] + value: number + onChange: (index: number) => void +}) { + return ( + onChange(Number(v))} aria-label={label} className="max-w-full"> + + {options.map((o, i) => ( + + {o} + + ))} + + + ) +} + +/** Плашка строки с накопленной правкой: «Не сохранено» или «Будет удалено». */ +export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) { + return ( + + {mark === 'deleted' ? 'Будет удалено' : 'Не сохранено'} + + ) +} + +/** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */ +export function PageTabsList({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) { + return ( + + {label} + {count !== undefined ? {count} : null} + + ) +} + +/** Строка настройки как на досках: слева название и описание, справа элемент управления. */ +export function SettingRow({ title, description, children }: { title: string; description: string; children: ReactNode }) { + return ( +
+
+ {title} + {description} +
+
{children}
+
+ ) +} + +/** Frame с заголовком, итоговой плашкой справа и строками настроек (блок «form» досок). */ +export function FormFrame({ + title, + description, + badge, + tone = 'ok', + children, +}: { + title: string + description: string + badge: string + tone?: Tone + children: ReactNode +}) { + return ( + + +
+ {title} + {description} +
+ {badge} +
+ {children} + + ) +} diff --git a/apps/web/src/components/portal/canvas-blocks.tsx b/apps/web/src/components/portal/canvas-blocks.tsx new file mode 100644 index 0000000..e0c37ba --- /dev/null +++ b/apps/web/src/components/portal/canvas-blocks.tsx @@ -0,0 +1,142 @@ +import type { ReactNode } from 'react' +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 '@evobgp/ui/lib/utils' +import type { Tone } from './tone' +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 ( + + +
+ {title} + {description} +
+ {action} +
+ +
    + {items.map((c) => { + const body = ( + <> +
    + + + {c.icon} + + {c.title} + + + {c.badge} + +
    +

    {c.desc}

    +

    {c.meta}

    + + ) + const cls = 'bg-muted/25 w-full rounded-lg border p-3.5 text-left' + return ( +
  • + {c.onSelect ? ( + + ) : ( +
    {body}
    + )} +
  • + ) + })} +
+
+ + ) +} + +const DOT: Record = { + ok: 'bg-success', + wn: 'bg-warning', + er: 'bg-destructive', + mu: 'bg-muted-foreground', + in: 'bg-chart-4', +} + +/** + * Лента событий как на доске «Изменения»: время, точка важности с линией, плашка, заголовок + * и строка «кто · источник · IP». + */ +export function ActivityFeed({ + title, + description, + items, + action, +}: { + title: string + description: string + items: Array<{ t: string; title: string; text: string; label: string; tone: Tone }> + action?: ReactNode +}) { + return ( + + +
+ {title} + {description} +
+ {action} +
+ +
    + {items.map((e) => ( +
  1. + {e.t} + + + + + + + {e.label} + + {e.title} + {e.text} + +
  2. + ))} +
+
+ + ) +} diff --git a/apps/web/src/components/portal/chrome-context.tsx b/apps/web/src/components/portal/chrome-context.tsx new file mode 100644 index 0000000..6d8aeb7 --- /dev/null +++ b/apps/web/src/components/portal/chrome-context.tsx @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' +import { ChromeConfigContext, UiStoreContext, type ChromeConfig, type Contrast, type Density, type UiStore } from './chrome-store' + +export function ChromeConfigProvider({ value, children }: { value: ChromeConfig; children: ReactNode }) { + return {children} +} + +function readStored(key: string, fallback: T): T { + try { + const raw = window.localStorage.getItem(key) + return raw ? (JSON.parse(raw) as T) : fallback + } catch { + return fallback + } +} + +function writeStored(key: string, value: unknown) { + try { + window.localStorage.setItem(key, JSON.stringify(value)) + } catch { + /* без сохранения настроек */ + } +} + +/** Плотность, контраст, закреплённые пункты и палитра ⌘K. Атрибуты html[data-density|contrast] читает portal-theme.css. */ +export function PortalUiProvider({ children, storageKey = 'portal-ui' }: { children: ReactNode; storageKey?: string }) { + const [paletteOpen, setPaletteOpen] = useState(false) + const [density, setDensityState] = useState(() => readStored(`${storageKey}:density`, 'comfortable')) + const [contrast, setContrastState] = useState(() => readStored(`${storageKey}:contrast`, 'standard')) + const [pins, setPins] = useState(() => readStored(`${storageKey}:pins`, [])) + + const setDensity = useCallback( + (v: Density) => { + setDensityState(v) + writeStored(`${storageKey}:density`, v) + }, + [storageKey], + ) + const setContrast = useCallback( + (v: Contrast) => { + setContrastState(v) + writeStored(`${storageKey}:contrast`, v) + }, + [storageKey], + ) + const togglePin = useCallback( + (id: string) => { + setPins((prev) => { + const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] + writeStored(`${storageKey}:pins`, next) + return next + }) + }, + [storageKey], + ) + + useEffect(() => { + const root = document.documentElement + root.dataset.density = density + root.dataset.contrast = contrast + return () => { + delete root.dataset.density + delete root.dataset.contrast + } + }, [density, contrast]) + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault() + setPaletteOpen((v) => !v) + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, []) + + const value = useMemo( + () => ({ paletteOpen, setPaletteOpen, pins, togglePin, density, setDensity, contrast, setContrast }), + [paletteOpen, pins, togglePin, density, setDensity, contrast, setContrast], + ) + return {children} +} diff --git a/apps/web/src/components/portal/chrome-store.ts b/apps/web/src/components/portal/chrome-store.ts new file mode 100644 index 0000000..7763f0d --- /dev/null +++ b/apps/web/src/components/portal/chrome-store.ts @@ -0,0 +1,86 @@ +import { createContext, useContext, type ComponentType, type ReactNode } from 'react' +import type { LucideIcon } from 'lucide-react' + +export type NavItem = { + id: string + label: string + to: string + icon: LucideIcon + group: string + /** Точка проблемы в углу иконки. */ + problem?: { tone: 'er' | 'wn'; label: string } +} + +export type MonitorItem = { + id: string + tone: 'er' | 'wn' | 'ok' + title: string + text: string + age: string + to: string +} + +export type MonitorMetric = { l: string; v: string; p: number; tone: 'warning' | 'success' } + +export type PaletteAction = { + id: string + label: string + /** Горячая буква справа. */ + hint: string + icon: ComponentType<{ className?: string; 'aria-hidden'?: boolean }> + to: string +} + +export type PaletteObject = { + id: string + group: string + label: string + hint: string + dot?: 'ok' | 'er' | 'mu' + to: string +} + +/** Данные оболочки: меню, монитор, палитра и пользователь. В макете — суррогатные, в портале — из API. */ +export type ChromeConfig = { + navItems: NavItem[] + navGroups: string[] + /** Верх бокового меню: бренд и переключатель приложений. */ + brand: ReactNode + user: { name: string; email: string; initials: string } + securityTo: string + onLogout: () => void + fresh: (pathname: string) => { badge: string; text: string } + crumbFor?: (pathname: string, label: string | undefined) => string + monitor: { items: MonitorItem[]; metrics: MonitorMetric[]; updated: string; footerTo: string; footerLabel: string } + palette: { actions: PaletteAction[]; objects: PaletteObject[]; assistantTo: string; example: string } +} + +export const ChromeConfigContext = createContext(null) + +export function useChromeConfig(): ChromeConfig { + const ctx = useContext(ChromeConfigContext) + if (!ctx) throw new Error('ChromeConfigProvider отсутствует') + return ctx +} + +export type Density = 'comfortable' | 'compact' +export type Contrast = 'standard' | 'high' + +export type UiStore = { + paletteOpen: boolean + setPaletteOpen: (open: boolean) => void + pins: string[] + togglePin: (id: string) => void + density: Density + setDensity: (v: Density) => void + contrast: Contrast + setContrast: (v: Contrast) => void +} + +export const UiStoreContext = createContext(null) + +export function useUiStore(): UiStore { + const ctx = useContext(UiStoreContext) + if (!ctx) throw new Error('PortalUiProvider отсутствует') + return ctx +} diff --git a/apps/web/src/components/portal/chrome.tsx b/apps/web/src/components/portal/chrome.tsx new file mode 100644 index 0000000..dd43333 --- /dev/null +++ b/apps/web/src/components/portal/chrome.tsx @@ -0,0 +1,569 @@ +import { + useMemo, + useState, + type ComponentType, +} from 'react' +import { + Link, + useNavigate, + useRouterState, +} from '@tanstack/react-router' +import { + ActivityIcon, + ArrowRightIcon, + ChevronDownIcon, + ChevronsUpDownIcon, + FingerprintIcon, + InfoIcon, + LogOutIcon, + SearchIcon, + StarIcon, + MoonIcon, +} from 'lucide-react' +import { + useTheme, +} from 'next-themes' +import { + Badge, +} from '@/components/reui/badge' +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, +} from '@evobgp/ui/components/breadcrumb' +import { + Button, +} from '@evobgp/ui/components/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '@evobgp/ui/components/dialog' +import { + Kbd, +} from '@evobgp/ui/components/kbd' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@evobgp/ui/components/popover' +import { + Separator, +} from '@evobgp/ui/components/separator' +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + SidebarSeparator, + SidebarTrigger, + useSidebar, +} from '@evobgp/ui/components/sidebar' +import { + cn, +} from '@evobgp/ui/lib/utils' +import { + Segmented, +} from './bits' +import { + useChromeConfig, + useUiStore, + type NavItem, +} from './chrome-store' + +const LABEL_CLASS = 'text-muted-foreground text-[11px] font-medium uppercase' + +function PortalNavUser() { + const { isMobile } = useSidebar() + const { theme, setTheme, resolvedTheme } = useTheme() + const { density, setDensity, contrast, setContrast } = useUiStore() + const { user, securityTo, onLogout } = useChromeConfig() + const navigate = useNavigate() + const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark' + + return ( + + + + + } + > +
+ {user.initials} +
+
+ {user.name} + {user.email} +
+ +
+ +
+
+ {user.initials} +
+
+ {user.name} + {user.email} +
+
+ + +
+ Тема + setTheme(i === 1 ? 'light' : 'dark')} + /> + Плотность + setDensity(i === 1 ? 'compact' : 'comfortable')} + /> + Контраст + setContrast(i === 1 ? 'high' : 'standard')} + /> +
+ + +
+
+
+
+ ) +} + +const DOT_CLASS = { er: 'bg-destructive', wn: 'bg-warning' } as const + +function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; pathname: string; pinned: boolean; onTogglePin: () => void }) { + const Icon = item.icon + const active = pathname === item.to || pathname.startsWith(`${item.to}/`) + return ( + + } + > + + {item.label} + {item.problem ? ( + + ) : null} + + + + + + ) +} + +export function PortalSidebar() { + const pathname = useRouterState({ select: (s) => s.location.pathname }) + const { pins, togglePin } = useUiStore() + const { navItems, navGroups, brand } = useChromeConfig() + const [closed, setClosed] = useState>({}) + const pinned = navItems.filter((i) => pins.includes(i.id)) + return ( + + + {brand} + + + {pinned.length > 0 ? ( + + Закреплённые + + + {pinned.map((item) => ( + togglePin(item.id)} /> + ))} + + + + ) : null} + {navGroups.map((group, gi) => ( +
+ {gi > 0 ? : null} + + setClosed((c) => ({ ...c, [group]: !c[group] }))} + /> + } + className="h-7 cursor-pointer justify-between text-[11px] font-semibold tracking-wide uppercase" + > + {group} + + + + + {navItems.filter((i) => i.group === group).map((item) => ( + togglePin(item.id)} + /> + ))} + + + +
+ ))} +
+ + + +
+ ) +} + +/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине окна, как на досках. */ +function MonitorPopover() { + const { monitor } = useChromeConfig() + const MONITOR_ITEMS = monitor.items + const MONITOR_METRICS = monitor.metrics + const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok') + const errors = bad.filter((m) => m.tone === 'er').length + const warnings = bad.length - errors + const ok = MONITOR_ITEMS.length - bad.length + return ( + + + } + > + + Внимание + + + {errors} + + {warnings} + + + {bad.length} + + + + +
+
+ Состояние системы + {monitor.updated} +
+ 0 ? "destructive-light" : warnings > 0 ? "warning-light" : "success-light"} size="sm">{errors > 0 ? "Сбой" : warnings > 0 ? "Внимание" : "Норма"} +
+
+ {[ + { n: errors, l: 'сбой', c: 'text-destructive' }, + { n: warnings, l: 'предупреждения', c: 'text-warning' }, + { n: ok, l: 'в норме', c: 'text-success' }, + ].map((x) => ( + + {x.n} + {x.l} + + ))} +
+
    + {MONITOR_ITEMS.filter((m) => m.tone !== 'ok').map((m) => ( +
  • + + + + {m.title} + {m.text} + + {m.age} + +
  • + ))} +
+
+ {MONITOR_METRICS.map((m) => ( +
+ {m.l} + {m.v} +
+ +
+
+ ))} +
+ + {monitor.footerLabel} + + +
+
+ ) +} + +function FreshnessPopover() { + const pathname = useRouterState({ select: (st) => st.location.pathname }) + const { fresh: freshFor } = useChromeConfig() + const fresh = freshFor(pathname) + return ( + + + } + > + + + + {fresh.badge} +

{fresh.text}

+
+
+ ) +} + +export function PortalHeader() { + const pathname = useRouterState({ select: (s) => s.location.pathname }) + const { setPaletteOpen } = useUiStore() + const { navItems, crumbFor } = useChromeConfig() + const current = navItems.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`)) + + return ( +
+ + + + + + {crumbFor ? crumbFor(pathname, current?.label) : (current?.label ?? 'Auth Portal')} + + + +
+ + + +
+
+ ) +} + +type PaletteEntry = { + id: string + group: string + label: string + hint: string + icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }> + dot?: 'ok' | 'er' | 'mu' + run: () => void +} + +const DOT_BG = { ok: 'bg-success', er: 'bg-destructive', mu: 'bg-muted-foreground' } as const + +/** Палитра ⌘K как на доске: 640px, группы заглавными, подсказки моноширинным справа, подвал с подсказкой. */ +export function CommandPalette() { + const { paletteOpen, setPaletteOpen } = useUiStore() + const { navItems, palette } = useChromeConfig() + const navigate = useNavigate() + const { theme, setTheme, resolvedTheme } = useTheme() + const [query, setQuery] = useState('') + const [active, setActive] = useState(0) + + const shown = useMemo(() => { + const q = query.trim().toLowerCase() + const match = (...t: string[]) => !q || t.some((x) => x.toLowerCase().includes(q)) + const go = (to: string) => () => void navigate({ to }) + const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark' + const list: PaletteEntry[] = [ + ...[ + ...palette.actions.map((x) => ({ id: x.id, label: x.label, hint: x.hint, icon: x.icon, run: go(x.to) })), + { id: 'act-theme', label: 'Переключить тему', hint: 'T', icon: MoonIcon, run: () => setTheme(current === 'dark' ? 'light' : 'dark') }, + ] + .filter((e) => match(e.label)) + .map((e) => ({ ...e, group: 'Действия' })), + ...navItems.filter((i) => match(i.label, i.group)) + .slice(0, 7) + .map((i) => ({ id: `nav-${i.id}`, group: 'Разделы', label: i.label, hint: i.group, icon: i.icon, run: go(i.to) })), + ...Object.values( + palette.objects + .filter((o) => match(o.label, o.hint)) + .reduce>((acc, o) => ((acc[o.group] ??= []).push(o), acc), {}), + ).flatMap((g) => g.slice(0, 5).map((o) => ({ id: o.id, group: o.group, label: o.label, hint: o.hint, dot: o.dot, run: go(o.to) }))), + ] + // Запрос из трёх и более слов — черновик ассистента (как на доске «Палитра ⌘K»). + if (query.trim().split(/\s+/).filter(Boolean).length >= 3) { + return [ + { + id: 'ai', + group: 'Ассистент · запрос на естественном языке', + label: `Понял так: «${query.trim()}»`, + hint: 'Черновик: запрос разобран автоматически, результат нужно проверить.', + run: () => void navigate({ to: palette.assistantTo }), + } as PaletteEntry, + ...list, + ] + } + return list + }, [query, navigate, theme, resolvedTheme, setTheme, navItems, palette]) + + const groups = useMemo(() => { + const map = new Map>() + shown.forEach((e, index) => { + const list = map.get(e.group) ?? [] + list.push({ ...e, index }) + map.set(e.group, list) + }) + return [...map.entries()] + }, [shown]) + + function close() { + setPaletteOpen(false) + setQuery('') + setActive(0) + } + + return ( + (o ? setPaletteOpen(true) : close())}> + + Командная палитра + Разделы, пользователи, приложения и действия +
+ + { + setQuery(e.target.value) + setActive(0) + }} + onKeyDown={(e) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setActive((a) => Math.min(a + 1, shown.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setActive((a) => Math.max(a - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + shown[active]?.run() + close() + } + }} + /> + Esc +
+
+ {groups.length === 0 ? ( +

Ничего не найдено

+ ) : ( + groups.map(([group, list]) => ( +
+
{group}
+ {list.map((e) => { + const Icon = e.icon + return ( + + ) + })} +
+ )) + )} +
+
+ ↑↓ выбор + ↵ открыть + Esc закрыть + {palette.example} +
+
+
+ ) +} diff --git a/apps/web/src/components/portal/data-page-layout.ts b/apps/web/src/components/portal/data-page-layout.ts new file mode 100644 index 0000000..b56e5e9 --- /dev/null +++ b/apps/web/src/components/portal/data-page-layout.ts @@ -0,0 +1,26 @@ +import type { DataGridFeatures, DataGridProps } from '@/components/reui/data-grid/data-grid' + +export const DATA_GRID_CELL_PAD = 'py-3' +export const DATA_GRID_CELL_PAD_FIRST = 'pl-5 py-3' +export const DATA_GRID_CELL_PAD_LAST = 'pr-4 py-3' + +export const DATA_GRID_LAYOUT: NonNullable['tableLayout']> = { + rowBorder: true, + headerBackground: true, + headerBorder: true, + columnsResizable: false, +} + +export const DATA_GRID_CLASS_NAMES: NonNullable['tableClassNames']> = { + headerRow: 'border-b border-border', + bodyRow: 'group/row', +} + +export const DATA_GRID_CONTAINER_CLASS = 'w-full rounded-none border-0' + +/** Отступы ячейки по положению колонки: первая — pl-5, последняя — pr-4. */ +export function cellPad(index: number, count: number) { + if (index === 0) return DATA_GRID_CELL_PAD_FIRST + if (index === count - 1) return DATA_GRID_CELL_PAD_LAST + return DATA_GRID_CELL_PAD +} diff --git a/apps/web/src/components/portal/data-page.tsx b/apps/web/src/components/portal/data-page.tsx new file mode 100644 index 0000000..1f29da5 --- /dev/null +++ b/apps/web/src/components/portal/data-page.tsx @@ -0,0 +1,123 @@ +/** + * Каркас списочной страницы как в MikrotikManager (/servers): карточка Frame без полей, тулбар + * «сегменты · фильтры · поиск · счётчик», таблица с подложкой шапки и ячейками py-3. + */ +import type { ReactNode } from 'react' +import type { Column } from '@tanstack/react-table' +import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, SearchIcon } from 'lucide-react' +import { Frame, FramePanel } from '@/components/reui/frame' +import { InputGroup, InputGroupAddon, InputGroupInput } from '@evobgp/ui/components/input-group' +import { cn } from '@evobgp/ui/lib/utils' +import type { DataGridFeatures } from '@/components/reui/data-grid/data-grid' + +export function DataPageCard({ children, className }: { children: ReactNode; className?: string }) { + return ( + + {children} + + ) +} + +export function SegmentedControl({ + value, + onChange, + options, + label, +}: { + value: T + onChange: (value: T) => void + options: { value: T; label: string; count?: number }[] + label?: string +}) { + return ( +
+ {options.map((o) => ( + + ))} +
+ ) +} + +export function DataPageToolbar({ + leading, + segmented, + filterControl, + search, + onSearchChange, + searchPlaceholder = 'Поиск…', + countLabel, + actions, +}: { + leading?: ReactNode + segmented?: { value: string; onChange: (value: string) => void; options: { value: string; label: string; count?: number }[]; label?: string } + filterControl?: ReactNode + search?: string + onSearchChange?: (value: string) => void + searchPlaceholder?: string + countLabel?: string + actions?: ReactNode +}) { + return ( +
+ {leading} + {segmented ? : null} + {filterControl} + {onSearchChange ? ( + + + + + onSearchChange(e.target.value)} /> + + ) : null} + {countLabel ? {countLabel} : null} + {actions} +
+ ) +} + +export function DataGridSortHeader({ + column, + title, + className, +}: { + column: Column + title: string + className?: string +}) { + const sorted = column.getIsSorted() + if (!column.getCanSort()) { + return {title} + } + return ( + + ) +} diff --git a/apps/web/src/components/portal/format.ts b/apps/web/src/components/portal/format.ts new file mode 100644 index 0000000..d825508 --- /dev/null +++ b/apps/web/src/components/portal/format.ts @@ -0,0 +1,45 @@ +const DATE = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' }) +const SHORT = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' }) +const TIME = new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }) + +function parse(iso: string | null | undefined): Date | null { + if (!iso) return null + const d = new Date(iso) + return Number.isNaN(d.getTime()) ? null : d +} + +/** 12.09.2026 */ +export function fmtDate(iso: string | null | undefined, empty = '—') { + const d = parse(iso) + return d ? DATE.format(d) : empty +} + +/** «сегодня 09:41», «вчера 22:15», иначе «12.09 09:41». */ +export function fmtWhen(iso: string | null | undefined, empty = '—', now = Date.now()) { + const d = parse(iso) + if (!d) return empty + const dayMs = 24 * 60 * 60 * 1000 + const startOfToday = new Date(now) + startOfToday.setHours(0, 0, 0, 0) + const diff = startOfToday.getTime() - new Date(d).setHours(0, 0, 0, 0) + if (diff === 0) return `сегодня ${TIME.format(d)}` + if (diff === dayMs) return `вчера ${TIME.format(d)}` + return `${SHORT.format(d)} ${TIME.format(d)}` +} + +/** Время дня 14:02. */ +export function fmtTime(iso: string | null | undefined, empty = '—') { + const d = parse(iso) + return d ? TIME.format(d) : empty +} + +/** «5 дн назад», «3 ч назад», «только что». */ +export function fmtAgo(iso: string | null | undefined, empty = '—', now = Date.now()) { + const d = parse(iso) + if (!d) return empty + const sec = Math.max(0, Math.round((now - d.getTime()) / 1000)) + if (sec < 60) return 'только что' + if (sec < 3600) return `${Math.floor(sec / 60)} мин назад` + if (sec < 86400) return `${Math.floor(sec / 3600)} ч назад` + return `${Math.floor(sec / 86400)} дн назад` +} diff --git a/apps/web/src/components/portal/kpi-strip.tsx b/apps/web/src/components/portal/kpi-strip.tsx new file mode 100644 index 0000000..3ef8933 --- /dev/null +++ b/apps/web/src/components/portal/kpi-strip.tsx @@ -0,0 +1,74 @@ +import { Badge } from '@/components/reui/badge' +import { IconTile } from '@/components/reui/icon-tile' +import { Frame, FramePanel } from '@/components/reui/frame' +import { cn } from '@evobgp/ui/lib/utils' +import type { KpiItem, KpiTone } from './kpi' + +const VALUE: Record = { + neutral: 'text-foreground', + ok: 'text-foreground', + in: 'text-foreground', + wn: 'text-warning', + er: 'text-destructive', +} + +/** Колонки по числу ячеек и ширине окна (на досках — ширина приложения): 4 → 4/2/1, 3 → 3/1, 2 → 2/1. */ +const COLS: Record = { + 1: 'grid-cols-1', + 2: 'grid-cols-2 max-[640px]:grid-cols-1', + 3: 'grid-cols-1 min-[1240px]:grid-cols-3', + 4: 'grid-cols-1 min-[640px]:grid-cols-2 min-[1240px]:grid-cols-4', +} + +const TILE: Record = { + neutral: 'text-muted-foreground', + ok: 'text-success', + in: 'text-info', + wn: 'text-warning', + er: 'text-destructive', +} + +const GLYPH: Record = { neutral: '', ok: '●', in: '', wn: '▲', er: '✕' } + +const BADGE = { + neutral: 'outline', + ok: 'success-light', + in: 'info-light', + wn: 'warning-light', + er: 'destructive-light', +} as const + +/** + * Полоса показателей как на досках: один Frame, ячейки разделены линиями, + * в ячейке подпись, крупное значение и плашка-подсказка. + */ +export function KpiStrip({ items, 'aria-label': ariaLabel }: { items: KpiItem[]; 'aria-label'?: string }) { + return ( + + +
+ {items.map((k) => { + const Icon = k.icon + return ( +
+ + + +
+
{k.label}
+
+ {k.value} + + {GLYPH[k.tone] ? {GLYPH[k.tone]} : null} + {k.hint} + +
+
+
+ ) + })} +
+
+ + ) +} diff --git a/apps/web/src/components/portal/kpi.tsx b/apps/web/src/components/portal/kpi.tsx new file mode 100644 index 0000000..d9b4abb --- /dev/null +++ b/apps/web/src/components/portal/kpi.tsx @@ -0,0 +1,24 @@ +import type { LucideIcon } from 'lucide-react' + +export type KpiTone = 'neutral' | 'ok' | 'wn' | 'er' | 'in' + +export type KpiItem = { + id: string + label: string + value: string + /** Плашка-подсказка рядом со значением. */ + hint: string + tone: KpiTone + /** Иконка показателя: плитка слева от подписи. */ + icon: LucideIcon +} + +/** Плитка KPI: иконка, подпись, значение (цветом при wn/er) и плашка-подсказка. */ +export function kpi( + id: string, + label: string, + value: string, + opts: { icon: LucideIcon; tone?: KpiTone; badge: string }, +): KpiItem { + return { id, label, value, hint: opts.badge, tone: opts.tone ?? 'neutral', icon: opts.icon } +} diff --git a/apps/web/src/components/portal/section-grid.tsx b/apps/web/src/components/portal/section-grid.tsx new file mode 100644 index 0000000..d7d66c2 --- /dev/null +++ b/apps/web/src/components/portal/section-grid.tsx @@ -0,0 +1,488 @@ +import { useMemo, useState, type ReactNode } from 'react' +import { Columns3Icon, MoreHorizontalIcon } from 'lucide-react' +import { toast } from 'sonner' +import { useTable, type ColumnDef, type RowSelectionState, type SortingState } from '@tanstack/react-table' +import { DATA_GRID_I18N_RU as DATA_GRID_RU } from '@/lib/reui-i18n-ru' +import { DataGrid, dataGridFeatures, type DataGridFeatures } from '@/components/reui/data-grid/data-grid' +import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination' +import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area' +import { + DataGridTableDndRowHandle, + DataGridTableDndRows, +} from '@/components/reui/data-grid/data-grid-table-dnd-rows' +import type { DragEndEvent } from '@dnd-kit/core' +import { + DataGridTable, + DataGridTableRowSelect, + DataGridTableRowSelectAll, +} from '@/components/reui/data-grid/data-grid-table' +import { Button } from '@evobgp/ui/components/button' +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@evobgp/ui/components/dropdown-menu' +import { Switch } from '@evobgp/ui/components/switch' +import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' +import { cn } from '@evobgp/ui/lib/utils' +import { StatusBadge } from './bits' +import { + DATA_GRID_CELL_PAD, + DATA_GRID_CELL_PAD_FIRST, + DATA_GRID_CELL_PAD_LAST, + DATA_GRID_CLASS_NAMES, + DATA_GRID_LAYOUT, +} from './data-page-layout' +import { + DataGridSortHeader, + DataPageCard, + DataPageToolbar, +} from './data-page' +import type { Tone } from './tone' +import type { RowMark } from './tone' +import { TONE_DOT } from './tone' + +export type GridColumn = { + id: string + title: string + /** Ширина колонки; сумма задаёт минимальную ширину таблицы. */ + size: number + cell: (row: T) => ReactNode + /** Ключ сортировки; без него колонка не сортируется. */ + sortValue?: (row: T) => string | number +} + +export type GridView = { label: string; match: (row: T) => boolean } + +/** Карточка строки для режима «Карточки» (как на доске «Пользователи»). */ +export type GridCard = { + name: string + sub?: string + tone: Tone + status: string + cells: Array<{ l: string; v: string }> +} + +/** Действия меню строки «⋮»: на досках — Открыть, Включить/Выключить, Дублировать, Удалить…. */ +export type RowMenuConfig = { + onOpen?: () => void + toggleLabel?: string + onToggle?: () => void + onDuplicate?: () => void + onDelete?: () => void +} + +export type GridBulk = { + onLabel?: string + offLabel?: string + onRun?: (ids: string[]) => void + offRun?: (ids: string[]) => void + onDelete?: (ids: string[]) => void +} + +const SELECT_COLUMN_SIZE = 44 +const DRAG_COLUMN_SIZE = 44 +const ACTIONS_COLUMN_SIZE = 120 + +/** + * Секция-таблица по доскам Auth Portal: шапка (название, действия, «Таблица/Карточки», поиск, «Колонки»), + * сохранённые виды линией со счётчиками, панель массовых действий, таблица со статусом и меню строки, + * подвал «Показано N из M». Ширина таблицы не меньше суммы колонок — прокрутка внутри контейнера. + */ +export function SectionGrid({ + title, + description, + rows, + columns, + getRowId, + views, + searchText, + searchPlaceholder = 'Поиск…', + onRowClick, + selectable = true, + bulk, + tools, + card, + rowSwitch, + rowMenu, + onReorder, + rowStatus, + pageSize = 50, + emptyMessage = 'Нет записей по текущим фильтрам.', + className, +}: { + title: string + description: string + rows: T[] + columns: GridColumn[] + getRowId: (row: T) => string + views?: GridView[] + searchText?: (row: T) => string + searchPlaceholder?: string + onRowClick?: (row: T) => void + selectable?: boolean + bulk?: GridBulk + /** Кнопки действий в шапке; функция получает id выбранных строк. */ + tools?: ReactNode | ((selectedIds: string[]) => ReactNode) + /** Включает переключатель «Таблица / Карточки». */ + card?: (row: T) => GridCard + /** Переключатель «включено» в последней колонке (если у строки он есть). */ + rowSwitch?: { value: (row: T) => boolean | undefined; onChange: (row: T, on: boolean) => void } + rowMenu?: (row: T) => RowMenuConfig + /** Включает перетаскивание строк (dnd-kit). Передаёт id строки и id строки, на место которой её бросили. */ + onReorder?: (activeId: string, overId: string) => void + /** Пометка строки: «Не сохранено» (dirty/new) или «Будет удалено» (deleted). */ + rowStatus?: (row: T) => RowMark | undefined + pageSize?: number + /** Свой текст подвала вместо «Показано N из M» (как «Показано 8 из 148» на доске). */ + footer?: string + emptyMessage?: string + className?: string +}) { + const storageKey = `design-preview:view:${title}` + const [view, setViewState] = useState(() => { + try { + const v = Number(window.localStorage.getItem(storageKey)) + return Number.isInteger(v) && v >= 0 && v < (views?.length ?? 0) ? v : 0 + } catch { + return 0 + } + }) + const setView = (i: number) => { + setViewState(i) + try { + window.localStorage.setItem(storageKey, String(i)) + } catch { + /* без сохранения вида */ + } + } + const [query, setQuery] = useState('') + const [sorting, setSorting] = useState([]) + const [rowSelection, setRowSelection] = useState({}) + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize }) + const [hidden, setHidden] = useState>({}) + const [cards, setCards] = useState(false) + + const data = useMemo(() => { + const matchView = views?.[view]?.match + const q = query.trim().toLowerCase() + return rows.filter((row) => { + if (matchView && !matchView(row)) return false + if (q && searchText && !searchText(row).toLowerCase().includes(q)) return false + return true + }) + }, [rows, views, view, query, searchText]) + + const visibleColumns = useMemo(() => columns.filter((c, i) => i === 0 || !hidden[c.id]), [columns, hidden]) + + const defs = useMemo[]>(() => { + const list: ColumnDef[] = [] + if (onReorder) { + list.push({ + id: 'drag', + header: () => Порядок, + cell: () => , + enableSorting: false, + enableResizing: false, + size: DRAG_COLUMN_SIZE, + }) + } + if (selectable) { + list.push({ + id: 'select', + header: () => , + cell: ({ row }) => ( +
e.stopPropagation()}> + +
+ ), + enableSorting: false, + enableResizing: false, + size: SELECT_COLUMN_SIZE, + meta: { headerClassName: 'pl-5 py-3', cellClassName: 'pl-5 py-3' }, + }) + } + visibleColumns.forEach((col, i) => { + const pad = i === 0 && !selectable && !onReorder ? DATA_GRID_CELL_PAD_FIRST : DATA_GRID_CELL_PAD + list.push({ + id: col.id, + accessorFn: col.sortValue ?? (() => ''), + header: ({ column }) => , + cell: ({ row }) => col.cell(row.original), + enableSorting: Boolean(col.sortValue), + enableResizing: false, + size: col.size, + meta: { headerTitle: col.title, headerClassName: pad, cellClassName: pad }, + }) + }) + list.push({ + id: '__actions', + header: () => Действия, + cell: ({ row }) => { + const r = row.original + const on = rowSwitch?.value(r) + const menu: RowMenuConfig = rowMenu?.(r) ?? {} + const soon = (what: string) => () => toast(`${what} (макет)`) + return ( +
e.stopPropagation()}> + {on !== undefined && rowSwitch ? ( + rowSwitch.onChange(r, v)} aria-label="Включено" /> + ) : null} + + }> + + + + onRowClick?.(r))}>Открыть + + {menu.toggleLabel ?? (on === undefined ? 'Повторить проверку' : on ? 'Выключить' : 'Включить')} + + Дублировать + + Удалить… + + + +
+ ) + }, + enableSorting: false, + enableResizing: false, + size: ACTIONS_COLUMN_SIZE, + meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST }, + }) + return list + }, [visibleColumns, selectable, onReorder, rowSwitch, rowMenu, onRowClick]) + + const table = useTable({ + features: dataGridFeatures, + columns: defs, + data, + getRowId, + state: { sorting, rowSelection, pagination }, + enableRowSelection: selectable, + onSortingChange: setSorting, + onRowSelectionChange: setRowSelection, + onPaginationChange: setPagination, + }) + + const dataIds = table.getRowModel().rows.map((r) => r.id) + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + if (!onReorder || !active || !over || active.id === over.id) return + onReorder(String(active.id), String(over.id)) + } + + // Колонки сжимаются под ширину рамки (текст переносится, как на досках); прокрутка — только на узких экранах. + const minWidth = Math.round( + visibleColumns.reduce( + (sum, c) => sum + c.size, + (selectable ? SELECT_COLUMN_SIZE : 0) + (onReorder ? DRAG_COLUMN_SIZE : 0) + ACTIONS_COLUMN_SIZE, + ) * 0.7, + ) + const selectedIds = table.getSelectedRowModel().rows.map((r) => r.id) + const clear = () => setRowSelection({}) + const paged = data.length > pageSize + const showCards = Boolean(card) && cards + + const countLabel = `${data.length} ${data.length === rows.length ? 'записей' : `из ${rows.length}`}` + + return ( + + +

{title}

+ { + setView(Number(v)) + setPagination((p) => ({ ...p, pageIndex: 0 })) + }, + options: views.map((v, i) => ({ value: String(i), label: v.label, count: rows.filter(v.match).length })), + } + : undefined + } + leading={ + card ? ( + setCards(v === 'cards')} aria-label="Вид списка"> + + + Таблица + + + Карточки + + + + ) : undefined + } + search={query} + onSearchChange={ + searchText + ? (v) => { + setQuery(v) + setPagination((p) => ({ ...p, pageIndex: 0 })) + } + : undefined + } + searchPlaceholder={searchPlaceholder} + countLabel={countLabel} + actions={ + <> + {typeof tools === 'function' ? tools(selectedIds) : tools} + {columns.length > 1 ? ( + + } + > + + + + {columns.slice(1).map((c) => ( + setHidden((h) => ({ ...h, [c.id]: !v }))}> + {c.title} + + ))} + + + ) : null} + + } + /> + {selectable && selectedIds.length > 0 ? ( +
+ Выбрано: {selectedIds.length} + + + + + +
+ ) : null} + {showCards && card ? ( +
    + {data.map((r) => { + const c = card(r) + const id = getRowId(r) + const marked = rowStatus?.(r) + return ( +
  • +
    + + setRowSelection((s) => { + const next = { ...s } + if (e.target.checked) next[id] = true + else delete next[id] + return next + }) + } + className="accent-primary size-4" + /> + {c.status} +
    + +
    + {c.cells.map((x) => ( +
    +
    {x.l}
    +
    {x.v}
    +
    + ))} +
    +
  • + ) + })} +
+ ) : ( + +
+ {onReorder ? : } +
+
+ )} + {paged && !showCards ? : null} +
+
+ ) +} + +/** Двухстрочная ячейка: заголовок + подпись. */ +export function TwoLineCell({ title, sub, dot }: { title: ReactNode; sub?: ReactNode; dot?: Tone }) { + return ( +
+ {dot ? : null} +
+ {title} + {sub ? {sub} : null} +
+
+ ) +} diff --git a/apps/web/src/components/portal/tone.ts b/apps/web/src/components/portal/tone.ts new file mode 100644 index 0000000..b45a9d4 --- /dev/null +++ b/apps/web/src/components/portal/tone.ts @@ -0,0 +1,28 @@ +export type Tone = 'ok' | 'wn' | 'er' | 'mu' | 'in' + +/** Пометка строки с накопленной правкой. */ +export type RowMark = 'new' | 'dirty' | 'deleted' + + +type BadgeVariant = + | 'success-light' + | 'warning-light' + | 'destructive-light' + | 'info-light' + | 'outline' + +export const TONE_BADGE: Record = { + ok: 'success-light', + wn: 'warning-light', + er: 'destructive-light', + in: 'info-light', + mu: 'outline', +} + +export const TONE_DOT: Record = { + ok: 'bg-success', + wn: 'bg-warning', + er: 'bg-destructive', + in: 'bg-success', + mu: 'bg-muted-foreground/50', +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 8eb77f8..5ddac46 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -5,6 +5,7 @@ import { RouterProvider } from '@tanstack/react-router' import { Toaster } from '@evobgp/ui/components/sonner' import '@evobgp/ui/globals.css' +import './portal-theme.css' import { queryClient } from '@/lib/queryClient' import { createRouter } from '@/lib/router' diff --git a/apps/web/src/portal-theme.css b/apps/web/src/portal-theme.css new file mode 100644 index 0000000..1b6a106 --- /dev/null +++ b/apps/web/src/portal-theme.css @@ -0,0 +1,23 @@ +/* Плотность портала. Палитра, шрифты и таблицы — нативные токены ReUI из @authportal/ui/globals.css. */ +html[data-density='compact'] [data-slot='data-grid-table'] :is(th, td) { + padding-block: 0.25rem; +} + +html[data-density='compact'] [data-slot='sidebar-inset'] > div:last-child { + gap: 0.75rem; +} + +/* Видимый фокус и reduced motion — как в макете MikrotikManager v2. */ +html [data-slot='sidebar-wrapper'] :focus-visible, +html #main-content :focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + html #main-content *, + html [data-slot='sidebar-wrapper'] * { + animation: none !important; + transition: none !important; + } +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 4dbb9ef..aa290c1 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,5 +1,5 @@ import { Outlet, createRootRouteWithContext } from '@tanstack/react-router' -import { AppShell } from '@/components/layout/app-shell' +import { PortalShell } from '@/components/layout/portal-shell' interface RouterContext { queryClient: import('@tanstack/react-query').QueryClient @@ -11,8 +11,8 @@ export const Route = createRootRouteWithContext()({ function RootComponent() { return ( - + - + ) }