feat(web): обновить компоненты и добавить новые элементы интерфейса
Docker / build (push) Failing after 22s

- Добавлены новые правила для использования ReUI и обновлены существующие правила.
- Внедрены новые компоненты, такие как Frame, Alert, IconStack и DetailPanel, для улучшения интерфейса.
- Обновлены существующие компоненты, включая DataGridCard и EmptyState, с использованием новых элементов.
- Добавлены новые утилиты для фильтрации данных и управления состоянием.
- Обновлен .gitignore для исключения файлов окружения.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-17 15:30:16 +07:00
co-authored by Cursor
parent a11fbc6efd
commit 35e5d8430d
26 changed files with 2136 additions and 498 deletions
+37 -79
View File
@@ -6,110 +6,68 @@ alwaysApply: false
# Frontend UI Patterns # Frontend UI Patterns
См. также: [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`reui-mcp.mdc`](reui-mcp.mdc), [Components](https://ui.shadcn.com/docs/components), [ReUI llms.txt](https://reui.io/llms.txt). См. также: [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`reui-mcp.mdc`](reui-mcp.mdc), [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md), [ReUI llms.txt](https://reui.io/llms.txt).
## Surface
**`UI_SURFACE = 'frame'`** (`lib/ui-surface.ts`). Ops/list/dashboard/detail/settings — ReUI Frame, не Card shell.
## Docs workflow (обязательно) ## Docs workflow (обязательно)
0. Codegraph `codegraph_explore` — найти существующие shared/domain-компоненты 0. Codegraph / поиск `reui-kit/*`
1. [`reui-mcp.mdc`](reui-mcp.mdc) — если нужен Data Grid, Filters, Autocomplete и др. 1. MCP **`user-reui`** — search → `get_block` / `compose_page` (`surface: "frame"`) — cite `previewUrl` + `docsUrl`
2. MCP `plugin-shadcn-shadcn` — search → examples → add command (`@shadcn` / `@reui`) 2. Primitives: MCP `plugin-shadcn-shadcn` + CLI docs
3. CLI: `@shadcn/*` → `pnpm dlx shadcn@latest docs <component>`; `@reui/*` → ReUI docs + llms.txt 3. CLI: `cd apps/web && pnpm dlx shadcn@latest add @reui/...`
4. Код по examples + docs API (только после совпадения MCP ↔ docs) 4. Adapt в kit — не hand-roll
5. Context7 — **только** TanStack Router/Query, Recharts (не shadcn/ReUI primitives) 5. Context7 — только TanStack / Recharts
6. MCP `get_audit_checklist` — перед merge 6. `validate_usage` / `get_audit_checklist`
7. Codegraph `codegraph_status` — Pending sync пустой
## Иерархия компонентов ## Иерархия компонентов
``` ```
@cfdm/ui/components/* ← shadcn CLI (packages/ui) @cfdm/ui/components/* ← shadcn CLI (packages/ui)
@/components/reui/* ← ReUI CLI @reui/* (apps/web) @/components/reui/* ← ReUI CLI @reui/* (apps/web)
apps/web/src/components/ ← shared + domain + layout apps/web/src/components/
page-shell.tsx ← обёртка страницы reui-kit/ ← ResourcePage, KpiStatGrid, OpsDashboard, SettingsShell
page-shell.tsx
page-header.tsx page-header.tsx
empty-state.tsx empty-state.tsx ← Empty + IconStack (empty-state-12)
query-state.tsx query-state.tsx
confirm-dialog.tsx form-sheet.tsx
data-grid-card.tsx layout/
section-cards.tsx domain-*
status-badge.tsx
form-sheet.tsx ← Sheet + RHF FormProvider
form-field.tsx ← Field + Controller + aria-invalid
loading-button.tsx ← Button + Spinner + label swap
section-cards-skeleton.tsx
table-skeleton.tsx
layout/ ← app-shell, site-header
domain-* ← бизнес-компоненты
``` ```
## Матрица стандартизации ## Матрица стандартизации
| Элемент | Shared | Primitive | | Элемент | Shared | Primitive / Preview |
|---------|--------|-----------| |---------|--------|---------------------|
| Page wrapper | `PageShell` | — | | Page wrapper | `PageShell` | — |
| Page title | `PageHeader` | — | | Page title | `PageHeader` | — |
| Stat metrics | `SectionCards` | `Card` | | KPI | `reui-kit/KpiStatGrid` | [stats-12](https://reui.io/preview/base/stats-12) |
| Data list | `DataGridCard` | `@reui/data-grid`, `Table` | | Data list | `reui-kit/ResourcePage` | [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) |
| Filters toolbar | `VpsFiltersToolbar` | `@reui/filters` | | Dashboard | `reui-kit/OpsDashboard` | [dashboard-1](https://reui.io/preview/base/dashboard-1) |
| Autocomplete | `AutoCompleteInput` | `@reui/autocomplete` | | Settings | `reui-kit/SettingsShell` | [settings-16](https://reui.io/preview/base/settings-16) |
| Number input | — | `@reui/number-field` | | Filters | ResourcePage / domain toolbars | `@reui/filters` |
| Date range | `lib/date-selector-i18n.ts` | `@reui/date-selector` | | Empty | `EmptyState` | [empty-state-12](https://reui.io/preview/base/empty-state-12) |
| Empty | `EmptyState` | `Empty` | | Loading / Error | `QueryState` | Skeleton, ReUI Alert |
| Loading / Error | `QueryState` | `Skeleton`, `Alert` | | Status | `StatusBadge` | `@reui/badge` |
| Status | `StatusBadge` | `Badge`, `@reui/badge` | | Create/Edit | `FormSheet` | Sheet, Field |
| Create/Edit | `FormSheet` + `*-edit-sheet.tsx` | `Sheet`, `Field` | | Delete confirm | `ConfirmDialog` | AlertDialog |
| Form field | `FormField` | `Field`, `Input`, `Select` | | Nav | `AppShell` | [app-shell-12](https://reui.io/preview/base/app-shell-12) |
| Submit button | `LoadingButton` | `Button`, `Spinner` |
| Table wrapper | `DataGridCard` | `Table`, `Card` |
| Delete confirm | `ConfirmDialog` | `AlertDialog` |
| List row | — | `Item variant="outline" size="sm"` |
| Nav | `AppSidebar` | `Sidebar` |
| Breadcrumbs | `SiteHeader` | `Breadcrumb` |
| Dates | `lib/format.ts` | — |
## Overlay selection **Устарело (не использовать на новых экранах):** `DataGridCard`, `SectionCards` (Card KPI).
| Сценарий | Компонент |
|----------|-----------|
| Create/edit форма | `Sheet` |
| Destructive confirm | `AlertDialog` via `ConfirmDialog` |
| Modal preview | `Dialog` |
## Block registry
| Зона | Block |
|------|-------|
| Shell | sidebar-07 |
| Dashboard | dashboard-01 |
| Login | login-03 |
## Spacing ## Spacing
``` ```
PageShell: gap-4 md:gap-6, px-4 lg:px-6 py-4 md:py-6 PageShell / main: gap-4 md:gap-6, px-4 md:px-6
Card grid: gap-4
FieldGroup: gap-4 FieldGroup: gap-4
Item list: gap-2
Toolbar: gap-2 Toolbar: gap-2
``` ```
**Запрещено:** `space-y-*`, raw colors (`bg-emerald-*`), custom empty divs, page-level Spinner. **Запрещено:** `space-y-*`, raw colors, Card как ops-shell, page-level Spinner.
## UX/UI 2026 (состояния данных) ## Button hierarchy
Каждый блок: **default, hover, focus, disabled, empty, loading, error**. Max 1 primary (`default`) на экран; остальные `outline` / `ghost`.
- **Loading** — `Skeleton` с размерами финального контента (`QueryState skeleton={…}`), не Spinner на странице
- **Empty** — `EmptyState` с CTA (кнопка создания)
- **Zero-results** — отдельный empty с «Сбросить фильтр» (не «Создать»)
- **Error** — `QueryState` + `onRetry` + иконка + текст
- **Overflow** — `truncate`, `max-w-*`, `Tooltip`; `tabular-nums` для чисел
- **Density** — таблицы `h-10 text-sm`; max 1 primary CTA на экран
- **A11y** — `aria-invalid` на полях, `aria-label`/`sr-only` на icon-only кнопках, цвет не единственный сигнал статуса
## Button hierarchy (max 1 primary per screen)
1. `default` — главный CTA
2. `outline` — вторичные действия
3. `ghost` / `link` — навигация, cancel
4. `destructive` — только с `ConfirmDialog`
+53 -71
View File
@@ -5,105 +5,87 @@ alwaysApply: true
# ReUI MCP — обязательно для enterprise-компонентов # ReUI MCP — обязательно для enterprise-компонентов
ReUI — first-class shadcn registry с enterprise-компонентами (Data Grid, Filters, Stepper и др.). Проект использует **Base UI** (`style: base-nova`), не Radix. ReUI — first-class shadcn registry. Проект: **Base UI** (`style: base-nova`), surface lock **`frame`**.
Связанные правила: [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`frontend-monorepo.mdc`](frontend-monorepo.mdc), [`frontend-shadcn.mdc`](frontend-shadcn.mdc). Связанные: [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`frontend-monorepo.mdc`](frontend-monorepo.mdc), [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md).
| Документ | URL | | Документ | URL |
|----------|-----| |----------|-----|
| **llms.txt (карта для ИИ)** | https://reui.io/llms.txt | | **llms.txt** | https://reui.io/llms.txt |
| **Get Started** | https://reui.io/docs/get-started | | **Get Started** | https://reui.io/docs/get-started |
| **Styling (semantic tokens)** | https://reui.io/docs/styling | | **Styling** | https://reui.io/docs/styling |
| **MCP** | https://reui.io/docs/mcp | | **MCP** | https://reui.io/docs/mcp |
| **Base UI components** | https://reui.io/docs/components/base/<name> | | **License** | https://reui.io/docs/license-setup |
| **Base UI** | https://reui.io/docs/components/base/<name> |
## Primary MCP
1. **`user-reui`** — `search` / `get_block` / `compose_page` (`surface: "frame"`) — cite `previewUrl`
2. **`plugin-shadcn-shadcn`** — primitives `@shadcn`; `@reui` вторично
## Когда использовать ReUI vs shadcn ## Когда использовать ReUI vs shadcn
| Задача | Registry | Импорт | | Задача | Registry | Импорт |
|--------|----------|--------| |--------|----------|--------|
| Button, Card, Sheet, Field, Sidebar | `@shadcn` | `@cfdm/ui/components/*` | | Button, Sheet, Field, Sidebar | `@shadcn` | `@cfdm/ui/components/*` |
| Blocks (sidebar-07, dashboard-01) | `@shadcn` | blocks → `apps/web/src/components/` | | Frame surface | `@reui` | `@/components/reui/frame` |
| Data Grid (sort, pagination, virtual, columns) | `@reui` | `@/components/reui/data-grid/*` → обёртка `DataGridCard` | | Data Grid lists | `@reui` | `reui-kit/ResourcePage` |
| Мультифильтры | `@reui` | `@/components/reui/filters` → `VpsFiltersToolbar` | | KPI | `@reui` stats-12 | `reui-kit/KpiStatGrid` |
| Number field со stepper | `@reui` | `@/components/reui/number-field` | | Filters | `@reui` | `@/components/reui/filters` |
| Autocomplete | `@reui` | `@/components/reui/autocomplete` → `AutoCompleteInput` | | Semantic badge | `@reui` | `@/components/reui/badge` / `StatusBadge` |
| Date selector / range | `@reui` | `@/components/reui/date-selector` | | Number / date / autocomplete / color | `@reui` | `@/components/reui/*` |
| Color picker (формы) | `@reui` | `@/components/reui/color-picker` — hex только в swatch data |
| Semantic badge (success/info/warning) | `@reui` | `@/components/reui/badge` или `StatusBadge` |
**Простые списки** — shadcn `Table`. **Сложные data-списки** — `DataGridCard` (ReUI data-grid), не shadcn Data Table. **Сложные списки** — `ResourcePage` (Frame + data-grid + filters), не Card `DataGridCard`, не raw `<table>`.
## Источники для ИИ (порядок) ## MCP workflow
1. [ReUI llms.txt](https://reui.io/llms.txt) — полный каталог компонентов 0. Поиск `reui-kit/*`, `@/components/reui/*`
2. MCP `plugin-shadcn-shadcn` с `registries: ["@reui"]` 1. `user-reui` `search` (`surface: "frame"`)
3. Docs с **Copy Markdown**: `https://reui.io/docs/components/base/<name>` 2. CLI из `apps/web`: `pnpm dlx shadcn@latest add @reui/<name> --yes`
4. CLI dry-run: `cd apps/web && pnpm dlx shadcn@latest add @reui/<name> --dry-run` 3. Post-add: shadcn imports → `@cfdm/ui/components/*`
4. Adapt → kit / route
5. `validate_usage` + `get_audit_checklist`
## MCP workflow (ветка ReUI) ## Размещение
0. **Codegraph** `codegraph_explore` — найти `DataGridCard`, `VpsFiltersToolbar` и существующие `@/components/reui/*`
1. **`get_project_registries`** — должен содержать `@reui`
2. **`search_items_in_registries`** с `registries: ["@reui"]`, `query`: `"data grid"`, `"filters"`, `"stepper"` и т.д.
3. **`get_item_examples_from_registries`** — полный код перед JSX
4. **`get_add_command_for_items`** — точная CLI-команда
5. Выполнить add из `apps/web` (см. [`frontend-monorepo.mdc`](frontend-monorepo.mdc))
6. **Docs:** ReUI `https://reui.io/docs/components/base/<name>` + llms.txt — **не** ui.shadcn.com для `@reui/*`
7. Post-add: импорты shadcn-примитивов → `@cfdm/ui/components/*`
8. `pnpm install` + `pnpm --filter web build`
## Размещение и импорты
| Слой | Путь | Импорт | | Слой | Путь | Импорт |
|------|------|--------| |------|------|--------|
| shadcn primitives | `packages/ui/src/components/` | `@cfdm/ui/components/*` | | shadcn primitives | `packages/ui/src/components/` | `@cfdm/ui/components/*` |
| ReUI enterprise | `apps/web/src/components/reui/` | `@/components/reui/*` | | ReUI enterprise | `apps/web/src/components/reui/` | `@/components/reui/*` |
| Проектные обёртки | `apps/web/src/components/` | `@/components/data-grid-card` и т.д. | | Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` |
```bash ## License
cd apps/web
pnpm dlx shadcn@latest add @reui/data-grid ```env
pnpm dlx shadcn@latest add @reui/filters # apps/web/.env.local
REUI_LICENSE_KEY=
``` ```
## Уже установленные ReUI-компоненты `apps/web/components.json` → `@reui` с `Authorization: Bearer ${REUI_LICENSE_KEY}`.
`autocomplete`, `badge`, `color-picker`, `data-grid/*`, `date-selector`, `filters`, `number-field` — в [`apps/web/src/components/reui/`](apps/web/src/components/reui/). ## Эталоны preview
Перед добавлением дубликата — проверить Codegraph и существующие обёртки. | Зона | Preview |
|------|---------|
## Зависимости (только `apps/web`, не `packages/ui`) | KPI | https://reui.io/preview/base/stats-12 |
| List | https://reui.io/preview/base/data-grid-filtering-2 |
| npm-пакет | ReUI-компоненты | | Settings | https://reui.io/preview/base/settings-16 |
|-----------|-----------------| | Shell | https://reui.io/preview/base/app-shell-12 |
| `@tanstack/react-table` | data-grid | | Empty | https://reui.io/preview/base/empty-state-12 |
| `@tanstack/react-virtual` | data-grid (virtual) | | Dashboard | https://reui.io/preview/base/dashboard-1 |
| `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/modifiers`, `@dnd-kit/utilities` | data-grid dnd, sortable, kanban |
| `date-fns`, `react-day-picker` | date-selector |
После `shadcn add @reui/...` — проверить, что CLI добавил недостающие deps в [`apps/web/package.json`](apps/web/package.json).
## Semantic tokens (Styling)
ReUI расширяет тему shadcn токенами `--success`, `--info`, `--warning`, `--destructive-foreground`, `--invert` — уже в [`packages/ui/src/styles/globals.css`](packages/ui/src/styles/globals.css).
- Badge/Alert: `variant="success"` / `"info"` / `"warning"` — не `bg-emerald-*`
- Базовая тема: `pnpm dlx shadcn@latest apply b2fA --only theme`
- ReUI-токены: по [Styling guide](https://reui.io/docs/styling); не править `globals.css` вручную без сверки с docs
## Запрещено ## Запрещено
- Копипаст с reui.io без CLI `add @reui/...` - Копипаст с reui.io без CLI
- Класть ReUI в `packages/ui` или импортировать как `@cfdm/ui` - ReUI в `packages/ui`
- Radix-варианты (`/docs/components/radix/...`) — только Base UI - Card как ops-shell
- Raw Tailwind-цвета вместо ReUI semantic `variant` - Hand-roll data-grid/filters/KPI при наличии kit
- Писать data-grid/filters с нуля, если есть `@reui/*` и shared-обёртки - Смешивать Card и Frame на ops-экране
## Чеклист ## Чеклист
- [ ] Задача требует ReUI? (см. матрицу выше) - [ ] `surface: frame`
- [ ] MCP search с `registries: ["@reui"]` + examples - [ ] `user-reui` + previewUrl
- [ ] `shadcn add @reui/<name>` из `apps/web` - [ ] CLI add из `apps/web`
- [ ] Импорты: `@cfdm/ui` для примитивов, `@/components/reui` для ReUI - [ ] Kit / `@/components/reui` / `@cfdm/ui`
- [ ] Зависимости в `apps/web/package.json` - [ ] `pnpm --filter web build`
- [ ] `pnpm --filter web build` без ошибок
+4
View File
@@ -13,6 +13,10 @@ data
dist-ssr dist-ssr
*.local *.local
# Env
.env
.env.local
# pnpm # pnpm
.pnpm-store .pnpm-store
coverage coverage
+1
View File
@@ -102,6 +102,7 @@ pnpm --filter web test # Vitest frontend
## ReUI ## ReUI
- Registry: `@reui` в [`apps/web/components.json`](apps/web/components.json) - Registry: `@reui` в [`apps/web/components.json`](apps/web/components.json)
- **Design contract:** [`docs/ui-design-contract.md`](docs/ui-design-contract.md) — surface `frame`, kit `reui-kit/`
- AI-карта: [llms.txt](https://reui.io/llms.txt) - AI-карта: [llms.txt](https://reui.io/llms.txt)
- Установка: `cd apps/web && pnpm dlx shadcn@latest add @reui/<name>` - Установка: `cd apps/web && pnpm dlx shadcn@latest add @reui/<name>`
- Правила: [`reui-mcp.mdc`](.cursor/rules/reui-mcp.mdc), [`shadcn-mcp.mdc`](.cursor/rules/shadcn-mcp.mdc) - Правила: [`reui-mcp.mdc`](.cursor/rules/reui-mcp.mdc), [`shadcn-mcp.mdc`](.cursor/rules/shadcn-mcp.mdc)
+4
View File
@@ -1,4 +1,8 @@
# Frontend (Vite) # Frontend (Vite)
# VITE_API_URL= # VITE_API_URL=
# ReUI PRO license (blocks / premium registry). Free components work without it.
# https://reui.io/docs/license-setup
REUI_LICENSE_KEY=
# Публичные URL приложений и integration token — в UI: Настройки → Интеграции # Публичные URL приложений и integration token — в UI: Настройки → Интеграции
+6 -1
View File
@@ -11,7 +11,12 @@
}, },
"iconLibrary": "lucide", "iconLibrary": "lucide",
"registries": { "registries": {
"@reui": "https://reui.io/r/{style}/{name}.json" "@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": {
"Authorization": "Bearer ${REUI_LICENSE_KEY}"
}
}
}, },
"aliases": { "aliases": {
"components": "@/components", "components": "@/components",
@@ -0,0 +1,50 @@
import type { ReactNode } from 'react'
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
import { cn } from '@cfdm/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>
)
}
+27 -13
View File
@@ -28,6 +28,14 @@ import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-colu
import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility' import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
import { EmptyState } from './empty-state' import { EmptyState } from './empty-state'
import type { DataGridColumn } from './data-grid-types' import type { DataGridColumn } from './data-grid-types'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
const PAGINATION_LABELS = { const PAGINATION_LABELS = {
rowsPerPageLabel: 'Строк на странице', rowsPerPageLabel: 'Строк на странице',
@@ -131,21 +139,25 @@ function DataGridSectionHeader({
if (!title && !description && !actions) return null if (!title && !description && !actions) return null
return ( return (
<div className="flex items-center justify-between gap-2"> <FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-px">
{title ? <h3 className="text-sm font-medium">{title}</h3> : null} {title ? <FrameTitle className="text-balance">{title}</FrameTitle> : null}
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null} {description ? (
</div> <FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null} ) : null}
</div> </div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
) : null}
</FrameHeader>
) )
} }
function DataGridPaginationBar() { function DataGridPaginationBar() {
return ( return (
<div className="border-t border-border px-4 py-2.5"> <FrameFooter>
<DataGridPagination {...PAGINATION_LABELS} /> <DataGridPagination {...PAGINATION_LABELS} />
</div> </FrameFooter>
) )
} }
@@ -354,12 +366,14 @@ export function DataGridCard<TData extends object>({
if (data.length === 0) { if (data.length === 0) {
return ( return (
<div className={cn('flex flex-col gap-3', className)}> <Frame dense variant="default" spacing="sm" className={cn('w-full', className)}>
{hasHeader ? ( {hasHeader ? (
<DataGridSectionHeader title={title} description={description} actions={headerActions} /> <DataGridSectionHeader title={title} description={description} actions={headerActions} />
) : null} ) : null}
<FramePanel>
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} /> <EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
</div> </FramePanel>
</Frame>
) )
} }
@@ -379,12 +393,12 @@ export function DataGridCard<TData extends object>({
) )
return ( return (
<div className={cn('flex flex-col gap-3', className)}> <Frame dense variant="default" spacing="sm" className={cn('w-full', className)}>
{hasHeader ? ( {hasHeader ? (
<DataGridSectionHeader title={title} description={description} actions={headerActions} /> <DataGridSectionHeader title={title} description={description} actions={headerActions} />
) : null} ) : null}
{gridBody} <FramePanel className="p-0 shadow-none!">{gridBody}</FramePanel>
</div> </Frame>
) )
} }
+57 -14
View File
@@ -1,28 +1,71 @@
import type { ReactNode } from 'react' import { InboxIcon, type LucideIcon } from 'lucide-react'
import { IconStack } from '@/components/reui/icon-stack'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@cfdm/ui/components/empty'
import { cn } from '@cfdm/ui/lib/utils' import { cn } from '@cfdm/ui/lib/utils'
import type { ReactNode } from 'react'
interface EmptyStateProps { interface EmptyStateProps {
/** Lucide icon component or pre-rendered node (legacy). */
icon?: LucideIcon | ReactNode
title: string title: string
description?: string description?: string
icon?: ReactNode
action?: ReactNode action?: ReactNode
className?: string className?: string
/** Use IconStack media (empty-state-12). Default true. */
stackedIcon?: boolean
} }
export function EmptyState({ title, description, icon, action, className }: EmptyStateProps) { function isLucideIcon(icon: LucideIcon | ReactNode): icon is LucideIcon {
return ( return typeof icon === 'function'
<div }
className={cn(
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center', /** Empty state — ReUI empty-state-12. Preview: https://reui.io/preview/base/empty-state-12 */
export function EmptyState({
icon,
title,
description,
action,
className, className,
stackedIcon = true,
}: EmptyStateProps) {
const Icon = isLucideIcon(icon) ? icon : InboxIcon
const customIcon = icon && !isLucideIcon(icon) ? icon : null
return (
<Empty className={cn('max-w-md flex-none border-0 bg-transparent p-0', className)}>
<EmptyHeader className="gap-5 text-center">
<EmptyMedia className="mb-0">
{customIcon ? (
<div className="text-muted-foreground">{customIcon}</div>
) : stackedIcon ? (
<IconStack aria-hidden="true" className="h-14 w-12">
<Icon strokeWidth={1.9} aria-hidden="true" className="size-5" />
</IconStack>
) : (
<span className="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-lg [&_svg]:size-5">
<Icon aria-hidden="true" />
</span>
)} )}
> </EmptyMedia>
{icon ? <div className="text-muted-foreground">{icon}</div> : null} <div className="flex flex-col items-center gap-2">
<div className="flex flex-col gap-1"> <EmptyTitle className="text-base font-semibold tracking-tight">
<p className="text-sm font-medium">{title}</p> {title}
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null} </EmptyTitle>
</div> {description ? (
{action ? <div className="mt-2">{action}</div> : null} <EmptyDescription className="max-w-sm text-sm/relaxed">
{description}
</EmptyDescription>
) : null}
</div> </div>
</EmptyHeader>
{action ? <EmptyContent>{action}</EmptyContent> : null}
</Empty>
) )
} }
@@ -0,0 +1,116 @@
import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { cn } from '@cfdm/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,6 @@
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
export { KpiStatGrid, type KpiStatCard, 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,167 @@
import type { ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import { Frame, FramePanel } from '@/components/reui/frame'
import { Item, ItemMedia } from '@cfdm/ui/components/item'
import { cn } from '@cfdm/ui/lib/utils'
import { Skeleton } from '@cfdm/ui/components/skeleton'
export interface KpiStatCard {
id: string
label: string
value: string | number
hint?: string
to?: string
search?: Record<string, unknown>
onSelect?: () => void
selected?: boolean
icon?: ReactNode
iconClassName?: string
footer?: ReactNode
}
/** @deprecated Use KpiStatCard */
export type OpsKpiCard = KpiStatCard
interface KpiStatGridProps {
cards: KpiStatCard[]
isLoading?: boolean
emptyMessage?: ReactNode
emptyIcon?: ReactNode
className?: string
skeletonCount?: number
}
const DEFAULT_ICON_CLASS =
'text-muted-foreground [&_svg]:text-current'
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 KpiStatCardBody({ card }: { card: KpiStatCard }) {
return (
<div className="relative z-10 flex h-full flex-col items-start gap-4">
{card.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',
card.iconClassName ?? DEFAULT_ICON_CLASS,
)}
>
<ItemMedia variant="icon" className="size-auto">
{card.icon}
</ItemMedia>
</Item>
) : null}
<div className="flex flex-col gap-0.5">
<span className="text-foreground text-2xl leading-none font-bold tabular-nums">
{card.value}
</span>
<span className="text-muted-foreground text-sm font-medium">{card.label}</span>
{card.hint ? (
<span className="text-muted-foreground text-xs leading-snug">{card.hint}</span>
) : null}
</div>
{card.footer ? <div className="mt-auto w-full">{card.footer}</div> : null}
</div>
)
}
function KpiStatCardItem({ card }: { card: KpiStatCard }) {
const interactive = Boolean(card.to || card.onSelect)
const panelClass = cn(
'relative isolate flex h-full flex-col items-start gap-4',
card.selected && 'ring-primary/30 bg-muted/30 ring-1',
interactive &&
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
)
if (card.to) {
return (
<FramePanel className={panelClass}>
<Link to={card.to} search={card.search} className="focus-visible:outline-none">
<KpiStatCardBody card={card} />
</Link>
</FramePanel>
)
}
if (card.onSelect) {
return (
<FramePanel className={panelClass}>
<button
type="button"
onClick={card.onSelect}
className="w-full text-start focus-visible:outline-none"
>
<KpiStatCardBody card={card} />
</button>
</FramePanel>
)
}
return (
<FramePanel className={panelClass}>
<KpiStatCardBody card={card} />
</FramePanel>
)
}
function KpiStatGridSkeleton({ count }: { count: number }) {
return (
<Frame className="@container w-full">
<div className={cn('grid gap-1', kpiCols(count))}>
{Array.from({ length: count }).map((_, index) => (
<FramePanel key={index} className="flex flex-col gap-4">
<Skeleton className="size-10.5 rounded-lg" />
<Skeleton className="h-7 w-14" />
<Skeleton className="h-4 w-20" />
</FramePanel>
))}
</div>
</Frame>
)
}
/** KPI grid — ReUI stats-12. Preview: https://reui.io/preview/base/stats-12 */
export function KpiStatGrid({
cards,
isLoading = false,
emptyMessage,
emptyIcon,
className,
skeletonCount = 4,
}: KpiStatGridProps) {
if (isLoading) {
return <KpiStatGridSkeleton count={skeletonCount} />
}
if (cards.length === 0) {
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>
)
}
return (
<Frame className={cn('@container w-full', className)}>
<div className={cn('grid gap-1', kpiCols(cards.length))}>
{cards.map((card) => (
<KpiStatCardItem key={card.id} card={card} />
))}
</div>
</Frame>
)
}
@@ -0,0 +1,84 @@
import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import { PageHeader } from '@/components/page-header'
import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid'
export type OpsKpiCard = KpiStatCard
interface OpsDashboardProps {
title?: string
description?: string
kpiCards: OpsKpiCard[]
charts: ReactNode
queue: ReactNode
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 = 'Обзор VPS, платежей, тарифов и синхронизации',
kpiCards,
charts,
queue,
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="Требуют внимания">
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Требуют внимания</FrameTitle>
<FrameDescription>
Проблемы оплаты, истекающие VPS и ошибки синхронизации
</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 '@cfdm/ui/components/button'
import { Separator } from '@cfdm/ui/components/separator'
import { Skeleton } from '@cfdm/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,107 @@
import type { ReactNode } from 'react'
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
import { PlugIcon, SettingsIcon } from 'lucide-react'
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
import { cn } from '@cfdm/ui/lib/utils'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Separator } from '@cfdm/ui/components/separator'
export interface SettingsTabConfig {
id: string
to: string
label: string
icon?: ReactNode
exact?: boolean
}
const DEFAULT_TABS: SettingsTabConfig[] = [
{
id: 'general',
to: '/settings',
label: 'Общие',
exact: true,
icon: <SettingsIcon className="size-4" aria-hidden="true" />,
},
{
id: 'integrations',
to: '/settings/integrations',
label: 'Интеграции',
icon: <PlugIcon className="size-4" aria-hidden="true" />,
},
]
interface SettingsShellProps {
title?: string
description?: string
tabs?: SettingsTabConfig[]
}
/** Settings shell — preview https://reui.io/preview/base/settings-16 */
export function SettingsShell({
title = 'Настройки',
description = 'Параметры приложения и связи с другими сервисами',
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 = tab.exact
? pathname === tab.to || pathname === `${tab.to}/`
: 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>
)
}
+92
View File
@@ -0,0 +1,92 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cfdm/ui/lib/utils"
const alertVariants = cva(
[
"relative w-full text-sm border has-[>svg]:grid-cols-[calc(var(--spacing)*3)_1fr] grid-cols-[0_1fr] grid gap-y-0.5 items-center [&>svg:not([class*=size-])]:size-4",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_[data-slot=alert-action]]:sm:row-end-3",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:items-start",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_svg]:translate-y-0.5",
"rounded-lg",
"px-3",
"py-2.5",
"has-[>svg]:gap-x-2.5",
],
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"border-destructive/30 bg-destructive/4 [&>svg]:text-destructive",
info: "border-info/30 bg-info/4 [&>svg]:text-info",
success: "border-success/30 bg-success/4 [&>svg]:text-success",
warning: "border-warning/30 bg-warning/4 [&>svg]:text-warning",
invert:
"border-invert bg-invert text-invert-foreground [&_[data-slot=alert-description]]:text-invert-foreground/70",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn(
"flex gap-1.5 max-sm:col-start-2 max-sm:mt-2 max-sm:justify-start sm:col-start-3 sm:row-start-1 sm:justify-end sm:self-center",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
+175
View File
@@ -0,0 +1,175 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cfdm/ui/lib/utils"
/**
* CSS variable architecture for FramePanel theming:
*
* The Frame parent sets --frame-panel-bg and --frame-panel-border-color.
* FramePanel consumes them directly via bg-(--frame-panel-bg) and
* border-(--frame-panel-border-color). This means:
*
* - variant="inverse" overrides those vars on Frame → all panels pick it up
* - <FramePanel className="bg-blue-50"> adds a direct utility on the element
* which wins over bg-(--frame-panel-bg) by Tailwind source order — no
* :not() or !important needed
*/
const frameVariants = cva(
[
"relative flex flex-col bg-muted/50 gap-(--frame-gap) px-(--frame-px) py-(--frame-py) rounded-(--frame-radius)",
"(--radius-xl)] [--frame-radius:var(--radius-xl)]",
"(--radius-none)] (--radius-2xl)] (--radius-lg)] (--radius-none)]",
"[--frame-gap:--spacing(0.75)] [--frame-px:--spacing(0.75)] [--frame-py:--spacing(0.75)] [--frame-panel-header-gap:0rem] [--frame-panel-footer-gap:--spacing(1)]",
"[--frame-panel-px-adjust:0px] [--frame-panel-py-adjust:0px] [--frame-panel-header-px-adjust:0px] [--frame-panel-header-py-adjust:0px] [--frame-panel-footer-px-adjust:0px] [--frame-panel-footer-py-adjust:0px]",
"[--frame-panel-px:calc(var(--frame-panel-px-base)_+_var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)_+_var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)_+_var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)_+_var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)_+_var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)_+_var(--frame-panel-footer-py-adjust))]",
"(1)] (1)] (1.25)] (1.5)] (1.5)] (0.5)] (1)] (1)]",
// Default panel token values — overridden per-variant below
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
],
{
variants: {
variant: {
default: "border border-[var(--frame-border-color)] bg-clip-padding",
inverse:
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
ghost: "",
},
spacing: {
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(1)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(1)] (3)] (1)] (3)] (3)]",
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(2.5)] (2)] (2)] (2)]",
default:
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(3)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(3)] (2)] (2)] (2)]",
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(4)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(4)] (2)] (2)] (2)]",
},
stacked: {
true: [
"gap-0 *:has-[+[data-slot=frame-panel]]:rounded-b-none",
"*:has-[+[data-slot=frame-panel]]:before:hidden",
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:rounded-t-none",
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:border-t-0",
],
false: [
"data-[spacing=sm]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-0.5",
"data-[spacing=default]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-1",
"data-[spacing=lg]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-2",
],
},
dense: {
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars
true: "p-0 gap-0 border-[var(--frame-border-color)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
false: "",
},
},
defaultVariants: {
variant: "default",
spacing: "default",
stacked: false,
dense: false,
},
}
)
function Frame({
className,
variant,
spacing,
stacked,
dense,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof frameVariants>) {
return (
<div
className={cn(
frameVariants({ variant, spacing, stacked, dense }),
className
)}
data-slot="frame"
data-spacing={spacing}
{...props}
/>
)
}
function FramePanel({
className,
fit,
...props
}: React.ComponentProps<"div"> & { fit?: boolean }) {
return (
<div
className={cn(
// bg-(--frame-panel-bg) and border-(--frame-panel-border-color) consume the
// CSS vars set by the Frame parent. Any explicit bg-* or border-* class passed
// via className overrides these by Tailwind source order - no ! needed.
"relative overflow-hidden rounded-(--frame-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
// `fit` sizes the panel to its content; otherwise it grows to fill the frame.
!fit && "grow",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-radius)-1px)] before:shadow-black/5",
"dark:bg-clip-border dark:before:shadow-white/5",
"px-(--frame-panel-px) py-(--frame-panel-py)",
className
)}
data-slot="frame-panel"
{...props}
/>
)
}
function FrameHeader({ className, ...props }: React.ComponentProps<"header">) {
return (
<header
className={cn(
"flex flex-col gap-(--frame-panel-header-gap) px-(--frame-panel-header-px) py-(--frame-panel-header-py)",
className
)}
data-slot="frame-panel-header"
{...props}
/>
)
}
function FrameTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("text-sm font-semibold", className)}
data-slot="frame-panel-title"
{...props}
/>
)
}
function FrameDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn("text-muted-foreground text-sm", className)}
data-slot="frame-panel-description"
{...props}
/>
)
}
function FrameFooter({ className, ...props }: React.ComponentProps<"footer">) {
return (
<footer
className={cn(
"flex flex-col gap-(--frame-panel-footer-gap) px-(--frame-panel-footer-px) py-(--frame-panel-footer-py)",
className
)}
data-slot="frame-panel-footer"
{...props}
/>
)
}
export {
Frame,
FramePanel,
FrameHeader,
FrameTitle,
FrameDescription,
FrameFooter,
frameVariants,
}
@@ -0,0 +1,90 @@
import { cn } from "@cfdm/ui/lib/utils"
type IconStackProps = React.ComponentProps<"div">
function IconStack({ className, children, style, ...props }: IconStackProps) {
return (
<div
data-slot="icon-stack"
className={cn(
"text-foreground **:data-[slot=icon-stack-layer]:fill-background relative h-20 w-18",
className
)}
style={
{
"--icon-stack-content-x": "71%",
"--icon-stack-content-y": "58%",
...style,
} as React.CSSProperties
}
{...props}
>
<svg
aria-hidden="true"
viewBox="0 0 72 81"
fill="none"
className="h-full w-full overflow-visible"
>
<ellipse
cx="36"
cy="76"
rx="30"
ry="7"
fill="currentColor"
fillOpacity="0.055"
className="blur-[4px]"
/>
<IconStackLayer opacity="0.4" />
<IconStackLayer opacity="0.6" x={13.65} y={6.04} />
<IconStackLayer opacity="0.8" x={27.32} y={12.08} active />
</svg>
{children ? (
<div
data-slot="icon-stack-content"
className="text-muted-foreground pointer-events-none absolute top-[var(--icon-stack-content-y)] left-[var(--icon-stack-content-x)] flex -translate-x-1/2 -translate-y-1/2 scale-x-90 -skew-y-26 items-center justify-center"
>
{children}
</div>
) : null}
</div>
)
}
function IconStackLayer({
active = false,
opacity,
x = 0,
y = 0,
}: {
active?: boolean
opacity: string
x?: number
y?: number
}) {
return (
<g opacity={opacity} transform={`translate(${x} ${y})`}>
<path
data-slot="icon-stack-layer"
d="M42.2538 2.046C41.4408 1.6325 40.3965 1.6677 39.2612 2.2424L7.9616 18.1934C5.3895 19.5039 3.301 23.1064 3.301 26.2322V64.3226C3.301 66.0677 3.9458 67.2943 4.962 67.8199L1.8363 66.229C0.8201 65.7104 0.1753 64.4771 0.1753 62.732V24.6412C0.1753 21.5085 2.2638 17.913 4.8359 16.6024L36.1355 0.6515C37.2778 0.0698 38.322 0.0416 39.128 0.4551L42.2538 2.046Z"
stroke="currentColor"
strokeOpacity={active ? "0.3" : "0.2"}
strokeWidth="0.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
data-slot="icon-stack-layer"
d="M42.2545 2.0456C43.2707 2.5643 43.9155 3.7979 43.9155 5.543V43.6337C43.9155 46.7665 41.827 50.3616 39.2549 51.6722L7.9554 67.6235C6.813 68.2052 5.7687 68.2331 4.9628 67.8196C3.9465 67.301 3.3018 66.0673 3.3018 64.3222V26.2318C3.3018 23.0991 5.3903 19.5036 7.9624 18.193L39.2619 2.2421C40.4043 1.6604 41.4486 1.6321 42.2545 2.0456Z"
stroke="currentColor"
strokeOpacity={active ? "0.3" : "0.2"}
strokeWidth="0.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
)
}
export { IconStack, type IconStackProps }
+39 -88
View File
@@ -1,7 +1,7 @@
import type { ReactElement, ReactNode } from 'react' import type { ReactElement, ReactNode } from 'react'
import { Card, CardContent } from '@cfdm/ui/components/card'
import { cn } from '@cfdm/ui/lib/utils' import { cn } from '@cfdm/ui/lib/utils'
import { TruncatedText } from '@/components/truncated-text' import { KpiStatGrid, type KpiStatCard } from '@/components/reui-kit/kpi-stat-grid'
import { Badge } from '@cfdm/ui/components/badge'
export interface SectionCardItem { export interface SectionCardItem {
label: ReactNode label: ReactNode
@@ -14,96 +14,47 @@ export interface SectionCardItem {
onClick?: () => void onClick?: () => void
} }
const VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = { /**
default: '', * @deprecated Prefer `KpiStatGrid` directly.
warning: 'border-warning/50', * Thin adapter → stats-12 Frame KPI. Preview: https://reui.io/preview/base/stats-12
destructive: 'border-destructive/50', */
}
function sectionGridClass(count: number): string {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'sm:grid-cols-2'
if (count === 3) return 'sm:grid-cols-2 lg:grid-cols-3'
if (count === 4) return 'sm:grid-cols-2 lg:grid-cols-4'
if (count === 5) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6'
return 'sm:grid-cols-2 lg:grid-cols-3'
}
const VALUE_VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
default: '',
warning: 'text-warning-foreground',
destructive: 'text-destructive',
}
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) { export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
return ( const cards: KpiStatCard[] = items.map((item, idx) => {
<div className={cn('grid gap-3', sectionGridClass(items.length), className)}> const label = typeof item.label === 'string' ? item.label : `kpi-${idx}`
{items.map((item, idx) => { const footer =
const clickable = Boolean(item.onClick) item.badge || item.hint ? (
const content = ( <div className="flex flex-wrap items-center gap-2">
<CardContent className="flex items-start gap-2.5 px-3 py-2.5"> {item.badge}
{item.icon ? ( {item.hint && !item.badge ? (
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground"> <span className="text-muted-foreground text-xs">{item.hint}</span>
{item.icon}
</span>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-center justify-between gap-2">
{typeof item.label === 'string' ? (
<TruncatedText className="text-xs text-muted-foreground">{item.label}</TruncatedText>
) : (
<span className="truncate text-xs text-muted-foreground">{item.label}</span>
)}
{item.badge ? <span className="shrink-0">{item.badge}</span> : null}
</div>
<div className="flex min-w-0 items-baseline gap-1.5">
<span
className={cn(
'flex items-center gap-1 text-lg font-semibold tabular-nums',
VALUE_VARIANT_CLASS[item.variant ?? 'default'],
)}
>
{item.value}
</span>
{item.hint ? (
typeof item.hint === 'string' ? (
<TruncatedText className="text-xs text-muted-foreground">· {item.hint}</TruncatedText>
) : (
<span className="truncate text-xs text-muted-foreground">· {item.hint}</span>
)
) : null} ) : null}
</div> </div>
</div> ) : undefined
</CardContent>
) return {
return ( id: label,
<Card label,
key={typeof item.label === 'string' ? item.label : idx} value: item.value as string | number,
hint: typeof item.hint === 'string' ? item.hint : undefined,
icon: item.icon,
selected: item.active,
onSelect: item.onClick,
footer:
item.variant && item.variant !== 'default' && !item.badge ? (
<Badge
variant="outline"
className={cn( className={cn(
'gap-0', item.variant === 'warning' && 'border-warning/50 text-warning-foreground',
VARIANT_CLASS[item.variant ?? 'default'], item.variant === 'destructive' && 'border-destructive/50 text-destructive',
item.active && 'border-primary ring-1 ring-primary/30',
clickable && 'cursor-pointer transition-colors hover:bg-muted/40',
)} )}
onClick={item.onClick}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
onKeyDown={
clickable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
item.onClick?.()
}
}
: undefined
}
> >
{content} {item.variant === 'warning' ? 'Внимание' : 'Критично'}
</Card> </Badge>
) ) : (
})} footer
</div> ),
) }
})
return <KpiStatGrid cards={cards} className={className} />
} }
+15
View File
@@ -0,0 +1,15 @@
/**
* VPS Tracker UI surface contract (ReUI Pro).
*
* Ops / list / dashboard / detail / settings: **Frame** only.
* KPI: **stats-12** via `reui-kit/KpiStatGrid`.
* Lists: **data-grid-filtering-2** via `reui-kit/ResourcePage`.
*
* @see https://reui.io/preview/base/stats-12
* @see https://reui.io/preview/base/data-grid-filtering-2
* @see docs/ui-design-contract.md
*/
export const UI_SURFACE = 'frame' as const
/** Grid for ReUI stats-12 KPI rows (3–6 tiles). */
export const kpiStatGridClassName = '@container w-full'
+32 -51
View File
@@ -17,9 +17,7 @@ import {
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot' import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
import { dashboardStatsQueryOptions } from '@/queries/dashboard' import { dashboardStatsQueryOptions } from '@/queries/dashboard'
import { PageShell } from '@/components/page-shell' import { OpsDashboard, type KpiStatCard } from '@/components/reui-kit'
import { PageHeader } from '@/components/page-header'
import { SectionCards } from '@/components/section-cards'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card' import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
import type { DataGridColumn } from '@/components/data-grid-types' import type { DataGridColumn } from '@/components/data-grid-types'
@@ -69,12 +67,6 @@ function DashboardPage() {
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
return ( return (
<PageShell>
<PageHeader
title="Дашборд"
description="Сводка по VPS, балансам и здоровью инвентаря"
/>
<QueryState <QueryState
data={snapshot} data={snapshot}
isLoading={isLoading} isLoading={isLoading}
@@ -205,22 +197,16 @@ function DashboardPage() {
tabsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) tabsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
} }
return ( const kpiCards: KpiStatCard[] = [
<div className="flex flex-col gap-4 md:gap-6">
<DashboardInventoryAlert issuesCount={issuesCount} onGoToIssues={handleGoToIssues} />
{statsLoading ? (
<SectionCardsSkeleton count={6} />
) : (
<SectionCards
items={[
{ {
id: 'active-vps',
label: 'Активные VPS', label: 'Активные VPS',
value: `${activeCount} из ${totalCount}`, value: `${activeCount} из ${totalCount}`,
icon: <ServerIcon className="size-4" />, icon: <ServerIcon className="size-4" />,
onClick: () => navigate({ to: '/vps' }), to: '/vps',
}, },
{ {
id: 'burn',
label: 'Расход в месяц', label: 'Расход в месяц',
value: formatInBaseCurrency( value: formatInBaseCurrency(
stats?.monthlyBurnEstimate ?? 0, stats?.monthlyBurnEstimate ?? 0,
@@ -229,9 +215,10 @@ function DashboardPage() {
ratesData, ratesData,
), ),
icon: <TrendingUpIcon className="size-4" />, icon: <TrendingUpIcon className="size-4" />,
onClick: () => navigate({ to: '/reports' }), to: '/reports',
}, },
{ {
id: 'balance',
label: 'Баланс API', label: 'Баланс API',
value: formatInBaseCurrency( value: formatInBaseCurrency(
stats?.totalBalanceApi ?? 0, stats?.totalBalanceApi ?? 0,
@@ -240,55 +227,47 @@ function DashboardPage() {
ratesData, ratesData,
), ),
icon: <WalletIcon className="size-4" />, icon: <WalletIcon className="size-4" />,
onClick: () => navigate({ to: '/accounts' }), to: '/accounts',
}, },
{ {
id: 'runway',
label: 'Runway', label: 'Runway',
value: stats?.minRunwayDays != null ? `${stats.minRunwayDays} дн` : '—', value: stats?.minRunwayDays != null ? `${stats.minRunwayDays} дн` : '—',
icon: <ClockIcon className="size-4" />, icon: <ClockIcon className="size-4" />,
variant: stats?.minRunwayDays != null && stats.minRunwayDays < 14 ? 'warning' : 'default', to: '/accounts',
badge: footer:
stats?.minRunwayDays != null && stats.minRunwayDays < 14 ? ( stats?.minRunwayDays != null && stats.minRunwayDays < 14 ? (
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
&lt; 14 дн &lt; 14 дн
</Badge> </Badge>
) : undefined, ) : undefined,
onClick: () => navigate({ to: '/accounts' }),
}, },
{ {
id: 'expiring',
label: 'Истекает 7 дн', label: 'Истекает 7 дн',
value: value: expiringCount,
expiringCount > 0 ? (
<span className="inline-flex items-center gap-1">
{expiringCount}
<AlertTriangleIcon className="size-4" />
</span>
) : (
expiringCount
),
icon: <AlertTriangleIcon className="size-4" />, icon: <AlertTriangleIcon className="size-4" />,
variant: expiringCount > 0 ? 'warning' : 'default', onSelect: () => navigate({ to: '/vps', search: { health: 'expiring-soon' } }),
onClick: () => navigate({ to: '/vps', search: { health: 'expiring-soon' } }),
}, },
{ {
id: 'issues',
label: 'Проблемы', label: 'Проблемы',
value: value: issuesCount,
issuesCount > 0 ? (
<span className="inline-flex items-center gap-1">
{issuesCount}
<AlertTriangleIcon className="size-4" />
</span>
) : (
issuesCount
),
icon: <HashIcon className="size-4" />, icon: <HashIcon className="size-4" />,
variant: issuesCount > 0 ? 'destructive' : 'default', onSelect: handleGoToIssues,
onClick: handleGoToIssues,
}, },
]} ]
/>
)}
return (
<div className="flex flex-col gap-4 md:gap-6">
<DashboardInventoryAlert issuesCount={issuesCount} onGoToIssues={handleGoToIssues} />
<OpsDashboard
title="Дашборд"
description="Сводка по VPS, балансам и здоровью инвентаря"
kpiCards={statsLoading ? [] : kpiCards}
isLoading={statsLoading}
charts={
<ChartsGrid> <ChartsGrid>
<DashboardPaymentsChart <DashboardPaymentsChart
payments={snap.payments} payments={snap.payments}
@@ -306,7 +285,8 @@ function DashboardPage() {
className="h-full" className="h-full"
/> />
</ChartsGrid> </ChartsGrid>
}
queue={
<div ref={tabsRef}> <div ref={tabsRef}>
<Tabs <Tabs
value={activeTab} value={activeTab}
@@ -372,6 +352,8 @@ function DashboardPage() {
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
}
/>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" render={<Link to="/resources" />}> <Button variant="outline" size="sm" render={<Link to="/resources" />}>
@@ -390,6 +372,5 @@ function DashboardPage() {
) )
}} }}
</QueryState> </QueryState>
</PageShell>
) )
} }
+3 -38
View File
@@ -1,9 +1,7 @@
import { createFileRoute, Link, Outlet, useRouterState } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { cn } from '@cfdm/ui/lib/utils'
import { snapshotQueryOptions } from '@/queries/snapshot' import { snapshotQueryOptions } from '@/queries/snapshot'
import { PageShell } from '@/components/page-shell' import { SettingsShell } from '@/components/reui-kit'
import { PageHeader } from '@/components/page-header'
export const Route = createFileRoute('/_auth/settings')({ export const Route = createFileRoute('/_auth/settings')({
loader: ({ context: { queryClient } }) => loader: ({ context: { queryClient } }) =>
@@ -11,39 +9,6 @@ export const Route = createFileRoute('/_auth/settings')({
component: SettingsLayout, component: SettingsLayout,
}) })
const TABS = [
{ to: '/settings', label: 'Общие', exact: true },
{ to: '/settings/integrations', label: 'Интеграции', exact: false },
] as const
function SettingsLayout() { function SettingsLayout() {
const pathname = useRouterState({ select: (s) => s.location.pathname }) return <SettingsShell />
return (
<PageShell>
<PageHeader title="Настройки" description="Параметры приложения и связи с другими сервисами" />
<nav className="flex gap-1 border-b pb-0">
{TABS.map((tab) => {
const active = tab.exact
? pathname === tab.to || pathname === `${tab.to}/`
: pathname.startsWith(tab.to)
return (
<Link
key={tab.to}
to={tab.to}
className={cn(
'rounded-t-md px-4 py-2 text-sm font-medium transition-colors',
active
? 'border border-b-0 bg-background text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{tab.label}
</Link>
)
})}
</nav>
<Outlet />
</PageShell>
)
} }
+62
View File
@@ -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 |
| KPI | `stats-12` (primary); `card-35` compact strip | https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-35 |
| 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 | https://reui.io/preview/base/settings-16 |
| 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` | stats-12 KPI tiles |
| `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` + `@cfdm/ui` / `@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
+104
View File
@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cfdm/ui/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn(
"text-sm font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
+201
View File
@@ -0,0 +1,201 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cfdm/ui/lib/utils"
import { Separator } from "@cfdm/ui/components/separator"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn(
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
className
)}
{...props}
/>
)
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-2", className)}
{...props}
/>
)
}
const itemVariants = cva(
"group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted",
{
variants: {
variant: {
default: "border-transparent",
outline: "border-border",
muted: "border-transparent bg-muted/50",
},
size: {
default: "gap-2.5 px-3 py-2.5",
sm: "gap-2.5 px-3 py-2.5",
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Item({
className,
variant = "default",
size = "default",
render,
...props
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(itemVariants({ variant, size, className })),
},
props
),
render,
state: {
slot: "item",
variant,
size,
},
})
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
{
variants: {
variant: {
default: "bg-transparent",
icon: "[&_svg:not([class*='size-'])]:size-4",
image:
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
)
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
className
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}