diff --git a/.codegraph/daemon.pid b/.codegraph/daemon.pid index e32141d..b18ed3b 100644 --- a/.codegraph/daemon.pid +++ b/.codegraph/daemon.pid @@ -1,6 +1,6 @@ { - "pid": 43636, + "pid": 51184, "version": "0.9.9", "socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da", - "startedAt": 1783570299043 + "startedAt": 1784215907952 } diff --git a/.cursor/rules/web-shadcn.mdc b/.cursor/rules/web-shadcn.mdc index cae3ce1..0c6b07c 100644 --- a/.cursor/rules/web-shadcn.mdc +++ b/.cursor/rules/web-shadcn.mdc @@ -22,9 +22,12 @@ alwaysApply: false |------|------|------------| | shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) | | ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` | -| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, SectionCards, LoadingButton | +| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, LoadingButton | +| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, OpsDashboard, SettingsShell | | Роуты | `apps/web/src/routes/` | TanStack Router (file-based) | +**Design contract:** [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Surface: **frame**. KPI: [stats-12](https://reui.io/preview/base/stats-12). Lists: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2). + Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add `. ## Правила @@ -45,8 +48,8 @@ alwaysApply: false **WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки. *Проверка:* https://ui.shadcn.com/docs/components/form -**WEB-06** | MUST | Сложные data-списки — ReUI `DataGridCard` (ReUI data-grid, не shadcn Data Table). Простые списки — shadcn `Table`. -*Проверка:* `@/components/reui/data-grid` или `@evobgp/ui/components/table`. +**WEB-06** | MUST | Сложные data-списки — Frame + ReUI DataGrid через `reui-kit/ResourcePage` или `DataGridSection` (не Card shell, не shadcn Data Table). Surface lock: `UI_SURFACE = 'frame'` (`lib/ui-surface.ts`). Простые списки — shadcn `Table`. +*Проверка:* `@/components/reui-kit`, `@/components/reui/data-grid`, `docs/ui-design-contract.md`. **WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`. *Проверка:* https://ui.shadcn.com/docs/components/sonner diff --git a/.gitignore b/.gitignore index f23f327..4edcc06 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ Thumbs.db # Env / secrets (local only) .env +.env.local .env.* !.env.example !.env.*.example diff --git a/AGENTS.md b/AGENTS.md index a19eb7c..cc9961e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,4 +71,6 @@ pnpm --filter @evobgp/web run build Стек: React 19, TanStack Router/Query, shadcn/ui (base-nova, registry `@shadcn` + `@reui`), Tailwind v4, lucide-react. Legacy Svelte — в `web-legacy-svelte/` (архив, только референс при миграции). -UI-задачи начинаются с MCP `plugin-shadcn-shadcn` (search → examples → add command), затем CLI `pnpm dlx shadcn@latest add ...` из `apps/web`. См. также [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc) для Context7 ID стека. +**UI design contract:** [`docs/ui-design-contract.md`](docs/ui-design-contract.md) — surface `frame`, kit `apps/web/src/components/reui-kit/`. + +UI-задачи начинаются с MCP `user-reui` (`surface: "frame"`) + `plugin-shadcn-shadcn`, затем CLI `pnpm dlx shadcn@latest add ...` из `apps/web`. См. также [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc) для Context7 ID стека. diff --git a/apps/web/src/components/blocks/settings-7/components/settings-card.tsx b/apps/web/src/components/blocks/settings-7/components/settings-card.tsx index 9829937..7cf1c2d 100644 --- a/apps/web/src/components/blocks/settings-7/components/settings-card.tsx +++ b/apps/web/src/components/blocks/settings-7/components/settings-card.tsx @@ -1,16 +1,14 @@ -"use client" +import { type ReactNode } from 'react' -import { type ReactNode } from "react" - -import { cn } from "@evobgp/ui/lib/utils" +import { cn } from '@evobgp/ui/lib/utils' import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@evobgp/ui/components/card" + Frame, + FrameDescription, + FrameFooter, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' interface SettingsCardProps { title: string @@ -22,6 +20,7 @@ interface SettingsCardProps { footerClassName?: string } +/** Settings section — Frame surface. Preview: https://reui.io/preview/base/settings-16 */ export function SettingsCard({ title, description, @@ -32,28 +31,23 @@ export function SettingsCard({ footerClassName, }: SettingsCardProps) { return ( - - {/* Header */} - - {title} - {description ? {description} : null} - + + + + {title} + {description ? {description} : null} + - {/* Content */} - - {children} - +
{children}
- {footer ? ( - - {footer} - - ) : null} -
+ {footer ? ( + + {footer} + + ) : null} + + ) -} \ No newline at end of file +} diff --git a/apps/web/src/components/counted-line-tabs.tsx b/apps/web/src/components/counted-line-tabs.tsx new file mode 100644 index 0000000..4466756 --- /dev/null +++ b/apps/web/src/components/counted-line-tabs.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from 'react' +import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' +import { cn } from '@evobgp/ui/lib/utils' + +export interface CountedLineTab { + id: string + label: string + count?: number +} + +interface CountedLineTabsProps { + tabs: CountedLineTab[] + value: string + onValueChange: (value: string) => void + className?: string + listClassName?: string + children?: ReactNode +} + +/** Line tabs with count pills (c-tabs-2 / data-grid-filtering-2). */ +export function CountedLineTabs({ + tabs, + value, + onValueChange, + className, + listClassName, + children, +}: CountedLineTabsProps) { + return ( + + + {tabs.map((tab) => ( + + {tab.label} + {tab.count !== undefined ? ( + + {tab.count} + + ) : null} + + ))} + + {children} + + ) +} diff --git a/apps/web/src/components/data-grid-shell.tsx b/apps/web/src/components/data-grid-shell.tsx index 124bf31..645d181 100644 --- a/apps/web/src/components/data-grid-shell.tsx +++ b/apps/web/src/components/data-grid-shell.tsx @@ -4,10 +4,15 @@ import type { Table } from '@tanstack/react-table' import { cn } from '@evobgp/ui/lib/utils' import { DataGridToolbar } from '@/components/data-grid-toolbar' -import { PanelCard, panelCardContentFlushClassName, panelCardFooterClassName, panelCardInsetClassName } from '@/components/panel-card' +import { + PanelCard, + panelCardContentFlushClassName, + panelCardFooterClassName, +} from '@/components/panel-card' import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination' import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid' import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' +import { FrameFooter } from '@/components/reui/frame' import { DATA_GRID_MESSAGES_RU, DATA_GRID_PAGINATION_RU, @@ -52,9 +57,9 @@ export function DataGridShell({ {showPagination ? ( -
+ -
+ ) : null} ) diff --git a/apps/web/src/components/data-grid-toolbar.tsx b/apps/web/src/components/data-grid-toolbar.tsx index 96f4e78..195a363 100644 --- a/apps/web/src/components/data-grid-toolbar.tsx +++ b/apps/web/src/components/data-grid-toolbar.tsx @@ -16,12 +16,13 @@ interface DataGridToolbarProps { searchValue: string onSearchChange: (value: string) => void searchPlaceholder?: string + /** ReUI Filters trigger or custom filter controls. Preview: https://reui.io/preview/base/data-grid-filtering-2 */ filters?: ReactNode actions?: ReactNode className?: string } -/** Search row for data grids (ReUI c-input-group-37 pattern). */ +/** Search + optional ReUI Filters row for Frame data grids. */ export function DataGridToolbar({ searchValue, onSearchChange, @@ -58,19 +59,19 @@ export function DataGridToolbar({ filters ) : ( )} - {actions ?
{actions}
: null} + {actions ?
{actions}
: null} ) } diff --git a/apps/web/src/components/kpi-stat-grid.tsx b/apps/web/src/components/kpi-stat-grid.tsx index 24dbc2a..f7f1249 100644 --- a/apps/web/src/components/kpi-stat-grid.tsx +++ b/apps/web/src/components/kpi-stat-grid.tsx @@ -1,126 +1,13 @@ -import type { KeyboardEvent, ReactNode } from 'react' - -import { Frame, FramePanel } from '@/components/reui/frame' -import { cn } from '@evobgp/ui/lib/utils' -import { Item, ItemMedia } from '@evobgp/ui/components/item' - -export type KpiStatItem = { - id?: string - icon?: ReactNode - iconClassName?: string - value: ReactNode - label: ReactNode - footer?: ReactNode - active?: boolean - onClick?: () => void -} - -const DEFAULT_ICON_CLASS = - 'text-muted-foreground [&_svg]:text-current' - -function kpiStatGridClassName(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 @3xl:grid-cols-3' - if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4' - if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5' - if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6' - return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4' -} - -function handleCardKeyDown(onClick: () => void, event: KeyboardEvent) { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault() - onClick() - } -} - -/** Single KPI tile (ReUI stats-12 / dashboard PRO pattern). */ -export function KpiStatCard({ - item, - embedded = false, - className, -}: { - item: KpiStatItem - embedded?: boolean - className?: string -}) { - const clickable = Boolean(item.onClick) - - const panel = ( - handleCardKeyDown(item.onClick!, e) : undefined} - > - {item.icon ? ( - - - {item.icon} - - - ) : null} - -
-
{item.value}
-
{item.label}
-
- - {item.footer ?
{item.footer}
: null} -
- ) - - if (embedded) { - return ( - - {panel} - - ) - } - - return {panel} -} - -export function KpiStatGrid({ - items, - className, - embedded = false, - 'aria-label': ariaLabel, -}: { - items: KpiStatItem[] - className?: string - embedded?: boolean - 'aria-label'?: string -}) { - return ( -
-
- {items.map((item, index) => ( - - ))} -
-
- ) -} - -export function kpiStatItemKey(item: KpiStatItem, index: number): string { - if (item.id) return item.id - if (typeof item.label === 'string') return item.label - return `kpi-${index}` -} +/** + * Re-export from reui-kit — keep `@/components/kpi-stat-grid` imports stable. + * @see https://reui.io/preview/base/stats-12 + */ +export { + KpiStatGrid, + KpiStatCard, + KpiStatCardTile, + kpiStatItemKey, + type KpiStatItem, + type KpiStatCardData, + type OpsKpiCard, +} from '@/components/reui-kit/kpi-stat-grid' diff --git a/apps/web/src/components/panel-card.tsx b/apps/web/src/components/panel-card.tsx index 41c0fd4..8cf231b 100644 --- a/apps/web/src/components/panel-card.tsx +++ b/apps/web/src/components/panel-card.tsx @@ -1,21 +1,24 @@ import type { ReactNode } from 'react' import { - Card, - CardAction, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from '@evobgp/ui/components/card' + Frame, + FrameDescription, + FrameFooter, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' import { cn } from '@evobgp/ui/lib/utils' -/** REUI c-data-grid-19: card shell with default spacing and flush grid body. */ -export const panelCardClassName = 'gap-0 has-data-[slot=card-footer]:pb-0' +/** + * Frame shell class constants (legacy Card names kept for DataGridShell / toolbar). + * @see https://reui.io/docs/components/base/frame + */ +export const panelCardClassName = 'w-full gap-0 p-0' export const panelCardHeaderClassName = 'border-b' -export const panelCardContentFlushClassName = 'px-0' -/** Horizontal inset for toolbar / pagination rows (matches edgeCell px-5). */ +/** Flush body inside FramePanel (toolbar / data-grid). */ +export const panelCardContentFlushClassName = 'p-0' +/** Horizontal inset for toolbar / pagination rows. */ export const panelCardInsetClassName = 'px-5 py-3' export const panelCardFooterClassName = 'border-t bg-transparent' @@ -45,22 +48,45 @@ export function PanelCard({ size = 'default', }: PanelCardProps) { const hasHeader = Boolean(title || description || actions) + const spacing = size === 'sm' ? 'sm' : 'default' return ( - - {hasHeader ? ( - - {title ? {title} : null} - {description ? {description} : null} - {actions ? {actions} : null} - - ) : null} - {children != null && children !== false ? ( - {children} - ) : null} - {footer ? ( - {footer} - ) : null} - + + + {hasHeader ? ( + +
+ {title ? {title} : null} + {description ? ( + {description} + ) : null} +
+ {actions ? ( +
+ {actions} +
+ ) : null} +
+ ) : null} + {children != null && children !== false ? ( +
{children}
+ ) : null} + {footer ? ( + + {footer} + + ) : null} +
+ ) } diff --git a/apps/web/src/components/reui-kit/detail-panel.tsx b/apps/web/src/components/reui-kit/detail-panel.tsx new file mode 100644 index 0000000..e8a852a --- /dev/null +++ b/apps/web/src/components/reui-kit/detail-panel.tsx @@ -0,0 +1,116 @@ +import type { ReactNode } from 'react' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { cn } from '@evobgp/ui/lib/utils' + +export interface DetailMetricCard { + id: string + icon: ReactNode + label: string + description: string + footer?: ReactNode +} + +interface DetailPanelProps { + children: ReactNode + className?: string +} + +function DetailPanelRoot({ children, className }: DetailPanelProps) { + return
{children}
+} + +interface DetailPanelHeaderProps { + title: string + description?: string + actions?: ReactNode + children?: ReactNode +} + +function DetailPanelHeader({ + title, + description, + actions, + children, +}: DetailPanelHeaderProps) { + return ( + + +
+ {title} + {description ? ( + {description} + ) : null} +
+ {actions ? ( +
+ {actions} +
+ ) : null} +
+ {children ? {children} : null} + + ) +} + +function DetailPanelMetrics({ cards }: { cards: DetailMetricCard[] }) { + return ( +
+
+ {cards.map((card) => ( + + +
+ {card.icon} + + {card.label} + +
+
+ +

+ {card.description} +

+ {card.footer} +
+ + ))} +
+
+ ) +} + +function DetailPanelSection({ + title, + description, + children, +}: { + title?: string + description?: string + children: ReactNode +}) { + return ( +
+ {title ? ( +
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ ) : null} + {children} +
+ ) +} + +export const DetailPanel = Object.assign(DetailPanelRoot, { + Header: DetailPanelHeader, + Metrics: DetailPanelMetrics, + Section: DetailPanelSection, +}) diff --git a/apps/web/src/components/reui-kit/filter-utils.ts b/apps/web/src/components/reui-kit/filter-utils.ts new file mode 100644 index 0000000..a896d19 --- /dev/null +++ b/apps/web/src/components/reui-kit/filter-utils.ts @@ -0,0 +1,91 @@ +import type { Filter } from '@/components/reui/filters' + +export function getActiveFilters(filters: Filter[]) { + return filters.filter((filter) => { + const { values } = filter + if (!values || values.length === 0) return false + if (values.every((value) => typeof value === 'string' && value.trim() === '')) { + return false + } + if (values.every((value) => value === null || value === undefined)) { + return false + } + if (values.every((value) => Array.isArray(value) && value.length === 0)) { + return false + } + return true + }) +} + +export function applyFiltersToData( + data: T[], + filters: Filter[], + getFieldValue: (item: T, field: string) => unknown, +): T[] { + const active = getActiveFilters(filters) + let result = [...data] + + for (const filter of active) { + const { field, operator, values } = filter + result = result.filter((item) => { + const raw = getFieldValue(item, field) + const fieldValue = raw != null ? raw : '' + + switch (operator) { + case 'is': + return values.includes(fieldValue) + case 'is_not': + return !values.includes(fieldValue) + case 'is_any_of': + return values.some((value) => fieldValue === value) + case 'is_not_any_of': + return !values.some((value) => fieldValue === value) + case 'contains': { + const tokens = values + .map((value) => String(value).trim()) + .filter(Boolean) + if (tokens.length === 0) return true + return tokens.some((token) => + String(fieldValue).toLowerCase().includes(token.toLowerCase()), + ) + } + case 'not_contains': + return !values.some((value) => + String(fieldValue).toLowerCase().includes(String(value).toLowerCase()), + ) + case 'starts_with': + return values.some((value) => + String(fieldValue).toLowerCase().startsWith(String(value).toLowerCase()), + ) + case 'ends_with': + return values.some((value) => + String(fieldValue).toLowerCase().endsWith(String(value).toLowerCase()), + ) + case 'empty': + return fieldValue === '' || fieldValue == null + case 'not_empty': + return fieldValue !== '' && fieldValue != null + default: + return true + } + }) + } + + return result +} + +export function renderSelectedCount(values: unknown[]) { + if (values.length === 0) return 'Выберите…' + if (values.length > 1) return `${values.length} выбрано` + return null +} + +export function renderSingleSelectedLabel( + values: unknown[], + options: { value: string; label: string }[], +) { + const state = renderSelectedCount(values) + if (state) return state + const option = options.find((item) => item.value === values[0]) + return option?.label ?? String(values[0]) +} diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts new file mode 100644 index 0000000..6d8c935 --- /dev/null +++ b/apps/web/src/components/reui-kit/index.ts @@ -0,0 +1,24 @@ +export { + applyFiltersToData, + getActiveFilters, + renderSelectedCount, + renderSingleSelectedLabel, +} from './filter-utils' +export { + ResourcePage, + type ResourcePageProps, + type ResourcePageTab, +} from './resource-page' +export { + KpiStatGrid, + KpiStatCard, + KpiStatCardTile, + kpiStatItemKey, + type KpiStatItem, + type KpiStatCardData, + type KpiStatCard as KpiStatCardType, + type OpsKpiCard, +} from './kpi-stat-grid' +export { OpsDashboard } from './ops-dashboard' +export { DetailPanel, type DetailMetricCard } from './detail-panel' +export { SettingsShell, type SettingsTabConfig } from './settings-shell' diff --git a/apps/web/src/components/reui-kit/kpi-stat-grid.tsx b/apps/web/src/components/reui-kit/kpi-stat-grid.tsx new file mode 100644 index 0000000..f04196f --- /dev/null +++ b/apps/web/src/components/reui-kit/kpi-stat-grid.tsx @@ -0,0 +1,261 @@ +import type { KeyboardEvent, ReactNode } from 'react' +import { Link } from '@tanstack/react-router' + +import { Frame, FramePanel } from '@/components/reui/frame' +import { cn } from '@evobgp/ui/lib/utils' +import { Item, ItemMedia } from '@evobgp/ui/components/item' +import { Skeleton } from '@evobgp/ui/components/skeleton' + +/** + * KPI tile data — stats-12 visual (icon tile + value + label + footer). + * CFDM-compatible: id, label, value, hint?, to?, search?, onSelect?, selected? + * EvoBGP: icon?, iconClassName?, footer?, active?, onClick? + * + * @see https://reui.io/preview/base/stats-12 + */ +export type KpiStatItem = { + id?: string + label: ReactNode + value: ReactNode + hint?: ReactNode + to?: string + search?: Record + onSelect?: () => void + onClick?: () => void + selected?: boolean + active?: boolean + icon?: ReactNode + iconClassName?: string + footer?: ReactNode +} + +/** CFDM-compatible card shape (id required). */ +export type KpiStatCardData = KpiStatItem & { id: string } + +/** @deprecated Use KpiStatCardData */ +export type OpsKpiCard = KpiStatCardData + +/** @deprecated Use KpiStatCardData — type alias for CFDM kit parity */ +export type KpiStatCard = KpiStatCardData + +const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current' + +function kpiStatGridClassName(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 @3xl:grid-cols-3' + if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4' + if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5' + if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6' + return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4' +} + +function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onActivate() + } +} + +function resolveActivate(item: KpiStatItem): (() => void) | undefined { + return item.onClick ?? item.onSelect +} + +function isSelected(item: KpiStatItem): boolean { + return Boolean(item.selected ?? item.active) +} + +function KpiStatCardBody({ item }: { item: KpiStatItem }) { + const footer = + item.footer ?? + (item.hint ? ( + {item.hint} + ) : null) + + return ( + <> + {item.icon ? ( + + + {item.icon} + + + ) : null} + +
+
+ {item.value} +
+
{item.label}
+
+ + {footer ?
{footer}
: null} + + ) +} + +/** Single KPI tile (ReUI stats-12 / dashboard PRO pattern). */ +export function KpiStatCardTile({ + item, + embedded = false, + className, +}: { + item: KpiStatItem + embedded?: boolean + className?: string +}) { + const onActivate = resolveActivate(item) + const clickable = Boolean(item.to || onActivate) + const selected = isSelected(item) + + const panelClass = cn( + 'flex h-full flex-col items-start gap-6', + clickable && 'cursor-pointer transition-colors hover:bg-muted/30', + selected && 'ring-1 ring-primary/30', + className, + ) + + let panel: ReactNode + + if (item.to) { + panel = ( + + + + + + ) + } else if (onActivate) { + panel = ( + handleCardKeyDown(onActivate, e)} + > + + + ) + } else { + panel = ( + + + + ) + } + + if (embedded) { + return {panel} + } + + return {panel} +} + +/** @deprecated Prefer KpiStatCardTile — kept for existing imports */ +export function KpiStatCard({ + item, + embedded = false, + className, +}: { + item: KpiStatItem + embedded?: boolean + className?: string +}) { + return +} + +function KpiStatGridSkeleton({ count }: { count: number }) { + return ( +
+
+ {Array.from({ length: count }).map((_, index) => ( + + + +
+ + +
+ +
+ + ))} +
+
+ ) +} + +interface KpiStatGridProps { + /** EvoBGP primary API */ + items?: KpiStatItem[] + /** CFDM-compatible API */ + cards?: KpiStatCardData[] + isLoading?: boolean + emptyMessage?: ReactNode + emptyIcon?: ReactNode + className?: string + skeletonCount?: number + embedded?: boolean + 'aria-label'?: string +} + +export function KpiStatGrid({ + items, + cards, + isLoading = false, + emptyMessage, + emptyIcon, + className, + skeletonCount = 4, + embedded = false, + 'aria-label': ariaLabel, +}: KpiStatGridProps) { + if (isLoading) { + return + } + + const list = items ?? cards ?? [] + + if (list.length === 0 && (emptyMessage || emptyIcon)) { + return ( + + + {emptyIcon} + {emptyMessage ? ( +

{emptyMessage}

+ ) : null} +
+ + ) + } + + return ( +
+
+ {list.map((item, index) => ( + + ))} +
+
+ ) +} + +export function kpiStatItemKey(item: KpiStatItem, index: number): string { + if (item.id) return item.id + if (typeof item.label === 'string') return item.label + return `kpi-${index}` +} diff --git a/apps/web/src/components/reui-kit/ops-dashboard.tsx b/apps/web/src/components/reui-kit/ops-dashboard.tsx new file mode 100644 index 0000000..6f66f41 --- /dev/null +++ b/apps/web/src/components/reui-kit/ops-dashboard.tsx @@ -0,0 +1,86 @@ +import type { ReactNode } from 'react' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { Skeleton } from '@evobgp/ui/components/skeleton' +import { PageHeader } from '@/components/page-header' +import { KpiStatGrid, type KpiStatCardData } from './kpi-stat-grid' + +export type OpsKpiCard = KpiStatCardData + +interface OpsDashboardProps { + title?: string + description?: string + kpiCards: OpsKpiCard[] + charts: ReactNode + queue: ReactNode + queueTitle?: string + queueDescription?: string + isLoading?: boolean +} + +const rootClassName = + 'text-foreground @container flex w-full flex-col gap-2 md:gap-3' + +function OpsDashboardSkeleton() { + return ( +
+
+ + +
+ +
+ + +
+ +
+ ) +} + +export function OpsDashboard({ + title = 'Панель управления', + description = 'Обзор модулей, сети, задач и состояния плоскости управления', + kpiCards, + charts, + queue, + queueTitle = 'Требуют внимания', + queueDescription = 'Проблемы BGP, сбои задач и офлайн-спикеры', + isLoading = false, +}: OpsDashboardProps) { + if (isLoading) { + return + } + + return ( +
+ + +
+ +
+ +
+ {charts} +
+ +
+ + + {queueTitle} + {queueDescription} + + {queue} + +
+
+ ) +} diff --git a/apps/web/src/components/reui-kit/resource-page.tsx b/apps/web/src/components/reui-kit/resource-page.tsx new file mode 100644 index 0000000..fd619b7 --- /dev/null +++ b/apps/web/src/components/reui-kit/resource-page.tsx @@ -0,0 +1,370 @@ +import { useCallback, useMemo, useState, type ReactNode } from 'react' +import { + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, + type ColumnDef, + type PaginationState, + type RowSelectionState, + type SortingState, +} from '@tanstack/react-table' +import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react' + +import { CountedLineTabs } from '@/components/counted-line-tabs' +import { Badge } from '@/components/reui/badge' +import { DataGrid } 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 { DataGridTable } from '@/components/reui/data-grid/data-grid-table' +import { + Filters, + type Filter, + type FilterFieldConfig, +} from '@/components/reui/filters' +import { + Frame, + FrameDescription, + FrameFooter, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { Button } from '@evobgp/ui/components/button' +import { Separator } from '@evobgp/ui/components/separator' +import { Skeleton } from '@evobgp/ui/components/skeleton' +import { + Alert, + AlertDescription, + AlertTitle, +} from '@/components/reui/alert' +import { EmptyState } from '@/components/empty-state' +import { applyFiltersToData } from './filter-utils' + +export interface ResourcePageTab { + id: string + label: string + count?: number +} + +export interface ResourcePageProps { + title: string + description?: string + tabs?: ResourcePageTab[] + activeTab?: string + onTabChange?: (tabId: string) => void + tabFilter?: (item: T, tabId: string) => boolean + filterFields: FilterFieldConfig[] + filters: Filter[] + onFiltersChange: (filters: Filter[]) => void + onClearFilters?: () => void + getFilterFieldValue: (item: T, field: string) => unknown + columns: ColumnDef[] + data: T[] + getRowId: (row: T) => string + isLoading?: boolean + isError?: boolean + error?: Error | null + onRetry?: () => void + primaryAction?: ReactNode + emptyState?: { title: string; description?: string; action?: ReactNode } + pageSize?: number + enableRowSelection?: boolean + selectionToolbar?: (ctx: { + selectedIds: string[] + selectedCount: number + clearSelection: () => void + }) => ReactNode + toolbarExtra?: ReactNode + hideHeader?: boolean +} + +function ResourcePageSkeleton() { + return ( + + + + + + +
+ + {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ + ) +} + +export function ResourcePage({ + title, + description, + tabs, + activeTab: controlledTab, + onTabChange, + tabFilter, + filterFields, + filters, + onFiltersChange, + onClearFilters, + getFilterFieldValue, + columns, + data, + getRowId, + isLoading = false, + isError = false, + error = null, + onRetry, + primaryAction, + emptyState, + pageSize = 10, + enableRowSelection = false, + selectionToolbar, + toolbarExtra, + hideHeader = false, +}: ResourcePageProps) { + const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all') + const activeTab = controlledTab ?? internalTab + + const [sorting, setSorting] = useState([]) + const [rowSelection, setRowSelection] = useState({}) + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize, + }) + + const resetPagination = useCallback(() => { + setPagination((current) => + current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }, + ) + }, []) + + const filteredData = useMemo(() => { + let result = applyFiltersToData(data, filters, getFilterFieldValue) + if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') { + result = result.filter((item) => tabFilter(item, activeTab)) + } + return result + }, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab]) + + const tabCounts = useMemo(() => { + if (!tabs?.length || !tabFilter) return {} + const base = applyFiltersToData(data, filters, getFilterFieldValue) + const counts: Record = {} + for (const tab of tabs) { + counts[tab.id] = + tab.id === 'all' + ? base.length + : base.filter((item) => tabFilter(item, tab.id)).length + } + return counts + }, [tabs, tabFilter, data, filters, getFilterFieldValue]) + + const selectedIds = useMemo( + () => Object.keys(rowSelection).filter((id) => rowSelection[id]), + [rowSelection], + ) + + const selectedCount = selectedIds.length + + const clearSelection = useCallback(() => { + setRowSelection({}) + }, []) + + const table = useReactTable({ + data: filteredData, + columns, + getRowId, + state: { sorting, rowSelection, pagination }, + enableRowSelection, + onSortingChange: setSorting, + onRowSelectionChange: setRowSelection, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }) + + const handleTabChange = useCallback( + (value: string) => { + if (onTabChange) onTabChange(value) + else setInternalTab(value) + resetPagination() + }, + [onTabChange, resetPagination], + ) + + const handleFiltersChange = useCallback( + (next: Filter[]) => { + onFiltersChange(next) + resetPagination() + }, + [onFiltersChange, resetPagination], + ) + + const handleClear = useCallback(() => { + onClearFilters?.() + resetPagination() + }, [onClearFilters, resetPagination]) + + const countedTabs = useMemo( + () => + (tabs ?? []).map((tab) => ({ + id: tab.id, + label: tab.label, + count: tabCounts[tab.id] ?? tab.count ?? 0, + })), + [tabs, tabCounts], + ) + + if (isLoading) { + return + } + + if (isError) { + return ( + + + Ошибка загрузки + + {error?.message ?? 'Не удалось загрузить данные'} + {onRetry ? ( + + ) : null} + + + ) + } + + if (data.length === 0 && emptyState) { + return ( + + ) + } + + const emptyMessage = 'Нет записей по выбранным фильтрам.' + + return ( +
+ {selectionToolbar && selectedCount > 0 + ? selectionToolbar({ + selectedIds, + selectedCount, + clearSelection, + }) + : null} + + + {!hideHeader ? ( + +
+ {title} + {description ? ( + + {description} + + ) : null} +
+ {primaryAction ? ( +
+ {primaryAction} +
+ ) : null} +
+ ) : null} + + + {countedTabs.length > 0 ? ( + <> +
+ +
+ + + ) : null} + +
+ +
+ + + + + + + + + + + + +
+ +
+
+ ) +} diff --git a/apps/web/src/components/reui-kit/settings-shell.tsx b/apps/web/src/components/reui-kit/settings-shell.tsx new file mode 100644 index 0000000..9b02e08 --- /dev/null +++ b/apps/web/src/components/reui-kit/settings-shell.tsx @@ -0,0 +1,103 @@ +import type { ReactNode } from 'react' +import { Link, Outlet, useRouterState } from '@tanstack/react-router' +import { ServerCogIcon, SettingsIcon } from 'lucide-react' + +import { useIsMobile } from '@evobgp/ui/hooks/use-mobile' +import { cn } from '@evobgp/ui/lib/utils' +import { PageShell } from '@/components/page-shell' +import { PageHeader } from '@/components/page-header' +import { Separator } from '@evobgp/ui/components/separator' + +export interface SettingsTabConfig { + id: string + to: string + label: string + icon?: ReactNode +} + +/** Default nav for EvoBGP settings routes (`/settings`, `/tenant-settings`). */ +const DEFAULT_TABS: SettingsTabConfig[] = [ + { + id: 'ui', + to: '/settings', + label: 'Настройки UI', + icon: