Compare commits

...
25 Commits
Author SHA1 Message Date
DenozordecandClaude Sonnet 5.5 924f111e48 fix(web): restore dev proxy target to 8080
quality / changes (push) Successful in 9s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 37s
quality / web (push) Successful in 2m14s
CD / quality (push) Successful in 5m51s
CD / publish (push) Successful in 6m42s
Случайно попавший в предыдущий коммит порт 8081 для локального мока возвращён на 8080.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-24 00:19:07 +07:00
DenozordecandClaude Sonnet 5.5 7b0f09e292 fix(web): soften input focus highlight
quality / changes (push) Successful in 12s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 38s
quality / web (push) Successful in 2m12s
CD / quality (push) Successful in 3m12s
CD / publish (push) Successful in 46s
Поля ввода больше не рисуют сплошной синий outline поверх собственного кольца: остаётся тонкая рамка и мягкое кольцо 18%.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-24 00:18:59 +07:00
DenozordecandClaude Sonnet 5.5 574e92830b fix(web): drop single Overview tab on dashboard
quality / changes (push) Successful in 7s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 21s
quality / web (push) Successful in 1m5s
CD / quality (push) Successful in 1m36s
CD / publish (push) Successful in 2m59s
На панели вкладка «Обзор» была единственной и ничего не переключала — убрана, блоки идут сразу под KPI.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 23:46:52 +07:00
DenozordecandClaude Sonnet 5.5 3b79d834cd feat(web): dashboard as on the shared mockup board
quality / changes (push) Successful in 8s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 26s
quality / web (push) Successful in 1m24s
CD / quality (push) Successful in 2m0s
CD / publish (push) Successful in 3m7s
Панель переписана по доске BgpDashboard: действия в шапке, KPI-полоса, вкладка «Обзор», быстрые действия карточками, график префиксов по последним ревизиям, таблица модулей и лента активности. Графики загрузки BGP и потока операций убраны с панели — они живут в «Сети» и «Мониторинге».

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 22:38:11 +07:00
DenozordecandClaude Sonnet 5.5 aba18e15b7 feat(web): pages and tables as on shared mockup boards
quality / changes (push) Successful in 9s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 23s
quality / web (push) Successful in 1m22s
CD / quality (push) Successful in 1m57s
CD / publish (push) Successful in 3m31s
ResourcePage и FrameDataGrid получили раскладку досок: заголовок Frame с поиском и фильтрами, линейные виды со счётчиками, подвал «Показано N из M». Страница модулей — KPI-полоса, кнопка создания в шапке, вкладка. Операции: действия применения и перезагрузки BIRD в шапке, баннер при ошибках задач. Сеть: баннер пиров на одобрение. Кнопки шапок — стандартной высоты.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 21:59:12 +07:00
DenozordecandClaude Sonnet 5.5 53420b49a1 feat(web): dashboard modules grid and activity feed as on mockup boards
quality / changes (push) Successful in 8s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 23s
quality / web (push) Successful in 1m32s
CD / quality (push) Successful in 2m11s
CD / publish (push) Successful in 3m7s
Заголовки страниц приведены к доскам (20px). На панели добавлены таблица первых 5 модулей и лента активности по последним задачам.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 19:47:08 +07:00
DenozordecandClaude Sonnet 5.5 55ddce43f7 fix(web): отступы контента как в MikrotikManager (gap-5, p-6)
quality / changes (push) Successful in 11s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 35s
quality / web (push) Successful in 1m27s
CD / quality (push) Successful in 3m50s
CD / publish (push) Successful in 3m4s
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 19:40:47 +07:00
DenozordecandClaude Sonnet 5.5 bcb1c48997 fix(web): MikrotikManager palette and full-width content
Токены цвета в packages/ui синхронизированы с app/globals.css MikrotikManager (main): сине-серая палитра, тёмный сайдбар, статусные и chart-токены, шкала радиусов. Ограничение max-w 1480px у контента оболочки снято, отступ как у MikrotikManager (p-6).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 19:37:43 +07:00
DenozordecandClaude Sonnet 5.5 b20caef1f1 feat(web): align tables, KPI strip and tabs with shared mockup
quality / changes (push) Successful in 7s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 36s
quality / web (push) Successful in 1m4s
CD / quality (push) Successful in 1m49s
CD / publish (push) Successful in 9m35s
Таблицы ResourcePage и FrameDataGrid переведены на раскладку MikrotikManager: сегменты, фильтры и поиск в тулбаре, подложка шапки, ячейки py-3. KPI стали полосой в одном Frame, переключатели разделов — линейными вкладками. Старая оболочка (app-shell, nav-user, монитор, палитра) удалена: её заменил PortalShell. Тесты kit-раскладки обновлены под новый контракт.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 18:42:19 +07:00
DenozordecandClaude Sonnet 5.5 bf43f2846e feat(web): portal shell on shared mockup blocks
Боковое меню, шапка, монитор системы и палитра Ctrl+K переведены на общие блоки макета; данные берутся из живых запросов EvoBGP.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 18:34:42 +07:00
Denozordec f861d0fa8c fix(ci): restore changed-path detection on multi-commit pushes
quality / commitlint (push) Skipped
quality / changes (push) Successful in 7s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 26s
quality / web (push) Successful in 1m6s
quality / go (push) Successful in 55s
quality / bird2 (push) Successful in 16s
CD / quality (push) Successful in 2m59s
CD / publish (push) Successful in 5m17s
Push-чекаут в quality-воркфлоу делался с fetch-depth: 2, поэтому
BEFORE_SHA (github.event.before) при пуше нескольких коммитов
отсутствовал в чекауте, и git diff падал с "fatal: bad object"
(exit 128). Чекаут переведён на fetch-depth: 0; перед диффом
добавлен guard git cat-file -e: если before недоступен
(force-push), шаг не падает, а уходит в полный прогон через
empty-diff fallback.
2026-09-25 23:54:48 +07:00
Denozordec 2f914bcc65 fix(web): add horizontal inset to frame section content
quality / commitlint (push) Skipped
quality / changes (push) Failing after 8s
quality / openapi (push) Skipped
quality / web (push) Skipped
quality / go (push) Skipped
CD / quality (push) Failing after 11s
quality / bird2 (push) Skipped
quality / docker-check (push) Skipped
CD / publish (push) Skipped
Контент карточки «Сравнение ревизий» и шела аналитических карточек
прилипал к рамке FramePanel: в contentClassName передавался только
вертикальный py-, а FrameSection рендерит панель с p-0. Добавлен
px-5 — горизонтальная вставка по константе кита panelCardInsetClassName,
на одной оси с остальными карточками (мониторинг, дашборд).
2026-09-25 22:54:47 +07:00
Denozordec 7a10138990 fix(web): open select popups below trigger instead of over it
Селекты приложения использовали alignItemWithTrigger по умолчанию:
Base UI накладывает выбранный пункт списка на триггер, а у селектов с
плейсхолдером (без значения, например «Ревизия A/B» на странице
операций) попап целиком накрывал триггер — выглядел как сломанный
список вместо выпадающего меню. В обёртке SelectMenu/SelectField
выставлен alignItemWithTrigger=false — список раскрывается под
триггером у всех селектов приложения, как это делает и вендорский
data-grid-pagination.
2026-09-25 22:34:04 +07:00
Denozordec 391f34a53d chore: keep staged-context.ps1 array on single staged file
Пайплайн Sort-Object разворачивал одноэлементный массив в скаляр, из-за
чего под StrictMode падал доступ к .Count и скрипт возвращал exit 1 при
ровно одном застейдженном файле. Обёртка @() сохраняет массив; сообщения
коммитов по одному файлу снова проходят обязательную проверку.
2026-09-25 22:33:32 +07:00
Denozordec ecd48642de fix(web): align grid columns with reui reference and clean labels
Гриды операций и расписания выровнены по эталону data-grid-filtering-2:
явные размеры колонок (size в columnDef) вместо равномерного деления
при table-layout: fixed — колонка названия доминирует, второстепенные
сжаты по содержимому, пропали обрезанные имена («partner-dom…»,
«Обновлен...»). Убраны фейковые многоточия из JS-срезов: ID ревизии и
варианты выбора в сравнении теперь показываются целиком, а усечение
делает CSS-truncate. Подписи «Ревизия A/B» переведены на Field/FieldLabel
вместо самодельных span, шапка блока diff дополнена заголовком
«ревизия A → ревизия B» (CodeBlockTitle) слева от кнопок.
2026-09-25 22:14:02 +07:00
Denozordec 09580bbcab feat(web): add job run timeline and code-block revision diff
Раскрытие строки задачи показывает вертикальный ReUI Timeline:
создана (с триггером) -> взята в работу -> результат с текстом
ошибки. Diff ревизий переведён с самодельных <pre> на официальный
@reui/code-block: единый блок с разметкой добавленных и удалённых
строк (знаки +/- и тонировка), нумерацией, копированием и разворотом
длинных списков. ResourcePage (filtered-вариант) получил поддержку
expandedContent/getRowCanExpand — колонка раскрытия строится так же,
как во FrameDataGrid. Точечные упрощения: прогресс BGP-сессий в
мониторинге переведён на Progress из кита вместо самодельного бара;
удалены @deprecated-константы ui-surface.ts, мёртвый
data-grid-defaults.ts и неиспользуемый sparkline-ряд дашборда.
2026-09-25 21:46:57 +07:00
Denozordec 4d54dacd05 fix(web): localize reui grids and filters, unify ru terminology
Русификация переведена на официальные i18n-слои ReUI: полный русский
словарь DataGrid (lib/reui-i18n-ru.ts) подключён через data-grid-i18n
во FrameDataGrid и ResourcePage — меню сортировки, «Загрузка…»,
«Нет данных» и пагинация больше не откатываются к английским дефолтам
компонентов. Словарь фильтров дополнен ~30 недостающими ключами,
каскадер в мастере проверки переведён целиком. Исправлены
некорректные и разъехавшиеся лейблы: accept -> «Принять»,
API-триггер -> «через API», «арендатор» вместо «Тенант»,
«Наблюдатель» вместо «Просмотр», «Основной» вместо
«Мастер (плоскость)», «AS-префиксы», «DoH-профили», колонка канбана
«В очереди».
2026-09-25 21:46:24 +07:00
Denozordec a3562cc11e chore(web): update vendored ReUI components to latest registry
Обновлены вендоренные компоненты ReUI (base-nova) до актуальных версий
реестра: семейство data-grid (плюс новые data-grid-i18n и
data-grid-cell-selection), filters, cascader, frame, kanban, stepper,
timeline, badge, alert, icon-tile, icon-stack и новый code-block
(подсветка на shiki). Shadcn-примитивы в packages/ui обновлены тем же
запуском CLI. Импорты "cn" из новых файлов переведены на алиас
@evobgp/ui/lib/utils, пакет cn из dependencies удалён, локальные
eslint-disable в вендоренных файлах поправлены.
2026-09-25 21:45:56 +07:00
Denozordec 95d4305241 feat(web): show job trigger and module details in jobs list
quality / commitlint (push) Skipped
quality / changes (push) Successful in 11s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 1m3s
quality / web (push) Successful in 1m58s
quality / go (push) Successful in 3m21s
quality / bird2 (push) Successful in 19s
CD / quality (push) Successful in 7m6s
CD / publish (push) Successful in 9m18s
Колонка «Вид» в журнале задач (Операции → Задачи и Расписание) стала информативнее: подзаголовок с именами обновляемых модулей для tenant_refresh (из meta.module_ids), сообщение apply_summary для deploy_apply и бейдж триггера («по расписанию», «вручную», «после правки данных»). Поиск по журналу учитывает новые поля.

Бэкенд: meta.trigger теперь записывается для module_refresh и deploy_apply при ручных запусках (POST /v1/modules/{id}/refresh, /v1/apply, /v1/speakers/{id}/apply), а deploy_apply из цепочки обновления наследует триггер родительской задачи.
2026-09-25 16:16:53 +07:00
Denozordec dc803bcb34 refactor(web): remove deprecated dashboard components and enhance KPI grid
quality / commitlint (push) Skipped
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 46s
quality / web (push) Successful in 1m16s
quality / go (push) Successful in 2m42s
quality / bird2 (push) Successful in 16s
CD / quality (push) Successful in 5m19s
CD / publish (push) Successful in 7m19s
- Deleted unused components: `DashboardActivityTimeline`, `DashboardFramePanel`, `DashboardModulesGrid`, `DashboardRecentJobsGrid`, and `DashboardRecentRevisionsGrid` to streamline the dashboard.
- Updated `DashboardKpiGrid` to improve KPI display logic, including progress indicators and enhanced badge functionality.
- Refactored `DashboardNetworkHealth` to provide better status representation based on loading states and network conditions.
- Introduced new properties for KPI cards to support progress tracking and improved visual feedback.

This cleanup aims to enhance performance and maintainability of the dashboard while providing a better user experience.
2026-08-31 10:15:59 +07:00
Denozordec e469c421ca feat(web): enhance status toggle group and filter utilities
quality / commitlint (push) Skipped
quality / changes (push) Successful in 7s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 24s
quality / web (push) Successful in 1m1s
CD / quality (push) Successful in 1m37s
CD / publish (push) Successful in 2m53s
- Introduced new properties for status toggle items, including `icon` and `tone`, to improve visual representation.
- Updated the `StatusToggleGroup` component to utilize these new properties for better user feedback.
- Enhanced filter utilities with additional functions for managing filter queries, including `getPrimaryTextField`, `getExtraFilterFields`, and `setFilterTextValue`.
- Improved the `ResourcePageFiltered` component to integrate search functionality and manage filter states more effectively.

This update aims to streamline the user experience in managing statuses and filters across the application.
2026-08-23 13:16:00 +07:00
DenozordecandCursor 094f75913b fix(web): unify ReUI chrome and stop tab underline overlap
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 22s
quality / web (push) Successful in 1m0s
CD / quality (push) Successful in 1m35s
CD / publish (push) Successful in 2m59s
Секции страниц переведены на segmented Tabs (c-tabs-9), статус грида — ToggleGroup в toolbar. Убраны календарь задач, пустые вкладки мониторинга и demo-деревья ReUI. KPI overflow как в CFDM, DetailPanel на карточке модуля, один FrameSection.

Co-authored-by: Cursor <[email protected]>
2026-08-23 12:46:20 +07:00
Denozordec 764a4e5b4e feat(web): enhance action column functionality in data grid components
quality / commitlint (push) Skipped
quality / changes (push) Successful in 6s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 23s
quality / web (push) Successful in 1m4s
CD / quality (push) Successful in 1m36s
CD / publish (push) Successful in 2m55s
- Introduced `KIT_ACTION_COLUMN_SIZE` for consistent action column sizing across data grids.
- Updated `applyKitActionColumn` to enforce action column properties such as fixed size, no sorting, and no resizing.
- Enhanced `kitColumnPinning` logic to conditionally enable pinning based on horizontal scrolling.
- Added tests for `applyKitActionColumn` and `kitColumnPinning` to ensure expected behavior.
- Adjusted `AccessApiKeysGrid`, `ScheduleModulesGrid`, and `ResourcePageFiltered` components to utilize new action column features.
2026-08-23 02:58:07 +07:00
Denozordec 2d29a892f9 feat(web): integrate shadcn and enhance data grid styles
quality / changes (push) Successful in 8s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 31s
quality / web (push) Successful in 1m7s
quality / go (push) Successful in 1m10s
quality / bird2 (push) Successful in 17s
CD / quality (push) Successful in 3m20s
CD / publish (push) Successful in 4m5s
- Added `shadcn` dependency to the project for improved styling.
- Updated `kitDataGridTableClassNames` to include a new base class for pinned cells.
- Enhanced global styles by importing `shadcn/tailwind.css`.
- Added tests to verify new class names in the data grid component.
2026-08-23 02:33:22 +07:00
Denozordec d9bec85c02 feat(docker): add Docker Hub authentication for CI/CD workflows
quality / commitlint (push) Skipped
quality / changes (push) Successful in 7s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 21s
quality / web (push) Successful in 1m12s
quality / go (push) Successful in 1m3s
quality / bird2 (push) Successful in 16s
CD / quality (push) Successful in 3m7s
CD / publish (push) Successful in 3m58s
- Introduced `docker_hub_token` and `docker_hub_username` secrets for Docker Hub login in CI/CD workflows.
- Updated `.gitea/workflows/ci.yaml`, `.gitea/workflows/cd.yaml`, and `.gitea/workflows/quality.yaml` to utilize these secrets for Docker Hub authentication.
- Enhanced README and releasing documentation to clarify the use of Docker Hub credentials for image mirroring and builds.
2026-08-23 01:55:33 +07:00
257 changed files with 16004 additions and 19194 deletions

No files matched your search

+11 -8
View File
@@ -46,28 +46,31 @@ pnpm dlx shadcn@latest add @reui/data-grid # ReUI enterprise → apps/web/src/co
|--------|----------|--------|
| Button, Card, Sheet, Field, Sidebar | `@shadcn` | `@evobgp/ui/components/*` |
| Blocks (sidebar-07, dashboard-01) | `@shadcn` | blocks → `apps/web/src/components/` |
| Data Grid (sort, pagination, virtual) | `@reui` | `@/components/reui/data-grid/*` → обёртка `DataGridCard` |
| Data Grid (sort, pagination, virtual) | `@reui` | `@/components/reui/data-grid/*` → `reui-kit/ResourcePage` |
| Мультифильтры | `@reui` | `@/components/reui/filters` |
| Number field со stepper | `@reui` | `@/components/reui/number-field` |
| Autocomplete | `@reui` | `@/components/reui/autocomplete` → `AutoCompleteInput` |
| Date selector / range | `@reui` | `@/components/reui/date-selector` |
| Semantic badge (success/info/warning) | `@reui` | `@/components/reui/badge` или `StatusBadge` |
**Простые списки** — shadcn `Table`. **Сложные data-списки** — ReUI data-grid (через `DataGridCard`), не shadcn Data Table.
**Простые списки** — shadcn `Table`. **Сложные data-списки** — ReUI data-grid через `ResourcePage`, не shadcn Data Table.
## Уже установленные shared-обёртки
В `apps/web/src/components/`:
- `PageHeader`, `PageShell` — заголовки и обёртки страниц
- `QueryState` — обёртка loading/error/empty для TanStack Query
- `EmptyState` — пустые списки
- `EmptyState` — пустые списки (empty-state-14 DNA)
- `ConfirmDialog` — подтверждения (не `window.confirm`)
- `LoadingButton` — кнопка с loading-состоянием
- `StatusBadge` — статусные бейджи
- `SectionCards` — сетка KPI-карточек
- `Skeletons` (`TableSkeleton`, `SectionCardsSkeleton`) — скелетоны
- `LoadingButton` — SoT кнопка с loading (`Button` + `Spinner`)
- `StatusBadge` / `CategoryBadge` / `ModeBadge` — semantic ReUI Badge
- `SegmentedTabs` — секции страницы (c-tabs-9)
- `StatusToggleGroup` — статус грида (c-toggle-group-5)
- `FormDrawer` — Sheet overlay (sheet-8 / form-7)
- `Skeletons` (`TableSkeleton`, `KpiStatGridSkeleton`) — скелетоны
- `TruncatedText` — текст с тултипом
- `ModeToggle` — переключатель темы
Kit: `reui-kit/ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `SettingsShell`, `DetailPanel`, `FrameSection`.
Перед созданием новой обёртки — проверить существующие через Codegraph.
+2 -2
View File
@@ -70,14 +70,14 @@ alwaysApply: true
|------|------|--------|
| shadcn | `packages/ui/src/components/` | `@evobgp/ui/components/*` |
| ReUI CLI | `apps/web/src/components/reui/` | `@/components/reui/*` |
| PRO blocks (reference) | `apps/web/src/components/blocks/` | adapt into kit, не копипаст в routes |
| PRO blocks (reference) | — | CLI → adapt into kit; не держать demo-деревья в `src/components/blocks/` |
| Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` |
## Установленные ReUI (apps/web)
**Components:** `frame`, `data-grid/*`, `filters`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`, `icon-tile`, `stepper`
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell`
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell`, `FrameSection`
**Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
+10 -3
View File
@@ -23,10 +23,10 @@ alwaysApply: false
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, LoadingButton |
| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, QuickActionGrid, OpsDashboard, SettingsShell |
| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, QuickActionGrid, OpsDashboard, SettingsShell, DetailPanel, FrameSection |
| Роуты | `apps/web/src/routes/` | TanStack Router (file-based) |
**Design contract:** [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Surface: **frame**. KPI hybrid SoT: [stats-12](https://reui.io/preview/base/stats-12). Lists: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2). Quick Actions: `QuickActionGrid`.
**Design contract:** [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Surface: **frame**. KPI hybrid SoT: [stats-12](https://reui.io/preview/base/stats-12). Lists: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2). Page tabs: [c-tabs-9](https://reui.io/preview/base/components/c-tabs-9). Grid status: [c-toggle-group-5](https://reui.io/preview/base/components/c-toggle-group-5). Quick Actions: `QuickActionGrid`.
Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`.
@@ -48,9 +48,16 @@ alwaysApply: false
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
*Проверка:* https://ui.shadcn.com/docs/components/form
**WEB-06** | MUST | Сложные data-списки — Frame + ReUI DataGrid через `reui-kit/ResourcePage` или `DataGridSection` (не Card shell, не shadcn Data Table). Surface lock: `UI_SURFACE = 'frame'` (`lib/ui-surface.ts`). Простые списки — shadcn `Table`.
**WEB-06** | MUST | Сложные data-списки — Frame + ReUI DataGrid через `reui-kit/ResourcePage` (не Card shell, не shadcn Data Table). Surface lock: `UI_SURFACE = 'frame'` (`lib/ui-surface.ts`). Простые списки — shadcn `Table`.
*Проверка:* `@/components/reui-kit`, `@/components/reui/data-grid`, `docs/ui-design-contract.md`.
**WEB-06a** | MUST | Переключатели по роли UX:
- секции страницы (Сеть / Операции / Справочники) — native `Tabs` default list (`SegmentedTabs`, [c-tabs-9](https://reui.io/preview/base/components/c-tabs-9));
- статус грида (Все/Вкл) — `ToggleGroup` в toolbar (`StatusToggleGroup`, [c-toggle-group-5](https://reui.io/preview/base/components/c-toggle-group-5));
- настройки — вертикальный Tabs rail ([settings-3](https://reui.io/preview/base/settings-3)).
Не `variant="line"` для page/in-grid tabs.
*Проверка:* `@/components/segmented-tabs`, `@/components/status-toggle-group`.
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
*Проверка:* https://ui.shadcn.com/docs/components/sonner
+2
View File
@@ -39,6 +39,8 @@ Runner: `ubuntu-latest`, **bird2** из apt, Docker для **docker-check** (PR)
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `github.token`. Push OCI — **только PAT** (у `GITEA_TOKEN` нет права packages).
**`docker_hub_token`**: PAT Docker Hub (Settings → Actions → Secrets, можно на уровне организации). Логин перед зеркалом `docker.io` и `bake`, чтобы не ловить anonymous 429. Логин: `docker_hub_username` (если задан) или `gitea.actor`.
### Теги образов
```text
+8
View File
@@ -17,6 +17,8 @@ jobs:
allow_registry_login: false
secrets:
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
docker_hub_token: ${{ secrets.docker_hub_token }}
docker_hub_username: ${{ secrets.docker_hub_username }}
publish:
needs: [quality]
@@ -107,6 +109,12 @@ jobs:
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Log in to Docker Hub
if: steps.rel.outputs.released == 'true'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.docker_hub_username || gitea.actor }}
password: ${{ secrets.docker_hub_token }}
- name: Log in to Gitea Registry
if: steps.rel.outputs.released == 'true'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
+2
View File
@@ -21,3 +21,5 @@ jobs:
allow_registry_login: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
secrets:
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
docker_hub_token: ${{ secrets.docker_hub_token }}
docker_hub_username: ${{ secrets.docker_hub_username }}
+21 -2
View File
@@ -26,6 +26,10 @@ on:
secrets:
ACTIONS_PAT:
required: false
docker_hub_token:
required: false
docker_hub_username:
required: false
permissions:
contents: read
@@ -48,8 +52,10 @@ jobs:
fetch-depth: 0
- if: ${{ inputs.is_pull_request == false }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# Полная история: BEFORE_SHA = github.event.before при multi-commit push
# лежит глубже shallow-среза, и git diff падает с "bad object".
with:
fetch-depth: 2
fetch-depth: 0
- id: detect
name: Detect changed paths per module
env:
@@ -90,7 +96,14 @@ jobs:
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
before="$BEFORE_SHA"
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
FILES="$(git diff --name-only "$before" "$after")"
# Force-push мог отбросить before; отсутствие объекта — не ошибка,
# а сигнал уйти в полный прогон через пустой diff.
if git cat-file -e "$before^{commit}" 2>/dev/null; then
FILES="$(git diff --name-only "$before" "$after")"
else
echo "BEFORE_SHA $before not found in checkout — full pipeline fallback"
FILES=""
fi
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
FILES="$(git diff --name-only HEAD~1 HEAD)"
else
@@ -397,6 +410,12 @@ jobs:
name: evobgp
driver: docker-container
cleanup: false
- name: Log in to Docker Hub
if: secrets.docker_hub_token != ''
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.docker_hub_username || gitea.actor }}
password: ${{ secrets.docker_hub_token }}
- name: Log in to Gitea Registry
if: inputs.allow_registry_login
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
+1 -1
View File
@@ -23,7 +23,7 @@
| Конфиг BIRD, `birdc` | `internal/birdfmt/`, `internal/birddeploy/` |
| Бандлы и подписи | `internal/bundle/`, `internal/signing/` |
| Точки входа процессов | `cmd/*/` |
| Веб (SvelteKit) | `web/` |
| Веб (React + ReUI) | `apps/web/` |
| Compose, деплой | `deploy/compose/` |
Точки входа бинарников и их роли — в таблице в начале [docs/architecture.md](docs/architecture.md).
+1 -1
View File
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import globals from 'globals'
export default tseslint.config(
{ ignores: ['dist', 'src/routeTree.gen.ts', 'src/components/blocks/**'] },
{ ignores: ['dist', 'src/routeTree.gen.ts'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
+2
View File
@@ -40,6 +40,8 @@
"react-dom": "^19.2.0",
"react-hook-form": "^7.60.0",
"recharts": "3.8.0",
"shadcn": "^4.19.0",
"shiki": "^4.4.3",
"sonner": "^1.7.0",
"zod": "^3.25.0"
},
@@ -98,6 +98,7 @@ export function AccessApiKeysGrid({
{
id: 'actions',
enableSorting: false,
size: 88,
header: () => null,
cell: ({ row }) => {
const k = row.original
@@ -8,7 +8,7 @@ import {
TooltipTrigger,
} from '@evobgp/ui/components/tooltip'
import { PanelCard } from '@/components/panel-card'
import { FrameSection } from '@/components/reui-kit'
/** Chart Frame shell — chart-1 spacing, not Card.
* @see https://reui.io/preview/base/chart-1
@@ -51,16 +51,16 @@ export function AnalyticsCardShell({
)
return (
<PanelCard
<FrameSection
title={titleNode}
description={description}
actions={actions}
footer={footer}
className={cn('overflow-hidden', className)}
contentClassName="flex flex-col gap-5 py-5"
contentClassName="flex flex-col gap-5 px-5 py-5"
footerClassName={footer ? 'gap-2 px-5 py-4' : undefined}
>
{children}
</PanelCard>
</FrameSection>
)
}
@@ -1,4 +1,4 @@
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import { Badge } from '@/components/reui/badge'
import { cn } from '@evobgp/ui/lib/utils'
@@ -114,7 +114,7 @@ export function DashboardPlatformCard({
</Button>
<Button
className="flex-1"
onClick={() => navigate({ to: '/monitoring', search: { tab: 'system' } })}
onClick={() => navigate({ to: '/monitoring' })}
>
Мониторинг
</Button>
@@ -1,324 +0,0 @@
import { type ComponentProps } from "react"
import { Badge } from "@/components/reui/badge"
import { type DataGridFeatures } from "@/components/reui/data-grid/data-grid"
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
import { type ColumnDef } from "@tanstack/react-table"
import { cn } from "@evobgp/ui/lib/utils"
import { Button } from "@evobgp/ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@evobgp/ui/components/dropdown-menu"
import { type ModuleRecord, type ModuleStatus } from "./data"
import { CalendarDaysIcon, FileTextIcon, StarIcon, MoreHorizontalIcon, CopyIcon, ArchiveIcon } from "lucide-react"
export type ModuleRowAction = "open" | "favorite" | "duplicate" | "archive"
const moduleStatusVariant: Record<
ModuleStatus,
ComponentProps<typeof Badge>["variant"]
> = {
Planned: "info-outline",
Backlog: "outline",
"In Progress": "warning-outline",
}
const moduleStatusDotClass: Record<ModuleStatus, string> = {
Planned: "bg-sky-500 dark:bg-sky-400",
Backlog: "bg-muted-foreground/50",
"In Progress": "bg-amber-500 dark:bg-amber-400",
}
function DotSeparator() {
return (
<span
className="bg-muted-foreground/45 size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
)
}
function getProgressToneClass(value: number) {
if (value >= 75) return "text-emerald-500 dark:text-emerald-400"
if (value >= 40) return "text-amber-500 dark:text-amber-400"
if (value > 0) return "text-sky-500 dark:text-sky-400"
return "text-muted-foreground/35"
}
function getWindowDurationLabel(module: ModuleRecord) {
const start = new Date(module.dateStart).getTime()
const end = new Date(module.dateEnd).getTime()
const dayMs = 24 * 60 * 60 * 1000
const days = Math.max(1, Math.round((end - start) / dayMs))
return `${days}-day window`
}
function getCompactDateRange(module: ModuleRecord) {
return module.dateRange.replace(/, 2026/g, "")
}
function ModuleProgress({ module }: { module: ModuleRecord }) {
const value = module.progress
const radius = 18
const circumference = 2 * Math.PI * radius
const dashOffset = circumference - (value / 100) * circumference
const progressClassName = getProgressToneClass(value)
return (
<div className="flex min-w-0 items-center gap-2.5">
<div className="relative size-10 shrink-0">
<svg
viewBox="0 0 44 44"
className="absolute inset-0 size-10 -rotate-90"
aria-hidden="true"
>
<circle
cx="22"
cy="22"
r={radius}
fill="none"
className="stroke-muted-foreground/20"
strokeWidth="3.25"
/>
<circle
cx="22"
cy="22"
r={radius}
fill="none"
className={cn("stroke-current", progressClassName)}
strokeWidth="3.25"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
/>
</svg>
<span className="text-muted-foreground absolute inset-0 flex items-center justify-center text-[9px] leading-none font-medium tabular-nums">
{value}%
</span>
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-foreground text-sm font-medium tabular-nums">
{value}% ready
</span>
<span className="text-muted-foreground truncate text-xs tabular-nums">
{module.tasksCompleted}/{module.tasksTotal} tasks
</span>
</div>
</div>
)
}
function ModuleNameCell({ module }: { module: ModuleRecord }) {
return (
<div className="flex min-w-0 flex-col gap-1">
<span className="text-foreground truncate text-sm leading-5 font-medium">
{module.name}
</span>
<div className="text-muted-foreground flex min-w-0 flex-wrap items-center gap-1.5 text-xs">
<span className="shrink-0">{module.kind}</span>
<DotSeparator />
<span className="truncate">{module.owner.name}</span>
<DotSeparator />
<span className="truncate font-mono tracking-wide uppercase">
{module.id}
</span>
</div>
</div>
)
}
function ModuleDateCell({ module }: { module: ModuleRecord }) {
return (
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-foreground truncate text-sm font-medium tabular-nums">
{getCompactDateRange(module)}
</span>
<span className="text-muted-foreground inline-flex min-w-0 items-center gap-1.5 truncate text-xs">
<CalendarDaysIcon className="size-3.5 shrink-0" aria-hidden="true" />
{getWindowDurationLabel(module)}
</span>
</div>
)
}
function ModuleStatusCell({ module }: { module: ModuleRecord }) {
return (
<Badge variant={moduleStatusVariant[module.status]}>
<span
className={cn(
"size-1.5 shrink-0 rounded-full!",
moduleStatusDotClass[module.status]
)}
aria-hidden="true"
/>
{module.status}
</Badge>
)
}
function ModuleActions({
module,
onAction,
}: {
module: ModuleRecord
onAction: (action: ModuleRowAction, module: ModuleRecord) => void
}) {
return (
<div className="flex items-center justify-end gap-1">
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`Open ${module.name}`}
onClick={(event) => {
event.stopPropagation()
onAction("open", module)
}}
>
<FileTextIcon aria-hidden="true" />
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`${module.favorite ? "Unfavorite" : "Favorite"} ${
module.name
}`}
className={cn(module.favorite && "text-amber-500")}
onClick={(event) => {
event.stopPropagation()
onAction("favorite", module)
}}
>
<StarIcon aria-hidden="true" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`More actions for ${module.name}`}
onClick={(event) => event.stopPropagation()}
/>
}
>
<MoreHorizontalIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onAction("duplicate", module)}>
<CopyIcon aria-hidden="true" />
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => onAction("archive", module)}
>
<ArchiveIcon aria-hidden="true" />
Archive
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
export function createModuleGridColumns({
onAction,
}: {
onAction: (action: ModuleRowAction, module: ModuleRecord) => void
}): ColumnDef<DataGridFeatures, ModuleRecord>[] {
return [
{
accessorKey: "progress",
id: "progress",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => <ModuleProgress module={row.original} />,
size: 210,
enableSorting: true,
enableHiding: false,
enableResizing: false,
meta: {
headerTitle: "Progress",
},
},
{
accessorKey: "name",
id: "name",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => <ModuleNameCell module={row.original} />,
minSize: 300,
enableSorting: true,
enableHiding: false,
enableResizing: false,
meta: {
autoSize: true,
headerTitle: "Module",
},
},
{
accessorKey: "dateStart",
id: "dateStart",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => <ModuleDateCell module={row.original} />,
sortFn: (rowA, rowB) =>
new Date(rowA.original.dateStart).getTime() -
new Date(rowB.original.dateStart).getTime(),
size: 180,
enableSorting: true,
enableHiding: false,
enableResizing: false,
meta: {
headerTitle: "Window",
},
},
{
accessorKey: "status",
id: "status",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => <ModuleStatusCell module={row.original} />,
size: 126,
enableSorting: true,
enableHiding: false,
enableResizing: false,
meta: {
headerTitle: "Status",
},
},
{
id: "actions",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => (
<ModuleActions module={row.original} onAction={onAction} />
),
size: 104,
enableSorting: false,
enableHiding: false,
enableResizing: false,
meta: {
headerTitle: "Actions",
},
},
]
}
@@ -1,355 +0,0 @@
import { useCallback, useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import {
DataGrid,
dataGridFeatures,
} 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 {
useTable,
type PaginationState,
type SortingState,
} from "@tanstack/react-table"
import { toast } from "sonner"
import { Button } from "@evobgp/ui/components/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@evobgp/ui/components/dropdown-menu"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@evobgp/ui/components/input-group"
import { createModuleGridColumns, type ModuleRowAction } from "./columns"
import {
MODULE_RECORDS,
MODULE_STATUS_OPTIONS,
type ModuleRecord,
type ModuleStatus,
} from "./data"
import { FlagIcon, ChevronRightIcon, PackageIcon, SearchIcon, XIcon, ArrowUpDownIcon, ChevronDownIcon, FilterIcon } from "lucide-react"
type ModuleSort = "name" | "dateStart" | "progress" | "status"
const sortLabels: Record<ModuleSort, string> = {
name: "Name",
dateStart: "Window",
progress: "Progress",
status: "Status",
}
function buildSorting(sortBy: ModuleSort): SortingState {
return [{ id: sortBy, desc: false }]
}
function getModuleSearchBlob(module: ModuleRecord) {
return [
module.name,
module.id,
module.kind,
module.owner.name,
module.owner.role,
module.health,
module.status,
module.dateRange,
]
.join(" ")
.toLowerCase()
}
export function ModulesDataGridView() {
const [modules, setModules] = useState<ModuleRecord[]>(MODULE_RECORDS)
const [searchQuery, setSearchQuery] = useState("")
const [selectedStatuses, setSelectedStatuses] = useState<ModuleStatus[]>([])
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const [sortBy, setSortBy] = useState<ModuleSort>("name")
const [sorting, setSorting] = useState<SortingState>(() =>
buildSorting("name")
)
const filteredModules = useMemo(() => {
const normalizedSearchQuery = searchQuery.trim().toLowerCase()
return modules.filter((module) => {
const matchesSearch =
normalizedSearchQuery.length === 0 ||
getModuleSearchBlob(module).includes(normalizedSearchQuery)
const matchesStatus =
selectedStatuses.length === 0 ||
selectedStatuses.includes(module.status)
return matchesSearch && matchesStatus
})
}, [modules, searchQuery, selectedStatuses])
const activeFilterCount = selectedStatuses.length
const resetPagination = useCallback(() => {
setPagination((current) => ({
...current,
pageIndex: 0,
}))
}, [])
const handleSearchChange = useCallback(
(value: string) => {
setSearchQuery(value)
resetPagination()
},
[resetPagination]
)
const handleStatusToggle = useCallback(
(status: ModuleStatus, checked: boolean) => {
setSelectedStatuses((current) => {
if (checked) {
return current.includes(status) ? current : [...current, status]
}
return current.filter((item) => item !== status)
})
resetPagination()
},
[resetPagination]
)
const handleSortChange = useCallback(
(value: string) => {
const nextSort = value as ModuleSort
setSortBy(nextSort)
setSorting(buildSorting(nextSort))
resetPagination()
},
[resetPagination]
)
const handleModuleAction = useCallback(
(action: ModuleRowAction, module: ModuleRecord) => {
if (action === "favorite") {
setModules((current) =>
current.map((item) =>
item.id === module.id
? {
...item,
favorite: !item.favorite,
}
: item
)
)
toast.success(module.favorite ? "Removed favorite" : "Module starred", {
description: module.name,
})
return
}
if (action === "open") {
toast.info("Open module", {
description: `${module.name} (${module.kind})`,
})
return
}
toast.message(
action === "duplicate" ? "Duplicate module" : "Archive module",
{
description: `Connect this action to your ${module.name} module flow.`,
}
)
},
[]
)
const handleAddModule = () => {
toast.success("Add module", {
description: "Open your module creation dialog from this primary action.",
})
}
const columns = useMemo(
() => createModuleGridColumns({ onAction: handleModuleAction }),
[handleModuleAction]
)
// eslint-disable-next-line react-hooks/incompatible-library
const table = useTable({
features: dataGridFeatures,
data: filteredModules,
columns,
pageCount: Math.ceil(filteredModules.length / pagination.pageSize),
state: {
pagination,
sorting,
},
onPaginationChange: setPagination,
onSortingChange: setSorting,
getRowId: (row) => row.id,
})
return (
<DataGrid
table={table}
recordCount={filteredModules.length}
emptyMessage="No modules match the selected filters."
tableLayout={{
dense: true,
rowBorder: true,
headerSticky: false,
columnsVisibility: false,
columnsResizable: false,
columnsMovable: false,
width: "fixed",
}}
tableClassNames={{
bodyRow: "group/module-row [&>td]:h-16",
}}
>
<div className="flex w-full max-w-6xl flex-col">
<div className="flex flex-col gap-3 border-b px-0 py-3 lg:min-h-14 lg:flex-row lg:items-center lg:gap-4 lg:py-0">
<div className="flex min-w-0 items-center gap-2">
<FlagIcon className="size-3.5 shrink-0 fill-amber-400 text-amber-400" aria-hidden="true" />
<span className="text-muted-foreground truncate text-sm">
Keenthemes
</span>
<ChevronRightIcon className="text-muted-foreground size-3.5 shrink-0" aria-hidden="true" />
<PackageIcon className="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
<h2 className="text-foreground truncate text-sm font-medium">
Modules
</h2>
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2 lg:ml-auto lg:flex-nowrap">
<InputGroup className="w-full min-w-40 sm:w-48">
<InputGroupAddon align="inline-start">
<SearchIcon className="text-muted-foreground size-4" aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
value={searchQuery}
onChange={(event) => handleSearchChange(event.target.value)}
placeholder="Search..."
aria-label="Search modules"
/>
{searchQuery.length > 0 ? (
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label="Clear search"
onClick={() => handleSearchChange("")}
>
<XIcon className="size-4" aria-hidden="true" />
</InputGroupButton>
</InputGroupAddon>
) : null}
</InputGroup>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button type="button" variant="outline">
<ArrowUpDownIcon data-icon="inline-start" aria-hidden="true" />
{sortLabels[sortBy]}
<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />
</Button>
}
/>
<DropdownMenuContent align="end" className="min-w-44">
<DropdownMenuGroup>
{(["name", "dateStart", "progress", "status"] as const).map(
(value) => (
<DropdownMenuItem
key={value}
onClick={() => handleSortChange(value)}
>
{sortLabels[value]}
</DropdownMenuItem>
)
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button type="button" variant="outline">
<FilterIcon data-icon="inline-start" aria-hidden="true" />
Filters
{activeFilterCount > 0 ? (
<Badge variant="outline" radius="full">
{activeFilterCount}
</Badge>
) : null}
</Button>
}
/>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuGroup>
<DropdownMenuLabel>Status</DropdownMenuLabel>
{MODULE_STATUS_OPTIONS.map((status) => (
<DropdownMenuCheckboxItem
key={status}
checked={selectedStatuses.includes(status)}
closeOnClick={false}
onCheckedChange={(checked) =>
handleStatusToggle(status, checked === true)
}
>
{status}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{activeFilterCount > 0 ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
closeOnClick={false}
onClick={() => {
setSelectedStatuses([])
resetPagination()
}}
>
Reset filters
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
<Button type="button" onClick={handleAddModule}>
Add Module
</Button>
</div>
</div>
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<div className="border-t px-0 py-3">
{filteredModules.length > 0 ? (
<DataGridPagination
sizes={[5, 10, 15]}
info="{from} - {to} of {count} modules"
className="py-0"
/>
) : (
<p className="text-muted-foreground text-sm">0 modules</p>
)}
</div>
</div>
</DataGrid>
)
}
@@ -1,394 +0,0 @@
export type ModuleStatus = "Planned" | "Backlog" | "In Progress"
export type ModuleKind = "System" | "Feature" | "Area"
export type ModuleHealth = "On Track" | "Watch" | "Blocked"
export interface ModuleOwner {
name: string
initials: string
role: string
avatarSrc: string
}
export interface ModuleRecord {
id: string
name: string
kind: ModuleKind
owner: ModuleOwner
progress: number
tasksCompleted: number
tasksTotal: number
contributors: number
blockers: number
health: ModuleHealth
dateStart: string
dateEnd: string
dateRange: string
status: ModuleStatus
favorite: boolean
}
export const MODULE_STATUS_OPTIONS: ModuleStatus[] = [
"Planned",
"Backlog",
"In Progress",
]
const moduleOwners = {
maya: {
name: "Maya Patel",
initials: "MP",
role: "Release lead",
avatarSrc:
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=80&h=80&dpr=2&q=80",
},
jonah: {
name: "Jonah Lee",
initials: "JL",
role: "Product ops",
avatarSrc:
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=80&h=80&dpr=2&q=80",
},
nina: {
name: "Nina Santos",
initials: "NS",
role: "Workspace admin",
avatarSrc:
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=80&h=80&dpr=2&q=80",
},
elijah: {
name: "Elijah Morgan",
initials: "EM",
role: "Billing lead",
avatarSrc:
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=80&h=80&dpr=2&q=80",
},
priya: {
name: "Priya Shah",
initials: "PS",
role: "Lifecycle PM",
avatarSrc:
"https://images.unsplash.com/photo-1517841905240-472988babdf9?w=80&h=80&dpr=2&q=80",
},
omar: {
name: "Omar Haddad",
initials: "OH",
role: "Security owner",
avatarSrc:
"https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=80&h=80&dpr=2&q=80",
},
sofia: {
name: "Sofia Romero",
initials: "SR",
role: "Content lead",
avatarSrc:
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=80&h=80&dpr=2&q=80",
},
kenji: {
name: "Kenji Tan",
initials: "KT",
role: "Platform lead",
avatarSrc:
"https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=80&h=80&dpr=2&q=80",
},
lena: {
name: "Lena Brooks",
initials: "LB",
role: "Developer tools",
avatarSrc:
"https://images.unsplash.com/photo-1551836022-d5d88e9218df?w=80&h=80&dpr=2&q=80",
},
} satisfies Record<string, ModuleOwner>
export const MODULE_RECORDS: ModuleRecord[] = [
{
id: "core-workflow",
name: "Core Workflow",
kind: "System",
owner: moduleOwners.maya,
progress: 25,
tasksCompleted: 8,
tasksTotal: 32,
contributors: 6,
blockers: 0,
health: "On Track",
dateStart: "2026-04-17",
dateEnd: "2026-05-01",
dateRange: "Apr 17 - May 01, 2026",
status: "Planned",
favorite: false,
},
{
id: "onboarding-flow",
name: "Onboarding Flow",
kind: "Feature",
owner: moduleOwners.jonah,
progress: 0,
tasksCompleted: 0,
tasksTotal: 18,
contributors: 4,
blockers: 0,
health: "Watch",
dateStart: "2026-04-19",
dateEnd: "2026-05-03",
dateRange: "Apr 19 - May 03, 2026",
status: "Backlog",
favorite: false,
},
{
id: "workspace-setup",
name: "Workspace Setup",
kind: "Area",
owner: moduleOwners.nina,
progress: 0,
tasksCompleted: 2,
tasksTotal: 14,
contributors: 3,
blockers: 1,
health: "Blocked",
dateStart: "2026-04-21",
dateEnd: "2026-05-05",
dateRange: "Apr 21 - May 05, 2026",
status: "In Progress",
favorite: false,
},
{
id: "permission-matrix",
name: "Permission Matrix",
kind: "System",
owner: moduleOwners.maya,
progress: 42,
tasksCompleted: 11,
tasksTotal: 26,
contributors: 5,
blockers: 0,
health: "Watch",
dateStart: "2026-04-22",
dateEnd: "2026-05-06",
dateRange: "Apr 22 - May 06, 2026",
status: "In Progress",
favorite: true,
},
{
id: "billing-rules",
name: "Billing Rules",
kind: "Feature",
owner: moduleOwners.elijah,
progress: 64,
tasksCompleted: 21,
tasksTotal: 33,
contributors: 7,
blockers: 0,
health: "On Track",
dateStart: "2026-04-18",
dateEnd: "2026-05-02",
dateRange: "Apr 18 - May 02, 2026",
status: "In Progress",
favorite: false,
},
{
id: "notification-center",
name: "Notification Center",
kind: "Area",
owner: moduleOwners.priya,
progress: 18,
tasksCompleted: 5,
tasksTotal: 28,
contributors: 4,
blockers: 2,
health: "Blocked",
dateStart: "2026-04-23",
dateEnd: "2026-05-09",
dateRange: "Apr 23 - May 09, 2026",
status: "Backlog",
favorite: false,
},
{
id: "audit-trail",
name: "Audit Trail",
kind: "System",
owner: moduleOwners.omar,
progress: 76,
tasksCompleted: 19,
tasksTotal: 25,
contributors: 5,
blockers: 0,
health: "On Track",
dateStart: "2026-04-15",
dateEnd: "2026-04-30",
dateRange: "Apr 15 - Apr 30, 2026",
status: "In Progress",
favorite: true,
},
{
id: "template-library",
name: "Template Library",
kind: "Feature",
owner: moduleOwners.sofia,
progress: 33,
tasksCompleted: 10,
tasksTotal: 30,
contributors: 6,
blockers: 0,
health: "Watch",
dateStart: "2026-04-24",
dateEnd: "2026-05-10",
dateRange: "Apr 24 - May 10, 2026",
status: "Planned",
favorite: false,
},
{
id: "integration-hub",
name: "Integration Hub",
kind: "Area",
owner: moduleOwners.kenji,
progress: 58,
tasksCompleted: 14,
tasksTotal: 24,
contributors: 8,
blockers: 1,
health: "Watch",
dateStart: "2026-04-20",
dateEnd: "2026-05-04",
dateRange: "Apr 20 - May 04, 2026",
status: "In Progress",
favorite: false,
},
{
id: "api-console",
name: "API Console",
kind: "Feature",
owner: moduleOwners.lena,
progress: 91,
tasksCompleted: 29,
tasksTotal: 32,
contributors: 4,
blockers: 0,
health: "On Track",
dateStart: "2026-04-12",
dateEnd: "2026-04-26",
dateRange: "Apr 12 - Apr 26, 2026",
status: "In Progress",
favorite: true,
},
{
id: "role-automation",
name: "Role Automation",
kind: "System",
owner: moduleOwners.maya,
progress: 12,
tasksCompleted: 3,
tasksTotal: 25,
contributors: 3,
blockers: 1,
health: "Blocked",
dateStart: "2026-04-25",
dateEnd: "2026-05-12",
dateRange: "Apr 25 - May 12, 2026",
status: "Backlog",
favorite: false,
},
{
id: "workspace-invites",
name: "Workspace Invites",
kind: "Feature",
owner: moduleOwners.jonah,
progress: 47,
tasksCompleted: 15,
tasksTotal: 32,
contributors: 5,
blockers: 0,
health: "Watch",
dateStart: "2026-04-19",
dateEnd: "2026-05-06",
dateRange: "Apr 19 - May 06, 2026",
status: "Planned",
favorite: false,
},
{
id: "release-checklist",
name: "Release Checklist",
kind: "Area",
owner: moduleOwners.nina,
progress: 84,
tasksCompleted: 26,
tasksTotal: 31,
contributors: 7,
blockers: 0,
health: "On Track",
dateStart: "2026-04-16",
dateEnd: "2026-05-01",
dateRange: "Apr 16 - May 01, 2026",
status: "In Progress",
favorite: false,
},
{
id: "reporting-digest",
name: "Reporting Digest",
kind: "Feature",
owner: moduleOwners.priya,
progress: 5,
tasksCompleted: 2,
tasksTotal: 38,
contributors: 3,
blockers: 0,
health: "Watch",
dateStart: "2026-04-28",
dateEnd: "2026-05-16",
dateRange: "Apr 28 - May 16, 2026",
status: "Backlog",
favorite: false,
},
{
id: "security-review",
name: "Security Review",
kind: "System",
owner: moduleOwners.omar,
progress: 69,
tasksCompleted: 18,
tasksTotal: 26,
contributors: 6,
blockers: 2,
health: "Blocked",
dateStart: "2026-04-18",
dateEnd: "2026-05-07",
dateRange: "Apr 18 - May 07, 2026",
status: "In Progress",
favorite: false,
},
{
id: "help-center",
name: "Help Center",
kind: "Area",
owner: moduleOwners.sofia,
progress: 39,
tasksCompleted: 9,
tasksTotal: 23,
contributors: 4,
blockers: 0,
health: "On Track",
dateStart: "2026-04-23",
dateEnd: "2026-05-11",
dateRange: "Apr 23 - May 11, 2026",
status: "Planned",
favorite: false,
},
{
id: "data-retention",
name: "Data Retention",
kind: "System",
owner: moduleOwners.kenji,
progress: 22,
tasksCompleted: 7,
tasksTotal: 32,
contributors: 5,
blockers: 1,
health: "Watch",
dateStart: "2026-04-27",
dateEnd: "2026-05-14",
dateRange: "Apr 27 - May 14, 2026",
status: "Backlog",
favorite: false,
},
]
@@ -1,15 +0,0 @@
import { ModulesDataGridView } from "./components/data-grid-view"
export function Page() {
return (
<main
className="mx-auto flex min-h-svh w-full max-w-6xl items-start justify-center p-4 pt-8 sm:p-8 sm:pt-12"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Modules data grid
</h1>
<ModulesDataGridView />
</main>
)
}
@@ -1,394 +0,0 @@
"use client"
import { type ReactNode } from "react"
import { Badge } from "@/components/reui/badge"
import { type DataGridFeatures } from "@/components/reui/data-grid/data-grid"
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
import {
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from "@/components/reui/data-grid/data-grid-table"
import { Rating } from "@/components/reui/rating"
import { type ColumnDef } from "@tanstack/react-table"
import { format, parseISO } from "date-fns"
import { cn } from "@evobgp/ui/lib/utils"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@evobgp/ui/components/avatar"
import { Button } from "@evobgp/ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@evobgp/ui/components/dropdown-menu"
import { Item, ItemMedia } from "@evobgp/ui/components/item"
import { Switch } from "@evobgp/ui/components/switch"
import {
type AutomationKind,
type AutomationOwnerAvailability,
type AutomationState,
type IAutomationRecord,
} from "./data"
import { GitBranchIcon, RouteIcon, SparklesIcon, MailIcon, BellRingIcon, EllipsisVerticalIcon, PencilIcon, EyeIcon, CircleCheckIcon, ArchiveIcon } from "lucide-react"
export type AutomationAction = "edit" | "open" | "archive"
const automationKindStyles: Record<
AutomationKind,
{ chipClassName: string; icon: ReactNode }
> = {
sequence: {
chipClassName: "text-sky-600 dark:text-sky-300",
icon: (
<GitBranchIcon className="size-4" aria-hidden="true" />
),
},
routing: {
chipClassName: "text-violet-600 dark:text-violet-300",
icon: (
<RouteIcon className="size-4" aria-hidden="true" />
),
},
enrichment: {
chipClassName: "text-emerald-600 dark:text-emerald-300",
icon: (
<SparklesIcon className="size-4" aria-hidden="true" />
),
},
digest: {
chipClassName: "text-amber-600 dark:text-amber-300",
icon: (
<MailIcon className="size-4" aria-hidden="true" />
),
},
escalation: {
chipClassName: "text-rose-600 dark:text-rose-300",
icon: (
<BellRingIcon className="size-4" aria-hidden="true" />
),
},
}
const stateBadgeStyles: Record<
AutomationState,
{ label: string; dotClassName?: string }
> = {
live: {
label: "Live",
dotClassName: "bg-emerald-500",
},
review: {
label: "Needs approval",
dotClassName: "bg-amber-500",
},
drafts: {
label: "Draft",
dotClassName: "bg-slate-400 dark:bg-slate-300",
},
paused: {
label: "Paused",
dotClassName: "bg-zinc-400 dark:bg-zinc-300",
},
}
const updatedBucketLabel: Record<IAutomationRecord["updatedBucket"], string> = {
today: "Today",
"this-week": "This week",
older: "Older",
}
const availabilityColor: Record<AutomationOwnerAvailability, string> = {
online: "bg-green-500",
away: "bg-yellow-400",
busy: "bg-red-500",
offline: "bg-gray-500",
}
function AutomationKindChip({ kind }: { kind: AutomationKind }) {
const style = automationKindStyles[kind]
return (
<Item
render={<span />}
className={cn(
"p-0",
"border-background bg-muted flex size-9 items-center justify-center border-2 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4",
style.chipClassName
)}
aria-hidden="true"
>
<ItemMedia variant="icon" className="size-auto">
{style.icon}
</ItemMedia>
</Item>
)
}
function AutomationNameCell({ automation }: { automation: IAutomationRecord }) {
return (
<div className="flex min-w-0 items-center gap-3">
<AutomationKindChip kind={automation.kind} />
<div className="flex min-w-0 flex-col gap-px">
<span className="text-foreground truncate text-sm font-medium">
{automation.title}
</span>
<div className="text-muted-foreground flex min-w-0 items-center gap-1.5 text-xs">
<span className="truncate">{automation.runWindowLabel}</span>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span className="truncate">{automation.audienceLabel}</span>
</div>
</div>
</div>
)
}
function OwnerCell({ automation }: { automation: IAutomationRecord }) {
return (
<div className="flex items-center gap-2">
<div className="relative shrink-0">
<Avatar className="size-8">
{automation.owner.avatar ? (
<AvatarImage
src={automation.owner.avatar}
alt={automation.owner.name}
/>
) : null}
<AvatarFallback>{automation.owner.initials}</AvatarFallback>
</Avatar>
<span
className={cn(
"ring-background absolute right-0 bottom-0.5 size-2 rounded-full ring-2",
availabilityColor[automation.owner.availability]
)}
aria-hidden
/>
</div>
<div className="min-w-0">
<div className="text-foreground line-clamp-1 font-medium">
{automation.owner.name}
</div>
<div
className="text-muted-foreground line-clamp-1 text-xs"
title={automation.owner.email}
>
{automation.owner.email}
</div>
</div>
</div>
)
}
function StateCell({ automation }: { automation: IAutomationRecord }) {
const stateStyle = stateBadgeStyles[automation.state]
return (
<div className="flex min-w-0 items-center">
<Badge variant="outline" className="gap-1.5">
{stateStyle.dotClassName ? (
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
stateStyle.dotClassName
)}
aria-hidden="true"
/>
) : null}
{stateStyle.label}
</Badge>
</div>
)
}
function UpdatedCell({ automation }: { automation: IAutomationRecord }) {
return (
<div className="flex min-w-0 flex-col gap-px">
<span className="text-foreground text-sm">
{format(parseISO(automation.updatedAt), "MMM d, yyyy")}
</span>
<span className="text-muted-foreground text-xs">
{updatedBucketLabel[automation.updatedBucket]}
</span>
</div>
)
}
function RatingCell({ automation }: { automation: IAutomationRecord }) {
return <Rating rating={automation.rating} size="sm" showValue={true} />
}
function AutomationActionsCell({
automation,
onAction,
onToggleEnabled,
}: {
automation: IAutomationRecord
onAction: (action: AutomationAction, automation: IAutomationRecord) => void
onToggleEnabled: (automation: IAutomationRecord, nextValue: boolean) => void
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Open actions for ${automation.title}`}
>
<EllipsisVerticalIcon className="size-4" aria-hidden="true" />
</Button>
}
/>
{/* Content */}
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onAction("edit", automation)}>
<PencilIcon className="size-4" aria-hidden="true" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onAction("open", automation)}>
<EyeIcon className="size-4" aria-hidden="true" />
View Details
</DropdownMenuItem>
<DropdownMenuItem
closeOnClick={false}
onClick={(event) => {
// The Switch toggles itself and its click bubbles here; skip it
// so item-level activation (row click, Enter/Space) toggles once.
if (
event.target instanceof Element &&
event.target.closest('[data-slot="switch"]')
) {
return
}
onToggleEnabled(automation, !automation.enabled)
}}
className="justify-between gap-4"
>
<span className="flex items-center gap-2">
<CircleCheckIcon className="size-4" aria-hidden="true" />
Enabled
</span>
<Switch
size="sm"
aria-label={`Toggle ${automation.title}`}
checked={automation.enabled}
onCheckedChange={(checked) =>
onToggleEnabled(automation, checked)
}
/>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => onAction("archive", automation)}
>
<ArchiveIcon className="size-4" aria-hidden="true" />
Archive
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function createAutomationColumns({
onAction,
onToggleEnabled,
}: {
onAction: (action: AutomationAction, automation: IAutomationRecord) => void
onToggleEnabled: (automation: IAutomationRecord, nextValue: boolean) => void
}): ColumnDef<DataGridFeatures, IAutomationRecord>[] {
return [
{
id: "select",
header: () => <DataGridTableRowSelectAll />,
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
size: 30,
enableSorting: false,
enableResizing: false,
enableHiding: false,
},
{
accessorFn: (row) => row.title,
id: "workflow",
header: ({ column }) => (
<DataGridColumnHeader title="Workflow" column={column} />
),
cell: ({ row }) => <AutomationNameCell automation={row.original} />,
size: 340,
minSize: 200,
enableSorting: true,
enableHiding: false,
meta: {
autoSize: true,
headerClassName: "pl-3!",
cellClassName: "pl-3!",
},
},
{
accessorFn: (row) => row.owner.name,
id: "owner",
header: ({ column }) => (
<DataGridColumnHeader title="Owner" column={column} />
),
cell: ({ row }) => <OwnerCell automation={row.original} />,
size: 175,
enableSorting: true,
},
{
accessorFn: (row) => row.rating,
id: "rating",
header: ({ column }) => (
<DataGridColumnHeader title="Score" column={column} />
),
cell: ({ row }) => <RatingCell automation={row.original} />,
size: 150,
enableSorting: true,
},
{
accessorFn: (row) => row.state,
id: "state",
header: ({ column }) => (
<DataGridColumnHeader title="State" column={column} />
),
cell: ({ row }) => <StateCell automation={row.original} />,
size: 150,
enableSorting: true,
},
{
accessorFn: (row) => parseISO(row.updatedAt).getTime(),
id: "updatedAt",
header: ({ column }) => (
<DataGridColumnHeader title="Last updated" column={column} />
),
cell: ({ row }) => <UpdatedCell automation={row.original} />,
size: 125,
enableSorting: true,
},
{
id: "actions",
header: () => null,
cell: ({ row }) => (
<div className="flex items-center justify-end">
<AutomationActionsCell
automation={row.original}
onAction={onAction}
onToggleEnabled={onToggleEnabled}
/>
</div>
),
size: 56,
enableSorting: false,
},
]
}
@@ -1,679 +0,0 @@
import { useCallback, useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import {
DataGrid,
dataGridFeatures,
} 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 } from "@/components/reui/filters/filters"
import {
createFilterQuery,
createFilterRule,
flattenFilterConditions,
} from "@/components/reui/filters/filters-query"
import type { FilterCondition } from "@/components/reui/filters/filters-query"
import type {
FilterField,
FilterQuery,
} from "@/components/reui/filters/filters-types"
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import {
useTable,
type PaginationState,
type RowSelectionState,
type SortingState,
} from "@tanstack/react-table"
import { toast } from "sonner"
import { cn } from "@evobgp/ui/lib/utils"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@evobgp/ui/components/alert-dialog"
import { Button } from "@evobgp/ui/components/button"
import { Separator } from "@evobgp/ui/components/separator"
import { Tabs, TabsList, TabsTrigger } from "@evobgp/ui/components/tabs"
import { createAutomationColumns } from "./columns"
import {
AUTOMATION_TABS,
AUTOMATIONS,
DELIVERY_FILTER_OPTIONS,
getAutomationTabFromState,
OWNER_FILTER_OPTIONS,
UPDATED_FILTER_OPTIONS,
type AutomationState,
type AutomationTab,
type IAutomationRecord,
} from "./data"
import { SearchIcon, UsersIcon, RouteIcon, ClockIcon, PlusIcon, FilterIcon, FunnelXIcon } from "lucide-react"
type ToastTone = "success" | "neutral" | "destructive"
const toneStyles: Record<ToastTone, { dot: string }> = {
success: { dot: "bg-emerald-500" },
neutral: { dot: "bg-sky-500" },
destructive: { dot: "bg-rose-500" },
}
function showAutomationToast({
tone,
title,
description,
}: {
tone: ToastTone
title: string
description: string
}) {
toast.custom((id) => (
<div className="bg-popover text-popover-foreground border-border flex w-[356px] flex-col gap-3 rounded-md border p-4 shadow-lg">
<div className="flex items-start gap-2">
<span
className={cn(
"mt-1 flex size-2 shrink-0 rounded-full",
toneStyles[tone].dot
)}
aria-hidden="true"
/>
<div className="flex flex-1 flex-col gap-1">
<p className="text-sm font-semibold">{title}</p>
<p className="text-muted-foreground text-sm leading-relaxed text-pretty">
{description}
</p>
</div>
</div>
<div className="flex gap-2">
<Button size="xs" variant="outline" onClick={() => toast.dismiss(id)}>
Done
</Button>
</div>
</div>
))
}
function getAutomationSearchBlob(automation: IAutomationRecord) {
return [
automation.title,
automation.kind,
automation.state,
automation.deliveryMode,
automation.audienceLabel,
automation.runWindowLabel,
automation.owner.name,
automation.owner.email,
automation.owner.teamLabel,
automation.approvalRequired ? "approval required" : "auto-approved",
automation.enabled ? "enabled" : "disabled",
]
.join(" ")
.toLowerCase()
}
/** Operators that take no value, so an empty `values` list is expected. */
const VALUELESS_OPERATORS = new Set(["empty", "not_empty"])
function getActiveFilters(filters: FilterCondition[]) {
return filters.filter((filter) => {
const { operator, values } = filter
if (VALUELESS_OPERATORS.has(operator)) return true
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
})
}
function renderSelectedCount(values: unknown[]) {
if (values.length === 0) return "Select..."
if (values.length > 1) return `${values.length} selected`
return null
}
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])
}
function filterFieldValue(
automation: IAutomationRecord,
field: string
): unknown {
switch (field) {
case "workflow":
return getAutomationSearchBlob(automation)
case "ownerTeam":
return automation.owner.team
case "deliveryMode":
return automation.deliveryMode
case "updatedBucket":
return automation.updatedBucket
default:
return ""
}
}
function matchesFilterCondition(
fieldValue: unknown,
operator: string,
values: unknown[]
): boolean {
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_none_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
}
}
function applyFiltersToData(
data: IAutomationRecord[],
filters: FilterCondition[]
): IAutomationRecord[] {
const active = getActiveFilters(filters)
let result = [...data]
active.forEach((filter) => {
const { field, operator, values, negated } = filter
result = result.filter((item) => {
const raw = filterFieldValue(item, field)
const fieldValue = raw != null ? raw : ""
const matches = matchesFilterCondition(fieldValue, operator, values)
// A chip negated from its menu keeps its operator and flips its meaning.
return negated ? !matches : matches
})
})
return result
}
const OWNER_TEAM_FILTER_OPTIONS = OWNER_FILTER_OPTIONS.filter(
(option) => option.value !== "everyone"
).map((option) => ({
value: option.value,
label: option.label,
}))
const DELIVERY_MODE_FILTER_OPTIONS = DELIVERY_FILTER_OPTIONS.filter(
(option) => option.value !== "any"
).map((option) => ({
value: option.value,
label: option.label,
}))
const UPDATED_BUCKET_FILTER_OPTIONS = UPDATED_FILTER_OPTIONS.filter(
(option) => option.value !== "any"
).map((option) => ({
value: option.value,
label: option.label,
}))
const filterFields: FilterField[] = [
{
id: "workflow",
label: "Workflow",
icon: (
<SearchIcon className="size-3.5" aria-hidden />
),
type: "text",
placeholder: "Search...",
},
{
id: "ownerTeam",
label: "Owner team",
icon: (
<UsersIcon className="size-3.5" aria-hidden />
),
type: "select",
searchable: false,
options: OWNER_TEAM_FILTER_OPTIONS,
renderValue: ({ values }) =>
renderSingleSelectedLabel(values, OWNER_TEAM_FILTER_OPTIONS),
},
{
id: "deliveryMode",
label: "Delivery mode",
icon: (
<RouteIcon className="size-3.5" aria-hidden />
),
type: "select",
searchable: false,
options: DELIVERY_MODE_FILTER_OPTIONS,
renderValue: ({ values }) =>
renderSingleSelectedLabel(values, DELIVERY_MODE_FILTER_OPTIONS),
},
{
id: "updatedBucket",
label: "Last updated",
icon: (
<ClockIcon className="size-3.5" aria-hidden />
),
type: "select",
searchable: false,
options: UPDATED_BUCKET_FILTER_OPTIONS,
renderValue: ({ values }) =>
renderSingleSelectedLabel(values, UPDATED_BUCKET_FILTER_OPTIONS),
},
]
function createDefaultAutomationFilters(): FilterQuery {
return createFilterQuery([
createFilterRule({
id: "workflow-1",
path: ["workflow"],
operator: "contains",
value: "",
}),
])
}
function getTabCounts(records: IAutomationRecord[]) {
return {
all: records.length,
live: records.filter((record) => record.state === "live").length,
review: records.filter((record) => record.state === "review").length,
drafts: records.filter((record) => record.state === "drafts").length,
paused: records.filter((record) => record.state === "paused").length,
} satisfies Record<AutomationTab, number>
}
export function AutomationLibraryGridView() {
const [automations, setAutomations] =
useState<IAutomationRecord[]>(AUTOMATIONS)
const [activeTab, setActiveTab] = useState<AutomationTab>("all")
const [filterQuery, setFilterQuery] = useState<FilterQuery>(
createDefaultAutomationFilters
)
const filters = useMemo(
() => flattenFilterConditions(filterQuery),
[filterQuery]
)
const [sorting, setSorting] = useState<SortingState>([
{ id: "updatedAt", desc: true },
])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const [automationPendingArchive, setAutomationPendingArchive] =
useState<IAutomationRecord | null>(null)
const resetPagination = useCallback(() => {
setPagination((current) =>
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }
)
}, [])
const filteredBaseAutomations = useMemo(() => {
return applyFiltersToData(automations, filters)
}, [automations, filters])
const filteredAutomations = useMemo(
() =>
filteredBaseAutomations.filter((automation) =>
activeTab === "all"
? true
: getAutomationTabFromState(automation.state) === activeTab
),
[activeTab, filteredBaseAutomations]
)
const tabCounts = useMemo(
() => getTabCounts(filteredBaseAutomations),
[filteredBaseAutomations]
)
const filteredLiveCount = useMemo(
() =>
filteredAutomations.filter((automation) => automation.state === "live")
.length,
[filteredAutomations]
)
const filteredReviewCount = useMemo(
() =>
filteredAutomations.filter((automation) => automation.state === "review")
.length,
[filteredAutomations]
)
const selectedCount = useMemo(
() => Object.keys(rowSelection).length,
[rowSelection]
)
const columns = useMemo(
() =>
createAutomationColumns({
onAction: (action, automation) => {
if (action === "archive") {
setAutomationPendingArchive(automation)
return
}
if (action === "edit") {
showAutomationToast({
tone: "neutral",
title: "Workflow editor",
description: `Connect "${automation.title}" to your builder, side panel, or automation step editor.`,
})
return
}
showAutomationToast({
tone: "success",
title: "Workflow details",
description: `"${automation.title}" is ready for a detail route, run history drawer, or audit panel.`,
})
},
onToggleEnabled: (automation, nextValue) => {
const nextState: AutomationState =
nextValue && automation.state === "paused"
? "live"
: nextValue && automation.state === "drafts"
? "review"
: !nextValue && automation.state === "live"
? "paused"
: automation.state
setAutomations((current) =>
current.map((item) =>
item.id === automation.id
? {
...item,
enabled: nextValue,
state: nextState,
}
: item
)
)
showAutomationToast({
tone: nextValue ? "success" : "neutral",
title: nextValue ? "Workflow enabled" : "Workflow paused",
description: nextValue
? `"${automation.title}" is ready to run in the ${nextState === "review" ? "review" : "live"} queue.`
: `"${automation.title}" will stay available but will not continue running until resumed.`,
})
},
}),
[]
)
const table = useTable({
features: dataGridFeatures,
data: filteredAutomations,
columns,
getRowId: (row) => row.id,
state: {
sorting,
rowSelection,
pagination,
},
enableRowSelection: true,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
})
const handleClearControls = useCallback(() => {
setFilterQuery(createDefaultAutomationFilters())
resetPagination()
}, [resetPagination])
const handleFiltersChange = useCallback(
(nextQuery: FilterQuery) => {
setFilterQuery(nextQuery)
resetPagination()
},
[resetPagination]
)
const handleArchiveAutomation = useCallback(() => {
if (!automationPendingArchive) return
const automationToArchive = automationPendingArchive
setAutomations((current) =>
current.filter(
(automation) => automation.id !== automationPendingArchive.id
)
)
setRowSelection((current) => {
const next = { ...current }
delete next[automationPendingArchive.id]
return next
})
setAutomationPendingArchive(null)
resetPagination()
showAutomationToast({
tone: "destructive",
title: "Workflow archived",
description: `"${automationToArchive.title}" was removed from this automation library.`,
})
}, [automationPendingArchive, resetPagination])
const emptyMessage =
"No workflows match this automation slice. Switch tabs or clear the filters."
return (
<>
{/* Table */}
<DataGrid
table={table}
recordCount={filteredAutomations.length}
emptyMessage={emptyMessage}
tableLayout={{
dense: true,
}}
tableClassNames={{
edgeCell: "first:ps-3 last:pe-3",
}}
>
<Frame dense variant="default" spacing="sm" className="w-full">
<FrameHeader className="flex-row items-center justify-between gap-3">
<div className="flex flex-col gap-px">
<FrameTitle className="text-balance">
Automation Library
</FrameTitle>
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
<span>
{filteredAutomations.length} workflow
{filteredAutomations.length === 1 ? "" : "s"}
</span>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>{filteredLiveCount} live</span>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>{filteredReviewCount} review</span>
{selectedCount > 0 ? (
<>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>{selectedCount} selected</span>
</>
) : null}
</FrameDescription>
</div>
<Button type="button" className="shrink-0">
<PlusIcon className="size-4" aria-hidden="true" />
New workflow
</Button>
</FrameHeader>
<FramePanel className="p-0 shadow-none!">
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
<Tabs
value={activeTab}
onValueChange={(value) => {
setActiveTab(value as AutomationTab)
resetPagination()
}}
>
<TabsList variant="line" className="gap-5">
{AUTOMATION_TABS.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="gap-2 px-0 pt-0 pb-(--frame-panel-header-py) text-sm"
>
<span>{tab.label}</span>
<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">
{tabCounts[tab.value]}
</span>
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
<Separator />
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-2.5">
<Filters
query={filterQuery}
fields={filterFields}
onQueryChange={handleFiltersChange}
size="default"
trigger={
<Button variant="outline" aria-label="Filters">
<FilterIcon className="size-4" aria-hidden="true" />
Filters
</Button>
}
/>
<div className="flex flex-wrap items-center justify-end gap-2">
{selectedCount > 0 ? (
<Badge size="sm" variant="secondary">
{selectedCount} selected
</Badge>
) : null}
<Button
type="button"
variant="outline"
onClick={handleClearControls}
>
<FunnelXIcon className="size-4" aria-hidden="true" />
Clear
</Button>
</div>
</div>
<Separator />
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter>
<DataGridPagination />
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
<AlertDialog
open={automationPendingArchive != null}
onOpenChange={(open) => {
if (!open) {
setAutomationPendingArchive(null)
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Archive workflow?</AlertDialogTitle>
<AlertDialogDescription>
{automationPendingArchive
? `Archive "${automationPendingArchive.title}" from this automation library. Run history and ownership context can stay available in your backend, but this row will disappear from the grid preview.`
: "Archive this workflow from the automation library."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleArchiveAutomation}
render={
<Button type="button" variant="destructive">
Archive
</Button>
}
/>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -1,384 +0,0 @@
export type AutomationTab = "all" | "live" | "review" | "drafts" | "paused"
export type AutomationKind =
| "sequence"
| "routing"
| "enrichment"
| "digest"
| "escalation"
export type OwnerFilter =
| "everyone"
| "product"
| "engineering"
| "operations"
| "revenue"
| "support"
export type DeliveryFilter =
| "any"
| "scheduled"
| "event-driven"
| "manual"
| "hybrid"
export type UpdatedFilter = "any" | "today" | "this-week" | "older"
export type AutomationState = Exclude<AutomationTab, "all">
export type AutomationOwnerAvailability = "online" | "away" | "busy" | "offline"
export interface IAutomationOwner {
id: string
name: string
email: string
initials: string
avatar?: string
availability: AutomationOwnerAvailability
team: Exclude<OwnerFilter, "everyone">
teamLabel: string
}
export interface IAutomationRecord {
id: string
title: string
kind: AutomationKind
state: AutomationState
rating: number
deliveryMode: Exclude<DeliveryFilter, "any">
owner: IAutomationOwner
updatedAt: string
updatedBucket: Exclude<UpdatedFilter, "any">
enabled: boolean
approvalRequired: boolean
audienceLabel: string
runWindowLabel: string
}
const OWNERS: Record<string, IAutomationOwner> = {
maya: {
id: "maya-patel",
name: "Maya Patel",
email: "[email protected]",
initials: "MP",
avatar:
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
availability: "online",
team: "product",
teamLabel: "Product",
},
jonas: {
id: "jonas-reed",
name: "Jonas Reed",
email: "[email protected]",
initials: "JR",
avatar:
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
availability: "busy",
team: "engineering",
teamLabel: "Engineering",
},
priya: {
id: "priya-nair",
name: "Priya Nair",
email: "[email protected]",
initials: "PN",
avatar:
"https://images.unsplash.com/photo-1517841905240-472988babdf9?w=96&h=96&dpr=2&q=80",
availability: "away",
team: "operations",
teamLabel: "Operations",
},
emil: {
id: "emil-novak",
name: "Emil Novak",
email: "[email protected]",
initials: "EN",
avatar:
"https://images.unsplash.com/photo-1560250097-0b93528c311a?w=96&h=96&dpr=2&q=80",
availability: "offline",
team: "revenue",
teamLabel: "Revenue",
},
nora: {
id: "nora-ibrahim",
name: "Nora Ibrahim",
email: "[email protected]",
initials: "NI",
avatar:
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=96&h=96&dpr=2&q=80",
availability: "online",
team: "support",
teamLabel: "Support",
},
}
function automation(
input: Omit<IAutomationRecord, "owner"> & {
owner: keyof typeof OWNERS
}
): IAutomationRecord {
return {
...input,
owner: OWNERS[input.owner],
}
}
export const AUTOMATION_TABS: { value: AutomationTab; label: string }[] = [
{ value: "all", label: "All" },
{ value: "live", label: "Live" },
{ value: "review", label: "Needs Review" },
{ value: "drafts", label: "Drafts" },
{ value: "paused", label: "Paused" },
]
export const OWNER_FILTER_OPTIONS: {
value: OwnerFilter
label: string
}[] = [
{ value: "everyone", label: "Owner team" },
{ value: "product", label: "Product" },
{ value: "engineering", label: "Engineering" },
{ value: "operations", label: "Operations" },
{ value: "revenue", label: "Revenue" },
{ value: "support", label: "Support" },
]
export const DELIVERY_FILTER_OPTIONS: {
value: DeliveryFilter
label: string
}[] = [
{ value: "any", label: "Delivery mode" },
{ value: "scheduled", label: "Scheduled" },
{ value: "event-driven", label: "Event-driven" },
{ value: "manual", label: "Manual" },
{ value: "hybrid", label: "Hybrid" },
]
export const UPDATED_FILTER_OPTIONS: {
value: UpdatedFilter
label: string
}[] = [
{ value: "any", label: "Last updated" },
{ value: "today", label: "Today" },
{ value: "this-week", label: "This week" },
{ value: "older", label: "Older" },
]
export function getAutomationTabFromState(
state: AutomationState
): Exclude<AutomationTab, "all"> {
return state
}
export const AUTOMATIONS: IAutomationRecord[] = [
automation({
id: "renewal-touchpoint-orchestration",
title: "Renewal touchpoint orchestration",
kind: "sequence",
state: "live",
rating: 4.8,
deliveryMode: "scheduled",
owner: "emil",
updatedAt: "2026-04-11",
updatedBucket: "today",
enabled: true,
approvalRequired: false,
audienceLabel: "Renewal accounts",
runWindowLabel: "Weekdays 09:00",
}),
automation({
id: "delegated-sender-review-route",
title: "Delegated sender review route",
kind: "routing",
state: "review",
rating: 4.2,
deliveryMode: "manual",
owner: "priya",
updatedAt: "2026-04-11",
updatedBucket: "today",
enabled: false,
approvalRequired: true,
audienceLabel: "Delegated senders",
runWindowLabel: "Queue-based release",
}),
automation({
id: "launch-handoff-digest",
title: "Launch handoff digest",
kind: "digest",
state: "live",
rating: 4.7,
deliveryMode: "scheduled",
owner: "maya",
updatedAt: "2026-04-10",
updatedBucket: "this-week",
enabled: true,
approvalRequired: false,
audienceLabel: "Launch squad",
runWindowLabel: "Daily 08:30",
}),
automation({
id: "sla-escalation-watch",
title: "SLA escalation watch",
kind: "escalation",
state: "live",
rating: 4.9,
deliveryMode: "event-driven",
owner: "nora",
updatedAt: "2026-04-10",
updatedBucket: "this-week",
enabled: true,
approvalRequired: false,
audienceLabel: "Priority tickets",
runWindowLabel: "On trigger",
}),
automation({
id: "lead-enrichment-pass",
title: "Lead enrichment pass",
kind: "enrichment",
state: "paused",
rating: 3.9,
deliveryMode: "hybrid",
owner: "jonas",
updatedAt: "2026-04-09",
updatedBucket: "this-week",
enabled: false,
approvalRequired: false,
audienceLabel: "Inbound pipeline",
runWindowLabel: "Hourly batch",
}),
automation({
id: "sandbox-onboarding-sequence",
title: "Sandbox onboarding sequence",
kind: "sequence",
state: "drafts",
rating: 4.1,
deliveryMode: "scheduled",
owner: "priya",
updatedAt: "2026-04-08",
updatedBucket: "this-week",
enabled: false,
approvalRequired: false,
audienceLabel: "Trial workspaces",
runWindowLabel: "Pending QA",
}),
automation({
id: "partner-routing-fallback",
title: "Partner routing fallback",
kind: "routing",
state: "live",
rating: 4.4,
deliveryMode: "hybrid",
owner: "emil",
updatedAt: "2026-04-07",
updatedBucket: "this-week",
enabled: true,
approvalRequired: false,
audienceLabel: "Partner renewals",
runWindowLabel: "Live + nightly",
}),
automation({
id: "weekly-adoption-digest",
title: "Weekly adoption digest",
kind: "digest",
state: "paused",
rating: 3.8,
deliveryMode: "scheduled",
owner: "maya",
updatedAt: "2026-04-06",
updatedBucket: "this-week",
enabled: false,
approvalRequired: false,
audienceLabel: "Workspace champions",
runWindowLabel: "Fridays 16:00",
}),
automation({
id: "enterprise-risk-escalation",
title: "Enterprise risk escalation",
kind: "escalation",
state: "review",
rating: 4.3,
deliveryMode: "manual",
owner: "nora",
updatedAt: "2026-04-05",
updatedBucket: "older",
enabled: false,
approvalRequired: true,
audienceLabel: "Enterprise accounts",
runWindowLabel: "Manual release",
}),
automation({
id: "crm-enrichment-backfill",
title: "CRM enrichment backfill",
kind: "enrichment",
state: "live",
rating: 4.6,
deliveryMode: "scheduled",
owner: "jonas",
updatedAt: "2026-04-04",
updatedBucket: "older",
enabled: true,
approvalRequired: false,
audienceLabel: "Open opportunities",
runWindowLabel: "Nightly 01:00",
}),
automation({
id: "trial-conversion-follow-up",
title: "Trial conversion follow-up",
kind: "sequence",
state: "drafts",
rating: 4.0,
deliveryMode: "scheduled",
owner: "emil",
updatedAt: "2026-04-03",
updatedBucket: "older",
enabled: false,
approvalRequired: false,
audienceLabel: "Product-led signups",
runWindowLabel: "Awaiting copy",
}),
automation({
id: "owner-assignment-router",
title: "Owner assignment router",
kind: "routing",
state: "live",
rating: 4.5,
deliveryMode: "event-driven",
owner: "priya",
updatedAt: "2026-04-02",
updatedBucket: "older",
enabled: true,
approvalRequired: false,
audienceLabel: "Workspace requests",
runWindowLabel: "Immediate",
}),
automation({
id: "ops-exception-digest",
title: "Ops exception digest",
kind: "digest",
state: "review",
rating: 4.1,
deliveryMode: "scheduled",
owner: "priya",
updatedAt: "2026-04-11",
updatedBucket: "today",
enabled: false,
approvalRequired: true,
audienceLabel: "Ops leadership",
runWindowLabel: "Daily 18:00",
}),
automation({
id: "billing-retry-escalation",
title: "Billing retry escalation",
kind: "escalation",
state: "live",
rating: 4.7,
deliveryMode: "event-driven",
owner: "nora",
updatedAt: "2026-04-01",
updatedBucket: "older",
enabled: true,
approvalRequired: false,
audienceLabel: "Recovery queue",
runWindowLabel: "On failure",
}),
]
@@ -1,15 +0,0 @@
import { AutomationLibraryGridView } from "./components/data-grid-view"
export function Page() {
return (
<main
className="mx-auto flex min-h-svh w-full max-w-7xl items-start justify-center p-8 pt-12"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Automation library data grid
</h1>
<AutomationLibraryGridView />
</main>
)
}
@@ -1,106 +0,0 @@
import { type CSSProperties } from "react"
import {
Frame,
FrameFooter,
FramePanel,
} from "@/components/reui/frame"
import { IconStack } from "@/components/reui/icon-stack"
import { Button } from "@evobgp/ui/components/button"
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@evobgp/ui/components/empty"
import { Separator } from "@evobgp/ui/components/separator"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@evobgp/ui/components/tooltip"
import { Building2Icon, InfoIcon } from "lucide-react"
export function EmptyState() {
return (
<section
className="flex w-full max-w-5xl flex-col gap-8"
aria-labelledby="organizations-heading"
>
<div className="flex flex-col gap-6">
<h2
id="organizations-heading"
className="text-2xl font-semibold tracking-tight"
>
Organizations
</h2>
<Separator />
</div>
<TooltipProvider delay={200}>
<Frame spacing="xs" className="w-full" aria-label="Organizations setup">
<FramePanel className="flex min-h-[274px] items-center justify-center px-6 py-12">
<Empty className="max-w-md flex-none bg-transparent p-0">
<EmptyHeader className="gap-5">
<EmptyMedia className="mb-0">
<IconStack
aria-hidden="true"
className="h-20 w-18"
style={
{
"--icon-stack-content-x": "70%",
"--icon-stack-content-y": "57%",
} as CSSProperties
}
>
<Building2Icon className="size-4.5" strokeWidth="1.8" aria-hidden="true" />
</IconStack>
</EmptyMedia>
<div className="flex flex-col items-center gap-2">
<EmptyTitle className="text-base font-semibold tracking-tight">
Enable organizations
</EmptyTitle>
<EmptyDescription className="max-w-sm text-sm/relaxed">
Enable your users to create organizations, manage team
members, and control access with role-based permissions.
</EmptyDescription>
</div>
</EmptyHeader>
<EmptyContent className="mt-5 max-w-none gap-0">
<Button type="button">Enable organizations</Button>
</EmptyContent>
</Empty>
</FramePanel>
<FrameFooter className="flex-row items-center justify-start">
<Tooltip>
<TooltipTrigger
render={
<a
href="#organizations-docs"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-sm text-xs font-medium transition-colors outline-none focus-visible:ring-[3px]"
/>
}
>
<InfoIcon className="size-3.5" aria-hidden="true" />
<span>
Learn more about{" "}
<span className="text-primary">Organizations</span>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-64">
Organizations group users, teams, roles, and permissions under
one account boundary.
</TooltipContent>
</Tooltip>
</FrameFooter>
</Frame>
</TooltipProvider>
</section>
)
}
@@ -1,12 +0,0 @@
import { EmptyState } from "./components/empty-state"
export function Page() {
return (
<main
className="flex min-h-svh w-full items-start justify-center p-4 sm:p-8 md:p-10"
aria-labelledby="organizations-heading"
>
<EmptyState />
</main>
)
}
@@ -1,147 +0,0 @@
import type { ReactNode } from "react"
import type { BadgeProps } from "@/components/reui/badge"
import { ClaudeAiIcon } from "@evobgp/ui/components/svgs/claudeAiIcon"
import { CursorDark } from "@evobgp/ui/components/svgs/cursorDark"
import { CursorLight } from "@evobgp/ui/components/svgs/cursorLight"
import { GoogleCalendar } from "@evobgp/ui/components/svgs/googleCalendar"
import { ResendIconBlack } from "@evobgp/ui/components/svgs/resendIconBlack"
import { ResendIconWhite } from "@evobgp/ui/components/svgs/resendIconWhite"
import { Slack } from "@evobgp/ui/components/svgs/slack"
import { Stripe } from "@evobgp/ui/components/svgs/stripe"
import { Supabase } from "@evobgp/ui/components/svgs/supabase"
export type IntegrationStatus = "connected" | "available" | "preview"
export interface IntegrationStatusMeta {
label: string
variant: BadgeProps["variant"]
}
export interface IntegrationApp {
id: string
name: string
description: string
logo: ReactNode
status: IntegrationStatus
actionLabel: string
}
export const INTEGRATION_STATUS_META: Record<
IntegrationStatus,
IntegrationStatusMeta
> = {
connected: {
label: "Connected",
variant: "success-light",
},
available: {
label: "Available",
variant: "secondary",
},
preview: {
label: "Preview",
variant: "info-light",
},
}
const logoClassName = "block size-5"
function ThemeLogo({ light, dark }: { light: ReactNode; dark: ReactNode }) {
return (
<>
<span
aria-hidden="true"
className="flex size-5 items-center justify-center leading-none dark:hidden"
>
{light}
</span>
<span
aria-hidden="true"
className="hidden size-5 items-center justify-center leading-none dark:flex"
>
{dark}
</span>
</>
)
}
const CURSOR_LOGO = (
<ThemeLogo
light={<CursorLight className={logoClassName} />}
dark={<CursorDark className={logoClassName} />}
/>
)
const RESEND_LOGO = (
<ThemeLogo
light={<ResendIconBlack className={logoClassName} />}
dark={<ResendIconWhite className={logoClassName} />}
/>
)
export const INTEGRATION_APPS: IntegrationApp[] = [
{
id: "supabase",
name: "Supabase",
description:
"Sync workspace records, owners, and lifecycle events from your product database.",
logo: <Supabase className={logoClassName} aria-hidden="true" />,
status: "connected",
actionLabel: "Configure",
},
{
id: "slack",
name: "Slack",
description:
"Route approvals, launch notes, and incident updates into selected team channels.",
logo: <Slack className={logoClassName} aria-hidden="true" />,
status: "connected",
actionLabel: "Configure",
},
{
id: "stripe",
name: "Stripe",
description:
"Mirror subscription, invoice, and renewal signals into account reviews.",
logo: <Stripe className={logoClassName} aria-hidden="true" />,
status: "connected",
actionLabel: "Configure",
},
{
id: "google-calendar",
name: "Google Calendar",
description:
"Create planning reviews from milestones, customer dates, and release windows.",
logo: <GoogleCalendar className={logoClassName} aria-hidden="true" />,
status: "available",
actionLabel: "Install",
},
{
id: "resend",
name: "Resend",
description:
"Send branded transactional updates when workflows change status or ownership.",
logo: RESEND_LOGO,
status: "available",
actionLabel: "Install",
},
{
id: "cursor",
name: "Cursor",
description:
"Attach implementation context and AI coding notes to active product tasks.",
logo: CURSOR_LOGO,
status: "preview",
actionLabel: "Configure",
},
{
id: "claude",
name: "Claude",
description:
"Draft summaries, support notes, and rollout briefs from approved workspace data.",
logo: <ClaudeAiIcon className={logoClassName} aria-hidden="true" />,
status: "preview",
actionLabel: "Install",
},
]
@@ -1,109 +0,0 @@
import { Badge } from "@/components/reui/badge"
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import { Button } from "@evobgp/ui/components/button"
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemSeparator,
ItemTitle,
} from "@evobgp/ui/components/item"
import {
INTEGRATION_APPS,
INTEGRATION_STATUS_META,
type IntegrationApp,
} from "./data"
function IntegrationLogo({ app }: { app: IntegrationApp }) {
return (
<Item className="bg-muted/60 border-background flex size-10 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:block [&_svg]:size-5">
<ItemMedia variant="icon" className="size-auto translate-y-0! self-center!">
{app.logo}
</ItemMedia>
</Item>
)
}
function IntegrationRow({ app }: { app: IntegrationApp }) {
const status = INTEGRATION_STATUS_META[app.status]
return (
<Item role="listitem" className="items-center px-3.5 py-2.5 sm:px-4">
{/* Media */}
<ItemMedia className="translate-y-0! self-center!">
<IntegrationLogo app={app} />
</ItemMedia>
{/* Content */}
<ItemContent className="min-w-0 gap-0">
<ItemTitle className="w-full min-w-0 gap-2">
<span className="truncate">{app.name}</span>
<Badge variant={status.variant}>{status.label}</Badge>
</ItemTitle>
<ItemDescription className="line-clamp-1">
{app.description}
</ItemDescription>
</ItemContent>
{/* Actions */}
<ItemActions className="ml-auto shrink-0 justify-end gap-2">
<Button type="button" variant="outline" size="sm">
{app.actionLabel}
</Button>
</ItemActions>
</Item>
)
}
export function IntegrationSettings() {
return (
<div className="flex w-full max-w-4xl flex-col gap-5">
{/* Section */}
<section className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="flex flex-col gap-1">
<h3 className="text-sm font-semibold">Connected Apps</h3>
<p className="text-muted-foreground text-sm">
Manage source systems for automation, scheduling, support, and AI
workflows.
</p>
</div>
<Button type="button" size="sm" className="w-full sm:w-auto">
Build Connector
</Button>
</div>
<Frame>
<FrameHeader className="sr-only">
<FrameTitle>Integration Apps</FrameTitle>
<FrameDescription>
Available workspace apps and configuration actions.
</FrameDescription>
</FrameHeader>
<FramePanel className="p-0!">
<ItemGroup className="gap-0">
{INTEGRATION_APPS.map((app, index) => (
<div key={app.id}>
{index > 0 ? <ItemSeparator className="my-0" /> : null}
<IntegrationRow app={app} />
</div>
))}
</ItemGroup>
</FramePanel>
</Frame>
</section>
</div>
)
}
@@ -1,15 +0,0 @@
import { IntegrationSettings } from "./components/integration-settings"
export function Page() {
return (
<main
className="flex min-h-svh w-full items-start justify-center p-4 sm:p-8 md:p-12"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Integration settings
</h1>
<IntegrationSettings />
</main>
)
}
@@ -1,40 +0,0 @@
import { useState } from "react"
import { cn } from "@evobgp/ui/lib/utils"
import { FieldLegend, FieldSet } from "@evobgp/ui/components/field"
import { ACCENT_COLORS } from "./data"
import { CheckIcon } from "lucide-react"
// ── Color Picker ──
export function ColorPicker() {
const [selected, setSelected] = useState(ACCENT_COLORS[0].value)
return (
<FieldSet className="gap-0">
<FieldLegend className="sr-only">Accent color</FieldLegend>
{/* Actions */}
<div className="flex items-center gap-3">
{ACCENT_COLORS.map((color) => (
<button
key={color.value}
type="button"
aria-label={color.name}
onClick={() => setSelected(color.value)}
className={cn(
"flex size-7 items-center justify-center rounded-full transition-shadow",
selected === color.value &&
"ring-ring ring-offset-background ring-2 ring-offset-2"
)}
style={{ backgroundColor: color.value }}
>
{selected === color.value ? (
<CheckIcon className="size-3 text-white" aria-hidden="true" />
) : null}
</button>
))}
</div>
</FieldSet>
)
}
@@ -1,109 +0,0 @@
"use client"
import { type ReactNode } from "react"
// ── Types ──
export type SelectOption = {
value: string
label: string
}
export type Preference = {
id: string
label: string
description: string
defaultChecked: boolean
}
export type AccentColorOption = {
name: string
value: string
}
export type SettingBadgeVariant =
| "primary-light"
| "destructive-light"
| "info-light"
export type SettingFieldProps = {
title: string
description: string
badge?: { label: string; variant: SettingBadgeVariant }
children: ReactNode
last?: boolean
}
export type DateFormatOption = {
value: string
label: string
}
// ── Data ──
export const DAYS: SelectOption[] = [
{ value: "monday", label: "Monday" },
{ value: "tuesday", label: "Tuesday" },
{ value: "wednesday", label: "Wednesday" },
{ value: "thursday", label: "Thursday" },
{ value: "friday", label: "Friday" },
{ value: "saturday", label: "Saturday" },
{ value: "sunday", label: "Sunday" },
]
export const CURRENCIES: SelectOption[] = [
{ value: "usd", label: "USD · US Dollar" },
{ value: "eur", label: "EUR · Euro" },
{ value: "gbp", label: "GBP · British Pound" },
{ value: "jpy", label: "JPY · Japanese Yen" },
]
export const REGIONS: SelectOption[] = [
{ value: "us", label: "United States" },
{ value: "eu", label: "European Union" },
{ value: "uk", label: "United Kingdom" },
{ value: "jp", label: "Japan" },
{ value: "au", label: "Australia" },
]
export const ACCENT_COLORS: AccentColorOption[] = [
{ name: "Slate", value: "#64748b" },
{ name: "Rose", value: "#f43f5e" },
{ name: "Orange", value: "#f97316" },
{ name: "Violet", value: "#8b5cf6" },
{ name: "Emerald", value: "#10b981" },
{ name: "Sky", value: "#0ea5e9" },
]
export const PREFERENCES: Preference[] = [
{
id: "keyboard-shortcuts",
label: "Enable keyboard shortcuts.",
description: "Use shortcuts to speed up your workflow.",
defaultChecked: true,
},
{
id: "spellcheck",
label: "Auto spell-check.",
description: "Highlight spelling errors in text fields.",
defaultChecked: true,
},
{
id: "compact-mode",
label: "Compact Mode.",
description: "Reduce spacing for a denser interface layout.",
defaultChecked: false,
},
{
id: "animations",
label: "Reduce animations.",
description: "Minimize motion effects throughout the UI.",
defaultChecked: false,
},
]
export const DATE_FORMAT_OPTIONS: DateFormatOption[] = [
{ value: "mdy", label: "MM/DD/YYYY" },
{ value: "dmy", label: "DD/MM/YYYY" },
{ value: "ymd", label: "YYYY/MM/DD" },
]
@@ -1,57 +0,0 @@
import { useState } from "react"
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@evobgp/ui/components/field"
import { Switch } from "@evobgp/ui/components/switch"
import { PREFERENCES } from "./data"
// ── Editor Preferences ──
export function EditorPreferences() {
const [values, setValues] = useState<Record<string, boolean>>(() =>
Object.fromEntries(PREFERENCES.map((p) => [p.id, p.defaultChecked]))
)
return (
<FieldSet className="w-full gap-3">
<FieldLegend className="sr-only">Editor preferences</FieldLegend>
{/* Description */}
<FieldDescription className="sr-only">
Configure editing behavior and interface density.
</FieldDescription>
{/* List */}
<FieldGroup className="gap-3">
{PREFERENCES.map((pref) => (
<Field key={pref.id} orientation="horizontal" className="gap-3">
<Switch
id={`settings-3-${pref.id}`}
checked={values[pref.id]}
onCheckedChange={(value) =>
setValues((prev) => ({ ...prev, [pref.id]: value }))
}
className="mt-0.5"
/>
<FieldContent className="gap-0.5">
<FieldLabel htmlFor={`settings-3-${pref.id}`}>
{pref.label}
</FieldLabel>
<FieldDescription className="text-xs">
{pref.description}
</FieldDescription>
</FieldContent>
</Field>
))}
</FieldGroup>
</FieldSet>
)
}
@@ -1,267 +0,0 @@
"use client"
import { useState } from "react"
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import { Button } from "@evobgp/ui/components/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@evobgp/ui/components/field"
import { Input } from "@evobgp/ui/components/input"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@evobgp/ui/components/input-group"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import {
ToggleGroup,
ToggleGroupItem,
} from "@evobgp/ui/components/toggle-group"
import { ColorPicker } from "./color-picker"
import { CURRENCIES, DAYS, REGIONS } from "./data"
import { EditorPreferences } from "./editor-preferences"
import { SettingField } from "./setting-field"
import { PlusIcon, UploadIcon, GlobeIcon, CircleDollarSignIcon } from "lucide-react"
export function GeneralSettings() {
const [dateFormat, setDateFormat] = useState(["mdy"])
const [startOfWeek, setStartOfWeek] = useState("monday")
const [region, setRegion] = useState("us")
const [currency, setCurrency] = useState("usd")
const handleStartOfWeekChange = (value: string | null) => {
if (value !== null) {
setStartOfWeek(value)
}
}
const handleRegionChange = (value: string | null) => {
if (value !== null) {
setRegion(value)
}
}
const handleCurrencyChange = (value: string | null) => {
if (value !== null) {
setCurrency(value)
}
}
return (
<Frame className="w-full max-w-3xl">
<FrameHeader className="px-2! py-2.5!">
<FrameTitle>General Settings</FrameTitle>
<FrameDescription>Core app preferences.</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<FieldGroup className="gap-0">
{/* ── Project name ── */}
<SettingField
title="Project name"
description="The display name for your project across the platform."
labelFor="settings-3-project-name"
>
<Input id="settings-3-project-name" defaultValue="Acme Dashboard" />
</SettingField>
{/* ── API endpoint ── */}
<SettingField
title="API endpoint"
description="Set the base URL for your project API."
badge={{ label: "Required", variant: "destructive-light" }}
labelFor="settings-3-api-endpoint"
>
<InputGroup className="w-full">
<InputGroupAddon align="inline-start">
<InputGroupText>https://</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="settings-3-api-endpoint"
defaultValue="api.acme.io"
/>
</InputGroup>
</SettingField>
{/* ── Start of week ── */}
<SettingField
title="Start of week"
description="Choose which day marks the start of your week."
labelFor="settings-3-start-of-week"
>
<Select value={startOfWeek} onValueChange={handleStartOfWeekChange}>
<SelectTrigger id="settings-3-start-of-week" className="w-full">
<SelectValue>{getOptionLabel(DAYS, startOfWeek)}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{DAYS.map((day) => (
<SelectItem key={day.value} value={day.value}>
{day.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</SettingField>
{/* ── Allowed origins ── */}
<SettingField
title="Allowed origins"
description="Define the trusted domains for CORS requests."
>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button variant="outline">
<PlusIcon aria-hidden="true" />
Add origin
</Button>
<Button variant="outline">
<UploadIcon aria-hidden="true" />
Import from file
</Button>
</div>
</SettingField>
{/* ── Accent color ── */}
<SettingField
title="Accent color"
description="Select a color to represent your brand."
badge={{ label: "Upgrade to Pro", variant: "primary-light" }}
>
<ColorPicker />
</SettingField>
{/* ── Region & currency ── */}
<SettingField
title="Region & currency"
description="Adjust your regional preferences and currency."
contentClassName="@md/field-group:w-[22rem]"
>
<FieldSet className="w-full gap-3">
<FieldLegend className="sr-only">Region and currency</FieldLegend>
<FieldDescription className="sr-only">
Regional and financial formatting preferences.
</FieldDescription>
<FieldGroup className="gap-3">
<Field>
<FieldLabel htmlFor="settings-3-region">Region</FieldLabel>
<Select value={region} onValueChange={handleRegionChange}>
<SelectTrigger id="settings-3-region" className="w-full">
<GlobeIcon aria-hidden="true" />
<SelectValue>
{getOptionLabel(REGIONS, region)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{REGIONS.map((region) => (
<SelectItem key={region.value} value={region.value}>
{region.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="settings-3-currency">
Currency
</FieldLabel>
<Select value={currency} onValueChange={handleCurrencyChange}>
<SelectTrigger id="settings-3-currency" className="w-full">
<CircleDollarSignIcon aria-hidden="true" />
<SelectValue>
{getOptionLabel(CURRENCIES, currency)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{CURRENCIES.map((currency) => (
<SelectItem
key={currency.value}
value={currency.value}
>
{currency.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
</FieldGroup>
</FieldSet>
</SettingField>
{/* ── Date display format ── */}
<SettingField
title="Date display format"
description="Choose your preferred format for dates."
>
<ToggleGroup
multiple={false}
value={dateFormat}
onValueChange={(value) => {
if (value.length > 0) setDateFormat(value)
}}
variant="outline"
size="sm"
aria-label="Date display format"
>
<ToggleGroupItem value="mdy">MM/DD/YYYY</ToggleGroupItem>
<ToggleGroupItem value="dmy">DD/MM/YYYY</ToggleGroupItem>
<ToggleGroupItem value="ymd">YYYY/MM/DD</ToggleGroupItem>
</ToggleGroup>
</SettingField>
{/* ── Editor preferences ── */}
<SettingField
title="Editor preferences"
description="Adjust your editing environment and display options."
last
contentClassName="@md/field-group:w-[22rem]"
>
<EditorPreferences />
</SettingField>
</FieldGroup>
</FramePanel>
<FrameFooter className="flex flex-row justify-end gap-3">
<Button variant="outline">Reset</Button>
<Button>Save changes</Button>
</FrameFooter>
</Frame>
)
}
// ── Helper functions ──
function getOptionLabel(
options: Array<{ value: string; label: string }>,
value: string
) {
return options.find((option) => option.value === value)?.label ?? value
}
@@ -1,69 +0,0 @@
import { type ComponentProps, type ReactNode } from "react"
import { Badge } from "@/components/reui/badge"
import { cn } from "@evobgp/ui/lib/utils"
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldSeparator,
FieldTitle,
} from "@evobgp/ui/components/field"
interface SettingFieldProps {
title: string
description: string
badge?: {
label: string
variant: ComponentProps<typeof Badge>["variant"]
}
children: ReactNode
last?: boolean
labelFor?: string
contentClassName?: string
}
// ── Setting Field ──
export function SettingField({
title,
description,
badge,
children,
last,
labelFor,
contentClassName,
}: SettingFieldProps) {
return (
<>
<Field orientation="responsive" className="gap-4 px-4 py-4">
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
<div className="flex flex-wrap items-center gap-2">
{labelFor ? (
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
) : (
<FieldTitle>{title}</FieldTitle>
)}
{badge ? (
<Badge variant={badge.variant} size="sm">
{badge.label}
</Badge>
) : null}
</div>
<FieldDescription className="text-xs">{description}</FieldDescription>
</div>
<FieldContent
className={cn("min-w-0 @md/field-group:w-78", contentClassName)}
>
{children}
</FieldContent>
</Field>
{!last ? <FieldSeparator /> : null}
</>
)
}
@@ -1,9 +0,0 @@
import { GeneralSettings } from "./components/general-settings"
export function Page() {
return (
<div className="flex min-h-svh w-full items-start justify-center p-8 md:p-16">
<GeneralSettings />
</div>
)
}
@@ -1,194 +0,0 @@
export type WizardStepId = "company" | "ownership" | "documents"
export type RegistrationStatus = "registered" | "invited" | "new"
export type VendorCategory = "software" | "services" | "data" | "hardware"
export type VendorCountry = "us" | "gb" | "de" | "uz" | "sg"
export type DocumentId =
| "certificate"
| "tax-letter"
| "ownership-chart"
| "bank-letter"
export interface WizardStep {
id: WizardStepId
step: number
title: string
description: string
}
export interface WizardOption<TValue extends string = string> {
value: TValue
label: string
description?: string
}
export interface OwnerRecord {
id: string
name: string
address: string
share: string
}
export interface DocumentRequirement {
id: DocumentId
title: string
description: string
status: "Ready" | "Needs upload" | "Review"
}
export interface WizardValues {
companyName: string
registrationStatus: RegistrationStatus
portalId: string
category: VendorCategory
country: VendorCountry
contactEmail: string
website: string
owners: OwnerRecord[]
taxId: string
incorporationDate: string
ownershipAttested: boolean
documentIds: DocumentId[]
submissionNotes: string
}
export const WIZARD_STEPS: WizardStep[] = [
{
id: "company",
step: 1,
title: "Company Details",
description: "Vendor identity and procurement profile.",
},
{
id: "ownership",
step: 2,
title: "Compliance",
description: "Owners and tax IDs.",
},
{
id: "documents",
step: 3,
title: "Submit",
description: "Review and submit.",
},
]
export const REGISTRATION_OPTIONS: WizardOption<RegistrationStatus>[] = [
{
value: "registered",
label: "Yes",
description: "Vendor record already exists in the portal.",
},
{
value: "invited",
label: "Invited",
description: "Invitation sent, registration is pending.",
},
{
value: "new",
label: "No",
description: "Create a fresh procurement record.",
},
]
export const CATEGORY_OPTIONS: WizardOption<VendorCategory>[] = [
{
value: "software",
label: "Software subscription",
description: "SaaS, licenses, and usage-based software.",
},
{
value: "services",
label: "Professional services",
description: "Implementation, advisory, and support work.",
},
{
value: "data",
label: "Data provider",
description: "Market data, enrichment, and data feeds.",
},
{
value: "hardware",
label: "Hardware supplier",
description: "Devices, equipment, and physical goods.",
},
]
export const COUNTRY_OPTIONS: WizardOption<VendorCountry>[] = [
{
value: "us",
label: "United States",
},
{
value: "gb",
label: "United Kingdom",
},
{
value: "de",
label: "Germany",
},
{
value: "uz",
label: "Uzbekistan",
},
{
value: "sg",
label: "Singapore",
},
]
export const DOCUMENT_REQUIREMENTS: DocumentRequirement[] = [
{
id: "certificate",
title: "Certificate of incorporation",
description: "Legal entity proof.",
status: "Ready",
},
{
id: "tax-letter",
title: "Tax residency letter",
description: "Needed for payment release.",
status: "Needs upload",
},
{
id: "ownership-chart",
title: "Ownership chart",
description: "Confirms ownership threshold.",
status: "Review",
},
{
id: "bank-letter",
title: "Bank verification letter",
description: "Verifies payee details.",
status: "Ready",
},
]
export const DEFAULT_WIZARD_VALUES: WizardValues = {
companyName: "ReUI Labs",
registrationStatus: "registered",
portalId: "VEN-2048",
category: "software",
country: "us",
contactEmail: "[email protected]",
website: "https://reui.dev",
owners: [
{
id: "owner-1",
name: "Maya Chen",
address: "214 Mission Street, San Francisco, CA",
share: "52%",
},
{
id: "owner-2",
name: "Kai Morgan",
address: "18 King Street, Seattle, WA",
share: "28%",
},
],
taxId: "US-82-4917721",
incorporationDate: "08/08/2025",
ownershipAttested: true,
documentIds: ["certificate", "bank-letter"],
submissionNotes:
"Route final review to finance once tax residency is uploaded.",
}
@@ -1,646 +0,0 @@
import type { ReactNode } from "react"
import { Button } from "@evobgp/ui/components/button"
import { Checkbox } from "@evobgp/ui/components/checkbox"
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldTitle,
} from "@evobgp/ui/components/field"
import { Input } from "@evobgp/ui/components/input"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import { Textarea } from "@evobgp/ui/components/textarea"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@evobgp/ui/components/tooltip"
import {
CATEGORY_OPTIONS,
COUNTRY_OPTIONS,
DOCUMENT_REQUIREMENTS,
REGISTRATION_OPTIONS,
type DocumentId,
type OwnerRecord,
type VendorCategory,
type VendorCountry,
type WizardOption,
type WizardValues,
} from "./data"
import { InfoIcon, Trash2Icon, PlusIcon } from "lucide-react"
export type WizardErrors = Partial<Record<keyof WizardValues, string>>
type WizardValueChange = (
field: keyof WizardValues,
value: WizardValues[keyof WizardValues]
) => void
interface WizardSummary {
categoryLabel: string
countryLabel: string
registrationLabel: string
}
function RequiredMark() {
return <span className="text-destructive">*</span>
}
function FieldLabelHint({
htmlFor,
label,
hint,
children,
}: {
htmlFor?: string
label: string
hint: string
children: ReactNode
}) {
return (
<div className="flex w-fit items-center gap-1.5 leading-snug">
<FieldLabel htmlFor={htmlFor}>{children}</FieldLabel>
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex size-4 shrink-0 items-center justify-center rounded-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
aria-label={`${label} hint`}
/>
}
>
<InfoIcon className="size-3.5" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-56 text-xs text-balance">
{hint}
</TooltipContent>
</Tooltip>
</div>
)
}
function WizardSelectField<TValue extends string>({
id,
value,
options,
placeholder,
onValueChange,
}: {
id: string
value: TValue
options: WizardOption<TValue>[]
placeholder: string
onValueChange: (value: TValue) => void
}) {
const selectedOption =
options.find((option) => option.value === value) ?? null
return (
<Select
value={selectedOption}
items={options}
onValueChange={(nextValue) => {
if (nextValue) {
onValueChange(nextValue.value)
}
}}
>
<SelectTrigger id={id} className="w-full [&_small]:hidden">
<SelectValue>
{(item: WizardOption<TValue> | null) => item?.label ?? placeholder}
</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{options.map((option) => (
<SelectItem key={option.value} value={option}>
{option.description ? (
<span className="flex min-w-0 flex-col items-start gap-px">
<span className="truncate font-medium">{option.label}</span>
<small className="text-muted-foreground truncate text-xs">
{option.description}
</small>
</span>
) : (
option.label
)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}
function SummaryLine({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-4 text-sm">
<dt className="text-muted-foreground">{label}</dt>
<dd className="text-foreground min-w-0 text-right font-medium break-words">
{value}
</dd>
</div>
)
}
function SummarySection({
title,
children,
className,
}: {
title: string
children: ReactNode
className?: string
}) {
return (
<section
className={[
"border-border/70 flex min-w-0 flex-col gap-3 rounded-md border p-4",
className,
]
.filter(Boolean)
.join(" ")}
>
<h3 className="text-sm font-semibold">{title}</h3>
{children}
</section>
)
}
function SectionHeading({
id,
title,
description,
}: {
id: string
title: string
description: string
}) {
return (
<div className="flex flex-col gap-1">
<h2 id={id} className="text-base font-semibold">
{title}
</h2>
<p className="text-muted-foreground text-sm">{description}</p>
</div>
)
}
function RemoveRowButton({
label,
onClick,
}: {
label: string
onClick: () => void
}) {
return (
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive justify-self-start sm:justify-self-end"
aria-label={label}
onClick={onClick}
>
<Trash2Icon className="size-4" aria-hidden="true" />
</Button>
)
}
function OwnerRows({
owners,
error,
onOwnerChange,
onAddOwner,
onRemoveOwner,
}: {
owners: OwnerRecord[]
error?: string
onOwnerChange: (id: string, field: keyof OwnerRecord, value: string) => void
onAddOwner: () => void
onRemoveOwner: (id: string) => void
}) {
return (
<Field data-invalid={Boolean(error)} className="gap-3 sm:col-span-2">
<div className="flex flex-col gap-2">
{owners.map((owner, index) => (
<div
key={owner.id}
className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)_5rem_auto]"
>
<Field className="gap-1.5">
<FieldLabel htmlFor={`${owner.id}-name`} className="sr-only">
Owner {index + 1} name
</FieldLabel>
<Input
id={`${owner.id}-name`}
value={owner.name}
onChange={(event) =>
onOwnerChange(owner.id, "name", event.target.value)
}
placeholder="Owner full name"
autoComplete="name"
/>
</Field>
<Field className="gap-1.5">
<FieldLabel htmlFor={`${owner.id}-address`} className="sr-only">
Owner {index + 1} address
</FieldLabel>
<Input
id={`${owner.id}-address`}
value={owner.address}
onChange={(event) =>
onOwnerChange(owner.id, "address", event.target.value)
}
placeholder="Registered address"
autoComplete="street-address"
/>
</Field>
<Field className="gap-1.5">
<FieldLabel htmlFor={`${owner.id}-share`} className="sr-only">
Owner {index + 1} share
</FieldLabel>
<Input
id={`${owner.id}-share`}
value={owner.share}
onChange={(event) =>
onOwnerChange(owner.id, "share", event.target.value)
}
placeholder="Share"
inputMode="numeric"
/>
</Field>
<RemoveRowButton
label={`Remove owner ${index + 1}`}
onClick={() => onRemoveOwner(owner.id)}
/>
</div>
))}
</div>
{error ? <FieldError>{error}</FieldError> : null}
<div className="flex justify-end">
<Button
type="button"
variant="outline"
className="w-auto"
onClick={onAddOwner}
>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Add owner
</Button>
</div>
</Field>
)
}
function AttestationCheckbox({
id,
checked,
label,
description,
error,
onCheckedChange,
}: {
id: string
checked: boolean
label: string
description: string
error?: string
onCheckedChange: (checked: boolean) => void
}) {
return (
<Field
data-invalid={Boolean(error)}
orientation="horizontal"
className="gap-3"
>
<Checkbox
id={id}
checked={checked}
onCheckedChange={(nextChecked) => onCheckedChange(nextChecked === true)}
aria-invalid={Boolean(error)}
/>
<FieldContent>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<FieldDescription>{description}</FieldDescription>
</FieldContent>
{error ? <FieldError>{error}</FieldError> : null}
</Field>
)
}
export function CompanyStepFields({
values,
errors,
summary,
onValueChange,
}: {
values: WizardValues
errors: WizardErrors
summary: WizardSummary
onValueChange: WizardValueChange
}) {
return (
<section
className="flex flex-col gap-6"
aria-labelledby="wizard-2-company-title"
>
<SectionHeading
id="wizard-2-company-title"
title="Company Details"
description="Identify the vendor record before compliance review."
/>
<FieldGroup className="grid gap-5 sm:grid-cols-2">
<Field data-invalid={Boolean(errors.companyName)} className="gap-2">
<FieldLabelHint
htmlFor="wizard-2-company"
label="Legal Company Name"
hint="Match the registered legal entity name."
>
Legal Company Name <RequiredMark />
</FieldLabelHint>
<Input
id="wizard-2-company"
value={values.companyName}
onChange={(event) =>
onValueChange("companyName", event.target.value)
}
aria-invalid={Boolean(errors.companyName)}
autoComplete="organization"
/>
{errors.companyName ? (
<FieldError>{errors.companyName}</FieldError>
) : null}
</Field>
<Field className="gap-2">
<FieldLabelHint
htmlFor="wizard-2-registration"
label="Portal Registration"
hint="Use the status from the vendor portal."
>
Portal Registration
</FieldLabelHint>
<WizardSelectField
id="wizard-2-registration"
value={values.registrationStatus}
options={REGISTRATION_OPTIONS}
placeholder="Select status"
onValueChange={(value) =>
onValueChange("registrationStatus", value)
}
/>
<FieldDescription>{summary.registrationLabel}</FieldDescription>
</Field>
<Field className="gap-2">
<FieldLabel htmlFor="wizard-2-category">Category</FieldLabel>
<WizardSelectField<VendorCategory>
id="wizard-2-category"
value={values.category}
options={CATEGORY_OPTIONS}
placeholder="Select category"
onValueChange={(value) => onValueChange("category", value)}
/>
</Field>
<Field className="gap-2">
<FieldLabel htmlFor="wizard-2-country">Country</FieldLabel>
<WizardSelectField<VendorCountry>
id="wizard-2-country"
value={values.country}
options={COUNTRY_OPTIONS}
placeholder="Select country"
onValueChange={(value) => onValueChange("country", value)}
/>
</Field>
<Field data-invalid={Boolean(errors.contactEmail)} className="gap-2">
<FieldLabel htmlFor="wizard-2-email">
Vendor Email <RequiredMark />
</FieldLabel>
<Input
id="wizard-2-email"
value={values.contactEmail}
onChange={(event) =>
onValueChange("contactEmail", event.target.value)
}
aria-invalid={Boolean(errors.contactEmail)}
type="email"
autoComplete="email"
/>
{errors.contactEmail ? (
<FieldError>{errors.contactEmail}</FieldError>
) : null}
</Field>
<Field className="gap-2">
<FieldLabel htmlFor="wizard-2-website">Website</FieldLabel>
<Input
id="wizard-2-website"
value={values.website}
onChange={(event) => onValueChange("website", event.target.value)}
autoComplete="url"
/>
</Field>
</FieldGroup>
</section>
)
}
export function OwnershipStepFields({
values,
errors,
onValueChange,
onOwnerChange,
onAddOwner,
onRemoveOwner,
}: {
values: WizardValues
errors: WizardErrors
onValueChange: WizardValueChange
onOwnerChange: (id: string, field: keyof OwnerRecord, value: string) => void
onRemoveOwner: (id: string) => void
onAddOwner: () => void
}) {
return (
<section
className="flex flex-col gap-6"
aria-labelledby="wizard-2-ownership-title"
>
<SectionHeading
id="wizard-2-ownership-title"
title="Compliance"
description="Confirm ownership and tax records."
/>
<FieldGroup className="grid gap-5 sm:grid-cols-2">
<OwnerRows
owners={values.owners}
error={errors.owners}
onOwnerChange={onOwnerChange}
onAddOwner={onAddOwner}
onRemoveOwner={onRemoveOwner}
/>
<Field data-invalid={Boolean(errors.taxId)} className="gap-2">
<FieldLabelHint
htmlFor="wizard-2-tax"
label="Tax/VAT ID"
hint="Enter the identifier finance will validate."
>
Tax/VAT ID <RequiredMark />
</FieldLabelHint>
<Input
id="wizard-2-tax"
value={values.taxId}
onChange={(event) => onValueChange("taxId", event.target.value)}
aria-invalid={Boolean(errors.taxId)}
placeholder="Enter tax number"
/>
{errors.taxId ? <FieldError>{errors.taxId}</FieldError> : null}
</Field>
<Field
data-invalid={Boolean(errors.incorporationDate)}
className="gap-2"
>
<FieldLabelHint
htmlFor="wizard-2-incorporated"
label="Date Incorporated"
hint="Use the date from the registration record."
>
Date Incorporated <RequiredMark />
</FieldLabelHint>
<Input
id="wizard-2-incorporated"
value={values.incorporationDate}
onChange={(event) =>
onValueChange("incorporationDate", event.target.value)
}
aria-invalid={Boolean(errors.incorporationDate)}
placeholder="MM/DD/YYYY"
/>
{errors.incorporationDate ? (
<FieldError>{errors.incorporationDate}</FieldError>
) : null}
</Field>
<AttestationCheckbox
id="wizard-2-ownership-attested"
checked={values.ownershipAttested}
label="Ownership reviewed"
description="Records match company documents."
error={errors.ownershipAttested}
onCheckedChange={(checked) =>
onValueChange("ownershipAttested", checked)
}
/>
</FieldGroup>
</section>
)
}
export function DocumentsStepFields({
values,
errors,
summary,
onValueChange,
onDocumentToggle,
}: {
values: WizardValues
errors: WizardErrors
summary: WizardSummary
onValueChange: WizardValueChange
onDocumentToggle: (documentId: DocumentId, checked: boolean) => void
}) {
return (
<section
className="flex flex-col gap-6"
aria-labelledby="wizard-2-documents-title"
>
<SectionHeading
id="wizard-2-documents-title"
title="Submit"
description="Review files and confirm."
/>
<FieldGroup className="grid gap-6">
<Field data-invalid={Boolean(errors.documentIds)} className="gap-3">
<FieldLabelHint
label="Required Documents"
hint="Select files already received from the vendor."
>
Required Documents
</FieldLabelHint>
<FieldGroup className="grid gap-2 sm:grid-cols-2">
{DOCUMENT_REQUIREMENTS.map((document) => (
<FieldLabel key={document.id}>
<Field orientation="horizontal">
<Checkbox
checked={values.documentIds.includes(document.id)}
onCheckedChange={(checked) =>
onDocumentToggle(document.id, checked === true)
}
/>
<FieldContent>
<FieldTitle>{document.title}</FieldTitle>
<FieldDescription>{document.description}</FieldDescription>
</FieldContent>
</Field>
</FieldLabel>
))}
</FieldGroup>
{errors.documentIds ? (
<FieldError>{errors.documentIds}</FieldError>
) : null}
</Field>
<Field className="gap-2">
<FieldLabel htmlFor="wizard-2-notes">Reviewer Notes</FieldLabel>
<Textarea
id="wizard-2-notes"
value={values.submissionNotes}
onChange={(event) =>
onValueChange("submissionNotes", event.target.value)
}
className="min-h-24 resize-none"
/>
</Field>
<div className="grid gap-3 sm:grid-cols-2">
<SummarySection title="Vendor">
<dl className="flex flex-col gap-2.5">
<SummaryLine label="Company" value={values.companyName} />
<SummaryLine label="Category" value={summary.categoryLabel} />
<SummaryLine label="Country" value={summary.countryLabel} />
</dl>
</SummarySection>
<SummarySection title="Compliance">
<dl className="flex flex-col gap-2.5">
<SummaryLine label="Tax/VAT" value={values.taxId} />
<SummaryLine label="Owners" value={values.owners.length} />
<SummaryLine label="Status" value="Reviewed" />
</dl>
</SummarySection>
</div>
</FieldGroup>
</section>
)
}
@@ -1,426 +0,0 @@
"use client"
import { useCallback, useMemo, useState, type FormEvent } from "react"
import { Badge } from "@/components/reui/badge"
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import {
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/reui/stepper"
import { toast } from "sonner"
import { Button } from "@evobgp/ui/components/button"
import { Spinner } from "@evobgp/ui/components/spinner"
import {
CATEGORY_OPTIONS,
COUNTRY_OPTIONS,
DEFAULT_WIZARD_VALUES,
REGISTRATION_OPTIONS,
WIZARD_STEPS,
type DocumentId,
type OwnerRecord,
type WizardOption,
type WizardStepId,
type WizardValues,
} from "./data"
import {
CompanyStepFields,
DocumentsStepFields,
OwnershipStepFields,
type WizardErrors,
} from "./procurement-vendor-steps"
import { CheckIcon, FlagIcon, ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
function createDefaultValues(): WizardValues {
return {
...DEFAULT_WIZARD_VALUES,
owners: DEFAULT_WIZARD_VALUES.owners.map((owner) => ({ ...owner })),
documentIds: [...DEFAULT_WIZARD_VALUES.documentIds],
}
}
function isValidEmail(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
}
function getOptionLabel<TValue extends string>(
options: WizardOption<TValue>[],
value: TValue
) {
return options.find((option) => option.value === value)?.label ?? "Not set"
}
function getStepErrors(stepId: WizardStepId, values: WizardValues) {
const nextErrors: WizardErrors = {}
if (stepId === "company") {
if (!values.companyName.trim()) {
nextErrors.companyName = "Enter the legal company name."
}
if (!isValidEmail(values.contactEmail)) {
nextErrors.contactEmail = "Enter a valid procurement email."
}
}
if (stepId === "ownership") {
if (!values.taxId.trim()) {
nextErrors.taxId = "Enter the Tax/VAT ID."
}
if (!values.incorporationDate.trim()) {
nextErrors.incorporationDate = "Enter the incorporation date."
}
if (!values.owners.some((owner) => owner.name.trim())) {
nextErrors.owners = "Add at least one beneficial owner."
}
if (!values.ownershipAttested) {
nextErrors.ownershipAttested = "Confirm beneficial ownership review."
}
}
if (stepId === "documents" && values.documentIds.length < 2) {
nextErrors.documentIds = "Select at least two received documents."
}
return nextErrors
}
function getAllRequiredErrors(values: WizardValues) {
return {
...getStepErrors("company", values),
...getStepErrors("ownership", values),
...getStepErrors("documents", values),
}
}
function getFirstInvalidStep(values: WizardValues) {
if (Object.keys(getStepErrors("company", values)).length > 0) return 1
if (Object.keys(getStepErrors("ownership", values)).length > 0) return 2
if (Object.keys(getStepErrors("documents", values)).length > 0) return 3
return null
}
function DotSeparator() {
return (
<span
className="size-1 shrink-0 rounded-full bg-gray-400 dark:bg-gray-500"
aria-hidden="true"
/>
)
}
export function ProcurementVendorWizard() {
const [currentStep, setCurrentStep] = useState(2)
const [isSubmitting, setIsSubmitting] = useState(false)
const [values, setValues] = useState<WizardValues>(createDefaultValues)
const [errors, setErrors] = useState<WizardErrors>({})
const currentStepConfig = useMemo(
() =>
WIZARD_STEPS.find((step) => step.step === currentStep) ?? WIZARD_STEPS[0],
[currentStep]
)
const summary = useMemo(
() => ({
categoryLabel: getOptionLabel(CATEGORY_OPTIONS, values.category),
countryLabel: getOptionLabel(COUNTRY_OPTIONS, values.country),
registrationLabel: getOptionLabel(
REGISTRATION_OPTIONS,
values.registrationStatus
),
}),
[values.category, values.country, values.registrationStatus]
)
const handleValueChange = useCallback(
(field: keyof WizardValues, value: WizardValues[keyof WizardValues]) => {
setValues((current) => ({
...current,
[field]: value,
}))
setErrors((current) => {
if (!current[field]) {
return current
}
const nextErrors = { ...current }
delete nextErrors[field]
return nextErrors
})
},
[]
)
const handleOwnerChange = useCallback(
(id: string, field: keyof OwnerRecord, value: string) => {
setValues((current) => ({
...current,
owners: current.owners.map((owner) =>
owner.id === id ? { ...owner, [field]: value } : owner
),
}))
setErrors((current) => {
if (!current.owners) return current
const nextErrors = { ...current }
delete nextErrors.owners
return nextErrors
})
},
[]
)
const handleAddOwner = useCallback(() => {
setValues((current) => ({
...current,
owners: [
...current.owners,
{
id: `owner-${Date.now()}`,
name: "",
address: "",
share: "",
},
],
}))
}, [])
const handleRemoveOwner = useCallback((id: string) => {
setValues((current) => ({
...current,
owners: current.owners.filter((owner) => owner.id !== id),
}))
setErrors((current) => {
if (!current.owners) return current
const nextErrors = { ...current }
delete nextErrors.owners
return nextErrors
})
}, [])
const handleDocumentToggle = useCallback(
(documentId: DocumentId, checked: boolean) => {
setValues((current) => ({
...current,
documentIds: checked
? Array.from(new Set([...current.documentIds, documentId]))
: current.documentIds.filter((id) => id !== documentId),
}))
setErrors((current) => {
if (!current.documentIds) return current
const nextErrors = { ...current }
delete nextErrors.documentIds
return nextErrors
})
},
[]
)
const handleBack = useCallback(() => {
setCurrentStep((step) => Math.max(1, step - 1))
}, [])
const handleSubmit = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (isSubmitting) {
return
}
if (currentStepConfig.id !== "documents") {
const stepErrors = getStepErrors(currentStepConfig.id, values)
if (Object.keys(stepErrors).length > 0) {
setErrors((current) => ({ ...current, ...stepErrors }))
return
}
const nextStep = Math.min(currentStep + 1, WIZARD_STEPS.length)
setCurrentStep(nextStep)
return
}
const requiredErrors = getAllRequiredErrors(values)
const invalidStep = getFirstInvalidStep(values)
if (Object.keys(requiredErrors).length > 0 && invalidStep !== null) {
setErrors(requiredErrors)
setCurrentStep(invalidStep)
return
}
setIsSubmitting(true)
window.setTimeout(() => {
setIsSubmitting(false)
toast.success("Vendor file submitted", {
description: `${values.companyName} is ready for procurement review.`,
})
}, 900)
},
[currentStep, currentStepConfig.id, isSubmitting, values]
)
const isLastStep = currentStep === WIZARD_STEPS.length
const canGoBack = currentStep > 1 && !isSubmitting
return (
<form
onSubmit={handleSubmit}
className="flex w-full max-w-2xl flex-col"
aria-label="Procurement vendor onboarding wizard"
>
<Stepper
value={currentStep}
onValueChange={setCurrentStep}
indicators={{
completed: (
<CheckIcon className="size-3.5" aria-hidden="true" />
),
loading: <Spinner className="size-3.5" />,
}}
className="w-full"
>
<Frame stacked={true} className="w-full">
<FrameHeader>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px">
<FrameTitle>Procurement Vendor Setup</FrameTitle>
<FrameDescription className="inline-flex min-w-0 items-center gap-1.5 text-sm">
<span className="shrink-0">{values.portalId}</span>
<DotSeparator />
<span className="truncate">{summary.categoryLabel}</span>
</FrameDescription>
</div>
<Badge variant="warning-light">Review file</Badge>
</div>
</FrameHeader>
<FramePanel className="flex items-center overflow-x-auto">
<StepperNav
aria-label="Wizard progress"
className="min-w-full items-center"
>
{WIZARD_STEPS.map((step, index) => (
<StepperItem
key={step.id}
step={step.step}
loading={isSubmitting && step.step === currentStep}
className="relative min-w-0"
>
<StepperTrigger
type="button"
className="min-w-0 justify-start gap-2 disabled:opacity-100"
>
<StepperIndicator className="data-[state=active]:bg-primary/10 data-[state=active]:text-primary data-[state=completed]:bg-primary data-[state=inactive]:border-border data-[state=inactive]:text-foreground data-[state=active]:before:border-primary relative isolate size-6 overflow-visible rounded-full border before:pointer-events-none before:absolute before:inset-0 before:z-10 before:rounded-full before:border before:border-dashed before:border-transparent before:content-[''] data-[state=active]:border-0 data-[state=active]:before:animate-[spin_8s_linear_infinite] motion-reduce:data-[state=active]:before:animate-none">
{index + 1}
</StepperIndicator>
<StepperTitle className="text-foreground hidden truncate text-sm capitalize sm:block">
{step.title}
</StepperTitle>
</StepperTrigger>
{WIZARD_STEPS.length > index + 1 ? (
<StepperSeparator className="bg-border group-data-[state=completed]/step:bg-primary mx-2" />
) : null}
</StepperItem>
))}
</StepperNav>
</FramePanel>
<FramePanel>
<StepperPanel className="text-sm">
{WIZARD_STEPS.map((step) => (
<StepperContent key={step.id} value={step.step}>
{step.id === "company" ? (
<CompanyStepFields
values={values}
errors={errors}
summary={summary}
onValueChange={handleValueChange}
/>
) : null}
{step.id === "ownership" ? (
<OwnershipStepFields
values={values}
errors={errors}
onValueChange={handleValueChange}
onOwnerChange={handleOwnerChange}
onAddOwner={handleAddOwner}
onRemoveOwner={handleRemoveOwner}
/>
) : null}
{step.id === "documents" ? (
<DocumentsStepFields
values={values}
errors={errors}
summary={summary}
onValueChange={handleValueChange}
onDocumentToggle={handleDocumentToggle}
/>
) : null}
</StepperContent>
))}
</StepperPanel>
</FramePanel>
<FrameFooter className="gap-3 sm:flex-row sm:items-center sm:justify-between">
<FrameDescription className="hidden min-w-0 items-center gap-1.5 text-sm sm:flex">
<FlagIcon className="size-4 shrink-0" aria-hidden="true" />
{currentStepConfig.description}
</FrameDescription>
<div className="flex w-full items-center justify-end gap-2 sm:w-auto">
{canGoBack ? (
<Button
type="button"
variant="outline"
onClick={handleBack}
className="shrink-0"
>
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Previous
</Button>
) : null}
<Button
type="submit"
disabled={isSubmitting}
className="shrink-0"
>
{isSubmitting ? <Spinner data-icon="inline-start" /> : null}
{isLastStep ? "Submit" : "Next Step"}
{!isLastStep && !isSubmitting ? (
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
) : null}
</Button>
</div>
</FrameFooter>
</Frame>
</Stepper>
</form>
)
}
@@ -1,9 +0,0 @@
import { ProcurementVendorWizard } from "./components/procurement-vendor-wizard"
export function Page() {
return (
<div className="flex min-h-svh w-full items-start justify-center p-4 sm:p-6 lg:p-10">
<ProcurementVendorWizard />
</div>
)
}
+1 -50
View File
@@ -1,50 +1 @@
import type { ComponentProps, ReactNode } from 'react'
import { Badge } from '@/components/reui/badge'
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
const TONE_VARIANT: Record<string, BadgeVariant> = {
neutral: 'outline',
info: 'info-light',
warning: 'warning-light',
success: 'success-light',
}
export function ModeBadge({
enabled,
onLabel = 'включён',
offLabel = 'выключен',
className,
}: {
enabled: boolean
onLabel?: string
offLabel?: string
className?: string
}) {
return enabled ? (
<Badge variant="success-light" size="sm" radius="full" className={className}>
{onLabel}
</Badge>
) : (
<Badge variant="secondary" size="sm" radius="full" className={className}>
{offLabel}
</Badge>
)
}
export function CategoryBadge({
children,
tone = 'neutral',
className,
}: {
children: ReactNode
tone?: keyof typeof TONE_VARIANT
className?: string
}) {
return (
<Badge variant={TONE_VARIANT[tone]} size="sm" radius="full" className={className}>
{children}
</Badge>
)
}
export { CategoryBadge, ModeBadge } from '@/components/status-badge'
@@ -1,50 +0,0 @@
import type { ReactNode } from 'react'
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { cn } from '@evobgp/ui/lib/utils'
export interface CountedLineTab {
id: string
label: string
count?: number
}
interface CountedLineTabsProps {
tabs: CountedLineTab[]
value: string
onValueChange: (value: string) => void
className?: string
listClassName?: string
children?: ReactNode
}
/** Line tabs with count pills (c-tabs-2 / data-grid-filtering-2). */
export function CountedLineTabs({
tabs,
value,
onValueChange,
className,
listClassName,
children,
}: CountedLineTabsProps) {
return (
<Tabs value={value} onValueChange={onValueChange} className={className}>
<TabsList variant="line" className={cn('gap-5', listClassName)}>
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>{tab.label}</span>
{tab.count !== undefined ? (
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{tab.count}
</span>
) : null}
</TabsTrigger>
))}
</TabsList>
{children}
</Tabs>
)
}
@@ -1,102 +0,0 @@
import { AlertTriangle, CheckCircle, Info } from 'lucide-react'
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
import { Badge } from '@/components/reui/badge'
import {
Timeline,
TimelineContent,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from '@/components/reui/timeline'
import { cn } from '@evobgp/ui/lib/utils'
import { recentPlatformActivity } from '@/lib/metrics'
import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
const KIND_META = {
job: { icon: Info, className: 'text-info' },
revision: { icon: CheckCircle, className: 'text-success' },
network: { icon: AlertTriangle, className: 'text-warning' },
} as const
function statusBadgeVariant(status: string) {
const s = status.toLowerCase()
if (['ok', 'success', 'completed', 'done'].includes(s)) return 'success-light' as const
if (['running', 'queued', 'pending'].includes(s)) return 'info-light' as const
if (['warning', 'mismatch'].includes(s)) return 'warning-light' as const
if (['failed', 'error', 'cancelled'].includes(s)) return 'destructive-light' as const
return 'outline' as const
}
export function DashboardActivityTimeline({
jobs,
revisions,
peers,
speakers,
loading,
}: {
jobs: JobRow[]
revisions: RevisionRow[]
peers: PeerRow[]
speakers: SpeakerRow[]
loading?: boolean
}) {
const items = recentPlatformActivity(jobs, revisions, peers, speakers, 6)
return (
<DashboardFramePanel
title="Недавняя активность"
description="Задачи, ревизии и сетевые события"
className="h-full min-w-0"
>
{loading ? (
<p className="text-muted-foreground px-4 py-6 text-sm">Загрузка…</p>
) : items.length === 0 ? (
<p className="text-muted-foreground px-4 py-6 text-sm">Нет недавних событий</p>
) : (
<div className="px-4 py-4">
<Timeline defaultValue={items.length}>
{items.map((item, index) => {
const meta = KIND_META[item.kind]
const Icon = meta.icon
return (
<TimelineItem
key={item.id}
step={index + 1}
className="group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=vertical]/timeline:not-last:pb-4"
>
<TimelineHeader>
<TimelineSeparator className="bg-border! group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:top-2 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem)] group-data-[orientation=vertical]/timeline:translate-y-5" />
<TimelineIndicator className="border-none bg-transparent group-data-[orientation=vertical]/timeline:-left-6">
<span
className={cn(
'bg-muted/70 flex size-7 items-center justify-center rounded-full',
meta.className,
)}
>
<Icon className="size-3.5" aria-hidden />
</span>
</TimelineIndicator>
</TimelineHeader>
<TimelineContent className="min-w-0 pb-1 text-foreground">
<TimelineTitle className="text-sm leading-snug font-normal break-words">
{item.message}
</TimelineTitle>
<div className="mt-2">
<Badge variant={statusBadgeVariant(item.status)} size="sm">
{item.statusLabel ?? item.status}
</Badge>
</div>
</TimelineContent>
</TimelineItem>
)
})}
</Timeline>
</div>
)}
</DashboardFramePanel>
)
}
@@ -1,36 +0,0 @@
import type { ReactNode } from 'react'
import {
PanelCard,
panelCardContentFlushClassName,
} from '@/components/panel-card'
import { cn } from '@evobgp/ui/lib/utils'
/** Card-surface panel for dashboard sections (replaces legacy Frame shell). */
export function DashboardFramePanel({
title,
description,
actions,
children,
className,
contentClassName,
}: {
title?: string
description?: string
actions?: ReactNode
children: ReactNode
className?: string
contentClassName?: string
}) {
return (
<PanelCard
title={title}
description={description}
actions={actions}
className={cn('h-full', className)}
contentClassName={cn(panelCardContentFlushClassName, contentClassName)}
>
{children}
</PanelCard>
)
}
@@ -3,12 +3,10 @@ import {
Boxes,
ListChecks,
Network,
ServerCog,
Share2,
} from 'lucide-react'
import type { ReactNode } from 'react'
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import { Badge } from '@/components/reui/badge'
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
@@ -16,6 +14,11 @@ import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
type KpiCard = KpiStatItem & { icon: ReactNode }
function ratioPercent(part: number, total: number): number | undefined {
if (total <= 0) return undefined
return Math.round((part / total) * 100)
}
function buildKpis({
modules,
peers,
@@ -40,17 +43,34 @@ function buildKpis({
).length
const offlineSpeakers = Math.max(0, speakers.length - network.speakersOnline)
const riskCount = network.peersMismatch + failedJobs + offlineSpeakers
const disabledModules = Math.max(0, modules.length - enabledModules)
return [
{
id: 'modules',
icon: <Boxes aria-hidden />,
iconClassName: 'text-primary',
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`,
value: loading ? '—' : enabledModules,
label: 'Модули активны',
progress: loading ? undefined : ratioPercent(enabledModules, modules.length),
footer: (
<Badge variant="primary-light" size="sm">
{loading ? '…' : `${modules.length} всего`}
<Badge
variant={
loading || modules.length === 0
? 'outline'
: disabledModules === 0
? 'success-light'
: 'warning-light'
}
size="sm"
>
{loading
? '…'
: modules.length === 0
? 'нет модулей'
: disabledModules === 0
? 'все активны'
: `${disabledModules} выкл`}
</Badge>
),
},
@@ -60,6 +80,7 @@ function buildKpis({
iconClassName: 'text-info',
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
label: 'BGP готовность',
progress: loading || bgpPct === null ? undefined : bgpPct,
footer: (
<Badge
variant={
@@ -73,34 +94,9 @@ function buildKpis({
>
{loading || bgpPct === null
? 'нет включённых пиров'
: `${network.peersEstablished} установлено`}
</Badge>
),
},
{
id: 'peers',
icon: <Share2 aria-hidden />,
iconClassName: 'text-success',
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
label: 'Пиры установлены',
footer: (
<Badge variant="success-light" size="sm">
{loading ? '…' : `${network.peersTotal} в каталоге`}
</Badge>
),
},
{
id: 'speakers',
icon: <ServerCog aria-hidden />,
iconClassName: 'text-warning',
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
label: 'Спикеры в сети',
footer: (
<Badge
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
size="sm"
>
{loading ? '…' : 'в сети'}
: bgpPct >= 90
? 'сессии в норме'
: `${network.peersEstablished} установлено`}
</Badge>
),
},
@@ -112,7 +108,7 @@ function buildKpis({
label: 'Активные задачи',
footer: (
<Badge variant={running > 0 ? 'info-light' : 'outline'} size="sm">
{loading ? '…' : `${jobs.length} в выборке`}
{loading ? '…' : running > 0 ? 'выполняются' : 'очередь пуста'}
</Badge>
),
},
@@ -1,118 +0,0 @@
import { useMemo } from 'react'
import { KpiSparklineCard, type KpiSparklineMetric } from '@/components/patterns/kpi-sparkline-card'
import { kpiGridClassName } from '@/lib/ui-surface'
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
function syntheticSparkline(seed: number, points = 9): number[] {
const base = Math.max(4, seed)
return Array.from({ length: points }, (_, i) =>
Math.round(base * (0.82 + (i / points) * 0.18 + Math.sin(i + seed) * 0.04)),
)
}
function buildMetrics({
modules,
peers,
speakers,
jobs,
loading,
}: {
modules: ModuleRow[]
peers: PeerRow[]
speakers: SpeakerRow[]
jobs: JobRow[]
loading?: boolean
}): KpiSparklineMetric[] {
const enabledModules = modules.filter((m) => m.enabled !== false).length
const network = aggregateNetworkMetrics(peers, speakers)
const bgpPct =
network.peersEnabled > 0
? Math.round((network.peersEstablished / network.peersEnabled) * 100)
: 0
const running = runningJobCount(jobs)
const failedJobs = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
return [
{
id: 'bgp',
title: 'BGP готовность',
label: 'Установлено / включено',
value: loading || network.peersEnabled === 0 ? '—' : `${bgpPct}%`,
delta: loading ? '…' : bgpPct >= 90 ? 'стабильно' : 'внимание',
deltaVariant: bgpPct >= 90 ? 'success-light' : bgpPct >= 50 ? 'warning-light' : 'destructive-light',
detail: loading ? '' : `${network.peersEstablished} сессий`,
tone: bgpPct >= 90 ? 'success' : bgpPct >= 50 ? 'warning' : 'danger',
sparkline: syntheticSparkline(bgpPct || 40),
},
{
id: 'modules',
title: 'Модули',
label: 'Активные списки',
value: loading ? '—' : `${enabledModules}`,
delta: loading ? '…' : `${modules.length} всего`,
deltaVariant: 'primary-light',
detail: loading ? '' : 'маршрутизация',
tone: 'info',
sparkline: syntheticSparkline(enabledModules || 3),
},
{
id: 'speakers',
title: 'Спикеры',
label: 'В сети / всего',
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
delta:
loading || network.speakersTotal === 0
? '…'
: network.speakersOnline === network.speakersTotal
? 'все в сети'
: 'частично',
deltaVariant:
network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light',
detail: loading ? '' : 'live-снимок',
tone: network.speakersOnline === network.speakersTotal ? 'success' : 'warning',
sparkline: syntheticSparkline(network.speakersOnline || 2),
},
{
id: 'jobs',
title: 'Задачи',
label: 'Активные / ошибки',
value: loading ? '—' : String(running),
delta: loading ? '…' : failedJobs > 0 ? `${failedJobs} ошибок` : 'без сбоев',
deltaVariant: failedJobs > 0 ? 'destructive-light' : 'success-light',
detail: loading ? '' : `${jobs.length} в выборке`,
tone: failedJobs > 0 ? 'danger' : running > 0 ? 'info' : 'success',
sparkline: syntheticSparkline(running + failedJobs || 1),
},
]
}
export function DashboardKpiSparklineRow({
modules,
peers,
speakers,
jobs,
loading,
}: {
modules: ModuleRow[]
peers: PeerRow[]
speakers: SpeakerRow[]
jobs: JobRow[]
loading?: boolean
}) {
const metrics = useMemo(
() => buildMetrics({ modules, peers, speakers, jobs, loading }),
[modules, peers, speakers, jobs, loading],
)
return (
<section aria-label="KPI обзора" className={kpiGridClassName}>
{metrics.map((metric) => (
<KpiSparklineCard key={metric.id} metric={metric} />
))}
</section>
)
}
@@ -1,117 +0,0 @@
import { useMemo, useState } from 'react'
import { Link, useNavigate } from '@tanstack/react-router'
import { BoxesIcon, PlusIcon, SearchIcon } from 'lucide-react'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridNameCell } from '@/components/data-grid-cell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import {
ResourcePage,
createTextFilterQuery,
type DataGridColumnDef,
} from '@/components/reui-kit'
import { Button } from '@evobgp/ui/components/button'
import { moduleTypeRu } from '@/lib/ui-labels'
import type { ModuleRow } from '@/types/api'
const MODULE_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'enabled', label: 'Вкл' },
{ id: 'disabled', label: 'Выкл' },
]
const filterFields: FilterField[] = [
{
id: 'search',
label: 'Поиск',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
placeholder: 'Поиск модулей…',
},
]
function getFilterFieldValue(item: ModuleRow, field: string): unknown {
if (field === 'search') {
return `${item.name} ${item.type} ${moduleTypeRu(item.type)}`
}
return undefined
}
function tabFilter(item: ModuleRow, tabId: string): boolean {
if (tabId === 'enabled') return item.enabled !== false
if (tabId === 'disabled') return item.enabled === false
return true
}
export function DashboardModulesGrid({
modules,
isLoading = false,
}: {
modules: ModuleRow[]
isLoading?: boolean
}) {
const navigate = useNavigate()
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
createTextFilterQuery('search'),
)
const columns = useMemo<DataGridColumnDef<ModuleRow>[]>(
() => [
{
accessorKey: 'name',
id: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
cell: ({ row }) => <DataGridNameCell icon={BoxesIcon} title={row.original.name} />,
minSize: 180,
meta: { headerTitle: 'Модуль' },
},
{
accessorKey: 'type',
id: 'type',
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
cell: ({ row }) => <CategoryBadge>{moduleTypeRu(row.original.type)}</CategoryBadge>,
meta: { headerTitle: 'Тип' },
},
{
accessorKey: 'priority',
id: 'priority',
header: ({ column }) => <DataGridColumnHeader column={column} title="Приоритет" />,
cell: ({ row }) => (
<span className="font-mono text-sm tabular-nums">{row.original.priority}</span>
),
size: 88,
meta: { headerTitle: 'Приоритет' },
},
],
[],
)
return (
<ResourcePage
title="Модули"
description="Поиск и быстрый переход к настройке"
tabs={MODULE_TABS}
tabFilter={tabFilter}
filterFields={filterFields}
filterQuery={filterQuery}
onFilterQueryChange={setFilterQuery}
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
getFilterFieldValue={getFilterFieldValue}
columns={columns}
data={modules}
getRowId={(row) => row.id}
isLoading={isLoading}
primaryAction={
<Button variant="outline" size="sm" render={<Link to="/modules" search={{ create: true }} />}>
<PlusIcon />
Создать
</Button>
}
onRowClick={(row) =>
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })
}
emptyState={{ title: 'Нет модулей по выбранным фильтрам.' }}
/>
)
}
@@ -60,8 +60,28 @@ export function DashboardNetworkHealth({
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры в сети',
percent: loading ? 0 : utilization,
badge: (
<Badge variant="outline" radius="full" className="h-6 px-2 text-[10px]">
{loading ? '…' : `${established}/${total}`}
<Badge
variant={
loading || total === 0
? 'outline'
: offline === 0
? 'success-light'
: 'warning-light'
}
size="sm"
radius="full"
>
{loading
? '…'
: total === 0
? 'нет данных'
: offline === 0
? mode === 'peers'
? 'все установлены'
: 'все в сети'
: mode === 'peers'
? `${offline} не установлены`
: `${offline} офлайн`}
</Badge>
),
}}
@@ -0,0 +1,55 @@
import { useMemo } from 'react'
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@evobgp/ui/components/chart'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { fmtWhen } from '@/components/portal/format'
import type { RevisionRow } from '@/types/api'
const chartConfig = {
prefixes: { label: 'Префиксов', color: 'var(--chart-1)' },
} satisfies ChartConfig
/** Префиксов в опубликованной конфигурации по последним ревизиям (доска «Панель»). */
export function DashboardPrefixTrend({ revisions, loading }: { revisions: RevisionRow[]; loading?: boolean }) {
const data = useMemo(
() =>
[...revisions]
.sort((a, b) => a.created_at.localeCompare(b.created_at))
.slice(-12)
.map((r) => ({ id: r.id, when: fmtWhen(r.created_at), prefixes: r.materialized_prefix_count })),
[revisions],
)
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-col items-start gap-0.5">
<FrameTitle>Префиксов в опубликованной конфигурации</FrameTitle>
<FrameDescription className="text-pretty">Последние {data.length || 12} ревизий</FrameDescription>
</FrameHeader>
<FramePanel className="p-3">
{data.length < 2 ? (
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
{loading ? 'Загрузка…' : 'Недостаточно ревизий для графика'}
</div>
) : (
<ChartContainer config={chartConfig} className="aspect-auto h-64 w-full">
<AreaChart data={data} margin={{ left: 4, right: 12, top: 8 }}>
<defs>
<linearGradient id="prefix-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-prefixes)" stopOpacity={0.35} />
<stop offset="95%" stopColor="var(--color-prefixes)" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="id" tickLine={false} axisLine={false} tickMargin={8} minTickGap={24} />
<YAxis domain={([min, max]) => [Math.floor(min * 0.98), Math.ceil(max * 1.01)]} tickLine={false} axisLine={false} width={48} tickFormatter={(v: number) => v.toLocaleString('ru-RU')} />
<ChartTooltip content={<ChartTooltipContent labelFormatter={(_, p) => p?.[0]?.payload?.when ?? ''} />} />
<Area dataKey="prefixes" type="monotone" stroke="var(--color-prefixes)" strokeWidth={2} fill="url(#prefix-fill)" />
</AreaChart>
</ChartContainer>
)}
</FramePanel>
</Frame>
)
}
@@ -61,7 +61,6 @@ const ACTIONS: QuickActionItem[] = [
title: 'Мониторинг',
description: 'Состояние системы, BIRD и PostgreSQL.',
to: '/monitoring',
search: { tab: 'system' },
icon: <Gauge aria-hidden />,
iconClassName: 'text-destructive',
},
@@ -1,62 +0,0 @@
import { useMemo } from 'react'
import { ListTodo } from 'lucide-react'
import { DataGridNameCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { FrameDataGrid, type DataGridColumnDef } from '@/components/reui-kit'
import { jobKindRu } from '@/lib/ui-labels'
import type { JobRow } from '@/types/api'
export function DashboardRecentJobsGrid({
jobs,
nameById,
isLoading = false,
}: {
jobs: JobRow[]
nameById: Map<string, string>
isLoading?: boolean
}) {
const data = useMemo(() => jobs.slice(0, 8), [jobs])
const columns = useMemo<DataGridColumnDef<JobRow>[]>(
() => [
{
accessorKey: 'kind',
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
cell: ({ row }) => (
<DataGridNameCell
icon={ListTodo}
title={jobKindRu(row.original.kind)}
subtitle={
row.original.meta?.module_id
? (nameById.get(String(row.original.meta.module_id)) ?? undefined)
: undefined
}
/>
),
meta: { headerTitle: 'Вид' },
},
{
accessorKey: 'status',
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
cell: ({ row }) => <StatusBadge status={row.original.status} />,
meta: { headerTitle: 'Статус' },
},
],
[nameById],
)
return (
<FrameDataGrid
title="Недавние задачи"
description="Последние фоновые операции"
columns={columns}
data={data}
rowId={(row) => row.job_id}
emptyTitle="Нет задач"
pagination={false}
isLoading={isLoading}
/>
)
}
@@ -1,55 +0,0 @@
import { useMemo } from 'react'
import { GitCommitHorizontal } from 'lucide-react'
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { FrameDataGrid, type DataGridColumnDef } from '@/components/reui-kit'
import type { RevisionRow } from '@/types/api'
export function DashboardRecentRevisionsGrid({
revisions,
isLoading = false,
}: {
revisions: RevisionRow[]
isLoading?: boolean
}) {
const data = useMemo(() => revisions.slice(0, 8), [revisions])
const columns = useMemo<DataGridColumnDef<RevisionRow>[]>(
() => [
{
id: 'id',
accessorFn: (row) => row.id,
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
cell: ({ row }) => (
<DataGridNameCell icon={GitCommitHorizontal} title={`${row.original.id.slice(0, 10)}…`} />
),
meta: { headerTitle: 'ID' },
},
{
accessorKey: 'created_at',
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
cell: ({ row }) => (
<DataGridMutedCell>
{new Date(row.original.created_at).toLocaleString('ru-RU')}
</DataGridMutedCell>
),
meta: { headerTitle: 'Создана' },
},
],
[],
)
return (
<FrameDataGrid
title="Последние ревизии"
description="История конфигураций"
columns={columns}
data={data}
rowId={(row) => row.id}
emptyTitle="Нет ревизий"
pagination={false}
isLoading={isLoading}
/>
)
}
@@ -15,7 +15,7 @@ import {
import type { DohProfile } from '@/types/api'
const filterFields: FilterField[] = [
createSearchFilterField('search', 'Поиск', 'Поиск DoH профилей…'),
createSearchFilterField('search', 'Поиск', 'Поиск DoH-профилей…'),
]
export function DirectoriesDohGrid({
@@ -81,7 +81,7 @@ export function DirectoriesDohGrid({
return (
<ResourcePage
title="DoH профили"
title="DoH-профили"
description="Резолверы DNS-over-HTTPS для доменных модулей"
filterFields={filterFields}
filterQuery={filterQuery}
@@ -94,7 +94,7 @@ export function DirectoriesDohGrid({
isLoading={isLoading}
primaryAction={actions}
pinLastColumn={Boolean(canWrite && onEdit)}
emptyState={{ title: 'Нет DoH профилей', action: actions }}
emptyState={{ title: 'Нет DoH-профилей', action: actions }}
/>
)
}
+19 -2
View File
@@ -7,6 +7,10 @@ import { InboxIcon, type LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import { IconStack } from '@/components/reui/icon-stack'
import {
Frame,
FramePanel,
} from '@/components/reui/frame'
import {
Empty,
EmptyContent,
@@ -25,6 +29,8 @@ interface EmptyStateProps {
className?: string
stackedIcon?: boolean
centered?: boolean
/** Wrap in Frame (empty-state-14). Nested grids should leave this false. */
framed?: boolean
}
function isLucideIcon(icon: LucideIcon | ReactNode): icon is LucideIcon {
@@ -44,15 +50,16 @@ export function EmptyState({
className,
stackedIcon = true,
centered = true,
framed = false,
}: EmptyStateProps) {
const Icon = isLucideIcon(icon) ? icon : InboxIcon
const customIcon = icon && !isLucideIcon(icon) ? icon : null
const body = (
const empty = (
<Empty
className={cn(
'max-w-md flex-none border-0 bg-transparent p-0',
!centered && className,
!centered && !framed && className,
)}
>
<EmptyHeader className="gap-5 text-center">
@@ -88,6 +95,16 @@ export function EmptyState({
</Empty>
)
const body = framed ? (
<Frame dense spacing="sm" className={cn('w-full', !centered && className)}>
<FramePanel className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
{empty}
</FramePanel>
</Frame>
) : (
empty
)
if (!centered) return body
return (
@@ -1,107 +0,0 @@
"use client"
import { useState } from "react"
import { Checkbox } from "@evobgp/ui/components/checkbox"
import { Field } from "@evobgp/ui/components/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@evobgp/ui/components/input-group"
import { Label } from "@evobgp/ui/components/label"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@evobgp/ui/components/popover"
import { SearchIcon, XIcon, ListFilterIcon } from "lucide-react"
const statuses = ["Pending", "Shipped", "Cancelled"] as const
type Status = (typeof statuses)[number]
function toggleStatus(values: Status[], value: Status) {
return values.includes(value)
? values.filter((item) => item !== value)
: [...values, value]
}
export function Pattern() {
const [searchQuery, setSearchQuery] = useState("")
const [selectedStatuses, setSelectedStatuses] = useState<Status[]>([])
return (
<Field className="max-w-sm">
<InputGroup>
<InputGroupAddon align="inline-start">
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search orders..."
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
<InputGroupAddon align="inline-end" className="gap-1">
{searchQuery.length > 0 ? (
<InputGroupButton
aria-label="Clear search"
size="icon-xs"
variant="ghost"
onClick={() => setSearchQuery("")}
>
<XIcon aria-hidden="true" />
</InputGroupButton>
) : null}
<Popover>
<PopoverTrigger
render={
<InputGroupButton
variant="ghost"
size="xs"
className="gap-1.5"
aria-label="Filter order status"
/>
}
>
<ListFilterIcon className="size-3.5" aria-hidden="true" />
Status
{selectedStatuses.length > 0 ? (
<span className="text-muted-foreground tabular-nums">
{selectedStatuses.length}
</span>
) : null}
</PopoverTrigger>
<PopoverContent align="end" className="w-40 p-3">
<div className="flex flex-col gap-2">
{statuses.map((status) => (
<div key={status} className="flex items-center gap-2.5">
<Checkbox
id={`order-status-${status}`}
checked={selectedStatuses.includes(status)}
onCheckedChange={() =>
setSelectedStatuses((previous) =>
toggleStatus(previous, status)
)
}
/>
<Label
htmlFor={`order-status-${status}`}
className="text-sm font-normal"
>
{status}
</Label>
</div>
))}
</div>
</PopoverContent>
</Popover>
</InputGroupAddon>
</InputGroup>
</Field>
)
}
@@ -1,19 +0,0 @@
import { Field } from '@evobgp/ui/components/field'
import { SelectMenu } from '@/components/select-field'
const items = [
{ label: 'Select an item', value: 'placeholder' as const },
...Array.from({ length: 100 }).map((_, i) => ({
label: `Item ${i}`,
value: `item-${i}` as const,
})),
]
export function Pattern() {
return (
<Field className="max-w-xs">
<SelectMenu items={items} placeholder="Select an item" />
</Field>
)
}
@@ -1,129 +0,0 @@
import { Button } from "@evobgp/ui/components/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@evobgp/ui/components/card"
import { Input } from "@evobgp/ui/components/input"
import { Label } from "@evobgp/ui/components/label"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
export function Pattern() {
return (
<div className="flex w-full max-w-xs flex-col gap-6">
<Tabs defaultValue="account">
<TabsList variant="line" className="mb-3.5 w-full">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="account">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Account</CardTitle>
<CardDescription className="text-sm">
Update your account information.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="underline-name" className="text-sm">
Name
</Label>
<Input
id="underline-name"
defaultValue="Alex Chen"
className="h-9"
/>
</div>
<div className="space-y-2">
<Label htmlFor="underline-email" className="text-sm">
Email
</Label>
<Input
id="underline-email"
type="email"
defaultValue="[email protected]"
className="h-9"
/>
</div>
</CardContent>
<CardFooter className="pt-3">
<Button size="sm">Save changes</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="password">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Password</CardTitle>
<CardDescription className="text-sm">
Change your password here.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="underline-current" className="text-sm">
Current password
</Label>
<Input id="underline-current" type="password" className="h-9" />
</div>
<div className="space-y-2">
<Label htmlFor="underline-new" className="text-sm">
New password
</Label>
<Input id="underline-new" type="password" className="h-9" />
</div>
</CardContent>
<CardFooter className="pt-3">
<Button size="sm">Update password</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="settings">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Settings</CardTitle>
<CardDescription className="text-sm">
Manage your preferences.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="underline-theme" className="text-sm">
Theme
</Label>
<Input
id="underline-theme"
defaultValue="Light"
className="h-9"
/>
</div>
<div className="space-y-2">
<Label htmlFor="underline-language" className="text-sm">
Language
</Label>
<Input
id="underline-language"
defaultValue="English"
className="h-9"
/>
</div>
</CardContent>
<CardFooter className="pt-3">
<Button size="sm">Save settings</Button>
</CardFooter>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
@@ -1,46 +0,0 @@
import { Card, CardContent } from "@evobgp/ui/components/card"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
import { LayoutDashboardIcon, BarChart3Icon, SettingsIcon } from "lucide-react"
export function Pattern() {
return (
<div className="flex w-full max-w-md flex-col gap-6">
<Tabs defaultValue="overview">
<TabsList className="w-full">
<TabsTrigger value="overview">
<LayoutDashboardIcon className="size-4" />
Overview
</TabsTrigger>
<TabsTrigger value="analytics">
<BarChart3Icon className="size-4" />
Analytics
</TabsTrigger>
<TabsTrigger value="settings">
<SettingsIcon className="size-4" />
Settings
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<Card>
<CardContent>Overview dashboard content goes here.</CardContent>
</Card>
</TabsContent>
<TabsContent value="analytics">
<Card>
<CardContent>Analytics charts and metrics.</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings">
<Card>
<CardContent>Application settings and preferences.</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
@@ -1,61 +0,0 @@
import { Badge } from "@/components/reui/badge"
import { Card, CardContent } from "@evobgp/ui/components/card"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
export function Pattern() {
return (
<div className="flex w-full max-w-md flex-col gap-6">
<Tabs defaultValue="inbox">
<TabsList variant="line" className="mb-3.5 w-full">
<TabsTrigger value="inbox" className="gap-2">
Inbox
<Badge variant="primary-light" size="sm">
12
</Badge>
</TabsTrigger>
<TabsTrigger value="drafts" className="gap-2">
Drafts
<Badge variant="info-light" size="sm">
3
</Badge>
</TabsTrigger>
<TabsTrigger value="sent" className="gap-2">
Sent
</TabsTrigger>
<TabsTrigger value="spam" className="gap-2">
Spam
<Badge variant="destructive-light" size="sm">
24
</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value="inbox">
<Card>
<CardContent>12 unread messages in your inbox.</CardContent>
</Card>
</TabsContent>
<TabsContent value="drafts">
<Card>
<CardContent>3 drafts waiting to be sent.</CardContent>
</Card>
</TabsContent>
<TabsContent value="sent">
<Card>
<CardContent>All sent messages appear here.</CardContent>
</Card>
</TabsContent>
<TabsContent value="spam">
<Card>
<CardContent>24 spam messages detected.</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
+13 -3
View File
@@ -1,6 +1,8 @@
import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import { FieldGroup } from '@evobgp/ui/components/field'
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
import {
Sheet,
SheetContent,
@@ -20,7 +22,11 @@ interface FormDrawerProps {
className?: string
}
/** Create/edit overlay — Sheet DNA (sheet-8 / sheet-1). Preview: https://reui.io/preview/base/sheet-8 */
/**
* Create/edit overlay — Sheet DNA (sheet-8) + FieldGroup (form-7).
* @see https://reui.io/preview/base/sheet-8
* @see https://reui.io/preview/base/form-7
*/
export function FormDrawer({
open,
onOpenChange,
@@ -34,13 +40,17 @@ export function FormDrawer({
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className={cn('w-full gap-0 overflow-hidden p-0 sm:max-w-lg', className)}
className={cn('flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-lg', className)}
>
<SheetHeader className="shrink-0 border-b border-border/50">
<SheetTitle>{title}</SheetTitle>
{description ? <SheetDescription>{description}</SheetDescription> : null}
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">{children}</div>
<div className="min-h-0 flex-1 overflow-hidden">
<ScrollArea className="h-full">
<FieldGroup className="p-4">{children}</FieldGroup>
</ScrollArea>
</div>
<SheetFooter className="mt-0 shrink-0 border-t border-border/50 bg-muted/50">
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
{footer}
-14
View File
@@ -1,14 +0,0 @@
/**
* Re-export from reui-kit — keep `@/components/kpi-stat-grid` imports stable.
* @see https://reui.io/preview/base/stats-12
*/
export {
KpiStatGrid,
KpiStatCard,
KpiStatCardTile,
kpiStatItemKey,
type KpiStatItem,
type KpiStatCardData,
type KpiStatVariant,
type OpsKpiCard,
} from '@/components/reui-kit/kpi-stat-grid'
@@ -1,233 +0,0 @@
import {
LayoutDashboard,
Boxes,
Network,
Cog,
ListChecks,
Activity,
Settings,
BookText,
KeyRound,
Search,
} from 'lucide-react'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from '@evobgp/ui/components/sidebar'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@evobgp/ui/components/breadcrumb'
import { Separator } from '@evobgp/ui/components/separator'
import { TooltipProvider } from '@evobgp/ui/components/tooltip'
import { Link, useRouterState } from '@tanstack/react-router'
import type { ComponentType, CSSProperties, ReactNode } from 'react'
import { AppSwitcher } from '@/components/app-switcher'
import { AppsMenu } from '@/components/layout/apps-menu'
import { CommandPalette, type CommandPaletteItem } from '@/components/layout/command-palette'
import { NavUser } from '@/components/layout/nav-user'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { can, isAuthEnabled, permissionForPath } from '@/lib/auth'
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
interface NavItem {
to: string
label: string
icon: ComponentType<{ className?: string }>
description?: string
search?: Record<string, string>
/** Explicit permission override; when omitted derived from `to`. */
permission?: string
}
interface NavGroup {
label: string
items: NavItem[]
}
const NAV_GROUPS: NavGroup[] = [
{
label: 'Обзор',
items: [
{
to: '/dashboard',
label: 'Панель',
icon: LayoutDashboard,
description: 'Метрики, модули и активность',
},
],
},
{
label: 'Маршрутизация',
items: [
{ to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' },
{ to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' },
{ to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } },
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'BGP community и DoH' },
],
},
{
label: 'Операции',
items: [
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и применение', search: { tab: 'revisions' } },
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание обновления' },
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD', search: { tab: 'system' } },
],
},
{
label: 'Система',
items: [
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
{ to: '/settings', label: 'Настройки', icon: Settings, description: 'UI и BIRD', search: { tab: 'ui' } },
],
},
]
const ALL_NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items)
/** Nav visible for the current claim (or all items when portal auth is off). */
function useVisibleNavGroups(): NavGroup[] {
if (!isAuthEnabled()) return NAV_GROUPS
return NAV_GROUPS.map((group) => ({
...group,
items: group.items.filter((item) => {
const perm = item.permission ?? permissionForPath(item.to)
return !perm || can(perm)
}),
})).filter((group) => group.items.length > 0)
}
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
)
const PARENT_ROUTE: Record<string, string> = {
'/modules/new': '/modules',
}
const COMMAND_ITEMS: CommandPaletteItem[] = ALL_NAV_ITEMS.map((item) => ({
id: item.to,
label: item.label,
description: item.description,
to: item.to,
search: item.search,
keywords: [item.to.replace(/^\//, '')],
}))
/**
* Shared ops chrome etalon for CFDM / vps-tracker.
* @see https://reui.io/preview/base/app-shell-12
* @see docs/ui-design-contract.md — Shared App Shell chrome
*/
export function AppShell({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const visibleGroups = useVisibleNavGroups()
const activeItem =
ALL_NAV_ITEMS.find((i) => pathname === i.to || (i.to !== '/' && pathname.startsWith(`${i.to}/`))) ??
ALL_NAV_ITEMS[0]
const parentTo = PARENT_ROUTE[pathname] ?? PARENT_ROUTE[activeItem.to]
const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null
const pageTitle =
pathname.startsWith('/modules/') && pathname !== '/modules/new'
? 'Модуль'
: (ROUTE_LABELS[activeItem.to] ?? activeItem.label)
return (
<TooltipProvider delay={0}>
<SidebarProvider
style={
{
'--sidebar-width': '240px',
} as CSSProperties
}
>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<Sidebar collapsible="icon">
<SidebarHeader>
<AppSwitcher />
</SidebarHeader>
<SidebarContent>
{visibleGroups.map((group) => (
<SidebarGroup key={group.label}>
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => {
const Icon = item.icon
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
return (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
render={<Link to={item.to} search={item.search} />}
isActive={isActive}
tooltip={item.label}
>
<Icon className="size-4" />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter>
<NavUser />
</SidebarFooter>
</Sidebar>
<SidebarInset id="main-content">
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb>
<BreadcrumbList>
{parentLabel && parentTo ? (
<>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink render={<Link to={parentTo} />}>{parentLabel}</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
</>
) : null}
<BreadcrumbItem>
<BreadcrumbPage>{pageTitle}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
<AppsMenu />
<SystemMonitorPopover />
</div>
</header>
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
{children}
</div>
</SidebarInset>
<CommandPalette items={COMMAND_ITEMS} hotkeyOnly />
</SidebarProvider>
</TooltipProvider>
)
}
@@ -1,104 +0,0 @@
import { Link } from '@tanstack/react-router'
import { LayoutGridIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
import {
APP_SWITCHER_ICONS,
CURRENT_APP_ID,
} from '@/lib/app-switcher-config'
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
/** Header apps grid — app-shell-12 AppsMenu. @see https://reui.io/preview/base/app-shell-12 */
export function AppsMenu() {
const { config, isLoading } = useAppSwitcherConfig()
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon" aria-label="Приложения" />
}
>
<LayoutGridIcon
className="size-4.5 transition-colors"
aria-hidden="true"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
sideOffset={8}
className="w-72"
>
<DropdownMenuGroup>
<DropdownMenuLabel>
{isLoading ? 'Загрузка…' : config.menuLabel}
</DropdownMenuLabel>
<div className="grid grid-cols-3 gap-1 p-1">
{config.apps.map((app) => {
const Icon = APP_SWITCHER_ICONS[app.icon]
const isCurrent = app.id === CURRENT_APP_ID
if (isCurrent) {
return (
<DropdownMenuItem
key={app.id}
disabled
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden="true" />
</span>
<span className="text-xs font-medium">{app.name}</span>
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem
key={app.id}
nativeButton={false}
render={<a href={app.url} />}
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden="true" />
</span>
<span className="text-xs font-medium">{app.name}</span>
</DropdownMenuItem>
)
})}
</div>
<DropdownMenuSeparator />
{isAuthEnabled() ? (
<DropdownMenuItem
nativeButton={false}
render={<a href={authPortalUrl()} />}
className="justify-center text-sm font-medium"
>
Настроить на портале
</DropdownMenuItem>
) : (
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings" search={{ tab: 'ui' }} />}
className="justify-center text-sm font-medium"
>
Настройки
</DropdownMenuItem>
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -1,147 +0,0 @@
import { useEffect, useId, useMemo, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { Search } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Kbd } from '@evobgp/ui/components/kbd'
import {
SidebarGroup,
SidebarGroupContent,
} from '@evobgp/ui/components/sidebar'
export type CommandPaletteItem = {
id: string
label: string
description?: string
to: string
search?: Record<string, string>
keywords?: string[]
}
interface CommandPaletteProps {
items: CommandPaletteItem[]
className?: string
/** Hotkey-only: no sidebar search trigger (chrome parity with CFDM). */
hotkeyOnly?: boolean
}
export function CommandPalette({ items, hotkeyOnly = false }: CommandPaletteProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const searchInputId = useId()
const navigate = useNavigate()
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
setOpen(true)
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return items
return items.filter((item) => {
const haystack = [item.label, item.description, ...(item.keywords ?? [])]
.filter(Boolean)
.join(' ')
.toLowerCase()
return haystack.includes(q)
})
}, [items, query])
function go(item: CommandPaletteItem) {
setOpen(false)
setQuery('')
void navigate({ to: item.to, search: item.search })
}
return (
<>
{!hotkeyOnly ? (
<SidebarGroup className="p-0">
<SidebarGroupContent className="relative">
<Button
type="button"
variant="outline"
className="hover:bg-background h-8 w-full justify-start pl-7 font-normal transition-[width] duration-200 ease-linear in-data-[state=collapsed]:w-8! in-data-[state=collapsed]:pl-4! in-data-[state=collapsed]:text-transparent"
onClick={() => setOpen(true)}
>
Поиск…
</Button>
<Search
aria-hidden
className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 opacity-50 select-none"
/>
<Kbd className="absolute top-1/2 right-2 -translate-y-1/2 in-data-[state=collapsed]:hidden">
⌘K
</Kbd>
</SidebarGroupContent>
</SidebarGroup>
) : null}
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next)
if (!next) setQuery('')
}}
>
<DialogHeader className="sr-only">
<DialogTitle>Быстрый переход</DialogTitle>
<DialogDescription>Навигация по разделам EvoBGP</DialogDescription>
</DialogHeader>
<DialogContent className="max-w-md gap-0 px-0 py-0 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3">
<div className="flex items-center gap-3 border-b px-4 py-3">
<Search aria-hidden className="size-4 shrink-0 opacity-60" />
<Input
id={searchInputId}
className="h-9 border-none p-0 shadow-none outline-none focus-visible:ring-0"
autoFocus
placeholder="Раздел или действие…"
aria-label="Поиск разделов"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && filtered[0]) go(filtered[0])
}}
/>
</div>
<ul className="max-h-72 overflow-y-auto p-2" role="listbox">
{filtered.length === 0 ? (
<li className="text-muted-foreground px-2 py-6 text-center text-sm">Ничего не найдено</li>
) : (
filtered.map((item) => (
<li key={item.id}>
<button
type="button"
role="option"
className="hover:bg-accent flex w-full flex-col items-start gap-0.5 rounded-md px-3 py-2 text-left text-sm transition-colors"
onClick={() => go(item)}
>
<span className="font-medium">{item.label}</span>
{item.description ? (
<span className="text-muted-foreground text-xs">{item.description}</span>
) : null}
</button>
</li>
))
)}
</ul>
</DialogContent>
</Dialog>
</>
)
}
-229
View File
@@ -1,229 +0,0 @@
import { Link } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { useTheme } from 'next-themes'
import {
ChevronsUpDownIcon,
ExternalLinkIcon,
LogOutIcon,
MonitorIcon,
MoonIcon,
PaletteIcon,
SettingsIcon,
SunIcon,
} from 'lucide-react'
import { cn } from '@evobgp/ui/lib/utils'
import { Avatar, AvatarFallback } from '@evobgp/ui/components/avatar'
import { Button } from '@evobgp/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@evobgp/ui/components/sidebar'
import { setToken as setApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
import {
authPortalUrl,
clearPortalToken,
getClaims,
isAuthEnabled,
redirectToPortalLogout,
resetPortalHandoff,
} from '@/lib/auth'
/** Sidebar footer account menu — ReUI app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */
const THEMES = [
{
value: 'light',
label: 'Светлая',
icon: <SunIcon className="size-3.5" aria-hidden />,
},
{
value: 'dark',
label: 'Тёмная',
icon: <MoonIcon className="size-3.5" aria-hidden />,
},
{
value: 'system',
label: 'Системная',
icon: <MonitorIcon className="size-3.5" aria-hidden />,
},
] as const
function ThemeSegmentedToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const currentTheme = mounted ? (theme ?? 'system') : 'system'
return (
<div
role="radiogroup"
aria-label="Тема"
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{THEMES.map(({ value, label, icon }) => {
const isActive = currentTheme === value
return (
<Button
key={value}
type="button"
role="radio"
aria-checked={isActive}
aria-label={label}
variant="ghost"
size="icon-xs"
onClick={() => setTheme(value)}
className={cn(
'rounded-full',
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{icon}
</Button>
)
})}
</div>
)
}
function initials(source: string): string {
const base = source.trim()
if (!base) return '?'
const parts = base.split(/\s+/).filter(Boolean)
if (parts.length >= 2) {
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
}
return base.slice(0, 2).toUpperCase()
}
export function NavUser() {
const { isMobile } = useSidebar()
const authOn = isAuthEnabled()
const claims = getClaims()
const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость')
const email =
claims?.email?.trim() || (authOn ? '' : 'локальный API-токен')
const fallback = initials(name || email)
function handleSignOut() {
if (authOn) {
clearPortalToken()
resetPortalHandoff()
redirectToPortalLogout()
return
}
setApiToken(null)
if (typeof window !== 'undefined') {
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
window.location.assign('/settings?tab=ui&reason=token-required')
}
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton
size="lg"
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
/>
}
>
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">{fallback}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-muted-foreground">
{email || '—'}
</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--anchor-width) min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">
{fallback}
</AvatarFallback>
</Avatar>
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-muted-foreground">
{email || '—'}
</span>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings" search={{ tab: 'ui' }} />}
>
<SettingsIcon aria-hidden />
Настройки
</DropdownMenuItem>
{authOn ? (
<DropdownMenuItem
nativeButton={false}
render={<a href={authPortalUrl()} />}
>
<ExternalLinkIcon aria-hidden />
Открыть Auth Portal
</DropdownMenuItem>
) : null}
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<PaletteIcon aria-hidden />
Тема
<div className="ml-auto">
<ThemeSegmentedToggle />
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={handleSignOut}>
<LogOutIcon aria-hidden />
{authOn ? 'Выйти' : 'Сбросить токен'}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
@@ -0,0 +1,226 @@
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
ActivityIcon,
BookTextIcon,
BoxesIcon,
CogIcon,
KeyRoundIcon,
LayoutDashboardIcon,
ListChecksIcon,
NetworkIcon,
PlusIcon,
SearchIcon,
SettingsIcon,
} from 'lucide-react'
import { AppSwitcher } from '@/components/app-switcher'
import { CommandPalette, PortalHeader, PortalSidebar } from '@/components/portal/chrome'
import { ChromeConfigProvider, PortalUiProvider } from '@/components/portal/chrome-context'
import type { ChromeConfig, MonitorItem, MonitorMetric, NavItem, PaletteObject } from '@/components/portal/chrome-store'
import { can, getClaims, isAuthEnabled, permissionForPath, redirectToPortalLogout, clearPortalToken, resetPortalHandoff } from '@/lib/auth'
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
import { monitoringHealthQueryOptions } from '@/queries/monitoring'
import { networkBirdQueryOptions } from '@/queries/network'
import {
aggregateNetworkMetrics,
overviewJobsQueryOptions,
overviewModulesQueryOptions,
overviewPeersQueryOptions,
} from '@/queries/overview'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { SidebarInset, SidebarProvider } from '@evobgp/ui/components/sidebar'
import { TooltipProvider } from '@evobgp/ui/components/tooltip'
const FRESH: Record<string, { badge: string; text: string }> = {
'/dashboard': { badge: 'Live', text: 'GET /v1/modules · /v1/peers · /v1/jobs · данные обновляются при открытии страницы' },
'/modules': { badge: 'Каталог', text: 'GET /v1/modules · списки префиксов и AS' },
'/lookup': { badge: 'Проверка', text: 'GET /v1/lookup · IP или домен по спискам и community' },
'/network': { badge: 'BGP', text: 'GET /v1/peers · /v1/speakers · состояние сессий опрашивается живьём' },
'/directories': { badge: 'Справочники', text: 'BGP community и DoH-профили' },
'/operations': { badge: 'Ревизии', text: 'GET /v1/revisions · /v1/jobs · применение и откат' },
'/schedule': { badge: 'Задачи', text: 'Расписание обновления модулей' },
'/monitoring': { badge: 'Live', text: 'GET /health · /ready · BIRD через birdc' },
'/access': { badge: 'API', text: 'API-ключи и роли доступа' },
'/settings': { badge: 'Настройки', text: 'Интерфейс и BIRD' },
}
function freshFor(pathname: string) {
const key = Object.keys(FRESH)
.sort((a, b) => b.length - a.length)
.find((k) => pathname === k || pathname.startsWith(`${k}/`))
return FRESH[key ?? '/dashboard']!
}
function initialsOf(name: string) {
const parts = name.trim().split(/[\s@._-]+/).filter(Boolean)
return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase()
}
const NAV: Array<Omit<NavItem, 'problem'>> = [
{ id: 'dashboard', label: 'Панель', to: '/dashboard', icon: LayoutDashboardIcon, group: 'Обзор' },
{ id: 'modules', label: 'Модули', to: '/modules', icon: BoxesIcon, group: 'Маршрутизация' },
{ id: 'lookup', label: 'Проверка', to: '/lookup', icon: SearchIcon, group: 'Маршрутизация' },
{ id: 'network', label: 'Сеть', to: '/network', icon: NetworkIcon, group: 'Маршрутизация' },
{ id: 'directories', label: 'Справочники', to: '/directories', icon: BookTextIcon, group: 'Маршрутизация' },
{ id: 'operations', label: 'Операции', to: '/operations', icon: CogIcon, group: 'Операции' },
{ id: 'schedule', label: 'Задачи', to: '/schedule', icon: ListChecksIcon, group: 'Операции' },
{ id: 'monitoring', label: 'Мониторинг', to: '/monitoring', icon: ActivityIcon, group: 'Операции' },
{ id: 'access', label: 'Доступ', to: '/access', icon: KeyRoundIcon, group: 'Система' },
{ id: 'settings', label: 'Настройки', to: '/settings', icon: SettingsIcon, group: 'Система' },
]
function logout() {
if (isAuthEnabled()) {
clearPortalToken()
resetPortalHandoff()
redirectToPortalLogout()
}
}
/** Меню и данные оболочки EvoBGP: пункты по правам, точки проблем и монитор из живых запросов. */
function useChromeData(): ChromeConfig {
const healthQ = useQuery({ ...monitoringHealthQueryOptions(), refetchInterval: 30_000 })
const peersQ = useQuery({ ...overviewPeersQueryOptions(), refetchInterval: 30_000 })
const jobsQ = useQuery({ ...overviewJobsQueryOptions(), refetchInterval: 30_000 })
const modulesQ = useQuery(overviewModulesQueryOptions())
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
return useMemo<ChromeConfig>(() => {
const peers = peersQ.data?.items ?? []
const jobs = jobsQ.data?.items ?? []
const modules = modulesQ.data?.items ?? []
const net = aggregateNetworkMetrics(peers, [])
const failedJobs = jobs.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase())).length
const peersDown = Math.max(0, net.peersEnabled - net.peersEstablished)
const healthOk = healthQ.data?.ok ?? false
const birdRatio =
birdQ.data && birdQ.data.bgp_sessions_total > 0
? Math.round((birdQ.data.bgp_established / birdQ.data.bgp_sessions_total) * 100)
: null
const bgpPct = net.peersEnabled > 0 ? Math.round((net.peersEstablished / net.peersEnabled) * 100) : 0
const visible = NAV.filter((item) => {
if (!isAuthEnabled()) return true
const perm = permissionForPath(item.to)
return !perm || can(perm)
})
const problems: Record<string, NavItem['problem']> = {
network: peersDown > 0 ? { tone: 'er', label: `${peersDown} пиров не в Established` } : undefined,
schedule: failedJobs > 0 ? { tone: 'wn', label: `Ошибок задач: ${failedJobs}` } : undefined,
monitoring: healthQ.isSuccess && !healthOk ? { tone: 'er', label: 'API не отвечает как норма' } : undefined,
}
const navItems: NavItem[] = visible.map((i) => ({ ...i, problem: problems[i.id] }))
const groups = [...new Set(navItems.map((i) => i.group))]
const monitorItems: MonitorItem[] = []
if (healthQ.isSuccess && !healthOk) {
monitorItems.push({ id: 'health', tone: 'er', title: 'API EvoBGP не в норме', text: 'GET /health вернул ошибку', age: 'сейчас', to: '/monitoring' })
}
if (peersDown > 0) {
monitorItems.push({ id: 'peers', tone: 'er', title: `Пиров не в Established: ${peersDown}`, text: `Включено ${net.peersEnabled}, поднято ${net.peersEstablished}`, age: 'сейчас', to: '/network' })
}
if (failedJobs > 0) {
monitorItems.push({ id: 'jobs', tone: 'wn', title: `Ошибок задач: ${failedJobs}`, text: 'Проверьте расписание и журнал ревизий', age: 'недавно', to: '/schedule' })
}
if (monitorItems.length === 0) {
monitorItems.push({ id: 'ok', tone: 'ok', title: 'Всё в норме', text: 'API, BGP-сессии и задачи без проблем', age: 'сейчас', to: '/monitoring' })
}
const metrics: MonitorMetric[] = [
{ l: 'Состояние API', v: healthOk ? 'норма' : '—', p: healthOk ? 100 : 0, tone: healthOk ? 'success' : 'warning' },
{ l: 'BGP пиры', v: `${bgpPct}%`, p: bgpPct, tone: bgpPct >= 90 ? 'success' : 'warning' },
{ l: 'BIRD сессии', v: birdRatio === null ? '—' : `${birdRatio}%`, p: birdRatio ?? 0, tone: birdRatio === 100 ? 'success' : 'warning' },
{ l: 'Ошибки задач', v: String(failedJobs), p: Math.min(100, failedJobs * 10), tone: failedJobs > 0 ? 'warning' : 'success' },
]
const claims = getClaims()
const name = claims?.name?.trim() || (isAuthEnabled() ? 'Пользователь' : 'Гость')
const email = claims?.email?.trim() || (isAuthEnabled() ? '' : 'локальный API-токен')
const objects: PaletteObject[] = [
...modules.map<PaletteObject>((m) => ({
id: `module-${m.id}`,
group: 'Модули',
label: m.name,
hint: `${m.type}${m.enabled ? '' : ' · выключен'}`,
dot: m.enabled ? 'ok' : 'mu',
to: `/modules/${m.id}`,
})),
...peers.map<PaletteObject>((p) => ({
id: `peer-${p.id}`,
group: 'BGP-пиры',
label: p.name ?? p.neighbor,
hint: `${p.neighbor}${p.remote_asn ? ` · AS${p.remote_asn}` : ''}`,
dot: p.session_state === 'Established' ? 'ok' : 'er',
to: '/network',
})),
]
return {
navItems,
navGroups: groups,
brand: <AppSwitcher />,
user: { name, email, initials: initialsOf(name || email) },
securityTo: '/settings',
onLogout: logout,
fresh: freshFor,
monitor: { items: monitorItems, metrics, updated: 'обновлено сейчас', footerTo: '/monitoring', footerLabel: 'Мониторинг' },
palette: {
actions: [
{ id: 'act-module', label: 'Создать модуль', hint: 'M', icon: PlusIcon, to: '/modules/new' },
{ id: 'act-lookup', label: 'Проверить IP или домен', hint: 'L', icon: SearchIcon, to: '/lookup' },
{ id: 'act-ops', label: 'Открыть ревизии', hint: 'R', icon: CogIcon, to: '/operations' },
],
objects,
assistantTo: '/lookup',
example: 'Подсказка: «пиры не в Established»',
},
}
}, [healthQ.data, healthQ.isSuccess, peersQ.data, jobsQ.data, modulesQ.data, birdQ.data])
}
/** Оболочка EvoBGP: боковое меню, шапка, монитор, палитра ⌘K — блоки общего макета. */
export function PortalShell({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(true)
const [mounted, setMounted] = useState(false)
useEffect(() => {
// До 1100px меню сворачивается в колонку иконок (на телефоне его заменяет Sheet).
const mq = window.matchMedia('(min-width: 1100px)')
const sync = () => setOpen(mq.matches)
sync()
mq.addEventListener('change', sync)
setMounted(true)
return () => mq.removeEventListener('change', sync)
}, [])
const config = useChromeData()
if (!mounted) {
return (
<div className="flex min-h-svh flex-col gap-4 p-6" aria-busy>
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
</div>
)
}
return (
<PortalUiProvider>
<ChromeConfigProvider value={config}>
<TooltipProvider delay={0}>
<SidebarProvider open={open} onOpenChange={setOpen} style={{ '--sidebar-width': '240px' } as CSSProperties}>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<PortalSidebar />
<SidebarInset id="main-content" className="min-w-0">
<PortalHeader />
<div className="flex w-full min-w-0 flex-1 flex-col gap-5 p-4 md:p-6">{children}</div>
</SidebarInset>
<CommandPalette />
</SidebarProvider>
</TooltipProvider>
</ChromeConfigProvider>
</PortalUiProvider>
)
}
@@ -1,213 +0,0 @@
import { useMemo, type CSSProperties, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Activity, Bird, HeartPulse, ListChecks, Network } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { cn } from '@evobgp/ui/lib/utils'
import { Item, ItemMedia } from '@evobgp/ui/components/item'
import { Popover, PopoverContent, PopoverTrigger } from '@evobgp/ui/components/popover'
import { Progress } from '@evobgp/ui/components/progress'
import { monitoringHealthQueryOptions } from '@/queries/monitoring'
import { networkBirdQueryOptions } from '@/queries/network'
import { aggregateNetworkMetrics, overviewJobsQueryOptions, overviewPeersQueryOptions } from '@/queries/overview'
type MonitorMetric = {
id: string
label: string
value: string
unit: string
percent: number
icon: ReactNode
tone: 'success' | 'warning' | 'destructive' | 'info'
alert: boolean
}
function toneColor(tone: MonitorMetric['tone']) {
switch (tone) {
case 'success':
return 'var(--color-success)'
case 'warning':
return 'var(--color-warning)'
case 'destructive':
return 'var(--color-destructive)'
default:
return 'var(--color-info)'
}
}
function MetricCell({ metric }: { metric: MonitorMetric }) {
const color = toneColor(metric.tone)
return (
<div className="flex flex-col gap-2 p-3">
<div className="flex items-center justify-between gap-1">
<div className="flex min-w-0 items-center gap-1.5">
<Item
className="flex size-5 shrink-0 items-center justify-center p-0"
style={{ backgroundColor: `${color}18` }}
>
<ItemMedia variant="icon" className="size-auto" style={{ color }}>
{metric.icon}
</ItemMedia>
</Item>
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
</div>
<span className="shrink-0 text-xs font-semibold tabular-nums" style={{ color }}>
{metric.value}
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
</span>
</div>
<Progress
value={metric.percent}
className="**:data-[slot=progress-indicator]:bg-(--bar-color) **:data-[slot=progress-track]:h-1"
style={{ '--bar-color': color } as CSSProperties}
/>
</div>
)
}
/** Live system monitor popover (app-shell-7 pattern, EvoBGP API data). */
export function SystemMonitorPopover() {
const healthQ = useQuery({ ...monitoringHealthQueryOptions(), refetchInterval: 30_000 })
const peersQ = useQuery({ ...overviewPeersQueryOptions(), refetchInterval: 30_000 })
const jobsQ = useQuery({ ...overviewJobsQueryOptions(), refetchInterval: 30_000 })
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
const peers = peersQ.data?.items ?? []
const jobs = jobsQ.data?.items ?? []
const network = aggregateNetworkMetrics(peers, [])
const bgpPct =
network.peersEnabled > 0
? Math.round((network.peersEstablished / network.peersEnabled) * 100)
: 0
const failedJobs = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const runningJobs = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
const healthOk = healthQ.data?.ok ?? false
const birdRatio =
birdQ.data && birdQ.data.bgp_sessions_total > 0
? Math.round((birdQ.data.bgp_established / birdQ.data.bgp_sessions_total) * 100)
: null
const metrics = useMemo<MonitorMetric[]>(
() => [
{
id: 'api',
label: 'Состояние API',
value: healthOk ? 'норма' : '—',
unit: '',
percent: healthOk ? 100 : 0,
icon: <HeartPulse aria-hidden />,
tone: healthOk ? 'success' : 'destructive',
alert: !healthOk,
},
{
id: 'bgp',
label: 'BGP пиры',
value: String(bgpPct),
unit: '%',
percent: bgpPct,
icon: <Network aria-hidden />,
tone: bgpPct >= 90 ? 'success' : bgpPct >= 50 ? 'warning' : 'destructive',
alert: bgpPct < 70 && network.peersEnabled > 0,
},
{
id: 'bird',
label: 'BIRD сессии',
value: birdRatio !== null ? String(birdRatio) : '—',
unit: birdRatio !== null ? '%' : '',
percent: birdRatio ?? 0,
icon: <Bird aria-hidden />,
tone:
birdRatio === null
? 'info'
: birdRatio >= 100
? 'success'
: birdRatio >= 50
? 'warning'
: 'destructive',
alert: birdRatio !== null && birdRatio < 100,
},
{
id: 'jobs',
label: 'Ошибки задач',
value: String(failedJobs),
unit: 'шт.',
percent: Math.min(100, failedJobs * 10),
icon: <ListChecks aria-hidden />,
tone: failedJobs > 0 ? 'warning' : 'success',
alert: failedJobs > 0,
},
],
[bgpPct, birdRatio, failedJobs, healthOk, network.peersEnabled],
)
const spiking = metrics.some((m) => m.alert) || runningJobs > 5
return (
<Popover>
<PopoverTrigger
render={
<button
type="button"
aria-label="Монитор системы"
className={cn(
'relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none',
'border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
)}
/>
}
>
<span className="relative flex size-3.5 items-center justify-center">
<Activity
aria-hidden
className={cn(
'size-3.5 transition-colors',
spiking ? 'text-destructive' : 'text-muted-foreground',
)}
/>
{spiking ? (
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden />
) : null}
</span>
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span>
<Badge
variant={spiking ? 'destructive-light' : 'success-light'}
size="xs"
className="h-4 px-1.5 text-[10px]"
>
{spiking ? 'Внимание' : 'Норма'}
</Badge>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
<span className="text-foreground text-xs font-medium">Монитор EvoBGP</span>
<span className="text-muted-foreground text-[11px] tabular-nums">
{new Date().toLocaleTimeString('ru-RU')}
</span>
</div>
<div className="grid grid-cols-2">
{metrics.map((metric, i) => (
<div
key={metric.id}
className={cn(
i % 2 === 1 && 'border-border border-l',
i >= 2 && 'border-border border-t',
)}
>
<MetricCell metric={metric} />
</div>
))}
</div>
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
Активных задач: <span className="text-foreground font-medium tabular-nums">{runningJobs}</span>
{' · '}
Пиров в каталоге:{' '}
<span className="text-foreground font-medium tabular-nums">{network.peersTotal}</span>
</div>
</PopoverContent>
</Popover>
)
}
+5 -6
View File
@@ -1,18 +1,17 @@
import { Button } from '@evobgp/ui/components/button'
import { Loader2Icon } from 'lucide-react'
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import { Spinner } from '@evobgp/ui/components/spinner'
import type { ComponentProps, ReactNode } from 'react'
type LoadingButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
type LoadingButtonProps = ComponentProps<typeof Button> & {
loading?: boolean
variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'
children: ReactNode
}
/** SoT for async actions — Button + Spinner. */
export function LoadingButton({ loading, disabled, children, ...props }: LoadingButtonProps) {
return (
<Button disabled={disabled || loading} {...props}>
{loading ? <Loader2Icon className="animate-spin" data-icon="inline-start" /> : null}
{loading ? <Spinner data-icon="inline-start" /> : null}
{children}
</Button>
)
@@ -20,6 +20,7 @@ import {
import { CascaderInput, CascaderValue } from '@/components/reui/cascader/cascader-nav'
import type { CascaderNode } from '@/components/reui/cascader/cascader-types'
import { ApiError, apiMutate } from '@/lib/api-client'
import { CASCADER_LABELS_RU } from '@/lib/reui-i18n-ru'
import type {
BgpCommunity,
LookupQueryKind,
@@ -202,9 +203,7 @@ export function LookupAddStep({
selectable={allowEmptyCommunity ? 'any' : 'leaf'}
mode="columns"
labels={{
search: 'Поиск',
empty: 'Ничего не найдено',
back: 'Назад',
...CASCADER_LABELS_RU,
rootLevel: 'Модули',
}}
>
@@ -3,8 +3,8 @@ import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Checkbox } from '@evobgp/ui/components/checkbox'
import { Field, FieldDescription, FieldLabel } from '@evobgp/ui/components/field'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
@@ -171,42 +171,42 @@ export function ModuleCreateDialog({
}}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-create-name">Название</Label>
<Field>
<FieldLabel htmlFor="mod-create-name">Название</FieldLabel>
<Input
id="mod-create-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Имя модуля"
/>
</div>
</Field>
<div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3">
<Field orientation="horizontal">
<div className="grid min-w-0 flex-1 gap-1 pr-2">
<Label htmlFor="mod-create-enabled">Включён</Label>
<p className="text-xs text-muted-foreground">
<FieldLabel htmlFor="mod-create-enabled">Включён</FieldLabel>
<FieldDescription>
Выключенный модуль не участвует в обновлении и применении.
</p>
</FieldDescription>
</div>
<Checkbox
id="mod-create-enabled"
checked={enabled}
onCheckedChange={(v) => setEnabled(v === true)}
/>
</div>
</Field>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-create-priority">Приоритет</Label>
<Field>
<FieldLabel htmlFor="mod-create-priority">Приоритет</FieldLabel>
<Input
id="mod-create-priority"
type="number"
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
</div>
</Field>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-create-interval">Интервал обновления (сек)</Label>
<Field>
<FieldLabel htmlFor="mod-create-interval">Интервал обновления (сек)</FieldLabel>
<Input
id="mod-create-interval"
type="number"
@@ -215,17 +215,17 @@ export function ModuleCreateDialog({
value={refreshIntervalSec}
onChange={(e) => setRefreshIntervalSec(e.target.value)}
/>
</div>
</Field>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-create-cron">Cron (опционально)</Label>
<Field>
<FieldLabel htmlFor="mod-create-cron">Cron (опционально)</FieldLabel>
<Input
id="mod-create-cron"
placeholder="0 * * * *"
value={cronExpr}
onChange={(e) => setCronExpr(e.target.value)}
/>
</div>
</Field>
<CommunitySelect
id="mod-create-community"
@@ -248,8 +248,8 @@ export function ModuleCreateDialog({
}}
/>
<div className="flex flex-col gap-2">
<Label>DoH профили</Label>
<Field>
<FieldLabel>DoH профили</FieldLabel>
{dohProfiles.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
) : (
@@ -281,7 +281,7 @@ export function ModuleCreateDialog({
})}
</div>
)}
</div>
</Field>
</>
) : null}
</FormDrawer>
@@ -1,6 +1,6 @@
import { Clock, Gauge, Globe, Tags } from 'lucide-react'
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import { Badge } from '@/components/reui/badge'
import { KpiStatGridSkeleton } from '@/components/skeletons'
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
@@ -17,8 +17,8 @@ import type { ModuleRow } from '@/types/api'
const MODULE_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'enabled', label: 'Вкл' },
{ id: 'disabled', label: 'Выкл' },
{ id: 'enabled', label: 'Включённые' },
{ id: 'disabled', label: 'Выключенные' },
]
const TYPE_OPTIONS = [
@@ -66,7 +66,7 @@ function tabFilter(item: ModuleRow, tabId: string): boolean {
export function ModulesListGrid({
items,
isLoading = false,
title = 'Все модули',
title = 'Маршрутные модули',
actions,
}: {
items: ModuleRow[]
@@ -130,7 +130,7 @@ export function ModulesListGrid({
return (
<ResourcePage
title={title}
description="Маршрутные списки"
description="Поиск по имени · фильтр по типу · клик открывает модуль"
tabs={MODULE_TABS}
tabFilter={tabFilter}
filterFields={filterFields}
@@ -57,7 +57,7 @@ export const peerColumns: DataGridColumnDef<PeerRow>[] = [
export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown {
switch (field) {
case 'name':
return item.name ?? item.neighbor
return `${item.name ?? ''} ${item.neighbor} ${item.remote_asn ?? ''}`
case 'neighbor':
return item.neighbor
case 'session_state':
@@ -68,7 +68,7 @@ export const speakerColumns: DataGridColumnDef<SpeakerRow>[] = [
export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown {
switch (field) {
case 'endpoint':
return `${item.endpoint} ${item.agent_domain ?? ''}`
return `${item.endpoint} ${item.agent_domain ?? ''} ${item.id}`
case 'role':
return item.role
default:
@@ -185,7 +185,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
label="Роль"
items={[
{ value: 'replica', label: 'Реплика' },
{ value: 'master', label: 'Мастер (плоскость)' },
{ value: 'master', label: 'Основной' },
]}
value={role}
onValueChange={(v) => setRole(v ?? 'replica')}
@@ -0,0 +1,73 @@
import {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from '@/components/reui/timeline'
import { jobStatusRu, jobTriggerRu } from '@/lib/ui-labels'
import type { JobRow } from '@/types/api'
/**
* ReUI Timeline в раскрытии строки задач: создана → взята в работу → результат.
* Активный шаг выводится из фактических отметок времени задачи.
* @see https://reui.io/docs/components/base/timeline
*/
const ruDateTime = (value: string | null | undefined) =>
value ? new Date(value).toLocaleString('ru-RU') : undefined
function resultSubtitle(job: JobRow): string | undefined {
if (job.error) return job.error
const summary = job.meta?.apply_summary
if (summary && typeof summary === 'object') {
const message = (summary as Record<string, unknown>).message
if (typeof message === 'string' && message !== '') return message
}
return undefined
}
export function JobTimeline({ job }: { job: JobRow }) {
const trigger = jobTriggerRu(job.meta?.trigger)
const completed = job.finished_at ? 3 : job.started_at ? 2 : 1
const failed = ['failed', 'error'].includes(job.status.toLowerCase())
const subtitle = resultSubtitle(job)
return (
<div className="py-4 ps-3 pe-2">
<Timeline defaultValue={completed}>
<TimelineItem step={1}>
<TimelineHeader>
<TimelineDate>{ruDateTime(job.created_at)}</TimelineDate>
<TimelineTitle>Создана{trigger ? ` (${trigger})` : ''}</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>Идентификатор: {job.job_id}</TimelineContent>
</TimelineItem>
<TimelineItem step={2}>
<TimelineHeader>
<TimelineDate>{ruDateTime(job.started_at)}</TimelineDate>
<TimelineTitle>Взята в работу</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
</TimelineItem>
<TimelineItem step={3}>
<TimelineHeader>
<TimelineDate>{ruDateTime(job.finished_at)}</TimelineDate>
<TimelineTitle className={failed ? 'text-destructive' : undefined}>
{job.finished_at ? jobStatusRu(job.status) : 'Ещё выполняется'}
</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
{subtitle ? <TimelineContent>{subtitle}</TimelineContent> : null}
</TimelineItem>
</Timeline>
</div>
)
}
@@ -4,8 +4,10 @@ import { ListTodo, SearchIcon } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Badge } from '@/components/reui/badge'
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { JobTimeline } from '@/components/operations/job-timeline'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import {
@@ -14,7 +16,7 @@ import {
type DataGridColumnDef,
} from '@/components/reui-kit'
import { apiMutate } from '@/lib/api-client'
import { jobKindRu } from '@/lib/ui-labels'
import { jobKindRu, jobSubtitleRu, jobTriggerRu } from '@/lib/ui-labels'
import type { JobRow } from '@/types/api'
import type { QueryClient } from '@tanstack/react-query'
@@ -71,19 +73,27 @@ export function OperationsJobsGrid({
{
accessorKey: 'kind',
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
cell: ({ row }) => (
<DataGridNameCell
icon={ListTodo}
title={jobKindRu(row.original.kind)}
subtitle={
row.original.meta?.module_id
? (nameById.get(String(row.original.meta.module_id)) ??
String(row.original.meta.module_id))
: undefined
}
/>
),
cell: ({ row }) => {
const trigger = jobTriggerRu(row.original.meta?.trigger)
return (
<DataGridNameCell
icon={ListTodo}
title={
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate">{jobKindRu(row.original.kind)}</span>
{trigger ? (
<Badge variant="outline" size="sm" className="shrink-0 font-normal">
{trigger}
</Badge>
) : null}
</span>
}
subtitle={jobSubtitleRu(row.original.kind, row.original.meta, nameById)}
/>
)
},
meta: { headerTitle: 'Вид' },
size: 320,
},
{
accessorKey: 'status',
@@ -96,6 +106,7 @@ export function OperationsJobsGrid({
return <StatusBadge status={row.original.status} hint={hint} />
},
meta: { headerTitle: 'Статус' },
size: 150,
},
{
id: 'created_at',
@@ -109,6 +120,7 @@ export function OperationsJobsGrid({
</DataGridMutedCell>
),
meta: { headerTitle: 'Создана' },
size: 190,
},
{
id: 'finished_at',
@@ -122,6 +134,7 @@ export function OperationsJobsGrid({
</DataGridMutedCell>
),
meta: { headerTitle: 'Завершена' },
size: 190,
},
{
id: 'actions',
@@ -156,10 +169,9 @@ export function OperationsJobsGrid({
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
getFilterFieldValue={(item, field) => {
if (field !== 'search') return undefined
const moduleName = item.meta?.module_id
? (nameById.get(String(item.meta.module_id)) ?? String(item.meta.module_id))
: ''
return `${jobKindRu(item.kind)} ${item.status} ${item.job_id} ${moduleName}`
const subtitle = jobSubtitleRu(item.kind, item.meta, nameById)
const trigger = jobTriggerRu(item.meta?.trigger)
return `${jobKindRu(item.kind)} ${item.status} ${item.job_id} ${subtitle ?? ''} ${trigger ?? ''}`
}}
columns={columns}
data={items}
@@ -168,6 +180,8 @@ export function OperationsJobsGrid({
pinLastColumn
virtualization={items.length > 80}
emptyState={{ title: 'Нет задач' }}
getRowCanExpand={() => true}
expandedContent={(row) => <JobTimeline job={row} />}
/>
)
}
@@ -8,13 +8,22 @@ import {
KanbanColumn,
KanbanColumnContent,
KanbanItem,
KanbanOverlay,
} from '@/components/reui/kanban'
import { Frame, FramePanel } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import {
Item,
ItemContent,
ItemDescription,
ItemTitle,
} from '@evobgp/ui/components/item'
import { jobKindRu } from '@/lib/ui-labels'
import type { JobRow } from '@/types/api'
/**
* Operations jobs board view — ReUI Kanban (read-only columns).
* Operations jobs board — ReUI Kanban (read-only columns).
* @see https://reui.io/preview/base/kanban-board-8
* @see https://reui.io/docs/components/base/kanban
* @see https://reui.io/docs/components/base/frame
*/
@@ -22,7 +31,7 @@ import type { JobRow } from '@/types/api'
const COLUMN_ORDER = ['queued', 'running', 'succeeded', 'failed'] as const
const COLUMN_LABELS: Record<(typeof COLUMN_ORDER)[number], string> = {
queued: 'Очередь',
queued: 'В очереди',
running: 'Выполняются',
succeeded: 'Успешные',
failed: 'Ошибки',
@@ -78,9 +87,9 @@ export function OperationsJobsKanban({
>
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">{COLUMN_LABELS[columnId]}</p>
<span className="text-muted-foreground text-xs tabular-nums">
<Badge variant="outline" size="sm">
{columns[columnId]?.length ?? 0}
</span>
</Badge>
</div>
<KanbanColumnContent value={columnId}>
{(columns[columnId] ?? []).map((job) => (
@@ -88,24 +97,35 @@ export function OperationsJobsKanban({
key={job.job_id}
value={job.job_id}
disabled
className="bg-background flex flex-col gap-1 rounded-md border p-3"
className="p-0"
>
<p className="text-sm leading-tight font-medium">
{jobKindRu(job.kind)}
</p>
<StatusBadge status={job.status} />
{job.meta?.module_id ? (
<DataGridMutedCell>
{nameById.get(String(job.meta.module_id)) ??
String(job.meta.module_id)}
</DataGridMutedCell>
) : null}
<Item variant="outline" size="sm">
<ItemContent>
<ItemTitle>{jobKindRu(job.kind)}</ItemTitle>
<StatusBadge status={job.status} />
{job.meta?.module_id ? (
<ItemDescription>
<DataGridMutedCell>
{nameById.get(String(job.meta.module_id)) ??
String(job.meta.module_id)}
</DataGridMutedCell>
</ItemDescription>
) : null}
</ItemContent>
</Item>
</KanbanItem>
))}
</KanbanColumnContent>
</KanbanColumn>
))}
</KanbanBoard>
<KanbanOverlay>
<Item variant="outline" size="sm">
<ItemContent>
<ItemTitle>Задача</ItemTitle>
</ItemContent>
</Item>
</KanbanOverlay>
</Kanban>
</FramePanel>
</Frame>
@@ -56,9 +56,10 @@ export function OperationsRevisionsGrid({
accessorFn: (row) => row.id,
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
cell: ({ row }) => (
<DataGridNameCell icon={GitCommitHorizontal} title={`${row.original.id.slice(0, 12)}…`} />
<DataGridNameCell icon={GitCommitHorizontal} title={row.original.id} />
),
meta: { headerTitle: 'ID' },
size: 340,
},
{
accessorKey: 'created_at',
@@ -69,6 +70,7 @@ export function OperationsRevisionsGrid({
</DataGridMutedCell>
),
meta: { headerTitle: 'Создана' },
size: 210,
},
{
accessorKey: 'materialized_prefix_count',
@@ -79,6 +81,7 @@ export function OperationsRevisionsGrid({
</span>
),
meta: { headerTitle: 'Префиксов' },
size: 150,
},
{
id: 'actions',
@@ -91,7 +94,7 @@ export function OperationsRevisionsGrid({
<RefreshCw className="size-3.5" />
</Button>
}
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
title={`Откатиться к ревизии ${row.original.id}?`}
description="Будет создана новая ревизия на основе выбранной. Требуется роль оператора."
confirmLabel="Откатить"
destructive
+8 -11
View File
@@ -2,22 +2,19 @@ import type { ReactNode } from 'react'
interface PageHeaderProps {
title: string
description?: string
description?: ReactNode
actions?: ReactNode
}
/** Заголовок страницы как на досках макета: название 20px, описание 14px muted, действия справа. */
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex min-w-0 flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="flex min-w-0 flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description ? (
<p className="text-muted-foreground max-w-prose text-sm break-words">{description}</p>
) : null}
<header className="flex min-w-0 flex-wrap items-end justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
{description ? <p className="text-muted-foreground text-sm text-pretty">{description}</p> : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
) : null}
</div>
{actions ? <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div> : null}
</header>
)
}
+11 -93
View File
@@ -1,93 +1,11 @@
import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { cn } from '@evobgp/ui/lib/utils'
/**
* Frame shell for analytics / comparison panels (not list grids).
* @see https://reui.io/docs/components/base/frame
* @see https://reui.io/preview/base/chart-1
*/
export const panelCardClassName = 'w-full gap-0 p-0'
export const panelCardHeaderClassName = 'border-b'
/** Flush body inside FramePanel (toolbar / data-grid). */
export const panelCardContentFlushClassName = 'p-0'
/** Horizontal inset for toolbar / pagination rows. */
export const panelCardInsetClassName = 'px-5 py-3'
export const panelCardFooterClassName = 'border-t bg-transparent'
interface PanelCardProps {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
footer?: ReactNode
children?: ReactNode
className?: string
headerClassName?: string
contentClassName?: string
footerClassName?: string
size?: 'default' | 'sm'
}
export function PanelCard({
title,
description,
actions,
footer,
children,
className,
headerClassName,
contentClassName,
footerClassName,
size = 'default',
}: PanelCardProps) {
const hasHeader = Boolean(title || description || actions)
const spacing = size === 'sm' ? 'sm' : 'default'
return (
<Frame
dense
spacing={spacing}
className={cn(panelCardClassName, 'min-w-0', className)}
>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
{hasHeader ? (
<FrameHeader
className={cn(
panelCardHeaderClassName,
'flex-row items-start justify-between gap-3',
headerClassName,
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-px">
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? (
<FrameDescription>{description}</FrameDescription>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{actions}
</div>
) : null}
</FrameHeader>
) : null}
{children != null && children !== false ? (
<div className={cn('min-w-0 flex-1', contentClassName)}>{children}</div>
) : null}
{footer ? (
<FrameFooter className={cn(panelCardFooterClassName, footerClassName)}>
{footer}
</FrameFooter>
) : null}
</FramePanel>
</Frame>
)
}
export {
FrameSection,
FrameSection as PanelCard,
panelCardClassName,
panelCardHeaderClassName,
panelCardContentFlushClassName,
panelCardInsetClassName,
panelCardFooterClassName,
type FrameSectionProps,
type FrameSectionProps as PanelCardProps,
} from '@/components/reui-kit/frame-section'
@@ -1,24 +1,13 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import { IconStack } from '@/components/reui/icon-stack'
import {
Frame,
FramePanel,
} from '@/components/reui/frame'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@evobgp/ui/components/empty'
import { EmptyState } from '@/components/empty-state'
/** empty-state-14: Frame + IconStack. Preview: https://reui.io/preview/base/empty-state-14 */
export function IllustratedEmptyState({
title,
description,
icon: Icon,
icon,
action,
}: {
title: string
@@ -27,23 +16,13 @@ export function IllustratedEmptyState({
action?: ReactNode
}) {
return (
<Frame dense spacing="sm" className="w-full">
<FramePanel className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
{action ? <div className="w-full self-end">{action}</div> : null}
<Empty className="max-w-md gap-5 bg-transparent p-0">
<EmptyHeader className="items-center gap-5 text-center">
<EmptyMedia className="mb-0">
<IconStack aria-hidden>
<Icon strokeWidth={1.9} aria-hidden />
</IconStack>
</EmptyMedia>
<div className="flex flex-col items-center gap-2">
<EmptyTitle className="text-base font-semibold tracking-tight">{title}</EmptyTitle>
<EmptyDescription className="max-w-sm text-sm/relaxed">{description}</EmptyDescription>
</div>
</EmptyHeader>
</Empty>
</FramePanel>
</Frame>
<EmptyState
framed
centered={false}
icon={icon}
title={title}
description={description}
action={action}
/>
)
}
+1 -1
View File
@@ -1,7 +1,7 @@
export { DonutBreakdownCard } from './donut-breakdown-card'
export { IllustratedEmptyState } from './illustrated-empty-state'
export { KpiSparklineCard, type KpiSparklineMetric } from './kpi-sparkline-card'
export { KpiStatGrid, KpiStatCard, type KpiStatItem } from '@/components/kpi-stat-grid'
export { KpiStatGrid, KpiStatCard, type KpiStatItem } from '@/components/reui-kit'
export { PanelCorners } from './panel-corners'
export { ProjectsEmptyState } from './projects-empty-state'
export { SegmentedProgressCard, type SegmentStat } from './segmented-progress-card'
@@ -1,9 +1,9 @@
import { Boxes, Plus } from 'lucide-react'
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
import { EmptyState } from '@/components/empty-state'
import { Button } from '@evobgp/ui/components/button'
/** empty-state-3 pattern for first module. */
/** empty-state-14 for first module. Preview: https://reui.io/preview/base/empty-state-14 */
export function ProjectsEmptyState({
canCreate = true,
onCreate,
@@ -12,7 +12,9 @@ export function ProjectsEmptyState({
onCreate?: () => void
}) {
return (
<IllustratedEmptyState
<EmptyState
framed
centered={false}
icon={Boxes}
title="Создайте первый модуль"
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
+481
View File
@@ -0,0 +1,481 @@
import { useState, type ReactNode } from 'react'
import { CircleAlertIcon, InfoIcon, TriangleAlertIcon, XIcon } from 'lucide-react'
import { toast } from 'sonner'
import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@evobgp/ui/components/alert-dialog'
import { Button } from '@evobgp/ui/components/button'
import { cn } from '@evobgp/ui/lib/utils'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@evobgp/ui/components/sheet'
import type { Tone } from './tone'
import { TONE_BADGE } from './tone'
const TONE_GLYPH: Record<Tone, string> = { ok: '●', wn: '▲', er: '✕', mu: '○', in: '' }
/** Плашка статуса: слово + глиф (● ▲ ✕ ○), чтобы цвет не был единственным признаком. */
export function StatusBadge({ tone, children }: { tone: Tone; children: ReactNode }) {
const glyph = TONE_GLYPH[tone]
return (
<Badge variant={TONE_BADGE[tone]} size="sm" className="whitespace-nowrap">
{glyph ? <span aria-hidden className="text-[0.55rem]">{glyph}</span> : null}
{children}
</Badge>
)
}
/** Заголовок страницы: название, описание, метка свежести и действия. */
export function PageHead({
title,
sub,
actions,
}: {
title: string
sub: string
actions?: ReactNode
}) {
return (
<header className="flex flex-wrap items-end justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
<p className="text-muted-foreground text-sm">{sub}</p>
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</header>
)
}
const ALERT_ICON = {
er: <CircleAlertIcon aria-hidden />,
wn: <TriangleAlertIcon aria-hidden />,
in: <InfoIcon aria-hidden />,
} as const
const ALERT_VARIANT = {
er: 'destructive',
wn: 'warning',
in: 'info',
} as const
export function SignalAlert({
tone,
title,
children,
action,
}: {
tone: 'er' | 'wn' | 'in'
title: string
children: ReactNode
action?: ReactNode
}) {
return (
<Alert variant={ALERT_VARIANT[tone]}>
{ALERT_ICON[tone]}
<AlertTitle>{title}</AlertTitle>
<AlertDescription>{children}</AlertDescription>
{action ? <AlertAction>{action}</AlertAction> : null}
</Alert>
)
}
/** Боковое окно (как CreateUserSheet): заголовок, прокручиваемое тело, подвал. */
export function PortalSheet({
open,
onOpenChange,
title,
description,
children,
footer,
size = 'md',
dirty,
}: {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
children: ReactNode
footer?: ReactNode
/** Ширина панели: md — форма, lg — редактор с двумя колонками. */
size?: 'md' | 'lg'
/** Явный признак изменений (иначе ловим ввод в полях и переключатели). */
dirty?: boolean
}) {
const [touchedState, setTouched] = useState(false)
const touched = dirty ?? touchedState
const [guard, setGuard] = useState(false)
function requestChange(next: boolean) {
if (!next && touched) {
setGuard(true)
return
}
if (!next) setTouched(false)
onOpenChange(next)
}
return (
<>
<Sheet open={open} onOpenChange={requestChange}>
<SheetContent
side="right"
showCloseButton={false}
className={cn(
'inset-y-0 right-0 left-auto flex h-svh max-w-none flex-col gap-0 overflow-hidden rounded-none p-0 outline-none sm:max-w-none',
'data-[side=right]:sm:max-w-none',
size === 'lg'
? 'data-[side=right]:w-[min(45rem,100vw)]'
: 'data-[side=right]:w-[min(30rem,100vw)]',
)}
>
<SheetHeader className="shrink-0 gap-0 p-0">
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
<SheetTitle className="min-w-0 truncate text-base font-semibold">{title}</SheetTitle>
<SheetClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Закрыть"
className="text-muted-foreground hover:text-foreground"
/>
}
>
<XIcon aria-hidden="true" />
</SheetClose>
</div>
<SheetDescription className="text-muted-foreground px-4 pt-3 text-sm">
{description}
</SheetDescription>
</SheetHeader>
<div
className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4"
onInput={() => setTouched(true)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('[role=switch]')) setTouched(true)
}}
>
{children}
</div>
{footer ? (
<SheetFooter
className="flex-row flex-wrap justify-end gap-2 border-t px-4 py-3"
onClickCapture={(e) => {
// «Закрыть» и «Отмена» при изменённой форме тоже спрашивают подтверждение.
const label = (e.target as HTMLElement).closest('button')?.textContent?.trim()
if (touched && (label === 'Закрыть' || label === 'Отмена')) {
e.stopPropagation()
setGuard(true)
}
}}
>{footer}</SheetFooter>
) : null}
</SheetContent>
</Sheet>
<AlertDialog open={guard} onOpenChange={setGuard}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Закрыть без сохранения?</AlertDialogTitle>
<AlertDialogDescription>Изменения в форме будут потеряны.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Остаться</AlertDialogCancel>
<Button
type="button"
variant="destructive"
onClick={() => {
setGuard(false)
setTouched(false)
onOpenChange(false)
}}
>
Закрыть без сохранения
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
/** Подтверждение опасного действия вводом слова (email, «сбросить», «очистить»…). */
export function TypedConfirm({
open,
onOpenChange,
title,
description,
expected,
confirmLabel,
onConfirm,
}: {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: ReactNode
expected: string
confirmLabel: string
onConfirm: () => void
}) {
const [value, setValue] = useState('')
const matches = value.trim().toLowerCase() === expected.toLowerCase()
return (
<AlertDialog
open={open}
onOpenChange={(next) => {
if (!next) setValue('')
onOpenChange(next)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex flex-col gap-2">
<Label htmlFor="typed-confirm">
Введите <span className="font-mono font-semibold">{expected}</span> для подтверждения
</Label>
<Input
id="typed-confirm"
value={value}
autoComplete="off"
onChange={(e) => setValue(e.target.value)}
aria-invalid={value.length > 0 && !matches}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<Button
type="button"
variant="destructive"
disabled={!matches}
onClick={() => {
setValue('')
onConfirm()
}}
>
{confirmLabel}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
export function KvFrame({
title,
description,
items,
}: {
title: string
description: string
items: Array<{ label: string; value: ReactNode }>
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full">
<FrameHeader>
<FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<dl className="grid divide-y @container sm:grid-cols-2 sm:divide-y-0">
{items.map((item) => (
<div
key={item.label}
className="flex flex-col gap-0.5 border-b px-4 py-3 last:border-b-0 sm:odd:border-r"
>
<dt className="text-muted-foreground text-xs">{item.label}</dt>
<dd className="text-sm font-medium break-words">{item.value}</dd>
</div>
))}
</dl>
</FramePanel>
</Frame>
)
}
/** Список «значение + подпись + копировать» (параметры issuer). */
export function CopyList({
title,
description,
items,
}: {
title: string
description: string
items: Array<{ label: string; value: string }>
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full">
<FrameHeader>
<FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<ul className="flex flex-col">
{items.map((item) => (
<li
key={item.label}
className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-2.5 last:border-b-0"
>
<code className="min-w-0 flex-1 basis-72 font-mono text-[13px] break-all">{item.value}</code>
<span className="text-muted-foreground min-w-0 flex-1 basis-48 text-[13px]">{item.label}</span>
<Button
type="button"
variant="outline"
aria-label={`Скопировать: ${item.label}`}
onClick={() => {
void navigator.clipboard?.writeText(item.value).catch(() => undefined)
toast.success(`${item.label} скопирован`)
}}
>
Копировать
</Button>
</li>
))}
</ul>
</FramePanel>
</Frame>
)
}
/** Компактный список «ключ — значение» для боковых окон. */
export function KvList({ items }: { items: Array<[string, string]> }) {
return (
<dl className="flex flex-col divide-y rounded-lg border">
{items.map(([k, v]) => (
<div key={k} className="flex flex-col gap-0.5 px-3 py-2">
<dt className="text-muted-foreground text-xs">{k}</dt>
<dd className="text-sm break-words">{v}</dd>
</div>
))}
</dl>
)
}
export function CodeBlock({ label, children }: { label: string; children: string }) {
return (
<div className="flex flex-col gap-1">
<span className="text-muted-foreground text-xs">{label}</span>
<pre className="bg-muted/40 overflow-x-auto [scrollbar-width:none] rounded-md border p-3 font-mono text-xs leading-relaxed">{children}</pre>
</div>
)
}
/** Сегментированный переключатель на Tabs (вид таблицы, период). */
export function Segmented({
label,
options,
value,
onChange,
}: {
label: string
options: string[]
value: number
onChange: (index: number) => void
}) {
return (
<Tabs value={String(value)} onValueChange={(v) => onChange(Number(v))} aria-label={label} className="max-w-full">
<TabsList className="max-w-full justify-start overflow-x-auto [scrollbar-width:none]">
{options.map((o, i) => (
<TabsTrigger key={o} value={String(i)}>
{o}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)
}
/** Плашка строки с накопленной правкой: «Не сохранено» или «Будет удалено». */
export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) {
return (
<Badge variant={mark === 'deleted' ? 'destructive-light' : 'warning-light'} size="sm" className="whitespace-nowrap">
{mark === 'deleted' ? 'Будет удалено' : 'Не сохранено'}
</Badge>
)
}
/** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */
export function PageTabsList({ children }: { children: ReactNode }) {
return (
<TabsList variant="line" className="h-10! w-full justify-start gap-0.5 overflow-x-auto [scrollbar-width:none]">
{children}
</TabsList>
)
}
export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) {
return (
<TabsTrigger value={value} className="flex-none gap-1.5 px-4 text-sm">
{label}
{count !== undefined ? <span className="text-muted-foreground font-mono text-xs font-normal">{count}</span> : null}
</TabsTrigger>
)
}
/** Строка настройки как на досках: слева название и описание, справа элемент управления. */
export function SettingRow({ title, description, children }: { title: string; description: string; children: ReactNode }) {
return (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b px-4 py-3.5 last:border-b-0">
<div className="flex min-w-0 flex-1 basis-64 flex-col gap-0.5">
<b className="text-sm font-medium">{title}</b>
<span className="text-muted-foreground text-[13px]">{description}</span>
</div>
<div className="shrink-0">{children}</div>
</div>
)
}
/** Frame с заголовком, итоговой плашкой справа и строками настроек (блок «form» досок). */
export function FormFrame({
title,
description,
badge,
tone = 'ok',
children,
}: {
title: string
description: string
badge: string
tone?: Tone
children: ReactNode
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription>
</div>
<StatusBadge tone={tone}>{badge}</StatusBadge>
</FrameHeader>
<FramePanel className="p-0">{children}</FramePanel>
</Frame>
)
}
@@ -0,0 +1,142 @@
import type { ReactNode } from 'react'
import { Badge } from '@/components/reui/badge'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { cn } from '@evobgp/ui/lib/utils'
import type { Tone } from './tone'
import { StatusBadge } from './bits'
export type AppCardItem = {
id: string
title: string
desc: string
badge: string
badgeVariant?: 'info-light' | 'warning-light' | 'outline'
meta: string
icon: ReactNode
onSelect?: () => void
}
/**
* Плитки приложений как на доске «Приложения»: Frame с шапкой, внутри сетка auto-fit от 260px,
* в плитке иконка, название, плашка действия, описание и адрес моноширинным шрифтом.
*/
export function AppCards({
title,
description,
items,
action,
}: {
title: string
description: string
items: AppCardItem[]
action?: ReactNode
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
<FrameDescription className="text-pretty">{description}</FrameDescription>
</div>
{action}
</FrameHeader>
<FramePanel className="p-3">
<ul className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,16.25rem),1fr))] gap-3">
{items.map((c) => {
const body = (
<>
<div className="flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2.5">
<IconTile variant="elevated" size="sm" aria-hidden className="text-muted-foreground">
{c.icon}
</IconTile>
<b className="min-w-0 text-sm leading-tight font-semibold">{c.title}</b>
</span>
<Badge variant={c.badgeVariant ?? 'info-light'} size="sm" className="shrink-0">
{c.badge}
</Badge>
</div>
<p className="text-muted-foreground mt-1.5 text-[13px] text-pretty">{c.desc}</p>
<p className="text-muted-foreground mt-2 truncate font-mono text-[11px]">{c.meta}</p>
</>
)
const cls = 'bg-muted/25 w-full rounded-lg border p-3.5 text-left'
return (
<li key={c.id} className="min-w-0">
{c.onSelect ? (
<button
type="button"
onClick={c.onSelect}
aria-label={`${c.title}: ${c.badge}`}
className={cn(cls, 'hover:bg-muted/50 focus-visible:ring-ring transition-colors outline-none focus-visible:ring-2')}
>
{body}
</button>
) : (
<div className={cls}>{body}</div>
)}
</li>
)
})}
</ul>
</FramePanel>
</Frame>
)
}
const DOT: Record<Tone, string> = {
ok: 'bg-success',
wn: 'bg-warning',
er: 'bg-destructive',
mu: 'bg-muted-foreground',
in: 'bg-chart-4',
}
/**
* Лента событий как на доске «Изменения»: время, точка важности с линией, плашка, заголовок
* и строка «кто · источник · IP».
*/
export function ActivityFeed({
title,
description,
items,
action,
}: {
title: string
description: string
items: Array<{ t: string; title: string; text: string; label: string; tone: Tone }>
action?: ReactNode
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
<FrameDescription className="text-pretty">{description}</FrameDescription>
</div>
{action}
</FrameHeader>
<FramePanel className="py-1.5">
<ol>
{items.map((e) => (
<li key={`${e.t}-${e.title}`} className="grid grid-cols-[3.9rem_1rem_minmax(0,1fr)] items-start gap-2.5 px-4 py-2 text-[13px]">
<span className="text-muted-foreground pt-px font-mono text-xs tabular-nums">{e.t}</span>
<span aria-hidden className="flex h-full flex-col items-center">
<i className={cn('mt-1 size-[9px] rounded-full', DOT[e.tone])} />
<u className="bg-border mt-0.5 min-h-4.5 w-px flex-1 no-underline" />
</span>
<span className="min-w-0">
<span className="mr-2 inline-block align-middle">
<StatusBadge tone={e.tone}>{e.label}</StatusBadge>
</span>
<b className="font-medium">{e.title}</b>
<span className="text-muted-foreground mt-0.5 block text-xs">{e.text}</span>
</span>
</li>
))}
</ol>
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,83 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { ChromeConfigContext, UiStoreContext, type ChromeConfig, type Contrast, type Density, type UiStore } from './chrome-store'
export function ChromeConfigProvider({ value, children }: { value: ChromeConfig; children: ReactNode }) {
return <ChromeConfigContext.Provider value={value}>{children}</ChromeConfigContext.Provider>
}
function readStored<T>(key: string, fallback: T): T {
try {
const raw = window.localStorage.getItem(key)
return raw ? (JSON.parse(raw) as T) : fallback
} catch {
return fallback
}
}
function writeStored(key: string, value: unknown) {
try {
window.localStorage.setItem(key, JSON.stringify(value))
} catch {
/* без сохранения настроек */
}
}
/** Плотность, контраст, закреплённые пункты и палитра ⌘K. Атрибуты html[data-density|contrast] читает portal-theme.css. */
export function PortalUiProvider({ children, storageKey = 'portal-ui' }: { children: ReactNode; storageKey?: string }) {
const [paletteOpen, setPaletteOpen] = useState(false)
const [density, setDensityState] = useState<Density>(() => readStored(`${storageKey}:density`, 'comfortable'))
const [contrast, setContrastState] = useState<Contrast>(() => readStored(`${storageKey}:contrast`, 'standard'))
const [pins, setPins] = useState<string[]>(() => readStored(`${storageKey}:pins`, []))
const setDensity = useCallback(
(v: Density) => {
setDensityState(v)
writeStored(`${storageKey}:density`, v)
},
[storageKey],
)
const setContrast = useCallback(
(v: Contrast) => {
setContrastState(v)
writeStored(`${storageKey}:contrast`, v)
},
[storageKey],
)
const togglePin = useCallback(
(id: string) => {
setPins((prev) => {
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
writeStored(`${storageKey}:pins`, next)
return next
})
},
[storageKey],
)
useEffect(() => {
const root = document.documentElement
root.dataset.density = density
root.dataset.contrast = contrast
return () => {
delete root.dataset.density
delete root.dataset.contrast
}
}, [density, contrast])
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setPaletteOpen((v) => !v)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
const value = useMemo<UiStore>(
() => ({ paletteOpen, setPaletteOpen, pins, togglePin, density, setDensity, contrast, setContrast }),
[paletteOpen, pins, togglePin, density, setDensity, contrast, setContrast],
)
return <UiStoreContext.Provider value={value}>{children}</UiStoreContext.Provider>
}
@@ -0,0 +1,86 @@
import { createContext, useContext, type ComponentType, type ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
export type NavItem = {
id: string
label: string
to: string
icon: LucideIcon
group: string
/** Точка проблемы в углу иконки. */
problem?: { tone: 'er' | 'wn'; label: string }
}
export type MonitorItem = {
id: string
tone: 'er' | 'wn' | 'ok'
title: string
text: string
age: string
to: string
}
export type MonitorMetric = { l: string; v: string; p: number; tone: 'warning' | 'success' }
export type PaletteAction = {
id: string
label: string
/** Горячая буква справа. */
hint: string
icon: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
to: string
}
export type PaletteObject = {
id: string
group: string
label: string
hint: string
dot?: 'ok' | 'er' | 'mu'
to: string
}
/** Данные оболочки: меню, монитор, палитра и пользователь. В макете — суррогатные, в портале — из API. */
export type ChromeConfig = {
navItems: NavItem[]
navGroups: string[]
/** Верх бокового меню: бренд и переключатель приложений. */
brand: ReactNode
user: { name: string; email: string; initials: string }
securityTo: string
onLogout: () => void
fresh: (pathname: string) => { badge: string; text: string }
crumbFor?: (pathname: string, label: string | undefined) => string
monitor: { items: MonitorItem[]; metrics: MonitorMetric[]; updated: string; footerTo: string; footerLabel: string }
palette: { actions: PaletteAction[]; objects: PaletteObject[]; assistantTo: string; example: string }
}
export const ChromeConfigContext = createContext<ChromeConfig | null>(null)
export function useChromeConfig(): ChromeConfig {
const ctx = useContext(ChromeConfigContext)
if (!ctx) throw new Error('ChromeConfigProvider отсутствует')
return ctx
}
export type Density = 'comfortable' | 'compact'
export type Contrast = 'standard' | 'high'
export type UiStore = {
paletteOpen: boolean
setPaletteOpen: (open: boolean) => void
pins: string[]
togglePin: (id: string) => void
density: Density
setDensity: (v: Density) => void
contrast: Contrast
setContrast: (v: Contrast) => void
}
export const UiStoreContext = createContext<UiStore | null>(null)
export function useUiStore(): UiStore {
const ctx = useContext(UiStoreContext)
if (!ctx) throw new Error('PortalUiProvider отсутствует')
return ctx
}
+569
View File
@@ -0,0 +1,569 @@
import {
useMemo,
useState,
type ComponentType,
} from 'react'
import {
Link,
useNavigate,
useRouterState,
} from '@tanstack/react-router'
import {
ActivityIcon,
ArrowRightIcon,
ChevronDownIcon,
ChevronsUpDownIcon,
FingerprintIcon,
InfoIcon,
LogOutIcon,
SearchIcon,
StarIcon,
MoonIcon,
} from 'lucide-react'
import {
useTheme,
} from 'next-themes'
import {
Badge,
} from '@/components/reui/badge'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
} from '@evobgp/ui/components/breadcrumb'
import {
Button,
} from '@evobgp/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import {
Kbd,
} from '@evobgp/ui/components/kbd'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@evobgp/ui/components/popover'
import {
Separator,
} from '@evobgp/ui/components/separator'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarSeparator,
SidebarTrigger,
useSidebar,
} from '@evobgp/ui/components/sidebar'
import {
cn,
} from '@evobgp/ui/lib/utils'
import {
Segmented,
} from './bits'
import {
useChromeConfig,
useUiStore,
type NavItem,
} from './chrome-store'
const LABEL_CLASS = 'text-muted-foreground text-[11px] font-medium uppercase'
function PortalNavUser() {
const { isMobile } = useSidebar()
const { theme, setTheme, resolvedTheme } = useTheme()
const { density, setDensity, contrast, setContrast } = useUiStore()
const { user, securityTo, onLogout } = useChromeConfig()
const navigate = useNavigate()
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
return (
<SidebarMenu>
<SidebarMenuItem>
<Popover>
<PopoverTrigger
render={
<SidebarMenuButton
size="lg"
aria-label={`Профиль и настройки интерфейса: ${user.name}, ${user.email}`}
className="bg-muted/40 aria-expanded:bg-muted"
/>
}
>
<div className="bg-background text-foreground flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
{user.initials}
</div>
<div className="grid min-w-0 flex-1 text-left leading-tight">
<span className="truncate text-sm font-semibold">{user.name}</span>
<span className="text-muted-foreground truncate text-xs">{user.email}</span>
</div>
<ChevronsUpDownIcon className="text-muted-foreground ml-auto size-4" aria-hidden />
</PopoverTrigger>
<PopoverContent side={isMobile ? 'top' : 'right'} align="end" sideOffset={8} className="w-72 gap-3">
<div className="flex items-center gap-2.5">
<div className="bg-background flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
{user.initials}
</div>
<div className="grid leading-tight">
<span className="text-sm font-semibold">{user.name}</span>
<span className="text-muted-foreground text-xs">{user.email}</span>
</div>
</div>
<Separator />
<button
type="button"
onClick={() => void navigate({ to: securityTo })}
className="hover:bg-muted flex items-center gap-2 rounded-md px-1 py-1 text-left text-sm"
>
<FingerprintIcon className="text-muted-foreground size-4" aria-hidden />
Безопасность
</button>
<div className="flex flex-col gap-1.5">
<span className={LABEL_CLASS}>Тема</span>
<Segmented
label="Тема"
options={['Тёмная', 'Светлая']}
value={current === 'light' ? 1 : 0}
onChange={(i) => setTheme(i === 1 ? 'light' : 'dark')}
/>
<span className={LABEL_CLASS}>Плотность</span>
<Segmented
label="Плотность"
options={['Обычная', 'Компактная']}
value={density === 'compact' ? 1 : 0}
onChange={(i) => setDensity(i === 1 ? 'compact' : 'comfortable')}
/>
<span className={LABEL_CLASS}>Контраст</span>
<Segmented
label="Контраст"
options={['Стандартный', 'Высокий']}
value={contrast === 'high' ? 1 : 0}
onChange={(i) => setContrast(i === 1 ? 'high' : 'standard')}
/>
</div>
<Separator />
<button
type="button"
onClick={onLogout}
className="text-muted-foreground hover:text-foreground flex items-center gap-2 text-left text-sm"
>
<LogOutIcon className="size-4" aria-hidden />
Выйти
</button>
</PopoverContent>
</Popover>
</SidebarMenuItem>
</SidebarMenu>
)
}
const DOT_CLASS = { er: 'bg-destructive', wn: 'bg-warning' } as const
function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; pathname: string; pinned: boolean; onTogglePin: () => void }) {
const Icon = item.icon
const active = pathname === item.to || pathname.startsWith(`${item.to}/`)
return (
<SidebarMenuItem>
<SidebarMenuButton
tooltip={item.problem ? `${item.label} · ${item.problem.label}` : item.label}
isActive={active}
className="relative pr-8 group-data-[collapsible=icon]:pr-2!"
render={<Link to={item.to} />}
>
<Icon className="size-4" />
<span>{item.label}</span>
{item.problem ? (
<span
role="img"
aria-label={item.problem.label}
className={cn(
'ml-auto size-2 shrink-0 rounded-full group-data-[collapsible=icon]:absolute group-data-[collapsible=icon]:top-1 group-data-[collapsible=icon]:right-1 group-data-[collapsible=icon]:ml-0 group-data-[collapsible=icon]:size-1.5 group-data-[collapsible=icon]:ring-2 group-data-[collapsible=icon]:ring-sidebar',
DOT_CLASS[item.problem.tone],
)}
/>
) : null}
</SidebarMenuButton>
<SidebarMenuAction
showOnHover={!pinned}
aria-label={pinned ? `Открепить ${item.label}` : `Закрепить ${item.label}`}
aria-pressed={pinned}
onClick={onTogglePin}
>
<StarIcon className={cn(pinned && 'fill-current')} aria-hidden />
</SidebarMenuAction>
</SidebarMenuItem>
)
}
export function PortalSidebar() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const { pins, togglePin } = useUiStore()
const { navItems, navGroups, brand } = useChromeConfig()
const [closed, setClosed] = useState<Record<string, boolean>>({})
const pinned = navItems.filter((i) => pins.includes(i.id))
return (
<Sidebar collapsible="icon">
<SidebarHeader className="pb-1.5">
{brand}
</SidebarHeader>
<SidebarContent>
{pinned.length > 0 ? (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel className="text-[11px] font-semibold tracking-wide uppercase">Закреплённые</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{pinned.map((item) => (
<NavEntry key={item.id} item={item} pathname={pathname} pinned onTogglePin={() => togglePin(item.id)} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
) : null}
{navGroups.map((group, gi) => (
<div key={group} className="flex flex-col">
{gi > 0 ? <SidebarSeparator className="mx-2 my-1 h-px shrink-0 hidden group-data-[collapsible=icon]:block" /> : null}
<SidebarGroup className="py-0">
<SidebarGroupLabel
render={
<button
type="button"
aria-expanded={!closed[group]}
onClick={() => setClosed((c) => ({ ...c, [group]: !c[group] }))}
/>
}
className="h-7 cursor-pointer justify-between text-[11px] font-semibold tracking-wide uppercase"
>
{group}
<ChevronDownIcon className={cn('size-3.5 transition-transform', closed[group] && '-rotate-90')} aria-hidden />
</SidebarGroupLabel>
<SidebarGroupContent className={cn(closed[group] && 'group-data-[collapsible=icon]:block hidden')}>
<SidebarMenu className="gap-px">
{navItems.filter((i) => i.group === group).map((item) => (
<NavEntry
key={item.id}
item={item}
pathname={pathname}
pinned={pins.includes(item.id)}
onTogglePin={() => togglePin(item.id)}
/>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</div>
))}
</SidebarContent>
<SidebarFooter>
<PortalNavUser />
</SidebarFooter>
</Sidebar>
)
}
/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине окна, как на досках. */
function MonitorPopover() {
const { monitor } = useChromeConfig()
const MONITOR_ITEMS = monitor.items
const MONITOR_METRICS = monitor.metrics
const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok')
const errors = bad.filter((m) => m.tone === 'er').length
const warnings = bad.length - errors
const ok = MONITOR_ITEMS.length - bad.length
return (
<Popover>
<PopoverTrigger
render={
<Button
variant="outline"
type="button"
aria-label={`Состояние системы: внимание, ${errors} сбой, ${warnings} предупреждения`}
className="border-warning/40 bg-warning/10 hover:bg-warning/20 aria-expanded:bg-warning/20 relative h-8 gap-2 px-2 max-[760px]:w-8 max-[760px]:border-transparent max-[760px]:bg-transparent max-[760px]:px-0"
/>
}
>
<ActivityIcon className="text-warning size-4" aria-hidden />
<span className="text-warning hidden text-sm font-medium min-[1000px]:inline">Внимание</span>
<span className="hidden items-center gap-1.5 text-sm tabular-nums min-[1240px]:inline-flex">
<span className="bg-destructive size-1.5 rounded-full" aria-hidden />
{errors}
<span className="bg-warning size-1.5 rounded-full" aria-hidden />
{warnings}
</span>
<span className="bg-destructive hidden size-4.5 min-w-4.5 items-center justify-center rounded-full px-1 text-[11px] font-semibold text-white min-[760px]:inline-flex min-[1240px]:hidden">
{bad.length}
</span>
<span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[760px]:hidden" aria-hidden />
</PopoverTrigger>
<PopoverContent align="end" className="flex w-[24rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2">
<div className="flex items-start justify-between gap-2 px-1.5 pt-1.5">
<div className="grid leading-tight">
<b className="text-sm font-semibold">Состояние системы</b>
<span className="text-muted-foreground text-xs">{monitor.updated}</span>
</div>
<Badge variant={errors > 0 ? "destructive-light" : warnings > 0 ? "warning-light" : "success-light"} size="sm">{errors > 0 ? "Сбой" : warnings > 0 ? "Внимание" : "Норма"}</Badge>
</div>
<div className="grid grid-cols-3 gap-1.5">
{[
{ n: errors, l: 'сбой', c: 'text-destructive' },
{ n: warnings, l: 'предупреждения', c: 'text-warning' },
{ n: ok, l: 'в норме', c: 'text-success' },
].map((x) => (
<span key={x.l} className="bg-muted/55 text-muted-foreground flex flex-col rounded-lg px-2.5 py-2 text-[11.5px]">
<b className={cn('text-lg leading-tight font-semibold tabular-nums', x.c)}>{x.n}</b>
{x.l}
</span>
))}
</div>
<ul className="flex flex-col gap-0.5">
{MONITOR_ITEMS.filter((m) => m.tone !== 'ok').map((m) => (
<li key={m.id}>
<Link to={m.to} className="hover:bg-muted/55 flex items-center gap-2.5 rounded-md px-1.5 py-2 outline-none focus-visible:bg-muted/55">
<span aria-hidden className={cn('size-2 shrink-0 rounded-full', m.tone === 'er' ? 'bg-destructive' : 'bg-warning')} />
<span className="grid min-w-0 flex-1 leading-tight">
<b className="truncate text-[13px] font-medium">{m.title}</b>
<span className="text-muted-foreground truncate text-xs">{m.text}</span>
</span>
<span className="text-muted-foreground shrink-0 text-[11.5px]">{m.age}</span>
</Link>
</li>
))}
</ul>
<div className="grid grid-cols-2 gap-1.5">
{MONITOR_METRICS.map((m) => (
<div key={m.l} className="bg-muted/55 flex min-w-0 flex-col gap-1.5 rounded-lg px-3 py-2.5">
<span className="text-muted-foreground text-xs leading-snug">{m.l}</span>
<b className={cn('text-base font-semibold whitespace-nowrap tabular-nums', m.tone === 'success' ? 'text-success' : 'text-warning')}>{m.v}</b>
<div className="bg-muted-foreground/25 mt-auto h-[3px] overflow-hidden rounded-full">
<i className={cn('block h-full rounded-full', m.tone === 'success' ? 'bg-success' : 'bg-warning')} style={{ width: `${m.p}%` }} />
</div>
</div>
))}
</div>
<Link to={monitor.footerTo} className="text-muted-foreground hover:text-foreground flex items-center justify-between px-1.5 pt-1 pb-0.5 text-sm">
{monitor.footerLabel}
<ArrowRightIcon className="size-3.5" aria-hidden />
</Link>
</PopoverContent>
</Popover>
)
}
function FreshnessPopover() {
const pathname = useRouterState({ select: (st) => st.location.pathname })
const { fresh: freshFor } = useChromeConfig()
const fresh = freshFor(pathname)
return (
<Popover>
<PopoverTrigger
render={
<Button type="button" variant="ghost" size="icon" aria-label="Источники и свежесть данных" title="Источники и свежесть данных" />
}
>
<InfoIcon aria-hidden />
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
<Badge variant="success-light" size="sm" className="w-fit">{fresh.badge}</Badge>
<p className="text-muted-foreground text-sm">{fresh.text}</p>
</PopoverContent>
</Popover>
)
}
export function PortalHeader() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const { setPaletteOpen } = useUiStore()
const { navItems, crumbFor } = useChromeConfig()
const current = navItems.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
return (
<header className="bg-background/80 sticky top-0 z-20 flex min-h-12 shrink-0 items-center gap-2 border-b px-4 py-1.5 backdrop-blur md:px-6">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb className="min-w-0">
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem className="min-w-0">
<BreadcrumbPage className="truncate">{crumbFor ? crumbFor(pathname, current?.label) : (current?.label ?? 'Auth Portal')}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex shrink-0 items-center gap-2">
<FreshnessPopover />
<Button
type="button"
variant="outline"
size="icon"
aria-label="Командная палитра (⌘K)"
className="md:hidden"
onClick={() => setPaletteOpen(true)}
>
<SearchIcon aria-hidden />
</Button>
<MonitorPopover />
</div>
</header>
)
}
type PaletteEntry = {
id: string
group: string
label: string
hint: string
icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
dot?: 'ok' | 'er' | 'mu'
run: () => void
}
const DOT_BG = { ok: 'bg-success', er: 'bg-destructive', mu: 'bg-muted-foreground' } as const
/** Палитра ⌘K как на доске: 640px, группы заглавными, подсказки моноширинным справа, подвал с подсказкой. */
export function CommandPalette() {
const { paletteOpen, setPaletteOpen } = useUiStore()
const { navItems, palette } = useChromeConfig()
const navigate = useNavigate()
const { theme, setTheme, resolvedTheme } = useTheme()
const [query, setQuery] = useState('')
const [active, setActive] = useState(0)
const shown = useMemo(() => {
const q = query.trim().toLowerCase()
const match = (...t: string[]) => !q || t.some((x) => x.toLowerCase().includes(q))
const go = (to: string) => () => void navigate({ to })
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
const list: PaletteEntry[] = [
...[
...palette.actions.map((x) => ({ id: x.id, label: x.label, hint: x.hint, icon: x.icon, run: go(x.to) })),
{ id: 'act-theme', label: 'Переключить тему', hint: 'T', icon: MoonIcon, run: () => setTheme(current === 'dark' ? 'light' : 'dark') },
]
.filter((e) => match(e.label))
.map((e) => ({ ...e, group: 'Действия' })),
...navItems.filter((i) => match(i.label, i.group))
.slice(0, 7)
.map((i) => ({ id: `nav-${i.id}`, group: 'Разделы', label: i.label, hint: i.group, icon: i.icon, run: go(i.to) })),
...Object.values(
palette.objects
.filter((o) => match(o.label, o.hint))
.reduce<Record<string, typeof palette.objects>>((acc, o) => ((acc[o.group] ??= []).push(o), acc), {}),
).flatMap((g) => g.slice(0, 5).map((o) => ({ id: o.id, group: o.group, label: o.label, hint: o.hint, dot: o.dot, run: go(o.to) }))),
]
// Запрос из трёх и более слов — черновик ассистента (как на доске «Палитра ⌘K»).
if (query.trim().split(/\s+/).filter(Boolean).length >= 3) {
return [
{
id: 'ai',
group: 'Ассистент · запрос на естественном языке',
label: `Понял так: «${query.trim()}»`,
hint: 'Черновик: запрос разобран автоматически, результат нужно проверить.',
run: () => void navigate({ to: palette.assistantTo }),
} as PaletteEntry,
...list,
]
}
return list
}, [query, navigate, theme, resolvedTheme, setTheme, navItems, palette])
const groups = useMemo(() => {
const map = new Map<string, Array<PaletteEntry & { index: number }>>()
shown.forEach((e, index) => {
const list = map.get(e.group) ?? []
list.push({ ...e, index })
map.set(e.group, list)
})
return [...map.entries()]
}, [shown])
function close() {
setPaletteOpen(false)
setQuery('')
setActive(0)
}
return (
<Dialog open={paletteOpen} onOpenChange={(o) => (o ? setPaletteOpen(true) : close())}>
<DialogContent className="top-[90px] w-[min(40rem,94vw)] max-w-none translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-none" showCloseButton={false}>
<DialogTitle className="sr-only">Командная палитра</DialogTitle>
<DialogDescription className="sr-only">Разделы, пользователи, приложения и действия</DialogDescription>
<div className="flex items-center gap-2.5 border-b px-4 py-3.5">
<SearchIcon className="text-muted-foreground size-[18px]" aria-hidden />
<input
autoFocus
value={query}
aria-label="Поиск и команды"
placeholder="Раздел, пользователь, приложение или действие…"
className="min-w-0 flex-1 bg-transparent text-[15px] outline-none"
onChange={(e) => {
setQuery(e.target.value)
setActive(0)
}}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setActive((a) => Math.min(a + 1, shown.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActive((a) => Math.max(a - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
shown[active]?.run()
close()
}
}}
/>
<Kbd>Esc</Kbd>
</div>
<div className="max-h-[430px] overflow-y-auto" role="listbox" aria-label="Результаты">
{groups.length === 0 ? (
<p className="text-muted-foreground px-4 py-6 text-center text-sm">Ничего не найдено</p>
) : (
groups.map(([group, list]) => (
<div key={group} className="flex flex-col">
<div className="text-muted-foreground px-4 pt-2.5 pb-1 text-[11px] tracking-[0.05em] uppercase">{group}</div>
{list.map((e) => {
const Icon = e.icon
return (
<button
key={e.id}
type="button"
role="option"
aria-selected={e.index === active}
onMouseEnter={() => setActive(e.index)}
onClick={() => {
e.run()
close()
}}
className={cn('flex w-full items-center gap-3 px-4 py-[9px] text-left text-sm outline-none', e.index === active && 'bg-muted/60')}
>
{Icon ? <Icon className="size-4 shrink-0" aria-hidden /> : null}
{e.dot ? <span className={cn('size-2 shrink-0 rounded-full', DOT_BG[e.dot])} aria-hidden /> : null}
<span className="min-w-0 truncate">{e.label}</span>
<span className="text-muted-foreground ml-auto truncate font-mono text-xs">{e.hint}</span>
</button>
)
})}
</div>
))
)}
</div>
<div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 border-t px-4 py-2 text-[11.5px]">
<span>↑↓ выбор</span>
<span>↵ открыть</span>
<span>Esc закрыть</span>
<span className="ml-auto">{palette.example}</span>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,26 @@
import type { DataGridFeatures, DataGridProps } from '@/components/reui/data-grid/data-grid'
export const DATA_GRID_CELL_PAD = 'py-3'
export const DATA_GRID_CELL_PAD_FIRST = 'pl-5 py-3'
export const DATA_GRID_CELL_PAD_LAST = 'pr-4 py-3'
export const DATA_GRID_LAYOUT: NonNullable<DataGridProps<DataGridFeatures, object>['tableLayout']> = {
rowBorder: true,
headerBackground: true,
headerBorder: true,
columnsResizable: false,
}
export const DATA_GRID_CLASS_NAMES: NonNullable<DataGridProps<DataGridFeatures, object>['tableClassNames']> = {
headerRow: 'border-b border-border',
bodyRow: 'group/row',
}
export const DATA_GRID_CONTAINER_CLASS = 'w-full rounded-none border-0'
/** Отступы ячейки по положению колонки: первая — pl-5, последняя — pr-4. */
export function cellPad(index: number, count: number) {
if (index === 0) return DATA_GRID_CELL_PAD_FIRST
if (index === count - 1) return DATA_GRID_CELL_PAD_LAST
return DATA_GRID_CELL_PAD
}
@@ -0,0 +1,123 @@
/**
* Каркас списочной страницы как в MikrotikManager (/servers): карточка Frame без полей, тулбар
* «сегменты · фильтры · поиск · счётчик», таблица с подложкой шапки и ячейками py-3.
*/
import type { ReactNode } from 'react'
import type { Column } from '@tanstack/react-table'
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, SearchIcon } from 'lucide-react'
import { Frame, FramePanel } from '@/components/reui/frame'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@evobgp/ui/components/input-group'
import { cn } from '@evobgp/ui/lib/utils'
import type { DataGridFeatures } from '@/components/reui/data-grid/data-grid'
export function DataPageCard({ children, className }: { children: ReactNode; className?: string }) {
return (
<Frame dense className={cn('w-full min-w-0', className)}>
<FramePanel className="flex w-full min-w-0 flex-col gap-0 overflow-hidden p-0 py-0 shadow-none">{children}</FramePanel>
</Frame>
)
}
export function SegmentedControl<T extends string>({
value,
onChange,
options,
label,
}: {
value: T
onChange: (value: T) => void
options: { value: T; label: string; count?: number }[]
label?: string
}) {
return (
<div role="group" aria-label={label} className="border-border bg-muted/40 flex w-fit items-center gap-1 rounded-md border p-0.5">
{options.map((o) => (
<button
key={o.value}
type="button"
aria-pressed={value === o.value}
onClick={() => onChange(o.value)}
className={cn(
'flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors',
value === o.value ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
)}
>
{o.label}
{o.count != null ? <span className="text-xs tabular-nums">{o.count}</span> : null}
</button>
))}
</div>
)
}
export function DataPageToolbar({
leading,
segmented,
filterControl,
search,
onSearchChange,
searchPlaceholder = 'Поиск…',
countLabel,
actions,
}: {
leading?: ReactNode
segmented?: { value: string; onChange: (value: string) => void; options: { value: string; label: string; count?: number }[]; label?: string }
filterControl?: ReactNode
search?: string
onSearchChange?: (value: string) => void
searchPlaceholder?: string
countLabel?: string
actions?: ReactNode
}) {
return (
<div className="flex flex-wrap items-center gap-3 border-b px-5 py-3">
{leading}
{segmented ? <SegmentedControl {...segmented} /> : null}
{filterControl}
{onSearchChange ? (
<InputGroup className="max-w-sm min-w-[220px]">
<InputGroupAddon>
<SearchIcon className="size-3.5" aria-hidden />
</InputGroupAddon>
<InputGroupInput aria-label={searchPlaceholder} placeholder={searchPlaceholder} value={search ?? ''} onChange={(e) => onSearchChange(e.target.value)} />
</InputGroup>
) : null}
{countLabel ? <span className="text-muted-foreground ml-auto text-sm">{countLabel}</span> : null}
{actions}
</div>
)
}
export function DataGridSortHeader<TData extends object>({
column,
title,
className,
}: {
column: Column<DataGridFeatures, TData, unknown>
title: string
className?: string
}) {
const sorted = column.getIsSorted()
if (!column.getCanSort()) {
return <span className={cn('text-muted-foreground text-xs font-medium', className)}>{title}</span>
}
return (
<button
type="button"
className={cn(
'text-muted-foreground hover:text-foreground focus-visible:outline-ring -ml-1 inline-flex items-center gap-1.5 rounded-md px-1 py-0.5 text-xs font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
className,
)}
onClick={column.getToggleSortingHandler()}
>
{title}
{sorted === 'asc' ? (
<ArrowUpIcon aria-hidden className="text-foreground size-3" />
) : sorted === 'desc' ? (
<ArrowDownIcon aria-hidden className="text-foreground size-3" />
) : (
<ArrowUpDownIcon aria-hidden className="size-3 opacity-35" />
)}
</button>
)
}
+45
View File
@@ -0,0 +1,45 @@
const DATE = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })
const SHORT = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' })
const TIME = new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' })
function parse(iso: string | null | undefined): Date | null {
if (!iso) return null
const d = new Date(iso)
return Number.isNaN(d.getTime()) ? null : d
}
/** 12.09.2026 */
export function fmtDate(iso: string | null | undefined, empty = '—') {
const d = parse(iso)
return d ? DATE.format(d) : empty
}
/** «сегодня 09:41», «вчера 22:15», иначе «12.09 09:41». */
export function fmtWhen(iso: string | null | undefined, empty = '—', now = Date.now()) {
const d = parse(iso)
if (!d) return empty
const dayMs = 24 * 60 * 60 * 1000
const startOfToday = new Date(now)
startOfToday.setHours(0, 0, 0, 0)
const diff = startOfToday.getTime() - new Date(d).setHours(0, 0, 0, 0)
if (diff === 0) return `сегодня ${TIME.format(d)}`
if (diff === dayMs) return `вчера ${TIME.format(d)}`
return `${SHORT.format(d)} ${TIME.format(d)}`
}
/** Время дня 14:02. */
export function fmtTime(iso: string | null | undefined, empty = '—') {
const d = parse(iso)
return d ? TIME.format(d) : empty
}
/** «5 дн назад», «3 ч назад», «только что». */
export function fmtAgo(iso: string | null | undefined, empty = '—', now = Date.now()) {
const d = parse(iso)
if (!d) return empty
const sec = Math.max(0, Math.round((now - d.getTime()) / 1000))
if (sec < 60) return 'только что'
if (sec < 3600) return `${Math.floor(sec / 60)} мин назад`
if (sec < 86400) return `${Math.floor(sec / 3600)} ч назад`
return `${Math.floor(sec / 86400)} дн назад`
}
@@ -0,0 +1,74 @@
import { Badge } from '@/components/reui/badge'
import { IconTile } from '@/components/reui/icon-tile'
import { Frame, FramePanel } from '@/components/reui/frame'
import { cn } from '@evobgp/ui/lib/utils'
import type { KpiItem, KpiTone } from './kpi'
const VALUE: Record<KpiTone, string> = {
neutral: 'text-foreground',
ok: 'text-foreground',
in: 'text-foreground',
wn: 'text-warning',
er: 'text-destructive',
}
/** Колонки по числу ячеек и ширине окна (на досках — ширина приложения): 4 → 4/2/1, 3 → 3/1, 2 → 2/1. */
const COLS: Record<number, string> = {
1: 'grid-cols-1',
2: 'grid-cols-2 max-[640px]:grid-cols-1',
3: 'grid-cols-1 min-[1240px]:grid-cols-3',
4: 'grid-cols-1 min-[640px]:grid-cols-2 min-[1240px]:grid-cols-4',
}
const TILE: Record<KpiTone, string> = {
neutral: 'text-muted-foreground',
ok: 'text-success',
in: 'text-info',
wn: 'text-warning',
er: 'text-destructive',
}
const GLYPH: Record<KpiTone, string> = { neutral: '', ok: '●', in: '', wn: '▲', er: '✕' }
const BADGE = {
neutral: 'outline',
ok: 'success-light',
in: 'info-light',
wn: 'warning-light',
er: 'destructive-light',
} as const
/**
* Полоса показателей как на досках: один Frame, ячейки разделены линиями,
* в ячейке подпись, крупное значение и плашка-подсказка.
*/
export function KpiStrip({ items, 'aria-label': ariaLabel }: { items: KpiItem[]; 'aria-label'?: string }) {
return (
<Frame spacing="sm" className="w-full min-w-0" aria-label={ariaLabel}>
<FramePanel className="overflow-hidden p-0">
<div className={cn('bg-border grid gap-px', COLS[Math.min(Math.max(items.length, 1), 4)])}>
{items.map((k) => {
const Icon = k.icon
return (
<div key={k.id} className="bg-card flex min-w-0 items-center gap-3 px-[18px] py-3.5">
<IconTile variant="outline" aria-hidden className={cn('shrink-0', TILE[k.tone])}>
<Icon />
</IconTile>
<div className="flex min-w-0 flex-col gap-1">
<div className="text-muted-foreground text-[13px]">{k.label}</div>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className={cn('text-2xl font-semibold tracking-tight tabular-nums', VALUE[k.tone])}>{k.value}</span>
<Badge variant={BADGE[k.tone]} size="sm" className="whitespace-nowrap">
{GLYPH[k.tone] ? <span aria-hidden className="text-[0.55rem]">{GLYPH[k.tone]}</span> : null}
{k.hint}
</Badge>
</div>
</div>
</div>
)
})}
</div>
</FramePanel>
</Frame>
)
}
+24
View File
@@ -0,0 +1,24 @@
import type { LucideIcon } from 'lucide-react'
export type KpiTone = 'neutral' | 'ok' | 'wn' | 'er' | 'in'
export type KpiItem = {
id: string
label: string
value: string
/** Плашка-подсказка рядом со значением. */
hint: string
tone: KpiTone
/** Иконка показателя: плитка слева от подписи. */
icon: LucideIcon
}
/** Плитка KPI: иконка, подпись, значение (цветом при wn/er) и плашка-подсказка. */
export function kpi(
id: string,
label: string,
value: string,
opts: { icon: LucideIcon; tone?: KpiTone; badge: string },
): KpiItem {
return { id, label, value, hint: opts.badge, tone: opts.tone ?? 'neutral', icon: opts.icon }
}
@@ -0,0 +1,488 @@
import { useMemo, useState, type ReactNode } from 'react'
import { Columns3Icon, MoreHorizontalIcon } from 'lucide-react'
import { toast } from 'sonner'
import { useTable, type ColumnDef, type RowSelectionState, type SortingState } from '@tanstack/react-table'
import { DATA_GRID_I18N_RU as DATA_GRID_RU } from '@/lib/reui-i18n-ru'
import { DataGrid, dataGridFeatures, type DataGridFeatures } 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 {
DataGridTableDndRowHandle,
DataGridTableDndRows,
} from '@/components/reui/data-grid/data-grid-table-dnd-rows'
import type { DragEndEvent } from '@dnd-kit/core'
import {
DataGridTable,
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from '@/components/reui/data-grid/data-grid-table'
import { Button } from '@evobgp/ui/components/button'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
import { Switch } from '@evobgp/ui/components/switch'
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { cn } from '@evobgp/ui/lib/utils'
import { StatusBadge } from './bits'
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
DATA_GRID_CLASS_NAMES,
DATA_GRID_LAYOUT,
} from './data-page-layout'
import {
DataGridSortHeader,
DataPageCard,
DataPageToolbar,
} from './data-page'
import type { Tone } from './tone'
import type { RowMark } from './tone'
import { TONE_DOT } from './tone'
export type GridColumn<T> = {
id: string
title: string
/** Ширина колонки; сумма задаёт минимальную ширину таблицы. */
size: number
cell: (row: T) => ReactNode
/** Ключ сортировки; без него колонка не сортируется. */
sortValue?: (row: T) => string | number
}
export type GridView<T> = { label: string; match: (row: T) => boolean }
/** Карточка строки для режима «Карточки» (как на доске «Пользователи»). */
export type GridCard = {
name: string
sub?: string
tone: Tone
status: string
cells: Array<{ l: string; v: string }>
}
/** Действия меню строки «⋮»: на досках — Открыть, Включить/Выключить, Дублировать, Удалить…. */
export type RowMenuConfig = {
onOpen?: () => void
toggleLabel?: string
onToggle?: () => void
onDuplicate?: () => void
onDelete?: () => void
}
export type GridBulk = {
onLabel?: string
offLabel?: string
onRun?: (ids: string[]) => void
offRun?: (ids: string[]) => void
onDelete?: (ids: string[]) => void
}
const SELECT_COLUMN_SIZE = 44
const DRAG_COLUMN_SIZE = 44
const ACTIONS_COLUMN_SIZE = 120
/**
* Секция-таблица по доскам Auth Portal: шапка (название, действия, «Таблица/Карточки», поиск, «Колонки»),
* сохранённые виды линией со счётчиками, панель массовых действий, таблица со статусом и меню строки,
* подвал «Показано N из M». Ширина таблицы не меньше суммы колонок — прокрутка внутри контейнера.
*/
export function SectionGrid<T extends object>({
title,
description,
rows,
columns,
getRowId,
views,
searchText,
searchPlaceholder = 'Поиск…',
onRowClick,
selectable = true,
bulk,
tools,
card,
rowSwitch,
rowMenu,
onReorder,
rowStatus,
pageSize = 50,
emptyMessage = 'Нет записей по текущим фильтрам.',
className,
}: {
title: string
description: string
rows: T[]
columns: GridColumn<T>[]
getRowId: (row: T) => string
views?: GridView<T>[]
searchText?: (row: T) => string
searchPlaceholder?: string
onRowClick?: (row: T) => void
selectable?: boolean
bulk?: GridBulk
/** Кнопки действий в шапке; функция получает id выбранных строк. */
tools?: ReactNode | ((selectedIds: string[]) => ReactNode)
/** Включает переключатель «Таблица / Карточки». */
card?: (row: T) => GridCard
/** Переключатель «включено» в последней колонке (если у строки он есть). */
rowSwitch?: { value: (row: T) => boolean | undefined; onChange: (row: T, on: boolean) => void }
rowMenu?: (row: T) => RowMenuConfig
/** Включает перетаскивание строк (dnd-kit). Передаёт id строки и id строки, на место которой её бросили. */
onReorder?: (activeId: string, overId: string) => void
/** Пометка строки: «Не сохранено» (dirty/new) или «Будет удалено» (deleted). */
rowStatus?: (row: T) => RowMark | undefined
pageSize?: number
/** Свой текст подвала вместо «Показано N из M» (как «Показано 8 из 148» на доске). */
footer?: string
emptyMessage?: string
className?: string
}) {
const storageKey = `design-preview:view:${title}`
const [view, setViewState] = useState(() => {
try {
const v = Number(window.localStorage.getItem(storageKey))
return Number.isInteger(v) && v >= 0 && v < (views?.length ?? 0) ? v : 0
} catch {
return 0
}
})
const setView = (i: number) => {
setViewState(i)
try {
window.localStorage.setItem(storageKey, String(i))
} catch {
/* без сохранения вида */
}
}
const [query, setQuery] = useState('')
const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize })
const [hidden, setHidden] = useState<Record<string, boolean>>({})
const [cards, setCards] = useState(false)
const data = useMemo(() => {
const matchView = views?.[view]?.match
const q = query.trim().toLowerCase()
return rows.filter((row) => {
if (matchView && !matchView(row)) return false
if (q && searchText && !searchText(row).toLowerCase().includes(q)) return false
return true
})
}, [rows, views, view, query, searchText])
const visibleColumns = useMemo(() => columns.filter((c, i) => i === 0 || !hidden[c.id]), [columns, hidden])
const defs = useMemo<ColumnDef<DataGridFeatures, T>[]>(() => {
const list: ColumnDef<DataGridFeatures, T>[] = []
if (onReorder) {
list.push({
id: 'drag',
header: () => <span className="sr-only">Порядок</span>,
cell: () => <DataGridTableDndRowHandle />,
enableSorting: false,
enableResizing: false,
size: DRAG_COLUMN_SIZE,
})
}
if (selectable) {
list.push({
id: 'select',
header: () => <DataGridTableRowSelectAll />,
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()}>
<DataGridTableRowSelect row={row} />
</div>
),
enableSorting: false,
enableResizing: false,
size: SELECT_COLUMN_SIZE,
meta: { headerClassName: 'pl-5 py-3', cellClassName: 'pl-5 py-3' },
})
}
visibleColumns.forEach((col, i) => {
const pad = i === 0 && !selectable && !onReorder ? DATA_GRID_CELL_PAD_FIRST : DATA_GRID_CELL_PAD
list.push({
id: col.id,
accessorFn: col.sortValue ?? (() => ''),
header: ({ column }) => <DataGridSortHeader column={column} title={col.title} />,
cell: ({ row }) => col.cell(row.original),
enableSorting: Boolean(col.sortValue),
enableResizing: false,
size: col.size,
meta: { headerTitle: col.title, headerClassName: pad, cellClassName: pad },
})
})
list.push({
id: '__actions',
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => {
const r = row.original
const on = rowSwitch?.value(r)
const menu: RowMenuConfig = rowMenu?.(r) ?? {}
const soon = (what: string) => () => toast(`${what} (макет)`)
return (
<div className="flex items-center justify-end gap-2" onClick={(e) => e.stopPropagation()}>
{on !== undefined && rowSwitch ? (
<Switch size="sm" checked={on} onCheckedChange={(v) => rowSwitch.onChange(r, v)} aria-label="Включено" />
) : null}
<DropdownMenu>
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon" aria-label="Действия" />}>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuItem onClick={menu.onOpen ?? (() => onRowClick?.(r))}>Открыть</DropdownMenuItem>
<DropdownMenuItem onClick={menu.onToggle ?? soon('Действие')}>
{menu.toggleLabel ?? (on === undefined ? 'Повторить проверку' : on ? 'Выключить' : 'Включить')}
</DropdownMenuItem>
<DropdownMenuItem onClick={menu.onDuplicate ?? soon('Дублирование')}>Дублировать</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={menu.onDelete ?? soon('Удаление')}>
Удалить…
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
},
enableSorting: false,
enableResizing: false,
size: ACTIONS_COLUMN_SIZE,
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
})
return list
}, [visibleColumns, selectable, onReorder, rowSwitch, rowMenu, onRowClick])
const table = useTable({
features: dataGridFeatures,
columns: defs,
data,
getRowId,
state: { sorting, rowSelection, pagination },
enableRowSelection: selectable,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
})
const dataIds = table.getRowModel().rows.map((r) => r.id)
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!onReorder || !active || !over || active.id === over.id) return
onReorder(String(active.id), String(over.id))
}
// Колонки сжимаются под ширину рамки (текст переносится, как на досках); прокрутка — только на узких экранах.
const minWidth = Math.round(
visibleColumns.reduce(
(sum, c) => sum + c.size,
(selectable ? SELECT_COLUMN_SIZE : 0) + (onReorder ? DRAG_COLUMN_SIZE : 0) + ACTIONS_COLUMN_SIZE,
) * 0.7,
)
const selectedIds = table.getSelectedRowModel().rows.map((r) => r.id)
const clear = () => setRowSelection({})
const paged = data.length > pageSize
const showCards = Boolean(card) && cards
const countLabel = `${data.length} ${data.length === rows.length ? 'записей' : `из ${rows.length}`}`
return (
<DataGrid
table={table}
i18n={DATA_GRID_RU}
recordCount={data.length}
onRowClick={onRowClick}
getRowStatus={rowStatus}
emptyMessage={emptyMessage}
tableLayout={{ ...DATA_GRID_LAYOUT, rowsDraggable: Boolean(onReorder) }}
tableClassNames={DATA_GRID_CLASS_NAMES}
>
<DataPageCard className={className}>
<h2 className="sr-only">{title}</h2>
<DataPageToolbar
segmented={
views
? {
value: String(view),
label: description,
onChange: (v) => {
setView(Number(v))
setPagination((p) => ({ ...p, pageIndex: 0 }))
},
options: views.map((v, i) => ({ value: String(i), label: v.label, count: rows.filter(v.match).length })),
}
: undefined
}
leading={
card ? (
<Tabs value={cards ? 'cards' : 'table'} onValueChange={(v) => setCards(v === 'cards')} aria-label="Вид списка">
<TabsList className="h-9! gap-0.5 p-[3px]">
<TabsTrigger value="table" className="h-7! px-2.5 text-[12.5px]">
Таблица
</TabsTrigger>
<TabsTrigger value="cards" className="h-7! px-2.5 text-[12.5px]">
Карточки
</TabsTrigger>
</TabsList>
</Tabs>
) : undefined
}
search={query}
onSearchChange={
searchText
? (v) => {
setQuery(v)
setPagination((p) => ({ ...p, pageIndex: 0 }))
}
: undefined
}
searchPlaceholder={searchPlaceholder}
countLabel={countLabel}
actions={
<>
{typeof tools === 'function' ? tools(selectedIds) : tools}
{columns.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button type="button" variant="outline" size="icon" aria-label="Выбрать колонки" title="Колонки" />}
>
<Columns3Icon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-52">
{columns.slice(1).map((c) => (
<DropdownMenuCheckboxItem key={c.id} checked={!hidden[c.id]} onCheckedChange={(v) => setHidden((h) => ({ ...h, [c.id]: !v }))}>
{c.title}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</>
}
/>
{selectable && selectedIds.length > 0 ? (
<div
role="toolbar"
aria-label="Массовые действия"
className="bg-primary/10 flex flex-wrap items-center gap-2 border-b px-3.5 py-2 text-[13px]"
>
<b className="mr-1.5 font-semibold">Выбрано: {selectedIds.length}</b>
<Button
type="button"
variant="outline"
onClick={() => {
if (bulk?.onRun) bulk.onRun(selectedIds)
else toast(`${bulk?.onLabel ?? 'Включить'}: ${selectedIds.length} (макет)`)
clear()
}}
>
{bulk?.onLabel ?? 'Включить'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
if (bulk?.offRun) bulk.offRun(selectedIds)
else toast(`${bulk?.offLabel ?? 'Выключить'}: ${selectedIds.length} (макет)`)
clear()
}}
>
{bulk?.offLabel ?? 'Выключить'}
</Button>
<Button
type="button"
variant="outline"
className="text-destructive"
onClick={() => {
if (bulk?.onDelete) bulk.onDelete(selectedIds)
else toast(`Удаление: ${selectedIds.length} (макет)`)
}}
>
Удалить…
</Button>
<span className="flex-1" />
<Button type="button" variant="outline" onClick={clear}>
Снять выбор
</Button>
</div>
) : null}
{showCards && card ? (
<ul className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,15rem),1fr))] gap-3 p-3">
{data.map((r) => {
const c = card(r)
const id = getRowId(r)
const marked = rowStatus?.(r)
return (
<li
key={id}
className={cn('bg-muted/25 flex flex-col gap-2 rounded-lg border p-3', marked === 'deleted' && 'opacity-60')}
>
<div className="flex items-center justify-between gap-2">
<input
type="checkbox"
aria-label={`Выбрать ${c.name}`}
checked={Boolean(rowSelection[id])}
onChange={(e) =>
setRowSelection((s) => {
const next = { ...s }
if (e.target.checked) next[id] = true
else delete next[id]
return next
})
}
className="accent-primary size-4"
/>
<StatusBadge tone={c.tone}>{c.status}</StatusBadge>
</div>
<button
type="button"
onClick={() => onRowClick?.(r)}
className="flex min-w-0 items-start gap-2 text-left"
aria-label={`Открыть: ${c.name}`}
>
<span aria-hidden className={cn('mt-1.5 size-2 shrink-0 rounded-full', TONE_DOT[c.tone])} />
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{c.name}</span>
{c.sub ? <span className="text-muted-foreground truncate font-mono text-[11px]">{c.sub}</span> : null}
</span>
</button>
<dl className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
{c.cells.map((x) => (
<div key={x.l} className="min-w-0">
<dt className="text-muted-foreground">{x.l}</dt>
<dd className="truncate font-mono">{x.v}</dd>
</div>
))}
</dl>
</li>
)
})}
</ul>
) : (
<DataGridScrollArea>
<div style={{ minWidth }}>
{onReorder ? <DataGridTableDndRows dataIds={dataIds} handleDragEnd={handleDragEnd} /> : <DataGridTable />}
</div>
</DataGridScrollArea>
)}
{paged && !showCards ? <DataGridPagination className="px-5 py-3" sizes={[8, 16, 32]} /> : null}
</DataPageCard>
</DataGrid>
)
}
/** Двухстрочная ячейка: заголовок + подпись. */
export function TwoLineCell({ title, sub, dot }: { title: ReactNode; sub?: ReactNode; dot?: Tone }) {
return (
<div className="flex min-w-0 items-start gap-2">
{dot ? <span aria-hidden className={cn('mt-1.5 size-2 shrink-0 rounded-full', TONE_DOT[dot])} /> : null}
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{title}</span>
{sub ? <span className="text-muted-foreground truncate font-mono text-[11px]">{sub}</span> : null}
</div>
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
export type Tone = 'ok' | 'wn' | 'er' | 'mu' | 'in'
/** Пометка строки с накопленной правкой. */
export type RowMark = 'new' | 'dirty' | 'deleted'
type BadgeVariant =
| 'success-light'
| 'warning-light'
| 'destructive-light'
| 'info-light'
| 'outline'
export const TONE_BADGE: Record<Tone, BadgeVariant> = {
ok: 'success-light',
wn: 'warning-light',
er: 'destructive-light',
in: 'info-light',
mu: 'outline',
}
export const TONE_DOT: Record<Tone, string> = {
ok: 'bg-success',
wn: 'bg-warning',
er: 'bg-destructive',
in: 'bg-success',
mu: 'bg-muted-foreground/50',
}
@@ -1,20 +1,29 @@
import { describe, expect, it } from 'vitest'
import { kitDataGridTableClassNames, kitDataGridTableLayout } from './frame-data-grid'
import {
applyKitActionColumn,
KIT_ACTION_COLUMN_SIZE,
kitColumnPinning,
kitDataGridTableClassNames,
kitDataGridTableLayout,
} from './frame-data-grid'
describe('kitDataGridTableLayout', () => {
it('filtering-2 defaults: dense, headerBackground false, width fixed', () => {
it('defaults макета: не dense, headerBackground true, width fixed', () => {
const layout = kitDataGridTableLayout()
expect(layout.dense).toBe(true)
expect(layout.headerBackground).toBe(false)
expect(layout.dense).toBe(false)
expect(layout.headerBackground).toBe(true)
expect(layout.width).toBe('fixed')
expect(layout.headerSticky).toBe(false)
expect('stripped' in layout ? layout.stripped : undefined).toBeUndefined()
expect(layout.columnsPinnable).toBe(false)
})
it('kit tableClassNames совпадает с filtering-2 edgeCell', () => {
expect(kitDataGridTableClassNames.edgeCell).toBe('first:ps-3 last:pe-3')
it('kit tableClassNames: поля краёв как в MikrotikManager', () => {
expect(kitDataGridTableClassNames.edgeCell).toBe('first:ps-5 last:pe-4')
expect(kitDataGridTableClassNames.base).toBe(
'[&_[data-pinned]]:bg-(--frame-panel-bg)',
)
})
it('именованные opts: auto + pin', () => {
@@ -24,6 +33,66 @@ describe('kitDataGridTableLayout', () => {
})
expect(layout.width).toBe('auto')
expect(layout.columnsPinnable).toBe(true)
expect(layout.headerBackground).toBe(false)
expect(layout.headerBackground).toBe(true)
})
})
describe('applyKitActionColumn', () => {
it('locks filtering-2 size on id=actions', () => {
const [name, actions] = applyKitActionColumn([
{ id: 'name', header: 'Name' },
{ id: 'actions', header: () => null, cell: () => 'x' },
])
expect(name?.id).toBe('name')
expect(actions?.size).toBe(KIT_ACTION_COLUMN_SIZE)
expect(actions?.minSize).toBe(KIT_ACTION_COLUMN_SIZE)
expect(actions?.maxSize).toBe(KIT_ACTION_COLUMN_SIZE)
expect(actions?.enableSorting).toBe(false)
expect(actions?.enableResizing).toBe(false)
})
it('keeps explicit size (data-grid-base-7 text button)', () => {
const [actions] = applyKitActionColumn([
{ id: 'actions', size: 104, cell: () => 'x' },
])
expect(actions?.size).toBe(104)
expect(actions?.minSize).toBe(104)
expect(actions?.maxSize).toBe(104)
})
it('does not rewrite a non-actions last column', () => {
const [col] = applyKitActionColumn([{ id: 'name', header: 'Name' }])
expect(col?.size).toBeUndefined()
expect(col?.enableResizing).toBeUndefined()
})
it('applies DNA when pinLastColumn even without id=actions', () => {
const [col] = applyKitActionColumn([{ id: 'other', cell: () => 'x' }], {
pinLastColumn: true,
})
expect(col?.size).toBe(KIT_ACTION_COLUMN_SIZE)
expect(col?.maxSize).toBe(KIT_ACTION_COLUMN_SIZE)
})
})
describe('kitColumnPinning', () => {
it('does not end-pin without horizontalScroll', () => {
const result = kitColumnPinning({
pinLastColumn: true,
lastColId: 'actions',
})
expect(result.enablePinning).toBe(false)
expect(result.columnPinning.end).toEqual([])
})
it('end-pins only with horizontalScroll', () => {
const result = kitColumnPinning({
pinLastColumn: true,
horizontalScroll: true,
lastColId: 'actions',
})
expect(result.enablePinning).toBe(true)
expect(result.columnPinning.end).toEqual(['actions'])
})
})
@@ -2,9 +2,17 @@ import {
createFilterQuery,
createFilterRule,
flattenFilterConditions,
flattenFilterRules,
isFilterRule,
updateFilterRule,
type FilterCondition,
} from '@/components/reui/filters/filters-query'
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import type {
FilterField,
FilterGroupNode,
FilterNode,
FilterQuery,
} from '@/components/reui/filters/filters-types'
/** Operators that take no value, so an empty `values` list is expected. */
const VALUELESS_OPERATORS = new Set(['empty', 'not_empty'])
@@ -131,6 +139,95 @@ export function createSearchFilterField(
}
}
export function getPrimaryTextField(fields: FilterField[]): FilterField | undefined {
return fields.find((field) => field.type === 'text' && !field.fields)
}
export function getExtraFilterFields(
fields: FilterField[],
primaryId?: string,
): FilterField[] {
if (!primaryId) return fields
return fields.filter((field) => field.id !== primaryId)
}
function ruleFieldId(path: string[]): string | undefined {
return path[0]
}
export function getFilterTextValue(query: FilterQuery, fieldId: string): string {
const rule = flattenFilterRules(query).find((item) => ruleFieldId(item.path) === fieldId)
if (rule?.value == null) return ''
if (Array.isArray(rule.value)) return rule.value.map(String).join(' ')
return String(rule.value)
}
export function setFilterTextValue(
query: FilterQuery,
fieldId: string,
text: string,
): FilterQuery {
const existing = flattenFilterRules(query).find((item) => ruleFieldId(item.path) === fieldId)
if (existing) {
return updateFilterRule(query, existing.id, {
operator: 'contains',
value: text,
})
}
return {
...query,
rules: [
createFilterRule({
id: `${fieldId}-1`,
path: [fieldId],
operator: 'contains',
value: text,
}),
...query.rules,
],
}
}
function mapGroupRules<V>(
group: FilterGroupNode<V>,
map: (node: FilterNode<V>) => FilterNode<V> | null,
): FilterGroupNode<V> {
const rules: FilterNode<V>[] = []
for (const child of group.rules) {
if (isFilterRule(child)) {
const next = map(child)
if (next) rules.push(next)
continue
}
const nested = mapGroupRules(child, map)
if (nested.rules.length > 0) rules.push(nested)
}
return { ...group, rules }
}
export function stripFieldFromQuery(query: FilterQuery, fieldId: string): FilterQuery {
return mapGroupRules(query, (node) => {
if (isFilterRule(node) && ruleFieldId(node.path) === fieldId) return null
return node
})
}
export function mergeFieldRules(
extraQuery: FilterQuery,
sourceQuery: FilterQuery,
fieldId: string,
): FilterQuery {
const searchRules = flattenFilterRules(sourceQuery).filter(
(rule) => ruleFieldId(rule.path) === fieldId,
)
const strippedExtra = stripFieldFromQuery(extraQuery, fieldId)
if (searchRules.length === 0) return strippedExtra
return {
...strippedExtra,
rules: [...searchRules, ...strippedExtra.rules],
}
}
export function renderSelectedCount(values: unknown[]) {
if (values.length === 0) return 'Выберите…'
if (values.length > 1) return `${values.length} выбрано`
@@ -47,7 +47,7 @@ import {
FrameTitle,
} from '@/components/reui/frame'
import { EmptyState } from '@/components/empty-state'
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
import { DATA_GRID_I18N_RU } from '@/lib/reui-i18n-ru'
export type DataGridColumnDef<TData extends object> = ColumnDef<DataGridFeatures, TData>
@@ -66,10 +66,10 @@ export function kitDataGridTableLayout(
} = {},
) {
return {
dense: opts.dense ?? true,
dense: opts.dense ?? false,
rowBorder: true,
headerSticky: false,
headerBackground: false,
headerBackground: true,
headerBorder: true,
width: opts.width ?? ('fixed' as const),
columnsVisibility: opts.columnsVisibility ?? false,
@@ -81,11 +81,88 @@ export function kitDataGridTableLayout(
}
}
/** filtering-2 edge inset — единственный tableClassNames в kit, не в domain. */
/** filtering-2 edge inset + Frame-surface pinned cells (not page --background). */
export const kitDataGridTableClassNames = {
edgeCell: 'first:ps-3 last:pe-3',
base: '[&_[data-pinned]]:bg-(--frame-panel-bg)',
edgeCell: 'first:ps-5 last:pe-4',
headerRow: 'border-b border-border',
} as const
/**
* Compact action column size from data-grid-filtering-2.
* Preview: https://reui.io/preview/base/data-grid-filtering-2
*/
export const KIT_ACTION_COLUMN_SIZE = 56
const ACTION_CELL_ALIGN = 'flex items-center justify-end'
function lastColumnId<T extends object>(columns: DataGridColumnDef<T>[]): string {
const last = columns[columns.length - 1]
if (!last) return ''
if (last.id) return last.id
if ('accessorKey' in last && typeof last.accessorKey === 'string') return last.accessorKey
return ''
}
function wrapActionCell<T extends object>(
cell: DataGridColumnDef<T>['cell'],
): DataGridColumnDef<T>['cell'] {
if (typeof cell !== 'function') {
return () => <div className={ACTION_CELL_ALIGN}>{cell as ReactNode}</div>
}
return (ctx) => <div className={ACTION_CELL_ALIGN}>{cell(ctx)}</div>
}
/**
* filtering-2 action column DNA: locked width, no sort/resize, inner justify-end
* (flex on the cell wrapper, never on `td` — that breaks rowBorder alignment).
* Preview: https://reui.io/preview/base/data-grid-filtering-2
*/
export function applyKitActionColumn<T extends object>(
columns: DataGridColumnDef<T>[],
opts: { pinLastColumn?: boolean } = {},
): DataGridColumnDef<T>[] {
if (columns.length === 0) return columns
const last = columns[columns.length - 1]
const lastId = lastColumnId(columns)
if (lastId !== 'actions' && !opts.pinLastColumn) return columns
const size = last.size ?? KIT_ACTION_COLUMN_SIZE
return [
...columns.slice(0, -1),
{
...last,
size,
minSize: last.minSize ?? size,
maxSize: last.maxSize ?? size,
enableSorting: false,
enableResizing: false,
cell: last.cell ? wrapActionCell(last.cell) : last.cell,
},
]
}
/** End-pin only when the grid actually scrolls horizontally (not for action columns). */
export function kitColumnPinning(opts: {
pinLastColumn?: boolean
horizontalScroll?: boolean
lastColId: string
pinLeftColumnIds?: string[]
}): {
enablePinning: boolean
columnPinning: { start: string[]; end: string[] }
} {
const pinLeft = opts.pinLeftColumnIds ?? []
const pinEnd = Boolean(opts.pinLastColumn && opts.horizontalScroll && opts.lastColId)
return {
enablePinning: pinEnd || pinLeft.length > 0,
columnPinning: {
start: pinLeft,
end: pinEnd ? [opts.lastColId] : [],
},
}
}
function loadStoredColumnVisibility(key: string): ColumnVisibilityState | undefined {
try {
const raw = localStorage.getItem(key)
@@ -222,6 +299,7 @@ function FrameDataGridBody<TData extends object>({
recordCount={data.length}
onRowClick={onRowClick}
emptyMessage={emptyTitle}
i18n={DATA_GRID_I18N_RU}
tableLayout={kitDataGridTableLayout({
dense,
width: tableWidth,
@@ -240,8 +318,11 @@ function FrameDataGridBody<TData extends object>({
{showPagination ? (
<>
<Separator />
<FrameFooter>
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
<FrameFooter className="flex-wrap justify-between gap-2">
<span className="text-muted-foreground text-xs tabular-nums">
Показано {table.getRowModel().rows.length} из {data.length}
</span>
<DataGridPagination sizes={[10, 25, 50]} />
</FrameFooter>
</>
) : null}
@@ -280,6 +361,7 @@ export function FrameDataGrid<TData extends object>({
expandedContent,
getRowCanExpand,
pinLeftColumnIds,
horizontalScroll = false,
tableWidth = 'fixed',
columnPinControls = false,
}: FrameDataGridProps<TData>) {
@@ -344,21 +426,23 @@ export function FrameDataGrid<TData extends object>({
}
const tableColumns: DataGridColumnDef<TData>[] = applyColumnPinControls(
[
...(expandedContent ? [expandColumn] : []),
...(enableRowSelection ? [selectColumn] : []),
...columns,
],
applyKitActionColumn(
[
...(expandedContent ? [expandColumn] : []),
...(enableRowSelection ? [selectColumn] : []),
...columns,
],
{ pinLastColumn },
),
columnPinControls,
)
const lastColId = pinLastColumn ? (tableColumns[tableColumns.length - 1]?.id ?? '') : ''
const pinLeft = pinLeftColumnIds ?? []
const enablePinning = pinLastColumn || pinLeft.length > 0
const columnPinning = {
start: pinLeft,
end: pinLastColumn && lastColId ? [lastColId] : [],
}
const { enablePinning, columnPinning } = kitColumnPinning({
pinLastColumn,
horizontalScroll,
lastColId: lastColumnId(tableColumns),
pinLeftColumnIds,
})
const table = useTable({
features: dataGridFeatures,
@@ -0,0 +1,93 @@
import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { cn } from '@evobgp/ui/lib/utils'
/**
* Frame shell for analytics / comparison / detail panels (not list grids).
* @see https://reui.io/docs/components/base/frame
* @see https://reui.io/preview/base/chart-1
*/
export const panelCardClassName = 'w-full gap-0 p-0'
export const panelCardHeaderClassName = 'border-b'
/** Flush body inside FramePanel (toolbar / data-grid). */
export const panelCardContentFlushClassName = 'p-0'
/** Horizontal inset for toolbar / pagination rows. */
export const panelCardInsetClassName = 'px-5 py-3'
export const panelCardFooterClassName = 'border-t bg-transparent'
export interface FrameSectionProps {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
footer?: ReactNode
children?: ReactNode
className?: string
headerClassName?: string
contentClassName?: string
footerClassName?: string
size?: 'default' | 'sm'
}
export function FrameSection({
title,
description,
actions,
footer,
children,
className,
headerClassName,
contentClassName,
footerClassName,
size = 'default',
}: FrameSectionProps) {
const hasHeader = Boolean(title || description || actions)
const spacing = size === 'sm' ? 'sm' : 'default'
return (
<Frame
dense
spacing={spacing}
className={cn(panelCardClassName, 'min-w-0', className)}
>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
{hasHeader ? (
<FrameHeader
className={cn(
panelCardHeaderClassName,
'flex-row items-start justify-between gap-3',
headerClassName,
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-px">
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? (
<FrameDescription>{description}</FrameDescription>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{actions}
</div>
) : null}
</FrameHeader>
) : null}
{children != null && children !== false ? (
<div className={cn('min-w-0 flex-1', contentClassName)}>{children}</div>
) : null}
{footer ? (
<FrameFooter className={cn(panelCardFooterClassName, footerClassName)}>
{footer}
</FrameFooter>
) : null}
</FramePanel>
</Frame>
)
}
@@ -36,3 +36,12 @@ export {
export { ExpandableResourceGrid } from './expandable-resource-grid'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell } from './settings-shell'
export {
FrameSection,
panelCardClassName,
panelCardHeaderClassName,
panelCardContentFlushClassName,
panelCardInsetClassName,
panelCardFooterClassName,
type FrameSectionProps,
} from './frame-section'
@@ -3,6 +3,7 @@ import { Link } from '@tanstack/react-router'
import { Frame, FramePanel } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { Progress } from '@evobgp/ui/components/progress'
import { cn } from '@evobgp/ui/lib/utils'
import { kpiCols } from './kpi-cols'
import { IconTile } from '@/components/reui/icon-tile'
@@ -29,6 +30,8 @@ export type KpiStatItem = {
iconClassName?: string
variant?: KpiStatVariant
footer?: ReactNode
/** 0–100 completion bar under the value (stats-4). Omit to hide. */
progress?: number
}
/** CFDM-compatible card shape (id required). */
@@ -48,6 +51,17 @@ const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
destructive: 'text-destructive',
}
const PROGRESS_TONE_CLASS: Record<KpiStatVariant, string> = {
default: '',
warning: '[&_[data-slot=progress-indicator]]:bg-warning',
destructive: '[&_[data-slot=progress-indicator]]:bg-destructive',
}
function clampProgress(value: number): number {
if (Number.isNaN(value)) return 0
return Math.min(100, Math.max(0, value))
}
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
@@ -67,7 +81,7 @@ function resolveFooter(item: KpiStatItem): ReactNode {
if (item.footer) return item.footer
if (typeof item.hint === 'string') {
return (
<Badge variant="outline" size="sm">
<Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
{item.hint}
</Badge>
)
@@ -81,30 +95,53 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const valueVariant = item.variant ?? 'default'
return (
<div className="relative z-10 flex h-full items-start gap-3">
<div className="@container relative z-10 flex h-full min-w-0 items-start gap-3">
{item.icon ? (
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
className={cn('size-10.5 shrink-0', item.iconClassName ?? DEFAULT_ICON_CLASS)}
>
{item.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
{footer ? <div className="shrink-0">{footer}</div> : null}
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
{item.label}
</div>
{footer ? (
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
{footer}
</div>
) : null}
</div>
<div
className={cn(
'text-2xl leading-none font-bold tabular-nums',
'min-w-0 break-all text-2xl leading-none font-bold tabular-nums',
VALUE_VARIANT_CLASS[valueVariant],
)}
>
{item.value}
</div>
{item.progress !== undefined ? (
<Progress
value={clampProgress(item.progress)}
className={cn(
'mt-1.5 w-full gap-0 **:data-[slot=progress-track]:h-1.5',
PROGRESS_TONE_CLASS[valueVariant],
)}
aria-label={
typeof item.label === 'string'
? `${item.label}: ${clampProgress(item.progress)}%`
: undefined
}
/>
) : null}
{footer ? (
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
) : null}
</div>
</div>
)
@@ -116,7 +153,7 @@ function panelClassName(item: KpiStatItem, className?: string) {
const selected = isSelected(item)
return cn(
'relative isolate flex h-full flex-col',
'relative isolate flex h-full min-w-0 flex-col',
clickable &&
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
selected && 'ring-primary/30 bg-muted/30 ring-1',
@@ -223,39 +260,61 @@ interface KpiStatGridProps {
'aria-label'?: string
}
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item)
/** Колонки полосы по ширине контейнера: 4 → 1/2/4, 3 → 1/3. */
function stripCols(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 @2xl:grid-cols-3'
if (count === 4) return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-4'
return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3'
}
/** Ячейка полосы показателей как на досках макета: плитка-иконка, подпись, значение и плашка рядом. */
function KpiStripCell({ item }: { item: KpiStatItem }) {
const onActivate = resolveActivate(item)
const footer = resolveFooter(item)
const valueVariant = item.variant ?? 'default'
const clickable = Boolean(item.to || onActivate)
const body = (
<div className="flex min-w-0 items-center gap-3 px-[18px] py-3.5">
{item.icon ? (
<IconTile variant="outline" aria-hidden="true" className={cn('shrink-0', item.iconClassName ?? DEFAULT_ICON_CLASS)}>
{item.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-col gap-1">
<div className="text-muted-foreground truncate text-[13px]">{item.label}</div>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className={cn('text-2xl font-semibold tracking-tight tabular-nums', VALUE_VARIANT_CLASS[valueVariant])}>
{item.value}
</span>
{footer ? <span className="min-w-0 max-w-full">{footer}</span> : null}
</div>
</div>
</div>
)
const cellClass = cn(
'bg-card relative min-w-0',
clickable && 'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
isSelected(item) && 'bg-muted/30',
)
if (item.to) {
return (
<FramePanel className={panelClass}>
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
<KpiStatCardBody item={item} />
<div className={cellClass}>
<Link to={item.to} search={item.search} className="block focus-visible:outline-none">
{body}
</Link>
</FramePanel>
</div>
)
}
if (onActivate) {
return (
<FramePanel
className={panelClass}
onClick={onActivate}
role="button"
tabIndex={0}
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
<div className={cellClass} onClick={onActivate} role="button" tabIndex={0} onKeyDown={(e) => handleCardKeyDown(onActivate, e)}>
{body}
</div>
)
}
return (
<FramePanel className={panelClass}>
<KpiStatCardBody item={item} />
</FramePanel>
)
return <div className={cellClass}>{body}</div>
}
/**
@@ -305,12 +364,14 @@ export function KpiStatGrid({
}
return (
<Frame className={cn('@container w-full min-w-0', className)} aria-label={ariaLabel}>
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
))}
</div>
<Frame spacing="sm" className={cn('@container w-full min-w-0', className)} aria-label={ariaLabel}>
<FramePanel className="overflow-hidden p-0">
<div className={cn('bg-border grid gap-px', stripCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStripCell key={kpiStatItemKey(item, index)} item={item} />
))}
</div>
</FramePanel>
</Frame>
)
}
@@ -12,7 +12,7 @@ interface OpsDashboardProps {
/** Slot after KPI (QuickActionGrid). Preview: stats-12 · card-12 */
afterKpi?: ReactNode
charts: ReactNode
queue: ReactNode
queue?: ReactNode
queueTitle?: string
queueDescription?: string
headerActions?: ReactNode
@@ -31,10 +31,12 @@ function OpsDashboardSkeleton() {
</header>
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
<div className="flex min-w-0 flex-col gap-4">
<Skeleton className="h-64 w-full rounded-xl" />
<Skeleton className="h-64 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
<div className="grid min-w-0 grid-cols-1 gap-4 @5xl:grid-cols-2">
<Skeleton className="h-56 w-full rounded-xl" />
<Skeleton className="h-56 w-full rounded-xl" />
</div>
</div>
<Skeleton className="h-48 w-full rounded-xl" />
</div>
)
}
@@ -76,15 +78,17 @@ export function OpsDashboard({
{charts}
</section>
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
<div className="flex min-w-0 flex-col gap-1">
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
{queueDescription ? (
<p className="text-muted-foreground max-w-prose text-sm">{queueDescription}</p>
) : null}
</div>
{queue}
</section>
{queue ? (
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
<div className="flex min-w-0 flex-col gap-1">
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
{queueDescription ? (
<p className="text-muted-foreground max-w-prose text-sm">{queueDescription}</p>
) : null}
</div>
{queue}
</section>
) : null}
</div>
)
}
Loaded 100 of 257 files, more files were not shown because too many files have changed in this diff. Show more