From 3859983ae537975d3e208524022ba008d63a5dc6 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 17 Jul 2026 15:30:41 +0700 Subject: [PATCH] refactor: update UI components to utilize Frame for improved layout and consistency Refactored multiple components, including PanelCard, SettingsCard, and DataGridShell, to replace Card with Frame for better organization and presentation. Enhanced the DataGridToolbar to support optional ReUI filters and improved the overall structure of the KPI stat grid. Updated .gitignore to include .env.local for local environment configurations, ensuring better management of environment variables. --- .codegraph/daemon.pid | 4 +- .cursor/rules/web-shadcn.mdc | 9 +- .gitignore | 1 + AGENTS.md | 4 +- .../settings-7/components/settings-card.tsx | 60 ++- apps/web/src/components/counted-line-tabs.tsx | 50 +++ apps/web/src/components/data-grid-shell.tsx | 11 +- apps/web/src/components/data-grid-toolbar.tsx | 15 +- apps/web/src/components/kpi-stat-grid.tsx | 139 +------ apps/web/src/components/panel-card.tsx | 80 ++-- .../src/components/reui-kit/detail-panel.tsx | 116 ++++++ .../src/components/reui-kit/filter-utils.ts | 91 +++++ apps/web/src/components/reui-kit/index.ts | 24 ++ .../src/components/reui-kit/kpi-stat-grid.tsx | 261 ++++++++++++ .../src/components/reui-kit/ops-dashboard.tsx | 86 ++++ .../src/components/reui-kit/resource-page.tsx | 370 ++++++++++++++++++ .../components/reui-kit/settings-shell.tsx | 103 +++++ apps/web/src/lib/ui-surface.ts | 13 +- apps/web/tsconfig.tsbuildinfo | 2 +- docs/ui-design-contract.md | 62 +++ web-legacy-svelte/.gitignore | 1 + 21 files changed, 1294 insertions(+), 208 deletions(-) create mode 100644 apps/web/src/components/counted-line-tabs.tsx create mode 100644 apps/web/src/components/reui-kit/detail-panel.tsx create mode 100644 apps/web/src/components/reui-kit/filter-utils.ts create mode 100644 apps/web/src/components/reui-kit/index.ts create mode 100644 apps/web/src/components/reui-kit/kpi-stat-grid.tsx create mode 100644 apps/web/src/components/reui-kit/ops-dashboard.tsx create mode 100644 apps/web/src/components/reui-kit/resource-page.tsx create mode 100644 apps/web/src/components/reui-kit/settings-shell.tsx create mode 100644 docs/ui-design-contract.md 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: