feat(web): обновить компоненты и добавить новые элементы интерфейса
Docker / build (push) Failing after 22s
Docker / build (push) Failing after 22s
- Добавлены новые правила для использования ReUI и обновлены существующие правила. - Внедрены новые компоненты, такие как Frame, Alert, IconStack и DetailPanel, для улучшения интерфейса. - Обновлены существующие компоненты, включая DataGridCard и EmptyState, с использованием новых элементов. - Добавлены новые утилиты для фильтрации данных и управления состоянием. - Обновлен .gitignore для исключения файлов окружения. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 <component>`; `@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`.
|
||||
|
||||
+53
-71
@@ -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/<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
|
||||
|
||||
| Задача | 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 `<table>`.
|
||||
|
||||
## Источники для ИИ (порядок)
|
||||
## 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/<name>`
|
||||
4. CLI dry-run: `cd apps/web && pnpm dlx shadcn@latest add @reui/<name> --dry-run`
|
||||
0. Поиск `reui-kit/*`, `@/components/reui/*`
|
||||
1. `user-reui` `search` (`surface: "frame"`)
|
||||
2. CLI из `apps/web`: `pnpm dlx shadcn@latest add @reui/<name> --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/<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/*` |
|
||||
| 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/<name>` из `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`
|
||||
|
||||
@@ -13,6 +13,10 @@ data
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# pnpm
|
||||
.pnpm-store
|
||||
coverage
|
||||
|
||||
@@ -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/<name>`
|
||||
- Правила: [`reui-mcp.mdc`](.cursor/rules/reui-mcp.mdc), [`shadcn-mcp.mdc`](.cursor/rules/shadcn-mcp.mdc)
|
||||
|
||||
@@ -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: Настройки → Интеграции
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
{title ? <h3 className="text-sm font-medium">{title}</h3> : null}
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
{title ? <FrameTitle className="text-balance">{title}</FrameTitle> : null}
|
||||
{description ? (
|
||||
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridPaginationBar() {
|
||||
return (
|
||||
<div className="border-t border-border px-4 py-2.5">
|
||||
<FrameFooter>
|
||||
<DataGridPagination {...PAGINATION_LABELS} />
|
||||
</div>
|
||||
</FrameFooter>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -354,12 +366,14 @@ export function DataGridCard<TData extends object>({
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-3', className)}>
|
||||
<Frame dense variant="default" spacing="sm" className={cn('w-full', className)}>
|
||||
{hasHeader ? (
|
||||
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
|
||||
) : null}
|
||||
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||
</div>
|
||||
<FramePanel>
|
||||
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -379,12 +393,12 @@ export function DataGridCard<TData extends object>({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-3', className)}>
|
||||
<Frame dense variant="default" spacing="sm" className={cn('w-full', className)}>
|
||||
{hasHeader ? (
|
||||
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
|
||||
) : null}
|
||||
{gridBody}
|
||||
</div>
|
||||
<FramePanel className="p-0 shadow-none!">{gridBody}</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon ? <div className="text-muted-foreground">{icon}</div> : null}
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{action ? <div className="mt-2">{action}</div> : null}
|
||||
</div>
|
||||
<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>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<EmptyTitle className="text-base font-semibold tracking-tight">
|
||||
{title}
|
||||
</EmptyTitle>
|
||||
{description ? (
|
||||
<EmptyDescription className="max-w-sm text-sm/relaxed">
|
||||
{description}
|
||||
</EmptyDescription>
|
||||
) : null}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import { Card, CardContent } from '@cfdm/ui/components/card'
|
||||
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 {
|
||||
label: ReactNode
|
||||
@@ -14,96 +14,47 @@ export interface SectionCardItem {
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
|
||||
default: '',
|
||||
warning: 'border-warning/50',
|
||||
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',
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Prefer `KpiStatGrid` directly.
|
||||
* Thin adapter → stats-12 Frame KPI. Preview: https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
|
||||
return (
|
||||
<div className={cn('grid gap-3', sectionGridClass(items.length), className)}>
|
||||
{items.map((item, idx) => {
|
||||
const clickable = Boolean(item.onClick)
|
||||
const content = (
|
||||
<CardContent className="flex items-start gap-2.5 px-3 py-2.5">
|
||||
{item.icon ? (
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground">
|
||||
{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}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
)
|
||||
return (
|
||||
<Card
|
||||
key={typeof item.label === 'string' ? item.label : idx}
|
||||
const cards: KpiStatCard[] = items.map((item, idx) => {
|
||||
const label = typeof item.label === 'string' ? item.label : `kpi-${idx}`
|
||||
const footer =
|
||||
item.badge || item.hint ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{item.badge}
|
||||
{item.hint && !item.badge ? (
|
||||
<span className="text-muted-foreground text-xs">{item.hint}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : undefined
|
||||
|
||||
return {
|
||||
id: label,
|
||||
label,
|
||||
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(
|
||||
'gap-0',
|
||||
VARIANT_CLASS[item.variant ?? 'default'],
|
||||
item.active && 'border-primary ring-1 ring-primary/30',
|
||||
clickable && 'cursor-pointer transition-colors hover:bg-muted/40',
|
||||
item.variant === 'warning' && 'border-warning/50 text-warning-foreground',
|
||||
item.variant === 'destructive' && 'border-destructive/50 text-destructive',
|
||||
)}
|
||||
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}
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
{item.variant === 'warning' ? 'Внимание' : 'Критично'}
|
||||
</Badge>
|
||||
) : (
|
||||
footer
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
return <KpiStatGrid cards={cards} className={className} />
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -17,9 +17,7 @@ import {
|
||||
|
||||
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
||||
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { SectionCards } from '@/components/section-cards'
|
||||
import { OpsDashboard, type KpiStatCard } from '@/components/reui-kit'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
|
||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||
@@ -69,26 +67,20 @@ function DashboardPage() {
|
||||
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Дашборд"
|
||||
description="Сводка по VPS, балансам и здоровью инвентаря"
|
||||
/>
|
||||
|
||||
<QueryState
|
||||
data={snapshot}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
skeleton={
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionCardsSkeleton count={6} />
|
||||
<TableSkeleton />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(snap) => {
|
||||
<QueryState
|
||||
data={snapshot}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
skeleton={
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionCardsSkeleton count={6} />
|
||||
<TableSkeleton />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(snap) => {
|
||||
const activeVps = snap.vps.filter((v) => v.status === 'active')
|
||||
const issues = computeInventoryHealth({ ...snap, syncLog: snap.syncLog ?? [] })
|
||||
const atRisk = buildAtRiskAccounts(snap.providerAccounts, snap.providers, snap.syncLog ?? [])
|
||||
@@ -205,173 +197,163 @@ function DashboardPage() {
|
||||
tabsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
const kpiCards: KpiStatCard[] = [
|
||||
{
|
||||
id: 'active-vps',
|
||||
label: 'Активные VPS',
|
||||
value: `${activeCount} из ${totalCount}`,
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
to: '/vps',
|
||||
},
|
||||
{
|
||||
id: 'burn',
|
||||
label: 'Расход в месяц',
|
||||
value: formatInBaseCurrency(
|
||||
stats?.monthlyBurnEstimate ?? 0,
|
||||
baseCur,
|
||||
snap.settings,
|
||||
ratesData,
|
||||
),
|
||||
icon: <TrendingUpIcon className="size-4" />,
|
||||
to: '/reports',
|
||||
},
|
||||
{
|
||||
id: 'balance',
|
||||
label: 'Баланс API',
|
||||
value: formatInBaseCurrency(
|
||||
stats?.totalBalanceApi ?? 0,
|
||||
baseCur,
|
||||
snap.settings,
|
||||
ratesData,
|
||||
),
|
||||
icon: <WalletIcon className="size-4" />,
|
||||
to: '/accounts',
|
||||
},
|
||||
{
|
||||
id: 'runway',
|
||||
label: 'Runway',
|
||||
value: stats?.minRunwayDays != null ? `${stats.minRunwayDays} дн` : '—',
|
||||
icon: <ClockIcon className="size-4" />,
|
||||
to: '/accounts',
|
||||
footer:
|
||||
stats?.minRunwayDays != null && stats.minRunwayDays < 14 ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
< 14 дн
|
||||
</Badge>
|
||||
) : undefined,
|
||||
},
|
||||
{
|
||||
id: 'expiring',
|
||||
label: 'Истекает 7 дн',
|
||||
value: expiringCount,
|
||||
icon: <AlertTriangleIcon className="size-4" />,
|
||||
onSelect: () => navigate({ to: '/vps', search: { health: 'expiring-soon' } }),
|
||||
},
|
||||
{
|
||||
id: 'issues',
|
||||
label: 'Проблемы',
|
||||
value: issuesCount,
|
||||
icon: <HashIcon className="size-4" />,
|
||||
onSelect: handleGoToIssues,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<DashboardInventoryAlert issuesCount={issuesCount} onGoToIssues={handleGoToIssues} />
|
||||
|
||||
{statsLoading ? (
|
||||
<SectionCardsSkeleton count={6} />
|
||||
) : (
|
||||
<SectionCards
|
||||
items={[
|
||||
{
|
||||
label: 'Активные VPS',
|
||||
value: `${activeCount} из ${totalCount}`,
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
onClick: () => navigate({ to: '/vps' }),
|
||||
},
|
||||
{
|
||||
label: 'Расход в месяц',
|
||||
value: formatInBaseCurrency(
|
||||
stats?.monthlyBurnEstimate ?? 0,
|
||||
baseCur,
|
||||
snap.settings,
|
||||
ratesData,
|
||||
),
|
||||
icon: <TrendingUpIcon className="size-4" />,
|
||||
onClick: () => navigate({ to: '/reports' }),
|
||||
},
|
||||
{
|
||||
label: 'Баланс API',
|
||||
value: formatInBaseCurrency(
|
||||
stats?.totalBalanceApi ?? 0,
|
||||
baseCur,
|
||||
snap.settings,
|
||||
ratesData,
|
||||
),
|
||||
icon: <WalletIcon className="size-4" />,
|
||||
onClick: () => navigate({ to: '/accounts' }),
|
||||
},
|
||||
{
|
||||
label: 'Runway',
|
||||
value: stats?.minRunwayDays != null ? `${stats.minRunwayDays} дн` : '—',
|
||||
icon: <ClockIcon className="size-4" />,
|
||||
variant: stats?.minRunwayDays != null && stats.minRunwayDays < 14 ? 'warning' : 'default',
|
||||
badge:
|
||||
stats?.minRunwayDays != null && stats.minRunwayDays < 14 ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
< 14 дн
|
||||
</Badge>
|
||||
) : undefined,
|
||||
onClick: () => navigate({ to: '/accounts' }),
|
||||
},
|
||||
{
|
||||
label: 'Истекает 7 дн',
|
||||
value:
|
||||
expiringCount > 0 ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{expiringCount}
|
||||
<AlertTriangleIcon className="size-4" />
|
||||
</span>
|
||||
) : (
|
||||
expiringCount
|
||||
),
|
||||
icon: <AlertTriangleIcon className="size-4" />,
|
||||
variant: expiringCount > 0 ? 'warning' : 'default',
|
||||
onClick: () => navigate({ to: '/vps', search: { health: 'expiring-soon' } }),
|
||||
},
|
||||
{
|
||||
label: 'Проблемы',
|
||||
value:
|
||||
issuesCount > 0 ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{issuesCount}
|
||||
<AlertTriangleIcon className="size-4" />
|
||||
</span>
|
||||
) : (
|
||||
issuesCount
|
||||
),
|
||||
icon: <HashIcon className="size-4" />,
|
||||
variant: issuesCount > 0 ? 'destructive' : 'default',
|
||||
onClick: handleGoToIssues,
|
||||
},
|
||||
]}
|
||||
<OpsDashboard
|
||||
title="Дашборд"
|
||||
description="Сводка по VPS, балансам и здоровью инвентаря"
|
||||
kpiCards={statsLoading ? [] : kpiCards}
|
||||
isLoading={statsLoading}
|
||||
charts={
|
||||
<ChartsGrid>
|
||||
<DashboardPaymentsChart
|
||||
payments={snap.payments}
|
||||
settings={snap.settings}
|
||||
ratesData={ratesData}
|
||||
className="h-full"
|
||||
/>
|
||||
<DashboardExpensesChart
|
||||
payments={snap.payments}
|
||||
balanceLedger={snap.balanceLedger ?? []}
|
||||
vps={snap.vps}
|
||||
providers={snap.providers}
|
||||
settings={snap.settings}
|
||||
ratesData={ratesData}
|
||||
className="h-full"
|
||||
/>
|
||||
</ChartsGrid>
|
||||
}
|
||||
queue={
|
||||
<div ref={tabsRef}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex w-full flex-col gap-4"
|
||||
>
|
||||
<TabsList variant="line" className="mb-0 h-auto w-fit gap-1 border-b border-border p-0">
|
||||
<TabsTrigger value="issues" className={cn(DASHBOARD_TAB_TRIGGER_CLASS, 'gap-2')}>
|
||||
Проблемы
|
||||
{issues.length > 0 ? (
|
||||
<Badge variant="secondary">{issues.length}</Badge>
|
||||
) : null}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="recent" className={DASHBOARD_TAB_TRIGGER_CLASS}>
|
||||
Последние VPS
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="risk" className={cn(DASHBOARD_TAB_TRIGGER_CLASS, 'gap-2')}>
|
||||
Аккаунты
|
||||
{atRisk.length > 0 ? (
|
||||
<Badge variant="outline">{atRisk.length}</Badge>
|
||||
) : null}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="issues" className="mt-0">
|
||||
<DataGridCard
|
||||
title="Здоровье инвентаря"
|
||||
description="Ставка, оплата, синк, баланс аккаунтов"
|
||||
columns={columnDefFromDataGrid(issueColumns)}
|
||||
data={issues}
|
||||
rowId={(i) => i.key}
|
||||
emptyTitle="Проблем не найдено"
|
||||
emptyDescription="Критичных проблем в инвентаре не обнаружено"
|
||||
pagination={false}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="recent" className="mt-0">
|
||||
<DataGridCard
|
||||
title="Последние VPS"
|
||||
description="Активные серверы"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" render={<Link to="/vps" />}>
|
||||
Все VPS
|
||||
</Button>
|
||||
}
|
||||
columns={columnDefFromDataGrid(vpsColumns)}
|
||||
data={activeVps.slice(0, 8)}
|
||||
rowId={(v) => v.id}
|
||||
pagination={false}
|
||||
onRowClick={(v) => navigate({ to: '/vps', search: { edit: v.id } })}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="risk" className="mt-0">
|
||||
<DataGridCard
|
||||
title="Аккаунты под риском"
|
||||
description="Низкий баланс или устаревший синк BILLmanager"
|
||||
columns={columnDefFromDataGrid(riskColumns)}
|
||||
data={atRisk}
|
||||
rowId={(r) => r.id}
|
||||
emptyTitle="Рисков нет"
|
||||
emptyDescription="Балансы и синхронизация в норме"
|
||||
pagination={false}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ChartsGrid>
|
||||
<DashboardPaymentsChart
|
||||
payments={snap.payments}
|
||||
settings={snap.settings}
|
||||
ratesData={ratesData}
|
||||
className="h-full"
|
||||
/>
|
||||
<DashboardExpensesChart
|
||||
payments={snap.payments}
|
||||
balanceLedger={snap.balanceLedger ?? []}
|
||||
vps={snap.vps}
|
||||
providers={snap.providers}
|
||||
settings={snap.settings}
|
||||
ratesData={ratesData}
|
||||
className="h-full"
|
||||
/>
|
||||
</ChartsGrid>
|
||||
|
||||
<div ref={tabsRef}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex w-full flex-col gap-4"
|
||||
>
|
||||
<TabsList variant="line" className="mb-0 h-auto w-fit gap-1 border-b border-border p-0">
|
||||
<TabsTrigger value="issues" className={cn(DASHBOARD_TAB_TRIGGER_CLASS, 'gap-2')}>
|
||||
Проблемы
|
||||
{issues.length > 0 ? (
|
||||
<Badge variant="secondary">{issues.length}</Badge>
|
||||
) : null}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="recent" className={DASHBOARD_TAB_TRIGGER_CLASS}>
|
||||
Последние VPS
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="risk" className={cn(DASHBOARD_TAB_TRIGGER_CLASS, 'gap-2')}>
|
||||
Аккаунты
|
||||
{atRisk.length > 0 ? (
|
||||
<Badge variant="outline">{atRisk.length}</Badge>
|
||||
) : null}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="issues" className="mt-0">
|
||||
<DataGridCard
|
||||
title="Здоровье инвентаря"
|
||||
description="Ставка, оплата, синк, баланс аккаунтов"
|
||||
columns={columnDefFromDataGrid(issueColumns)}
|
||||
data={issues}
|
||||
rowId={(i) => i.key}
|
||||
emptyTitle="Проблем не найдено"
|
||||
emptyDescription="Критичных проблем в инвентаре не обнаружено"
|
||||
pagination={false}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="recent" className="mt-0">
|
||||
<DataGridCard
|
||||
title="Последние VPS"
|
||||
description="Активные серверы"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" render={<Link to="/vps" />}>
|
||||
Все VPS
|
||||
</Button>
|
||||
}
|
||||
columns={columnDefFromDataGrid(vpsColumns)}
|
||||
data={activeVps.slice(0, 8)}
|
||||
rowId={(v) => v.id}
|
||||
pagination={false}
|
||||
onRowClick={(v) => navigate({ to: '/vps', search: { edit: v.id } })}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="risk" className="mt-0">
|
||||
<DataGridCard
|
||||
title="Аккаунты под риском"
|
||||
description="Низкий баланс или устаревший синк BILLmanager"
|
||||
columns={columnDefFromDataGrid(riskColumns)}
|
||||
data={atRisk}
|
||||
rowId={(r) => r.id}
|
||||
emptyTitle="Рисков нет"
|
||||
emptyDescription="Балансы и синхронизация в норме"
|
||||
pagination={false}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" render={<Link to="/resources" />}>
|
||||
@@ -389,7 +371,6 @@ function DashboardPage() {
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</QueryState>
|
||||
</PageShell>
|
||||
</QueryState>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { createFileRoute, Link, Outlet, useRouterState } from '@tanstack/react-router'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { SettingsShell } from '@/components/reui-kit'
|
||||
|
||||
export const Route = createFileRoute('/_auth/settings')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
@@ -11,39 +9,6 @@ export const Route = createFileRoute('/_auth/settings')({
|
||||
component: SettingsLayout,
|
||||
})
|
||||
|
||||
const TABS = [
|
||||
{ to: '/settings', label: 'Общие', exact: true },
|
||||
{ to: '/settings/integrations', label: 'Интеграции', exact: false },
|
||||
] as const
|
||||
|
||||
function SettingsLayout() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
|
||||
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>
|
||||
)
|
||||
return <SettingsShell />
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user