diff --git a/.cursor/rules/frontend-ui-patterns.mdc b/.cursor/rules/frontend-ui-patterns.mdc index ec4d47b..1ab5cab 100644 --- a/.cursor/rules/frontend-ui-patterns.mdc +++ b/.cursor/rules/frontend-ui-patterns.mdc @@ -6,110 +6,68 @@ alwaysApply: false # 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 (обязательно) -0. Codegraph `codegraph_explore` — найти существующие shared/domain-компоненты -1. [`reui-mcp.mdc`](reui-mcp.mdc) — если нужен Data Grid, Filters, Autocomplete и др. -2. MCP `plugin-shadcn-shadcn` — search → examples → add command (`@shadcn` / `@reui`) -3. CLI: `@shadcn/*` → `pnpm dlx shadcn@latest docs `; `@reui/*` → ReUI docs + llms.txt -4. Код по examples + docs API (только после совпадения MCP ↔ docs) -5. Context7 — **только** TanStack Router/Query, Recharts (не shadcn/ReUI primitives) -6. MCP `get_audit_checklist` — перед merge -7. Codegraph `codegraph_status` — Pending sync пустой +0. Codegraph / поиск `reui-kit/*` +1. MCP **`user-reui`** — search → `get_block` / `compose_page` (`surface: "frame"`) — cite `previewUrl` + `docsUrl` +2. Primitives: MCP `plugin-shadcn-shadcn` + CLI docs +3. CLI: `cd apps/web && pnpm dlx shadcn@latest add @reui/...` +4. Adapt в kit — не hand-roll +5. Context7 — только TanStack / Recharts +6. `validate_usage` / `get_audit_checklist` ## Иерархия компонентов ``` @cfdm/ui/components/* ← shadcn CLI (packages/ui) @/components/reui/* ← ReUI CLI @reui/* (apps/web) -apps/web/src/components/ ← shared + domain + layout - page-shell.tsx ← обёртка страницы +apps/web/src/components/ + reui-kit/ ← ResourcePage, KpiStatGrid, OpsDashboard, SettingsShell + page-shell.tsx page-header.tsx - empty-state.tsx + empty-state.tsx ← Empty + IconStack (empty-state-12) query-state.tsx - confirm-dialog.tsx - data-grid-card.tsx - section-cards.tsx - 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-* ← бизнес-компоненты + form-sheet.tsx + layout/ + domain-* ``` ## Матрица стандартизации -| Элемент | Shared | Primitive | -|---------|--------|-----------| +| Элемент | Shared | Primitive / Preview | +|---------|--------|---------------------| | Page wrapper | `PageShell` | — | | Page title | `PageHeader` | — | -| Stat metrics | `SectionCards` | `Card` | -| Data list | `DataGridCard` | `@reui/data-grid`, `Table` | -| Filters toolbar | `VpsFiltersToolbar` | `@reui/filters` | -| Autocomplete | `AutoCompleteInput` | `@reui/autocomplete` | -| Number input | — | `@reui/number-field` | -| Date range | `lib/date-selector-i18n.ts` | `@reui/date-selector` | -| Empty | `EmptyState` | `Empty` | -| Loading / Error | `QueryState` | `Skeleton`, `Alert` | -| Status | `StatusBadge` | `Badge`, `@reui/badge` | -| Create/Edit | `FormSheet` + `*-edit-sheet.tsx` | `Sheet`, `Field` | -| Form field | `FormField` | `Field`, `Input`, `Select` | -| 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` | — | +| KPI | `reui-kit/KpiStatGrid` | [stats-12](https://reui.io/preview/base/stats-12) | +| Data list | `reui-kit/ResourcePage` | [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) | +| Dashboard | `reui-kit/OpsDashboard` | [dashboard-1](https://reui.io/preview/base/dashboard-1) | +| Settings | `reui-kit/SettingsShell` | [settings-16](https://reui.io/preview/base/settings-16) | +| Filters | ResourcePage / domain toolbars | `@reui/filters` | +| Empty | `EmptyState` | [empty-state-12](https://reui.io/preview/base/empty-state-12) | +| Loading / Error | `QueryState` | Skeleton, ReUI Alert | +| Status | `StatusBadge` | `@reui/badge` | +| Create/Edit | `FormSheet` | Sheet, Field | +| Delete confirm | `ConfirmDialog` | AlertDialog | +| Nav | `AppShell` | [app-shell-12](https://reui.io/preview/base/app-shell-12) | -## Overlay selection - -| Сценарий | Компонент | -|----------|-----------| -| Create/edit форма | `Sheet` | -| Destructive confirm | `AlertDialog` via `ConfirmDialog` | -| Modal preview | `Dialog` | - -## Block registry - -| Зона | Block | -|------|-------| -| Shell | sidebar-07 | -| Dashboard | dashboard-01 | -| Login | login-03 | +**Устарело (не использовать на новых экранах):** `DataGridCard`, `SectionCards` (Card KPI). ## Spacing ``` -PageShell: gap-4 md:gap-6, px-4 lg:px-6 py-4 md:py-6 -Card grid: gap-4 -FieldGroup: gap-4 -Item list: gap-2 -Toolbar: gap-2 +PageShell / main: gap-4 md:gap-6, px-4 md:px-6 +FieldGroup: gap-4 +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**. - -- **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` +Max 1 primary (`default`) на экран; остальные `outline` / `ghost`. diff --git a/.cursor/rules/reui-mcp.mdc b/.cursor/rules/reui-mcp.mdc index 81aa90b..35dcfe7 100644 --- a/.cursor/rules/reui-mcp.mdc +++ b/.cursor/rules/reui-mcp.mdc @@ -5,105 +5,87 @@ alwaysApply: true # 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 | |----------|-----| -| **llms.txt (карта для ИИ)** | https://reui.io/llms.txt | +| **llms.txt** | https://reui.io/llms.txt | | **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 | -| **Base UI components** | https://reui.io/docs/components/base/ | +| **License** | https://reui.io/docs/license-setup | +| **Base UI** | https://reui.io/docs/components/base/ | + +## Primary MCP + +1. **`user-reui`** — `search` / `get_block` / `compose_page` (`surface: "frame"`) — cite `previewUrl` +2. **`plugin-shadcn-shadcn`** — primitives `@shadcn`; `@reui` вторично ## Когда использовать ReUI vs shadcn | Задача | Registry | Импорт | |--------|----------|--------| -| Button, Card, Sheet, Field, Sidebar | `@shadcn` | `@cfdm/ui/components/*` | -| Blocks (sidebar-07, dashboard-01) | `@shadcn` | blocks → `apps/web/src/components/` | -| Data Grid (sort, pagination, virtual, columns) | `@reui` | `@/components/reui/data-grid/*` → обёртка `DataGridCard` | -| Мультифильтры | `@reui` | `@/components/reui/filters` → `VpsFiltersToolbar` | -| Number field со stepper | `@reui` | `@/components/reui/number-field` | -| Autocomplete | `@reui` | `@/components/reui/autocomplete` → `AutoCompleteInput` | -| Date selector / range | `@reui` | `@/components/reui/date-selector` | -| Color picker (формы) | `@reui` | `@/components/reui/color-picker` — hex только в swatch data | -| Semantic badge (success/info/warning) | `@reui` | `@/components/reui/badge` или `StatusBadge` | +| Button, Sheet, Field, Sidebar | `@shadcn` | `@cfdm/ui/components/*` | +| Frame surface | `@reui` | `@/components/reui/frame` | +| Data Grid lists | `@reui` | `reui-kit/ResourcePage` | +| KPI | `@reui` stats-12 | `reui-kit/KpiStatGrid` | +| Filters | `@reui` | `@/components/reui/filters` | +| Semantic badge | `@reui` | `@/components/reui/badge` / `StatusBadge` | +| Number / date / autocomplete / color | `@reui` | `@/components/reui/*` | -**Простые списки** — shadcn `Table`. **Сложные data-списки** — `DataGridCard` (ReUI data-grid), не shadcn Data Table. +**Сложные списки** — `ResourcePage` (Frame + data-grid + filters), не Card `DataGridCard`, не raw ``. -## Источники для ИИ (порядок) +## MCP workflow -1. [ReUI llms.txt](https://reui.io/llms.txt) — полный каталог компонентов -2. MCP `plugin-shadcn-shadcn` с `registries: ["@reui"]` -3. Docs с **Copy Markdown**: `https://reui.io/docs/components/base/` -4. CLI dry-run: `cd apps/web && pnpm dlx shadcn@latest add @reui/ --dry-run` +0. Поиск `reui-kit/*`, `@/components/reui/*` +1. `user-reui` `search` (`surface: "frame"`) +2. CLI из `apps/web`: `pnpm dlx shadcn@latest add @reui/ --yes` +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/` + 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/*` | | 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 -cd apps/web -pnpm dlx shadcn@latest add @reui/data-grid -pnpm dlx shadcn@latest add @reui/filters +## License + +```env +# 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 и существующие обёртки. - -## Зависимости (только `apps/web`, не `packages/ui`) - -| npm-пакет | ReUI-компоненты | -|-----------|-----------------| -| `@tanstack/react-table` | data-grid | -| `@tanstack/react-virtual` | data-grid (virtual) | -| `@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 +| Зона | Preview | +|------|---------| +| KPI | https://reui.io/preview/base/stats-12 | +| List | https://reui.io/preview/base/data-grid-filtering-2 | +| Settings | https://reui.io/preview/base/settings-16 | +| Shell | https://reui.io/preview/base/app-shell-12 | +| Empty | https://reui.io/preview/base/empty-state-12 | +| Dashboard | https://reui.io/preview/base/dashboard-1 | ## Запрещено -- Копипаст с reui.io без CLI `add @reui/...` -- Класть ReUI в `packages/ui` или импортировать как `@cfdm/ui` -- Radix-варианты (`/docs/components/radix/...`) — только Base UI -- Raw Tailwind-цвета вместо ReUI semantic `variant` -- Писать data-grid/filters с нуля, если есть `@reui/*` и shared-обёртки +- Копипаст с reui.io без CLI +- ReUI в `packages/ui` +- Card как ops-shell +- Hand-roll data-grid/filters/KPI при наличии kit +- Смешивать Card и Frame на ops-экране ## Чеклист -- [ ] Задача требует ReUI? (см. матрицу выше) -- [ ] MCP search с `registries: ["@reui"]` + examples -- [ ] `shadcn add @reui/` из `apps/web` -- [ ] Импорты: `@cfdm/ui` для примитивов, `@/components/reui` для ReUI -- [ ] Зависимости в `apps/web/package.json` -- [ ] `pnpm --filter web build` без ошибок +- [ ] `surface: frame` +- [ ] `user-reui` + previewUrl +- [ ] CLI add из `apps/web` +- [ ] Kit / `@/components/reui` / `@cfdm/ui` +- [ ] `pnpm --filter web build` diff --git a/.gitignore b/.gitignore index b265c2c..8285057 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,10 @@ data dist-ssr *.local +# Env +.env +.env.local + # pnpm .pnpm-store coverage diff --git a/AGENTS.md b/AGENTS.md index 348c7d2..78030a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,6 +102,7 @@ pnpm --filter web test # Vitest frontend ## ReUI - 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) - Установка: `cd apps/web && pnpm dlx shadcn@latest add @reui/` - Правила: [`reui-mcp.mdc`](.cursor/rules/reui-mcp.mdc), [`shadcn-mcp.mdc`](.cursor/rules/shadcn-mcp.mdc) diff --git a/apps/web/.env.example b/apps/web/.env.example index 1104426..03b3dff 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,4 +1,8 @@ # Frontend (Vite) # 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: Настройки → Интеграции diff --git a/apps/web/components.json b/apps/web/components.json index 5afe9ff..6e7b97f 100644 --- a/apps/web/components.json +++ b/apps/web/components.json @@ -11,7 +11,12 @@ }, "iconLibrary": "lucide", "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": { "components": "@/components", diff --git a/apps/web/src/components/counted-line-tabs.tsx b/apps/web/src/components/counted-line-tabs.tsx new file mode 100644 index 0000000..f6cfe20 --- /dev/null +++ b/apps/web/src/components/counted-line-tabs.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from 'react' +import { Tabs, TabsList, TabsTrigger } from '@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.map((tab) => ( + + {tab.label} + {tab.count !== undefined ? ( + + {tab.count} + + ) : null} + + ))} + + {children} + + ) +} diff --git a/apps/web/src/components/data-grid-card.tsx b/apps/web/src/components/data-grid-card.tsx index 1b11de1..474c254 100644 --- a/apps/web/src/components/data-grid-card.tsx +++ b/apps/web/src/components/data-grid-card.tsx @@ -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 { EmptyState } from './empty-state' import type { DataGridColumn } from './data-grid-types' +import { + Frame, + FrameDescription, + FrameFooter, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' const PAGINATION_LABELS = { rowsPerPageLabel: 'Строк на странице', @@ -131,21 +139,25 @@ function DataGridSectionHeader({ if (!title && !description && !actions) return null return ( -
-
- {title ?

{title}

: null} - {description ?

{description}

: null} + +
+ {title ? {title} : null} + {description ? ( + {description} + ) : null}
- {actions ?
{actions}
: null} -
+ {actions ? ( +
{actions}
+ ) : null} + ) } function DataGridPaginationBar() { return ( -
+ -
+ ) } @@ -354,12 +366,14 @@ export function DataGridCard({ if (data.length === 0) { return ( -
+ {hasHeader ? ( ) : null} - -
+ + + + ) } @@ -379,12 +393,12 @@ export function DataGridCard({ ) return ( -
+ {hasHeader ? ( ) : null} - {gridBody} -
+ {gridBody} + ) } diff --git a/apps/web/src/components/empty-state.tsx b/apps/web/src/components/empty-state.tsx index fe1763e..6f8bc87 100644 --- a/apps/web/src/components/empty-state.tsx +++ b/apps/web/src/components/empty-state.tsx @@ -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 type { ReactNode } from 'react' interface EmptyStateProps { + /** Lucide icon component or pre-rendered node (legacy). */ + icon?: LucideIcon | ReactNode title: string description?: string - icon?: ReactNode action?: ReactNode 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 typeof icon === 'function' +} + +/** Empty state — ReUI empty-state-12. Preview: https://reui.io/preview/base/empty-state-12 */ +export function EmptyState({ + icon, + title, + description, + action, + className, + stackedIcon = true, +}: EmptyStateProps) { + const Icon = isLucideIcon(icon) ? icon : InboxIcon + const customIcon = icon && !isLucideIcon(icon) ? icon : null + return ( -
- {icon ?
{icon}
: null} -
-

{title}

- {description ?

{description}

: null} -
- {action ?
{action}
: null} -
+ + + + {customIcon ? ( +
{customIcon}
+ ) : stackedIcon ? ( + + ) : ( + + + )} +
+
+ + {title} + + {description ? ( + + {description} + + ) : null} +
+
+ {action ? {action} : null} +
) } diff --git a/apps/web/src/components/reui-kit/detail-panel.tsx b/apps/web/src/components/reui-kit/detail-panel.tsx new file mode 100644 index 0000000..28b8bd4 --- /dev/null +++ b/apps/web/src/components/reui-kit/detail-panel.tsx @@ -0,0 +1,116 @@ +import type { ReactNode } from 'react' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { cn } from '@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
{children}
+} + +interface DetailPanelHeaderProps { + title: string + description?: string + actions?: ReactNode + children?: ReactNode +} + +function DetailPanelHeader({ + title, + description, + actions, + children, +}: DetailPanelHeaderProps) { + return ( + + +
+ {title} + {description ? ( + {description} + ) : null} +
+ {actions ? ( +
+ {actions} +
+ ) : null} +
+ {children ? {children} : null} + + ) +} + +function DetailPanelMetrics({ cards }: { cards: DetailMetricCard[] }) { + return ( +
+
+ {cards.map((card) => ( + + +
+ {card.icon} + + {card.label} + +
+
+ +

+ {card.description} +

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

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ ) : null} + {children} +
+ ) +} + +export const DetailPanel = Object.assign(DetailPanelRoot, { + Header: DetailPanelHeader, + Metrics: DetailPanelMetrics, + Section: DetailPanelSection, +}) diff --git a/apps/web/src/components/reui-kit/filter-utils.ts b/apps/web/src/components/reui-kit/filter-utils.ts new file mode 100644 index 0000000..a896d19 --- /dev/null +++ b/apps/web/src/components/reui-kit/filter-utils.ts @@ -0,0 +1,91 @@ +import type { Filter } from '@/components/reui/filters' + +export function getActiveFilters(filters: Filter[]) { + return filters.filter((filter) => { + const { values } = filter + if (!values || values.length === 0) return false + if (values.every((value) => typeof value === 'string' && value.trim() === '')) { + return false + } + if (values.every((value) => value === null || value === undefined)) { + return false + } + if (values.every((value) => Array.isArray(value) && value.length === 0)) { + return false + } + return true + }) +} + +export function applyFiltersToData( + data: T[], + filters: Filter[], + getFieldValue: (item: T, field: string) => unknown, +): T[] { + const active = getActiveFilters(filters) + let result = [...data] + + for (const filter of active) { + const { field, operator, values } = filter + result = result.filter((item) => { + const raw = getFieldValue(item, field) + const fieldValue = raw != null ? raw : '' + + switch (operator) { + case 'is': + return values.includes(fieldValue) + case 'is_not': + return !values.includes(fieldValue) + case 'is_any_of': + return values.some((value) => fieldValue === value) + case 'is_not_any_of': + return !values.some((value) => fieldValue === value) + case 'contains': { + const tokens = values + .map((value) => String(value).trim()) + .filter(Boolean) + if (tokens.length === 0) return true + return tokens.some((token) => + String(fieldValue).toLowerCase().includes(token.toLowerCase()), + ) + } + case 'not_contains': + return !values.some((value) => + String(fieldValue).toLowerCase().includes(String(value).toLowerCase()), + ) + case 'starts_with': + return values.some((value) => + String(fieldValue).toLowerCase().startsWith(String(value).toLowerCase()), + ) + case 'ends_with': + return values.some((value) => + String(fieldValue).toLowerCase().endsWith(String(value).toLowerCase()), + ) + case 'empty': + return fieldValue === '' || fieldValue == null + case 'not_empty': + return fieldValue !== '' && fieldValue != null + default: + return true + } + }) + } + + return result +} + +export function renderSelectedCount(values: unknown[]) { + if (values.length === 0) return 'Выберите…' + if (values.length > 1) return `${values.length} выбрано` + return null +} + +export function renderSingleSelectedLabel( + values: unknown[], + options: { value: string; label: string }[], +) { + const state = renderSelectedCount(values) + if (state) return state + const option = options.find((item) => item.value === values[0]) + return option?.label ?? String(values[0]) +} diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts new file mode 100644 index 0000000..1e3b7c9 --- /dev/null +++ b/apps/web/src/components/reui-kit/index.ts @@ -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' diff --git a/apps/web/src/components/reui-kit/kpi-stat-grid.tsx b/apps/web/src/components/reui-kit/kpi-stat-grid.tsx new file mode 100644 index 0000000..c99d5e2 --- /dev/null +++ b/apps/web/src/components/reui-kit/kpi-stat-grid.tsx @@ -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 + 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 ( +
+ {card.icon ? ( + + + {card.icon} + + + ) : null} +
+ + {card.value} + + {card.label} + {card.hint ? ( + {card.hint} + ) : null} +
+ {card.footer ?
{card.footer}
: null} +
+ ) +} + +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 ( + + + + + + ) + } + + if (card.onSelect) { + return ( + + + + ) + } + + return ( + + + + ) +} + +function KpiStatGridSkeleton({ count }: { count: number }) { + return ( + +
+ {Array.from({ length: count }).map((_, index) => ( + + + + + + ))} +
+ + ) +} + +/** 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 + } + + if (cards.length === 0) { + return ( + + + {emptyIcon} + {emptyMessage ? ( +

{emptyMessage}

+ ) : null} +
+ + ) + } + + return ( + +
+ {cards.map((card) => ( + + ))} +
+ + ) +} diff --git a/apps/web/src/components/reui-kit/ops-dashboard.tsx b/apps/web/src/components/reui-kit/ops-dashboard.tsx new file mode 100644 index 0000000..a39d2fc --- /dev/null +++ b/apps/web/src/components/reui-kit/ops-dashboard.tsx @@ -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 ( +
+
+ + +
+ +
+ + +
+ +
+ ) +} + +export function OpsDashboard({ + title = 'Панель управления', + description = 'Обзор VPS, платежей, тарифов и синхронизации', + kpiCards, + charts, + queue, + isLoading = false, +}: OpsDashboardProps) { + if (isLoading) { + return + } + + return ( +
+ + +
+ +
+ +
+ {charts} +
+ +
+ + + Требуют внимания + + Проблемы оплаты, истекающие VPS и ошибки синхронизации + + + {queue} + +
+
+ ) +} diff --git a/apps/web/src/components/reui-kit/resource-page.tsx b/apps/web/src/components/reui-kit/resource-page.tsx new file mode 100644 index 0000000..a76ed43 --- /dev/null +++ b/apps/web/src/components/reui-kit/resource-page.tsx @@ -0,0 +1,370 @@ +import { useCallback, useMemo, useState, type ReactNode } from 'react' +import { + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, + type ColumnDef, + type PaginationState, + type RowSelectionState, + type SortingState, +} from '@tanstack/react-table' +import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react' + +import { CountedLineTabs } from '@/components/counted-line-tabs' +import { Badge } from '@/components/reui/badge' +import { DataGrid } from '@/components/reui/data-grid/data-grid' +import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination' +import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area' +import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' +import { + Filters, + type Filter, + type FilterFieldConfig, +} from '@/components/reui/filters' +import { + Frame, + FrameDescription, + FrameFooter, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { Button } from '@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 { + title: string + description?: string + tabs?: ResourcePageTab[] + activeTab?: string + onTabChange?: (tabId: string) => void + tabFilter?: (item: T, tabId: string) => boolean + filterFields: FilterFieldConfig[] + filters: Filter[] + onFiltersChange: (filters: Filter[]) => void + onClearFilters?: () => void + getFilterFieldValue: (item: T, field: string) => unknown + columns: ColumnDef[] + data: T[] + getRowId: (row: T) => string + isLoading?: boolean + isError?: boolean + error?: Error | null + onRetry?: () => void + primaryAction?: ReactNode + emptyState?: { title: string; description?: string; action?: ReactNode } + pageSize?: number + enableRowSelection?: boolean + selectionToolbar?: (ctx: { + selectedIds: string[] + selectedCount: number + clearSelection: () => void + }) => ReactNode + toolbarExtra?: ReactNode + hideHeader?: boolean +} + +function ResourcePageSkeleton() { + return ( + + + + + + +
+ + {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ + ) +} + +export function ResourcePage({ + title, + description, + tabs, + activeTab: controlledTab, + onTabChange, + tabFilter, + filterFields, + filters, + onFiltersChange, + onClearFilters, + getFilterFieldValue, + columns, + data, + getRowId, + isLoading = false, + isError = false, + error = null, + onRetry, + primaryAction, + emptyState, + pageSize = 10, + enableRowSelection = false, + selectionToolbar, + toolbarExtra, + hideHeader = false, +}: ResourcePageProps) { + const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all') + const activeTab = controlledTab ?? internalTab + + const [sorting, setSorting] = useState([]) + const [rowSelection, setRowSelection] = useState({}) + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize, + }) + + const resetPagination = useCallback(() => { + setPagination((current) => + current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }, + ) + }, []) + + const filteredData = useMemo(() => { + let result = applyFiltersToData(data, filters, getFilterFieldValue) + if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') { + result = result.filter((item) => tabFilter(item, activeTab)) + } + return result + }, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab]) + + const tabCounts = useMemo(() => { + if (!tabs?.length || !tabFilter) return {} + const base = applyFiltersToData(data, filters, getFilterFieldValue) + const counts: Record = {} + for (const tab of tabs) { + counts[tab.id] = + tab.id === 'all' + ? base.length + : base.filter((item) => tabFilter(item, tab.id)).length + } + return counts + }, [tabs, tabFilter, data, filters, getFilterFieldValue]) + + const selectedIds = useMemo( + () => Object.keys(rowSelection).filter((id) => rowSelection[id]), + [rowSelection], + ) + + const selectedCount = selectedIds.length + + const clearSelection = useCallback(() => { + setRowSelection({}) + }, []) + + const table = useReactTable({ + data: filteredData, + columns, + getRowId, + state: { sorting, rowSelection, pagination }, + enableRowSelection, + onSortingChange: setSorting, + onRowSelectionChange: setRowSelection, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }) + + const handleTabChange = useCallback( + (value: string) => { + if (onTabChange) onTabChange(value) + else setInternalTab(value) + resetPagination() + }, + [onTabChange, resetPagination], + ) + + const handleFiltersChange = useCallback( + (next: Filter[]) => { + onFiltersChange(next) + resetPagination() + }, + [onFiltersChange, resetPagination], + ) + + const handleClear = useCallback(() => { + onClearFilters?.() + resetPagination() + }, [onClearFilters, resetPagination]) + + const countedTabs = useMemo( + () => + (tabs ?? []).map((tab) => ({ + id: tab.id, + label: tab.label, + count: tabCounts[tab.id] ?? tab.count ?? 0, + })), + [tabs, tabCounts], + ) + + if (isLoading) { + return + } + + if (isError) { + return ( + + + Ошибка загрузки + + {error?.message ?? 'Не удалось загрузить данные'} + {onRetry ? ( + + ) : null} + + + ) + } + + if (data.length === 0 && emptyState) { + return ( + + ) + } + + const emptyMessage = 'Нет записей по выбранным фильтрам.' + + return ( +
+ {selectionToolbar && selectedCount > 0 + ? selectionToolbar({ + selectedIds, + selectedCount, + clearSelection, + }) + : null} + + + {!hideHeader ? ( + +
+ {title} + {description ? ( + + {description} + + ) : null} +
+ {primaryAction ? ( +
+ {primaryAction} +
+ ) : null} +
+ ) : null} + + + {countedTabs.length > 0 ? ( + <> +
+ +
+ + + ) : null} + +
+ +
+ + + + + + + + + + + + +
+ +
+
+ ) +} diff --git a/apps/web/src/components/reui-kit/settings-shell.tsx b/apps/web/src/components/reui-kit/settings-shell.tsx new file mode 100644 index 0000000..1f162b9 --- /dev/null +++ b/apps/web/src/components/reui-kit/settings-shell.tsx @@ -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: