Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26a96bc824 | ||
|
|
4fc5c96e63 | ||
|
|
3859983ae5 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"pid": 43636,
|
"pid": 51184,
|
||||||
"version": "0.9.9",
|
"version": "0.9.9",
|
||||||
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
||||||
"startedAt": 1783570299043
|
"startedAt": 1784215907952
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,12 @@ alwaysApply: false
|
|||||||
|------|------|------------|
|
|------|------|------------|
|
||||||
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
|
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
|
||||||
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
|
| 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) |
|
| Роуты | `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 <component>`.
|
Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`.
|
||||||
|
|
||||||
## Правила
|
## Правила
|
||||||
@@ -45,8 +48,8 @@ alwaysApply: false
|
|||||||
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
|
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
|
||||||
*Проверка:* https://ui.shadcn.com/docs/components/form
|
*Проверка:* https://ui.shadcn.com/docs/components/form
|
||||||
|
|
||||||
**WEB-06** | MUST | Сложные data-списки — ReUI `DataGridCard` (ReUI data-grid, не shadcn Data Table). Простые списки — shadcn `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/data-grid` или `@evobgp/ui/components/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`.
|
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
|
||||||
*Проверка:* https://ui.shadcn.com/docs/components/sonner
|
*Проверка:* https://ui.shadcn.com/docs/components/sonner
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Thumbs.db
|
|||||||
|
|
||||||
# Env / secrets (local only)
|
# Env / secrets (local only)
|
||||||
.env
|
.env
|
||||||
|
.env.local
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.*.example
|
!.env.*.example
|
||||||
|
|||||||
@@ -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/` (архив, только референс при миграции).
|
Стек: 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 стека.
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
Card,
|
Frame,
|
||||||
CardContent,
|
FrameDescription,
|
||||||
CardDescription,
|
FrameFooter,
|
||||||
CardFooter,
|
FrameHeader,
|
||||||
CardHeader,
|
FramePanel,
|
||||||
CardTitle,
|
FrameTitle,
|
||||||
} from "@evobgp/ui/components/card"
|
} from '@/components/reui/frame'
|
||||||
|
|
||||||
interface SettingsCardProps {
|
interface SettingsCardProps {
|
||||||
title: string
|
title: string
|
||||||
@@ -22,6 +20,7 @@ interface SettingsCardProps {
|
|||||||
footerClassName?: string
|
footerClassName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Settings section — Frame surface. Preview: https://reui.io/preview/base/settings-16 */
|
||||||
export function SettingsCard({
|
export function SettingsCard({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -32,28 +31,23 @@ export function SettingsCard({
|
|||||||
footerClassName,
|
footerClassName,
|
||||||
}: SettingsCardProps) {
|
}: SettingsCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card className={cn("gap-0 p-0", className)}>
|
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
|
||||||
{/* Header */}
|
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||||
<CardHeader className="gap-0 border-b px-5 py-3">
|
<FrameHeader className="gap-0 border-b px-5 py-3">
|
||||||
<CardTitle>{title}</CardTitle>
|
<FrameTitle>{title}</FrameTitle>
|
||||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||||
</CardHeader>
|
</FrameHeader>
|
||||||
|
|
||||||
{/* Content */}
|
<div className={cn('min-w-0', contentClassName)}>{children}</div>
|
||||||
<CardContent className={cn("p-0", contentClassName)}>
|
|
||||||
{children}
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
{footer ? (
|
{footer ? (
|
||||||
<CardFooter
|
<FrameFooter
|
||||||
className={cn(
|
className={cn('justify-end gap-2 border-t px-5 py-3', footerClassName)}
|
||||||
"justify-end gap-2 border-t px-5 py-3",
|
>
|
||||||
footerClassName
|
{footer}
|
||||||
)}
|
</FrameFooter>
|
||||||
>
|
) : null}
|
||||||
{footer}
|
</FramePanel>
|
||||||
</CardFooter>
|
</Frame>
|
||||||
) : null}
|
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 value={value} onValueChange={onValueChange} className={className}>
|
||||||
|
<TabsList variant="line" className={cn('gap-5', listClassName)}>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={tab.id}
|
||||||
|
value={tab.id}
|
||||||
|
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
|
||||||
|
>
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
{tab.count !== undefined ? (
|
||||||
|
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
|
||||||
|
{tab.count}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
{children}
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -122,6 +122,7 @@ function buildKpis({
|
|||||||
iconClassName: 'text-destructive',
|
iconClassName: 'text-destructive',
|
||||||
value: loading ? '—' : String(riskCount),
|
value: loading ? '—' : String(riskCount),
|
||||||
label: 'Риски',
|
label: 'Риски',
|
||||||
|
variant: riskCount > 0 ? 'destructive' : 'default',
|
||||||
footer: (
|
footer: (
|
||||||
<Badge variant={riskCount > 0 ? 'destructive-light' : 'success-light'} size="sm">
|
<Badge variant={riskCount > 0 ? 'destructive-light' : 'success-light'} size="sm">
|
||||||
{loading
|
{loading
|
||||||
|
|||||||
@@ -4,10 +4,15 @@ import type { Table } from '@tanstack/react-table'
|
|||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
import { DataGridToolbar } from '@/components/data-grid-toolbar'
|
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 { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||||
import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid'
|
import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid'
|
||||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||||
|
import { FrameFooter } from '@/components/reui/frame'
|
||||||
import {
|
import {
|
||||||
DATA_GRID_MESSAGES_RU,
|
DATA_GRID_MESSAGES_RU,
|
||||||
DATA_GRID_PAGINATION_RU,
|
DATA_GRID_PAGINATION_RU,
|
||||||
@@ -52,9 +57,9 @@ export function DataGridShell<TData extends object>({
|
|||||||
<DataGridTable />
|
<DataGridTable />
|
||||||
</DataGridContainer>
|
</DataGridContainer>
|
||||||
{showPagination ? (
|
{showPagination ? (
|
||||||
<div className={cn(panelCardFooterClassName, panelCardInsetClassName)}>
|
<FrameFooter className={cn(panelCardFooterClassName)}>
|
||||||
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
|
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
|
||||||
</div>
|
</FrameFooter>
|
||||||
) : null}
|
) : null}
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ interface DataGridToolbarProps {
|
|||||||
searchValue: string
|
searchValue: string
|
||||||
onSearchChange: (value: string) => void
|
onSearchChange: (value: string) => void
|
||||||
searchPlaceholder?: string
|
searchPlaceholder?: string
|
||||||
|
/** ReUI Filters trigger or custom filter controls. Preview: https://reui.io/preview/base/data-grid-filtering-2 */
|
||||||
filters?: ReactNode
|
filters?: ReactNode
|
||||||
actions?: ReactNode
|
actions?: ReactNode
|
||||||
className?: string
|
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({
|
export function DataGridToolbar({
|
||||||
searchValue,
|
searchValue,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
@@ -58,19 +59,19 @@ export function DataGridToolbar({
|
|||||||
filters
|
filters
|
||||||
) : (
|
) : (
|
||||||
<InputGroupButton
|
<InputGroupButton
|
||||||
|
aria-label="Фильтры"
|
||||||
|
size="icon-xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="xs"
|
disabled
|
||||||
className="pointer-events-none gap-1.5 opacity-0"
|
title="Передайте filters={<Filters … />} из @/components/reui/filters"
|
||||||
aria-hidden="true"
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
>
|
||||||
<ListFilterIcon className="size-3.5" />
|
<ListFilterIcon aria-hidden="true" />
|
||||||
</InputGroupButton>
|
</InputGroupButton>
|
||||||
)}
|
)}
|
||||||
</InputGroupAddon>
|
</InputGroupAddon>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
</Field>
|
</Field>
|
||||||
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
|
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,126 +1,14 @@
|
|||||||
import type { KeyboardEvent, ReactNode } from 'react'
|
/**
|
||||||
|
* Re-export from reui-kit — keep `@/components/kpi-stat-grid` imports stable.
|
||||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
* @see https://reui.io/preview/base/stats-12
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
*/
|
||||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
export {
|
||||||
|
KpiStatGrid,
|
||||||
export type KpiStatItem = {
|
KpiStatCard,
|
||||||
id?: string
|
KpiStatCardTile,
|
||||||
icon?: ReactNode
|
kpiStatItemKey,
|
||||||
iconClassName?: string
|
type KpiStatItem,
|
||||||
value: ReactNode
|
type KpiStatCardData,
|
||||||
label: ReactNode
|
type KpiStatVariant,
|
||||||
footer?: ReactNode
|
type OpsKpiCard,
|
||||||
active?: boolean
|
} from '@/components/reui-kit/kpi-stat-grid'
|
||||||
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<HTMLDivElement>) {
|
|
||||||
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 = (
|
|
||||||
<FramePanel
|
|
||||||
className={cn(
|
|
||||||
'flex h-full flex-col items-start gap-6',
|
|
||||||
clickable && 'cursor-pointer transition-colors hover:bg-muted/30',
|
|
||||||
item.active && 'ring-1 ring-primary/30',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
onClick={item.onClick}
|
|
||||||
role={clickable ? 'button' : undefined}
|
|
||||||
tabIndex={clickable ? 0 : undefined}
|
|
||||||
onKeyDown={clickable && item.onClick ? (e) => handleCardKeyDown(item.onClick!, e) : undefined}
|
|
||||||
>
|
|
||||||
{item.icon ? (
|
|
||||||
<Item
|
|
||||||
className={cn(
|
|
||||||
'border-background bg-muted flex size-10.5 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
|
||||||
item.iconClassName ?? DEFAULT_ICON_CLASS,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ItemMedia variant="icon" className="size-auto">
|
|
||||||
{item.icon}
|
|
||||||
</ItemMedia>
|
|
||||||
</Item>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="text-foreground text-2xl leading-none font-bold tabular-nums">{item.value}</div>
|
|
||||||
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{item.footer ? <div className="mt-auto w-full">{item.footer}</div> : null}
|
|
||||||
</FramePanel>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (embedded) {
|
|
||||||
return (
|
|
||||||
<Frame className="h-full ring-1 ring-foreground/10">
|
|
||||||
{panel}
|
|
||||||
</Frame>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Frame className="h-full">{panel}</Frame>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KpiStatGrid({
|
|
||||||
items,
|
|
||||||
className,
|
|
||||||
embedded = false,
|
|
||||||
'aria-label': ariaLabel,
|
|
||||||
}: {
|
|
||||||
items: KpiStatItem[]
|
|
||||||
className?: string
|
|
||||||
embedded?: boolean
|
|
||||||
'aria-label'?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
|
|
||||||
<div className={cn('grid gap-5', kpiStatGridClassName(items.length))}>
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<KpiStatCard
|
|
||||||
key={item.id ?? (typeof item.label === 'string' ? item.label : `kpi-${index}`)}
|
|
||||||
item={item}
|
|
||||||
embedded={embedded}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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}`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Frame,
|
||||||
CardAction,
|
FrameDescription,
|
||||||
CardContent,
|
FrameFooter,
|
||||||
CardDescription,
|
FrameHeader,
|
||||||
CardFooter,
|
FramePanel,
|
||||||
CardHeader,
|
FrameTitle,
|
||||||
CardTitle,
|
} from '@/components/reui/frame'
|
||||||
} from '@evobgp/ui/components/card'
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
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 panelCardHeaderClassName = 'border-b'
|
||||||
export const panelCardContentFlushClassName = 'px-0'
|
/** Flush body inside FramePanel (toolbar / data-grid). */
|
||||||
/** Horizontal inset for toolbar / pagination rows (matches edgeCell px-5). */
|
export const panelCardContentFlushClassName = 'p-0'
|
||||||
|
/** Horizontal inset for toolbar / pagination rows. */
|
||||||
export const panelCardInsetClassName = 'px-5 py-3'
|
export const panelCardInsetClassName = 'px-5 py-3'
|
||||||
export const panelCardFooterClassName = 'border-t bg-transparent'
|
export const panelCardFooterClassName = 'border-t bg-transparent'
|
||||||
|
|
||||||
@@ -45,22 +48,45 @@ export function PanelCard({
|
|||||||
size = 'default',
|
size = 'default',
|
||||||
}: PanelCardProps) {
|
}: PanelCardProps) {
|
||||||
const hasHeader = Boolean(title || description || actions)
|
const hasHeader = Boolean(title || description || actions)
|
||||||
|
const spacing = size === 'sm' ? 'sm' : 'default'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card size={size} className={cn(panelCardClassName, className)}>
|
<Frame
|
||||||
{hasHeader ? (
|
dense
|
||||||
<CardHeader className={cn(panelCardHeaderClassName, headerClassName)}>
|
spacing={spacing}
|
||||||
{title ? <CardTitle>{title}</CardTitle> : null}
|
className={cn(panelCardClassName, className)}
|
||||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
>
|
||||||
{actions ? <CardAction>{actions}</CardAction> : null}
|
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||||
</CardHeader>
|
{hasHeader ? (
|
||||||
) : null}
|
<FrameHeader
|
||||||
{children != null && children !== false ? (
|
className={cn(
|
||||||
<CardContent className={contentClassName}>{children}</CardContent>
|
panelCardHeaderClassName,
|
||||||
) : null}
|
'flex-row items-start justify-between gap-3',
|
||||||
{footer ? (
|
headerClassName,
|
||||||
<CardFooter className={cn(panelCardFooterClassName, footerClassName)}>{footer}</CardFooter>
|
)}
|
||||||
) : null}
|
>
|
||||||
</Card>
|
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
||||||
|
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||||
|
{description ? (
|
||||||
|
<FrameDescription>{description}</FrameDescription>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{actions ? (
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</FrameHeader>
|
||||||
|
) : null}
|
||||||
|
{children != null && children !== false ? (
|
||||||
|
<div className={cn('min-w-0 flex-1', contentClassName)}>{children}</div>
|
||||||
|
) : null}
|
||||||
|
{footer ? (
|
||||||
|
<FrameFooter className={cn(panelCardFooterClassName, footerClassName)}>
|
||||||
|
{footer}
|
||||||
|
</FrameFooter>
|
||||||
|
) : null}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetailPanelHeaderProps {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
actions?: ReactNode
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailPanelHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
}: DetailPanelHeaderProps) {
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className="w-full">
|
||||||
|
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
||||||
|
<FrameTitle>{title}</FrameTitle>
|
||||||
|
{description ? (
|
||||||
|
<FrameDescription>{description}</FrameDescription>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{actions ? (
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</FrameHeader>
|
||||||
|
{children ? <FramePanel className="flex flex-col gap-4">{children}</FramePanel> : null}
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailPanelMetrics({ cards }: { cards: DetailMetricCard[] }) {
|
||||||
|
return (
|
||||||
|
<div className="@container w-full">
|
||||||
|
<div className="grid gap-4 @2xl:grid-cols-3">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<Frame key={card.id} spacing="sm">
|
||||||
|
<FrameHeader className="px-1! py-1!">
|
||||||
|
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
|
||||||
|
{card.icon}
|
||||||
|
<span className="text-foreground text-sm font-medium">
|
||||||
|
{card.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="flex flex-col gap-2">
|
||||||
|
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||||
|
{card.description}
|
||||||
|
</p>
|
||||||
|
{card.footer}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailPanelSection({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
{title ? (
|
||||||
|
<header className="px-1">
|
||||||
|
<h2 className="text-sm font-semibold">{title}</h2>
|
||||||
|
{description ? (
|
||||||
|
<p className="text-muted-foreground text-sm">{description}</p>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DetailPanel = Object.assign(DetailPanelRoot, {
|
||||||
|
Header: DetailPanelHeader,
|
||||||
|
Metrics: DetailPanelMetrics,
|
||||||
|
Section: DetailPanelSection,
|
||||||
|
})
|
||||||
@@ -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<T>(
|
||||||
|
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])
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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 KpiStatVariant,
|
||||||
|
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'
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import type { KeyboardEvent, ReactNode } from 'react'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||||
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||||
|
|
||||||
|
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KPI tile data — horizontal compact hybrid (icon left + label/Badge + value).
|
||||||
|
* @see https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
|
export type KpiStatItem = {
|
||||||
|
id?: string
|
||||||
|
label: ReactNode
|
||||||
|
value: ReactNode
|
||||||
|
hint?: ReactNode
|
||||||
|
to?: string
|
||||||
|
search?: Record<string, unknown>
|
||||||
|
onSelect?: () => void
|
||||||
|
onClick?: () => void
|
||||||
|
selected?: boolean
|
||||||
|
active?: boolean
|
||||||
|
icon?: ReactNode
|
||||||
|
iconClassName?: string
|
||||||
|
variant?: KpiStatVariant
|
||||||
|
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'
|
||||||
|
|
||||||
|
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
|
||||||
|
default: 'text-foreground',
|
||||||
|
warning: 'text-warning',
|
||||||
|
destructive: 'text-destructive',
|
||||||
|
}
|
||||||
|
|
||||||
|
function kpiCols(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<HTMLDivElement>) {
|
||||||
|
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 resolveFooter(item: KpiStatItem): ReactNode {
|
||||||
|
if (item.footer) return item.footer
|
||||||
|
if (typeof item.hint === 'string') {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
{item.hint}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (item.hint) return item.hint
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||||
|
const footer = resolveFooter(item)
|
||||||
|
const valueVariant = item.variant ?? 'default'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative z-10 flex h-full items-start gap-3">
|
||||||
|
{item.icon ? (
|
||||||
|
<Item
|
||||||
|
className={cn(
|
||||||
|
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||||
|
item.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ItemMedia variant="icon" className="size-auto">
|
||||||
|
{item.icon}
|
||||||
|
</ItemMedia>
|
||||||
|
</Item>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
|
||||||
|
{footer ? <div className="shrink-0">{footer}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'text-2xl leading-none font-bold tabular-nums',
|
||||||
|
VALUE_VARIANT_CLASS[valueVariant],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function panelClassName(item: KpiStatItem, className?: string) {
|
||||||
|
const onActivate = resolveActivate(item)
|
||||||
|
const clickable = Boolean(item.to || onActivate)
|
||||||
|
const selected = isSelected(item)
|
||||||
|
|
||||||
|
return cn(
|
||||||
|
'relative isolate flex h-full flex-col',
|
||||||
|
clickable &&
|
||||||
|
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
|
||||||
|
selected && 'ring-primary/30 bg-muted/30 ring-1',
|
||||||
|
className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single KPI tile — used for embedded / standalone contexts. */
|
||||||
|
export function KpiStatCardTile({
|
||||||
|
item,
|
||||||
|
embedded = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
item: KpiStatItem
|
||||||
|
embedded?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const onActivate = resolveActivate(item)
|
||||||
|
const panelClass = panelClassName(item, className)
|
||||||
|
|
||||||
|
let panel: ReactNode
|
||||||
|
|
||||||
|
if (item.to) {
|
||||||
|
panel = (
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</Link>
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
} else if (onActivate) {
|
||||||
|
panel = (
|
||||||
|
<FramePanel
|
||||||
|
className={panelClass}
|
||||||
|
onClick={onActivate}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||||
|
>
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
panel = (
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Frame className="h-full">{panel}</Frame>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Prefer KpiStatCardTile — kept for existing imports */
|
||||||
|
export function KpiStatCard({
|
||||||
|
item,
|
||||||
|
embedded = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
item: KpiStatItem
|
||||||
|
embedded?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return <KpiStatCardTile item={item} embedded={embedded} className={className} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiStatGridSkeleton({ count }: { count: number }) {
|
||||||
|
return (
|
||||||
|
<Frame className="@container w-full">
|
||||||
|
<div className={cn('grid gap-2', kpiCols(count))}>
|
||||||
|
{Array.from({ length: count }).map((_, index) => (
|
||||||
|
<FramePanel key={index} className="flex items-start gap-3">
|
||||||
|
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-4.5 w-14 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-7 w-16" />
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KpiStatGridProps {
|
||||||
|
/** EvoBGP primary API */
|
||||||
|
items?: KpiStatItem[]
|
||||||
|
/** CFDM-compatible API */
|
||||||
|
cards?: KpiStatCardData[]
|
||||||
|
isLoading?: boolean
|
||||||
|
emptyMessage?: ReactNode
|
||||||
|
emptyIcon?: ReactNode
|
||||||
|
className?: string
|
||||||
|
skeletonCount?: number
|
||||||
|
/** Wrap each tile in its own Frame (analytics panels). */
|
||||||
|
embedded?: boolean
|
||||||
|
'aria-label'?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
|
||||||
|
const onActivate = resolveActivate(item)
|
||||||
|
const panelClass = panelClassName(item)
|
||||||
|
|
||||||
|
if (item.to) {
|
||||||
|
return (
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</Link>
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onActivate) {
|
||||||
|
return (
|
||||||
|
<FramePanel
|
||||||
|
className={panelClass}
|
||||||
|
onClick={onActivate}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||||
|
>
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hybrid KPI — EvoBGP visual + horizontal compact layout (icon left).
|
||||||
|
* Preview: https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
|
export function KpiStatGrid({
|
||||||
|
items,
|
||||||
|
cards,
|
||||||
|
isLoading = false,
|
||||||
|
emptyMessage,
|
||||||
|
emptyIcon,
|
||||||
|
className,
|
||||||
|
skeletonCount = 4,
|
||||||
|
embedded = false,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
}: KpiStatGridProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <KpiStatGridSkeleton count={skeletonCount} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = items ?? cards ?? []
|
||||||
|
|
||||||
|
if (list.length === 0 && (emptyMessage || emptyIcon)) {
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className={cn('w-full', className)}>
|
||||||
|
<FramePanel className="flex items-center gap-3 p-4">
|
||||||
|
{emptyIcon}
|
||||||
|
{emptyMessage ? (
|
||||||
|
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
|
||||||
|
) : null}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return (
|
||||||
|
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
|
||||||
|
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||||
|
{list.map((item, index) => (
|
||||||
|
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
|
||||||
|
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||||
|
{list.map((item, index) => (
|
||||||
|
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className={rootClassName}>
|
||||||
|
<header className="px-1">
|
||||||
|
<Skeleton className="h-7 w-56" />
|
||||||
|
<Skeleton className="mt-2 h-4 w-80 max-w-full" />
|
||||||
|
</header>
|
||||||
|
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
|
||||||
|
<div className="grid gap-2 @3xl:grid-cols-2">
|
||||||
|
<Skeleton className="h-64 w-full rounded-xl" />
|
||||||
|
<Skeleton className="h-64 w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-48 w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpsDashboard({
|
||||||
|
title = 'Панель управления',
|
||||||
|
description = 'Обзор модулей, сети, задач и состояния плоскости управления',
|
||||||
|
kpiCards,
|
||||||
|
charts,
|
||||||
|
queue,
|
||||||
|
queueTitle = 'Требуют внимания',
|
||||||
|
queueDescription = 'Проблемы BGP, сбои задач и офлайн-спикеры',
|
||||||
|
isLoading = false,
|
||||||
|
}: OpsDashboardProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <OpsDashboardSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={rootClassName}>
|
||||||
|
<PageHeader title={title} description={description} />
|
||||||
|
|
||||||
|
<section aria-label="Ключевые метрики">
|
||||||
|
<KpiStatGrid cards={kpiCards} skeletonCount={4} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
aria-label="Аналитика"
|
||||||
|
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
||||||
|
>
|
||||||
|
{charts}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-label={queueTitle}>
|
||||||
|
<Frame dense spacing="sm" className="w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>{queueTitle}</FrameTitle>
|
||||||
|
<FrameDescription>{queueDescription}</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel>{queue}</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<T extends object> {
|
||||||
|
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<T, unknown>[]
|
||||||
|
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 (
|
||||||
|
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<Skeleton className="h-5 w-48" />
|
||||||
|
<Skeleton className="mt-1 h-4 w-72" />
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="p-0">
|
||||||
|
<div className="flex flex-col gap-3 p-4">
|
||||||
|
<Skeleton className="h-9 w-full max-w-md" />
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-10 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourcePage<T extends object>({
|
||||||
|
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<T>) {
|
||||||
|
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
|
||||||
|
const activeTab = controlledTab ?? internalTab
|
||||||
|
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([])
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
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<string, number> = {}
|
||||||
|
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 <ResourcePageSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<CircleAlertIcon />
|
||||||
|
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||||
|
<AlertDescription className="flex flex-col gap-2">
|
||||||
|
<span>{error?.message ?? 'Не удалось загрузить данные'}</span>
|
||||||
|
{onRetry ? (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
|
||||||
|
Повторить
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0 && emptyState) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title={emptyState.title}
|
||||||
|
description={emptyState.description}
|
||||||
|
action={emptyState.action}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyMessage = 'Нет записей по выбранным фильтрам.'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{selectionToolbar && selectedCount > 0
|
||||||
|
? selectionToolbar({
|
||||||
|
selectedIds,
|
||||||
|
selectedCount,
|
||||||
|
clearSelection,
|
||||||
|
})
|
||||||
|
: null}
|
||||||
|
<DataGrid
|
||||||
|
table={table}
|
||||||
|
recordCount={filteredData.length}
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
|
tableLayout={{ dense: true }}
|
||||||
|
>
|
||||||
|
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||||
|
{!hideHeader ? (
|
||||||
|
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-col gap-px">
|
||||||
|
<FrameTitle className="text-balance">{title}</FrameTitle>
|
||||||
|
{description ? (
|
||||||
|
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
|
||||||
|
<span>{description}</span>
|
||||||
|
<span
|
||||||
|
className="bg-input size-1 shrink-0 rounded-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{filteredData.length} записей
|
||||||
|
</span>
|
||||||
|
{selectedCount > 0 ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="bg-input size-1 shrink-0 rounded-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>{selectedCount} выбрано</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</FrameDescription>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{primaryAction ? (
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||||
|
{primaryAction}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</FrameHeader>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<FramePanel className="p-0 shadow-none!">
|
||||||
|
{countedTabs.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
|
||||||
|
<CountedLineTabs
|
||||||
|
tabs={countedTabs}
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={handleTabChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
||||||
|
<Filters
|
||||||
|
filters={filters}
|
||||||
|
fields={filterFields}
|
||||||
|
onChange={handleFiltersChange}
|
||||||
|
size="default"
|
||||||
|
trigger={
|
||||||
|
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||||
|
<FilterIcon className="size-4" aria-hidden="true" />
|
||||||
|
Фильтры
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
{toolbarExtra}
|
||||||
|
{selectedCount > 0 ? (
|
||||||
|
<Badge size="sm" variant="secondary">
|
||||||
|
{selectedCount} выбрано
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{onClearFilters ? (
|
||||||
|
<Button type="button" variant="outline" onClick={handleClear}>
|
||||||
|
<FilterXIcon className="size-4" aria-hidden="true" />
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<DataGridScrollArea>
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGridScrollArea>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<FrameFooter>
|
||||||
|
<DataGridPagination
|
||||||
|
sizes={[5, 10, 20, 50]}
|
||||||
|
rowsPerPageLabel="Строк на странице"
|
||||||
|
info="{from} - {to} of {count}"
|
||||||
|
previousPageLabel="Предыдущая"
|
||||||
|
nextPageLabel="Следующая"
|
||||||
|
/>
|
||||||
|
</FrameFooter>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
</DataGrid>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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: <SettingsIcon className="size-4" aria-hidden="true" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tenant',
|
||||||
|
to: '/tenant-settings',
|
||||||
|
label: 'Настройки BIRD',
|
||||||
|
icon: <ServerCogIcon className="size-4" aria-hidden="true" />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
interface SettingsShellProps {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
tabs?: SettingsTabConfig[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsShell({
|
||||||
|
title = 'Настройки',
|
||||||
|
description = 'Подключение UI и параметры плоскости управления',
|
||||||
|
tabs = DEFAULT_TABS,
|
||||||
|
}: SettingsShellProps) {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<PageHeader title={title} description={description} />
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex gap-5',
|
||||||
|
isMobile ? 'flex-col' : 'flex-row items-start',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tabs.length > 1 ? (
|
||||||
|
<nav
|
||||||
|
aria-label="Разделы настроек"
|
||||||
|
className={cn(
|
||||||
|
'flex gap-1',
|
||||||
|
isMobile
|
||||||
|
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
|
||||||
|
: 'w-44 shrink-0 flex-col',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const isActive = pathname.startsWith(tab.to)
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tab.id}
|
||||||
|
to={tab.to}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||||
|
isMobile && 'shrink-0',
|
||||||
|
!isMobile && 'w-full',
|
||||||
|
isActive
|
||||||
|
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
|
||||||
|
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
{tab.label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -32,15 +32,17 @@ function hintToFooter(hint: ReactNode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toKpiStatItem(item: SectionCardItem, index: number): KpiStatItem {
|
function toKpiStatItem(item: SectionCardItem, index: number): KpiStatItem {
|
||||||
|
const variant = item.variant ?? 'default'
|
||||||
const footer =
|
const footer =
|
||||||
item.badge ?? (item.hint ? hintToFooter(item.hint) : undefined)
|
item.badge ?? (item.hint ? hintToFooter(item.hint) : undefined)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: typeof item.label === 'string' ? item.label : `section-${index}`,
|
id: typeof item.label === 'string' ? item.label : `section-${index}`,
|
||||||
icon: item.icon,
|
icon: item.icon,
|
||||||
iconClassName: VARIANT_ICON_CLASS[item.variant ?? 'default'],
|
iconClassName: VARIANT_ICON_CLASS[variant],
|
||||||
value: item.value,
|
value: item.value,
|
||||||
label: item.label,
|
label: item.label,
|
||||||
|
variant,
|
||||||
footer,
|
footer,
|
||||||
active: item.active,
|
active: item.active,
|
||||||
onClick: item.onClick,
|
onClick: item.onClick,
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* EvoBGP UI surface contract (ReUI Pro).
|
* EvoBGP UI surface contract (ReUI Pro).
|
||||||
*
|
*
|
||||||
* KPI tiles: **stats-12** pattern via `KpiStatGrid` / `SectionCards` (Frame + icon tile + badge).
|
* Ops / list / dashboard / detail / settings: **Frame** only.
|
||||||
* Panels and data grids: shadcn Card via `PanelCard` / `DataGridCard`.
|
* KPI tiles: **stats-12** via `KpiStatGrid` / `reui-kit/KpiStatGrid`.
|
||||||
|
* Lists: Frame + DataGrid (`ResourcePage` / `DataGridSection`), not Card shell.
|
||||||
*
|
*
|
||||||
* @see https://reui.io/blocks — stats-12, dashboard-1
|
* @see https://reui.io/preview/base/stats-12
|
||||||
* @see `.agents/skills/shadcn-react/SKILL.md`
|
* @see https://reui.io/preview/base/dashboard-1
|
||||||
|
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||||
|
* @see docs/ui-design-contract.md
|
||||||
*/
|
*/
|
||||||
export const UI_SURFACE = 'card' as const
|
export const UI_SURFACE = 'frame' as const
|
||||||
|
|
||||||
/** Grid for ReUI stats-12 KPI rows (3–6 tiles). */
|
/** Grid for ReUI stats-12 KPI rows (3–6 tiles). */
|
||||||
export const kpiStatGridClassName = '@container w-full'
|
export const kpiStatGridClassName = '@container w-full'
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,62 @@
|
|||||||
|
# UI Design Contract (ops apps)
|
||||||
|
|
||||||
|
Единый контракт для vps-tracker, CFDM и EvoBGP. Surface: **ReUI Frame**. Kit API: `apps/web/src/components/reui-kit/`.
|
||||||
|
|
||||||
|
Карта: [llms.txt](https://reui.io/llms.txt) · [Styling](https://reui.io/docs/styling) · [License](https://reui.io/docs/license-setup) · [Blocks](https://reui.io/blocks)
|
||||||
|
|
||||||
|
## Surface
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// apps/web/src/lib/ui-surface.ts
|
||||||
|
export const UI_SURFACE = 'frame' as const
|
||||||
|
```
|
||||||
|
|
||||||
|
Ops / list / dashboard / detail / settings — только **Frame**, не shadcn Card как shell. Не смешивать Card и Frame на одном ops-экране.
|
||||||
|
|
||||||
|
## Canonical PRO references
|
||||||
|
|
||||||
|
| Зона | Block | Preview |
|
||||||
|
|------|-------|---------|
|
||||||
|
| Shell | `app-shell-12` (+ cmdk/monitor где нужно) | https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/app-shell-7 |
|
||||||
|
| KPI | horizontal compact hybrid (icon left + label/Badge + value ± variant; EvoBGP visual) | https://reui.io/preview/base/stats-12 |
|
||||||
|
| Dashboard | `dashboard-1` | https://reui.io/preview/base/dashboard-1 |
|
||||||
|
| Lists | `data-grid-filtering-2` | https://reui.io/preview/base/data-grid-filtering-2 |
|
||||||
|
| Settings | `settings-16` + SettingRow (`settings-7`) | https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-7 |
|
||||||
|
| Auth | `auth-13` | https://reui.io/preview/base/auth-13 |
|
||||||
|
| Empty | `empty-state-12` | https://reui.io/preview/base/empty-state-12 |
|
||||||
|
| Forms | `form-7` → Sheet/Drawer | https://reui.io/preview/base/form-7 |
|
||||||
|
|
||||||
|
## Kit API (`reui-kit/`)
|
||||||
|
|
||||||
|
| Component | Role |
|
||||||
|
|-----------|------|
|
||||||
|
| `ResourcePage` | Frame + line tabs + Filters + DataGrid |
|
||||||
|
| `KpiStatGrid` | horizontal compact hybrid KPI tiles (`variant`, Badge) |
|
||||||
|
| `OpsDashboard` | KPI + charts + attention queue |
|
||||||
|
| `SettingsShell` | settings nav + Outlet |
|
||||||
|
| `DetailPanel` | detail Frame sections |
|
||||||
|
| `filter-utils` | apply/clear ReUI Filters |
|
||||||
|
|
||||||
|
## MCP workflow
|
||||||
|
|
||||||
|
1. MCP `user-reui` — `search` / `get_block` / `get_component` with `surface: "frame"`
|
||||||
|
2. Cite `previewUrl` + `docsUrl`
|
||||||
|
3. CLI from `apps/web`: `pnpm dlx shadcn@latest add @reui/...`
|
||||||
|
4. Adapt into kit — do not hand-roll KPI/grid/settings rows
|
||||||
|
5. `validate_usage` / `get_audit_checklist`
|
||||||
|
|
||||||
|
Primitives: MCP `plugin-shadcn-shadcn` + `@evobgp/ui`.
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
- Main: `gap-4 md:gap-6`, `px-4 md:px-6`
|
||||||
|
- No `space-y-*` / `space-x-*` — use `flex` + `gap-*`
|
||||||
|
- Max 1 primary CTA per screen
|
||||||
|
- Semantic tokens only (`variant="success"|"info"|"warning"`) — no raw `bg-emerald-*`
|
||||||
|
|
||||||
|
## Forbidden
|
||||||
|
|
||||||
|
- Card as ops list/dashboard shell
|
||||||
|
- Hand-rolled data tables when ReUI DataGrid exists
|
||||||
|
- Hand-rolled KPI grids when `KpiStatGrid` exists
|
||||||
|
- Mixing Card and Frame surfaces on one ops screen
|
||||||
@@ -14,6 +14,7 @@ Thumbs.db
|
|||||||
|
|
||||||
# Env
|
# Env
|
||||||
.env
|
.env
|
||||||
|
.env.local
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.test
|
!.env.test
|
||||||
|
|||||||
Reference in New Issue
Block a user