Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f861d0fa8c | ||
|
|
2f914bcc65 | ||
|
|
7a10138990 | ||
|
|
391f34a53d | ||
|
|
ecd48642de | ||
|
|
09580bbcab | ||
|
|
4d54dacd05 | ||
|
|
a3562cc11e | ||
|
|
95d4305241 | ||
|
|
dc803bcb34 | ||
|
|
e469c421ca | ||
|
|
094f75913b | ||
|
|
764a4e5b4e | ||
|
|
2d29a892f9 |
@@ -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/*` |
|
| Button, Card, Sheet, Field, Sidebar | `@shadcn` | `@evobgp/ui/components/*` |
|
||||||
| Blocks (sidebar-07, dashboard-01) | `@shadcn` | blocks → `apps/web/src/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` |
|
| Мультифильтры | `@reui` | `@/components/reui/filters` |
|
||||||
| Number field со stepper | `@reui` | `@/components/reui/number-field` |
|
| Number field со stepper | `@reui` | `@/components/reui/number-field` |
|
||||||
| Autocomplete | `@reui` | `@/components/reui/autocomplete` → `AutoCompleteInput` |
|
| Autocomplete | `@reui` | `@/components/reui/autocomplete` → `AutoCompleteInput` |
|
||||||
| Date selector / range | `@reui` | `@/components/reui/date-selector` |
|
| Date selector / range | `@reui` | `@/components/reui/date-selector` |
|
||||||
| Semantic badge (success/info/warning) | `@reui` | `@/components/reui/badge` или `StatusBadge` |
|
| 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-обёртки
|
## Уже установленные shared-обёртки
|
||||||
|
|
||||||
В `apps/web/src/components/`:
|
В `apps/web/src/components/`:
|
||||||
- `PageHeader`, `PageShell` — заголовки и обёртки страниц
|
- `PageHeader`, `PageShell` — заголовки и обёртки страниц
|
||||||
- `QueryState` — обёртка loading/error/empty для TanStack Query
|
- `QueryState` — обёртка loading/error/empty для TanStack Query
|
||||||
- `EmptyState` — пустые списки
|
- `EmptyState` — пустые списки (empty-state-14 DNA)
|
||||||
- `ConfirmDialog` — подтверждения (не `window.confirm`)
|
- `ConfirmDialog` — подтверждения (не `window.confirm`)
|
||||||
- `LoadingButton` — кнопка с loading-состоянием
|
- `LoadingButton` — SoT кнопка с loading (`Button` + `Spinner`)
|
||||||
- `StatusBadge` — статусные бейджи
|
- `StatusBadge` / `CategoryBadge` / `ModeBadge` — semantic ReUI Badge
|
||||||
- `SectionCards` — сетка KPI-карточек
|
- `SegmentedTabs` — секции страницы (c-tabs-9)
|
||||||
- `Skeletons` (`TableSkeleton`, `SectionCardsSkeleton`) — скелетоны
|
- `StatusToggleGroup` — статус грида (c-toggle-group-5)
|
||||||
|
- `FormDrawer` — Sheet overlay (sheet-8 / form-7)
|
||||||
|
- `Skeletons` (`TableSkeleton`, `KpiStatGridSkeleton`) — скелетоны
|
||||||
- `TruncatedText` — текст с тултипом
|
- `TruncatedText` — текст с тултипом
|
||||||
- `ModeToggle` — переключатель темы
|
|
||||||
|
Kit: `reui-kit/ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `SettingsShell`, `DetailPanel`, `FrameSection`.
|
||||||
|
|
||||||
Перед созданием новой обёртки — проверить существующие через Codegraph.
|
Перед созданием новой обёртки — проверить существующие через Codegraph.
|
||||||
|
|
||||||
|
|||||||
@@ -70,14 +70,14 @@ alwaysApply: true
|
|||||||
|------|------|--------|
|
|------|------|--------|
|
||||||
| shadcn | `packages/ui/src/components/` | `@evobgp/ui/components/*` |
|
| shadcn | `packages/ui/src/components/` | `@evobgp/ui/components/*` |
|
||||||
| ReUI CLI | `apps/web/src/components/reui/` | `@/components/reui/*` |
|
| 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/*` |
|
| Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` |
|
||||||
|
|
||||||
## Установленные ReUI (apps/web)
|
## Установленные 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`
|
**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`, …
|
**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`, …
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ alwaysApply: false
|
|||||||
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
|
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
|
||||||
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
|
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
|
||||||
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, LoadingButton |
|
| 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) |
|
| Роуты | `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>`.
|
Тема: `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` обёртки.
|
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
|
||||||
*Проверка:* https://ui.shadcn.com/docs/components/form
|
*Проверка:* https://ui.shadcn.com/docs/components/form
|
||||||
|
|
||||||
**WEB-06** | MUST | Сложные data-списки — 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`.
|
*Проверка:* `@/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`.
|
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
|
||||||
*Проверка:* https://ui.shadcn.com/docs/components/sonner
|
*Проверка:* https://ui.shadcn.com/docs/components/sonner
|
||||||
|
|
||||||
|
|||||||
@@ -52,8 +52,10 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- if: ${{ inputs.is_pull_request == false }}
|
- if: ${{ inputs.is_pull_request == false }}
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
# Полная история: BEFORE_SHA = github.event.before при multi-commit push
|
||||||
|
# лежит глубже shallow-среза, и git diff падает с "bad object".
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 0
|
||||||
- id: detect
|
- id: detect
|
||||||
name: Detect changed paths per module
|
name: Detect changed paths per module
|
||||||
env:
|
env:
|
||||||
@@ -94,7 +96,14 @@ jobs:
|
|||||||
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
||||||
before="$BEFORE_SHA"
|
before="$BEFORE_SHA"
|
||||||
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
||||||
|
# Force-push мог отбросить before; отсутствие объекта — не ошибка,
|
||||||
|
# а сигнал уйти в полный прогон через пустой diff.
|
||||||
|
if git cat-file -e "$before^{commit}" 2>/dev/null; then
|
||||||
FILES="$(git diff --name-only "$before" "$after")"
|
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
|
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||||
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
| Конфиг BIRD, `birdc` | `internal/birdfmt/`, `internal/birddeploy/` |
|
| Конфиг BIRD, `birdc` | `internal/birdfmt/`, `internal/birddeploy/` |
|
||||||
| Бандлы и подписи | `internal/bundle/`, `internal/signing/` |
|
| Бандлы и подписи | `internal/bundle/`, `internal/signing/` |
|
||||||
| Точки входа процессов | `cmd/*/` |
|
| Точки входа процессов | `cmd/*/` |
|
||||||
| Веб (SvelteKit) | `web/` |
|
| Веб (React + ReUI) | `apps/web/` |
|
||||||
| Compose, деплой | `deploy/compose/` |
|
| Compose, деплой | `deploy/compose/` |
|
||||||
|
|
||||||
Точки входа бинарников и их роли — в таблице в начале [docs/architecture.md](docs/architecture.md).
|
Точки входа бинарников и их роли — в таблице в начале [docs/architecture.md](docs/architecture.md).
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
|
|||||||
import globals from 'globals'
|
import globals from 'globals'
|
||||||
|
|
||||||
export default tseslint.config(
|
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],
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
|||||||
@@ -40,6 +40,8 @@
|
|||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.60.0",
|
||||||
"recharts": "3.8.0",
|
"recharts": "3.8.0",
|
||||||
|
"shadcn": "^4.19.0",
|
||||||
|
"shiki": "^4.4.3",
|
||||||
"sonner": "^1.7.0",
|
"sonner": "^1.7.0",
|
||||||
"zod": "^3.25.0"
|
"zod": "^3.25.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export function AccessApiKeysGrid({
|
|||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
|
size: 88,
|
||||||
header: () => null,
|
header: () => null,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const k = row.original
|
const k = row.original
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@evobgp/ui/components/tooltip'
|
} 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.
|
/** Chart Frame shell — chart-1 spacing, not Card.
|
||||||
* @see https://reui.io/preview/base/chart-1
|
* @see https://reui.io/preview/base/chart-1
|
||||||
@@ -51,16 +51,16 @@ export function AnalyticsCardShell({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelCard
|
<FrameSection
|
||||||
title={titleNode}
|
title={titleNode}
|
||||||
description={description}
|
description={description}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
footer={footer}
|
footer={footer}
|
||||||
className={cn('overflow-hidden', className)}
|
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}
|
footerClassName={footer ? 'gap-2 px-5 py-4' : undefined}
|
||||||
>
|
>
|
||||||
{children}
|
{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 { Badge } from '@/components/reui/badge'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export function DashboardPlatformCard({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
onClick={() => navigate({ to: '/monitoring', search: { tab: 'system' } })}
|
onClick={() => navigate({ to: '/monitoring' })}
|
||||||
>
|
>
|
||||||
Мониторинг
|
Мониторинг
|
||||||
</Button>
|
</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 +1 @@
|
|||||||
import type { ComponentProps, ReactNode } from 'react'
|
export { CategoryBadge, ModeBadge } from '@/components/status-badge'
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
Boxes,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
Network,
|
Network,
|
||||||
ServerCog,
|
|
||||||
Share2,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { ReactNode } from '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 { Badge } from '@/components/reui/badge'
|
||||||
|
|
||||||
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
|
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
|
||||||
@@ -16,6 +14,11 @@ import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
|
|||||||
|
|
||||||
type KpiCard = KpiStatItem & { icon: ReactNode }
|
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({
|
function buildKpis({
|
||||||
modules,
|
modules,
|
||||||
peers,
|
peers,
|
||||||
@@ -40,17 +43,34 @@ function buildKpis({
|
|||||||
).length
|
).length
|
||||||
const offlineSpeakers = Math.max(0, speakers.length - network.speakersOnline)
|
const offlineSpeakers = Math.max(0, speakers.length - network.speakersOnline)
|
||||||
const riskCount = network.peersMismatch + failedJobs + offlineSpeakers
|
const riskCount = network.peersMismatch + failedJobs + offlineSpeakers
|
||||||
|
const disabledModules = Math.max(0, modules.length - enabledModules)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'modules',
|
id: 'modules',
|
||||||
icon: <Boxes aria-hidden />,
|
icon: <Boxes aria-hidden />,
|
||||||
iconClassName: 'text-primary',
|
iconClassName: 'text-primary',
|
||||||
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`,
|
value: loading ? '—' : enabledModules,
|
||||||
label: 'Модули активны',
|
label: 'Модули активны',
|
||||||
|
progress: loading ? undefined : ratioPercent(enabledModules, modules.length),
|
||||||
footer: (
|
footer: (
|
||||||
<Badge variant="primary-light" size="sm">
|
<Badge
|
||||||
{loading ? '…' : `${modules.length} всего`}
|
variant={
|
||||||
|
loading || modules.length === 0
|
||||||
|
? 'outline'
|
||||||
|
: disabledModules === 0
|
||||||
|
? 'success-light'
|
||||||
|
: 'warning-light'
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? '…'
|
||||||
|
: modules.length === 0
|
||||||
|
? 'нет модулей'
|
||||||
|
: disabledModules === 0
|
||||||
|
? 'все активны'
|
||||||
|
: `${disabledModules} выкл`}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -60,6 +80,7 @@ function buildKpis({
|
|||||||
iconClassName: 'text-info',
|
iconClassName: 'text-info',
|
||||||
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
|
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
|
||||||
label: 'BGP готовность',
|
label: 'BGP готовность',
|
||||||
|
progress: loading || bgpPct === null ? undefined : bgpPct,
|
||||||
footer: (
|
footer: (
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
@@ -73,37 +94,12 @@ function buildKpis({
|
|||||||
>
|
>
|
||||||
{loading || bgpPct === null
|
{loading || bgpPct === null
|
||||||
? 'нет включённых пиров'
|
? 'нет включённых пиров'
|
||||||
|
: bgpPct >= 90
|
||||||
|
? 'сессии в норме'
|
||||||
: `${network.peersEstablished} установлено`}
|
: `${network.peersEstablished} установлено`}
|
||||||
</Badge>
|
</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 ? '…' : 'в сети'}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'jobs',
|
id: 'jobs',
|
||||||
icon: <ListChecks aria-hidden />,
|
icon: <ListChecks aria-hidden />,
|
||||||
@@ -112,7 +108,7 @@ function buildKpis({
|
|||||||
label: 'Активные задачи',
|
label: 'Активные задачи',
|
||||||
footer: (
|
footer: (
|
||||||
<Badge variant={running > 0 ? 'info-light' : 'outline'} size="sm">
|
<Badge variant={running > 0 ? 'info-light' : 'outline'} size="sm">
|
||||||
{loading ? '…' : `${jobs.length} в выборке`}
|
{loading ? '…' : running > 0 ? 'выполняются' : 'очередь пуста'}
|
||||||
</Badge>
|
</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' ? 'Утилизация пиров' : 'Спикеры в сети',
|
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры в сети',
|
||||||
percent: loading ? 0 : utilization,
|
percent: loading ? 0 : utilization,
|
||||||
badge: (
|
badge: (
|
||||||
<Badge variant="outline" radius="full" className="h-6 px-2 text-[10px]">
|
<Badge
|
||||||
{loading ? '…' : `${established}/${total}`}
|
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>
|
</Badge>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ const ACTIONS: QuickActionItem[] = [
|
|||||||
title: 'Мониторинг',
|
title: 'Мониторинг',
|
||||||
description: 'Состояние системы, BIRD и PostgreSQL.',
|
description: 'Состояние системы, BIRD и PostgreSQL.',
|
||||||
to: '/monitoring',
|
to: '/monitoring',
|
||||||
search: { tab: 'system' },
|
|
||||||
icon: <Gauge aria-hidden />,
|
icon: <Gauge aria-hidden />,
|
||||||
iconClassName: 'text-destructive',
|
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'
|
import type { DohProfile } from '@/types/api'
|
||||||
|
|
||||||
const filterFields: FilterField[] = [
|
const filterFields: FilterField[] = [
|
||||||
createSearchFilterField('search', 'Поиск', 'Поиск DoH профилей…'),
|
createSearchFilterField('search', 'Поиск', 'Поиск DoH-профилей…'),
|
||||||
]
|
]
|
||||||
|
|
||||||
export function DirectoriesDohGrid({
|
export function DirectoriesDohGrid({
|
||||||
@@ -81,7 +81,7 @@ export function DirectoriesDohGrid({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ResourcePage
|
<ResourcePage
|
||||||
title="DoH профили"
|
title="DoH-профили"
|
||||||
description="Резолверы DNS-over-HTTPS для доменных модулей"
|
description="Резолверы DNS-over-HTTPS для доменных модулей"
|
||||||
filterFields={filterFields}
|
filterFields={filterFields}
|
||||||
filterQuery={filterQuery}
|
filterQuery={filterQuery}
|
||||||
@@ -94,7 +94,7 @@ export function DirectoriesDohGrid({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
primaryAction={actions}
|
primaryAction={actions}
|
||||||
pinLastColumn={Boolean(canWrite && onEdit)}
|
pinLastColumn={Boolean(canWrite && onEdit)}
|
||||||
emptyState={{ title: 'Нет DoH профилей', action: actions }}
|
emptyState={{ title: 'Нет DoH-профилей', action: actions }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import { InboxIcon, type LucideIcon } from 'lucide-react'
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
import { IconStack } from '@/components/reui/icon-stack'
|
import { IconStack } from '@/components/reui/icon-stack'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FramePanel,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
import {
|
import {
|
||||||
Empty,
|
Empty,
|
||||||
EmptyContent,
|
EmptyContent,
|
||||||
@@ -25,6 +29,8 @@ interface EmptyStateProps {
|
|||||||
className?: string
|
className?: string
|
||||||
stackedIcon?: boolean
|
stackedIcon?: boolean
|
||||||
centered?: 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 {
|
function isLucideIcon(icon: LucideIcon | ReactNode): icon is LucideIcon {
|
||||||
@@ -44,15 +50,16 @@ export function EmptyState({
|
|||||||
className,
|
className,
|
||||||
stackedIcon = true,
|
stackedIcon = true,
|
||||||
centered = true,
|
centered = true,
|
||||||
|
framed = false,
|
||||||
}: EmptyStateProps) {
|
}: EmptyStateProps) {
|
||||||
const Icon = isLucideIcon(icon) ? icon : InboxIcon
|
const Icon = isLucideIcon(icon) ? icon : InboxIcon
|
||||||
const customIcon = icon && !isLucideIcon(icon) ? icon : null
|
const customIcon = icon && !isLucideIcon(icon) ? icon : null
|
||||||
|
|
||||||
const body = (
|
const empty = (
|
||||||
<Empty
|
<Empty
|
||||||
className={cn(
|
className={cn(
|
||||||
'max-w-md flex-none border-0 bg-transparent p-0',
|
'max-w-md flex-none border-0 bg-transparent p-0',
|
||||||
!centered && className,
|
!centered && !framed && className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<EmptyHeader className="gap-5 text-center">
|
<EmptyHeader className="gap-5 text-center">
|
||||||
@@ -88,6 +95,16 @@ export function EmptyState({
|
|||||||
</Empty>
|
</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
|
if (!centered) return body
|
||||||
|
|
||||||
return (
|
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import { FieldGroup } from '@evobgp/ui/components/field'
|
||||||
|
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
@@ -20,7 +22,11 @@ interface FormDrawerProps {
|
|||||||
className?: string
|
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({
|
export function FormDrawer({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -34,13 +40,17 @@ export function FormDrawer({
|
|||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
side="right"
|
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">
|
<SheetHeader className="shrink-0 border-b border-border/50">
|
||||||
<SheetTitle>{title}</SheetTitle>
|
<SheetTitle>{title}</SheetTitle>
|
||||||
{description ? <SheetDescription>{description}</SheetDescription> : null}
|
{description ? <SheetDescription>{description}</SheetDescription> : null}
|
||||||
</SheetHeader>
|
</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">
|
<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">
|
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||||
{footer}
|
{footer}
|
||||||
|
|||||||
@@ -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'
|
|
||||||
@@ -89,7 +89,7 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и применение', search: { tab: 'revisions' } },
|
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и применение', search: { tab: 'revisions' } },
|
||||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание обновления' },
|
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание обновления' },
|
||||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD', search: { tab: 'system' } },
|
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { Loader2Icon } from 'lucide-react'
|
import { Spinner } from '@evobgp/ui/components/spinner'
|
||||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
import type { ComponentProps, ReactNode } from 'react'
|
||||||
|
|
||||||
type LoadingButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
type LoadingButtonProps = ComponentProps<typeof Button> & {
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
|
|
||||||
size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'
|
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** SoT for async actions — Button + Spinner. */
|
||||||
export function LoadingButton({ loading, disabled, children, ...props }: LoadingButtonProps) {
|
export function LoadingButton({ loading, disabled, children, ...props }: LoadingButtonProps) {
|
||||||
return (
|
return (
|
||||||
<Button disabled={disabled || loading} {...props}>
|
<Button disabled={disabled || loading} {...props}>
|
||||||
{loading ? <Loader2Icon className="animate-spin" data-icon="inline-start" /> : null}
|
{loading ? <Spinner data-icon="inline-start" /> : null}
|
||||||
{children}
|
{children}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { CascaderInput, CascaderValue } from '@/components/reui/cascader/cascader-nav'
|
import { CascaderInput, CascaderValue } from '@/components/reui/cascader/cascader-nav'
|
||||||
import type { CascaderNode } from '@/components/reui/cascader/cascader-types'
|
import type { CascaderNode } from '@/components/reui/cascader/cascader-types'
|
||||||
import { ApiError, apiMutate } from '@/lib/api-client'
|
import { ApiError, apiMutate } from '@/lib/api-client'
|
||||||
|
import { CASCADER_LABELS_RU } from '@/lib/reui-i18n-ru'
|
||||||
import type {
|
import type {
|
||||||
BgpCommunity,
|
BgpCommunity,
|
||||||
LookupQueryKind,
|
LookupQueryKind,
|
||||||
@@ -202,9 +203,7 @@ export function LookupAddStep({
|
|||||||
selectable={allowEmptyCommunity ? 'any' : 'leaf'}
|
selectable={allowEmptyCommunity ? 'any' : 'leaf'}
|
||||||
mode="columns"
|
mode="columns"
|
||||||
labels={{
|
labels={{
|
||||||
search: 'Поиск',
|
...CASCADER_LABELS_RU,
|
||||||
empty: 'Ничего не найдено',
|
|
||||||
back: 'Назад',
|
|
||||||
rootLevel: 'Модули',
|
rootLevel: 'Модули',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { toast } from 'sonner'
|
|||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { Checkbox } from '@evobgp/ui/components/checkbox'
|
import { Checkbox } from '@evobgp/ui/components/checkbox'
|
||||||
|
import { Field, FieldDescription, FieldLabel } from '@evobgp/ui/components/field'
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
import { Label } from '@evobgp/ui/components/label'
|
|
||||||
|
|
||||||
import { FormDrawer } from '@/components/form-drawer'
|
import { FormDrawer } from '@/components/form-drawer'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
@@ -171,42 +171,42 @@ export function ModuleCreateDialog({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<Field>
|
||||||
<Label htmlFor="mod-create-name">Название</Label>
|
<FieldLabel htmlFor="mod-create-name">Название</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
id="mod-create-name"
|
id="mod-create-name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Имя модуля"
|
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">
|
<div className="grid min-w-0 flex-1 gap-1 pr-2">
|
||||||
<Label htmlFor="mod-create-enabled">Включён</Label>
|
<FieldLabel htmlFor="mod-create-enabled">Включён</FieldLabel>
|
||||||
<p className="text-xs text-muted-foreground">
|
<FieldDescription>
|
||||||
Выключенный модуль не участвует в обновлении и применении.
|
Выключенный модуль не участвует в обновлении и применении.
|
||||||
</p>
|
</FieldDescription>
|
||||||
</div>
|
</div>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="mod-create-enabled"
|
id="mod-create-enabled"
|
||||||
checked={enabled}
|
checked={enabled}
|
||||||
onCheckedChange={(v) => setEnabled(v === true)}
|
onCheckedChange={(v) => setEnabled(v === true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<Field>
|
||||||
<Label htmlFor="mod-create-priority">Приоритет</Label>
|
<FieldLabel htmlFor="mod-create-priority">Приоритет</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
id="mod-create-priority"
|
id="mod-create-priority"
|
||||||
type="number"
|
type="number"
|
||||||
value={priority}
|
value={priority}
|
||||||
onChange={(e) => setPriority(e.target.value)}
|
onChange={(e) => setPriority(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<Field>
|
||||||
<Label htmlFor="mod-create-interval">Интервал обновления (сек)</Label>
|
<FieldLabel htmlFor="mod-create-interval">Интервал обновления (сек)</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
id="mod-create-interval"
|
id="mod-create-interval"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -215,17 +215,17 @@ export function ModuleCreateDialog({
|
|||||||
value={refreshIntervalSec}
|
value={refreshIntervalSec}
|
||||||
onChange={(e) => setRefreshIntervalSec(e.target.value)}
|
onChange={(e) => setRefreshIntervalSec(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<Field>
|
||||||
<Label htmlFor="mod-create-cron">Cron (опционально)</Label>
|
<FieldLabel htmlFor="mod-create-cron">Cron (опционально)</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
id="mod-create-cron"
|
id="mod-create-cron"
|
||||||
placeholder="0 * * * *"
|
placeholder="0 * * * *"
|
||||||
value={cronExpr}
|
value={cronExpr}
|
||||||
onChange={(e) => setCronExpr(e.target.value)}
|
onChange={(e) => setCronExpr(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<CommunitySelect
|
<CommunitySelect
|
||||||
id="mod-create-community"
|
id="mod-create-community"
|
||||||
@@ -248,8 +248,8 @@ export function ModuleCreateDialog({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<Field>
|
||||||
<Label>DoH профили</Label>
|
<FieldLabel>DoH профили</FieldLabel>
|
||||||
{dohProfiles.length === 0 ? (
|
{dohProfiles.length === 0 ? (
|
||||||
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
|
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -281,7 +281,7 @@ export function ModuleCreateDialog({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Field>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</FormDrawer>
|
</FormDrawer>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Clock, Gauge, Globe, Tags } from 'lucide-react'
|
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 { Badge } from '@/components/reui/badge'
|
||||||
import { KpiStatGridSkeleton } from '@/components/skeletons'
|
import { KpiStatGridSkeleton } from '@/components/skeletons'
|
||||||
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
|
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export const peerColumns: DataGridColumnDef<PeerRow>[] = [
|
|||||||
export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown {
|
export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown {
|
||||||
switch (field) {
|
switch (field) {
|
||||||
case 'name':
|
case 'name':
|
||||||
return item.name ?? item.neighbor
|
return `${item.name ?? ''} ${item.neighbor} ${item.remote_asn ?? ''}`
|
||||||
case 'neighbor':
|
case 'neighbor':
|
||||||
return item.neighbor
|
return item.neighbor
|
||||||
case 'session_state':
|
case 'session_state':
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export const speakerColumns: DataGridColumnDef<SpeakerRow>[] = [
|
|||||||
export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown {
|
export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown {
|
||||||
switch (field) {
|
switch (field) {
|
||||||
case 'endpoint':
|
case 'endpoint':
|
||||||
return `${item.endpoint} ${item.agent_domain ?? ''}`
|
return `${item.endpoint} ${item.agent_domain ?? ''} ${item.id}`
|
||||||
case 'role':
|
case 'role':
|
||||||
return item.role
|
return item.role
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
|||||||
label="Роль"
|
label="Роль"
|
||||||
items={[
|
items={[
|
||||||
{ value: 'replica', label: 'Реплика' },
|
{ value: 'replica', label: 'Реплика' },
|
||||||
{ value: 'master', label: 'Мастер (плоскость)' },
|
{ value: 'master', label: 'Основной' },
|
||||||
]}
|
]}
|
||||||
value={role}
|
value={role}
|
||||||
onValueChange={(v) => setRole(v ?? 'replica')}
|
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 { toast } from 'sonner'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
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 { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +16,7 @@ import {
|
|||||||
type DataGridColumnDef,
|
type DataGridColumnDef,
|
||||||
} from '@/components/reui-kit'
|
} from '@/components/reui-kit'
|
||||||
import { apiMutate } from '@/lib/api-client'
|
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 { JobRow } from '@/types/api'
|
||||||
import type { QueryClient } from '@tanstack/react-query'
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
@@ -71,19 +73,27 @@ export function OperationsJobsGrid({
|
|||||||
{
|
{
|
||||||
accessorKey: 'kind',
|
accessorKey: 'kind',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
|
const trigger = jobTriggerRu(row.original.meta?.trigger)
|
||||||
|
return (
|
||||||
<DataGridNameCell
|
<DataGridNameCell
|
||||||
icon={ListTodo}
|
icon={ListTodo}
|
||||||
title={jobKindRu(row.original.kind)}
|
title={
|
||||||
subtitle={
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
row.original.meta?.module_id
|
<span className="truncate">{jobKindRu(row.original.kind)}</span>
|
||||||
? (nameById.get(String(row.original.meta.module_id)) ??
|
{trigger ? (
|
||||||
String(row.original.meta.module_id))
|
<Badge variant="outline" size="sm" className="shrink-0 font-normal">
|
||||||
: undefined
|
{trigger}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
|
subtitle={jobSubtitleRu(row.original.kind, row.original.meta, nameById)}
|
||||||
/>
|
/>
|
||||||
),
|
)
|
||||||
|
},
|
||||||
meta: { headerTitle: 'Вид' },
|
meta: { headerTitle: 'Вид' },
|
||||||
|
size: 320,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
@@ -96,6 +106,7 @@ export function OperationsJobsGrid({
|
|||||||
return <StatusBadge status={row.original.status} hint={hint} />
|
return <StatusBadge status={row.original.status} hint={hint} />
|
||||||
},
|
},
|
||||||
meta: { headerTitle: 'Статус' },
|
meta: { headerTitle: 'Статус' },
|
||||||
|
size: 150,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'created_at',
|
id: 'created_at',
|
||||||
@@ -109,6 +120,7 @@ export function OperationsJobsGrid({
|
|||||||
</DataGridMutedCell>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Создана' },
|
meta: { headerTitle: 'Создана' },
|
||||||
|
size: 190,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'finished_at',
|
id: 'finished_at',
|
||||||
@@ -122,6 +134,7 @@ export function OperationsJobsGrid({
|
|||||||
</DataGridMutedCell>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Завершена' },
|
meta: { headerTitle: 'Завершена' },
|
||||||
|
size: 190,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
@@ -156,10 +169,9 @@ export function OperationsJobsGrid({
|
|||||||
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
||||||
getFilterFieldValue={(item, field) => {
|
getFilterFieldValue={(item, field) => {
|
||||||
if (field !== 'search') return undefined
|
if (field !== 'search') return undefined
|
||||||
const moduleName = item.meta?.module_id
|
const subtitle = jobSubtitleRu(item.kind, item.meta, nameById)
|
||||||
? (nameById.get(String(item.meta.module_id)) ?? String(item.meta.module_id))
|
const trigger = jobTriggerRu(item.meta?.trigger)
|
||||||
: ''
|
return `${jobKindRu(item.kind)} ${item.status} ${item.job_id} ${subtitle ?? ''} ${trigger ?? ''}`
|
||||||
return `${jobKindRu(item.kind)} ${item.status} ${item.job_id} ${moduleName}`
|
|
||||||
}}
|
}}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={items}
|
data={items}
|
||||||
@@ -168,6 +180,8 @@ export function OperationsJobsGrid({
|
|||||||
pinLastColumn
|
pinLastColumn
|
||||||
virtualization={items.length > 80}
|
virtualization={items.length > 80}
|
||||||
emptyState={{ title: 'Нет задач' }}
|
emptyState={{ title: 'Нет задач' }}
|
||||||
|
getRowCanExpand={() => true}
|
||||||
|
expandedContent={(row) => <JobTimeline job={row} />}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,22 @@ import {
|
|||||||
KanbanColumn,
|
KanbanColumn,
|
||||||
KanbanColumnContent,
|
KanbanColumnContent,
|
||||||
KanbanItem,
|
KanbanItem,
|
||||||
|
KanbanOverlay,
|
||||||
} from '@/components/reui/kanban'
|
} from '@/components/reui/kanban'
|
||||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
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 { jobKindRu } from '@/lib/ui-labels'
|
||||||
import type { JobRow } from '@/types/api'
|
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/kanban
|
||||||
* @see https://reui.io/docs/components/base/frame
|
* @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_ORDER = ['queued', 'running', 'succeeded', 'failed'] as const
|
||||||
|
|
||||||
const COLUMN_LABELS: Record<(typeof COLUMN_ORDER)[number], string> = {
|
const COLUMN_LABELS: Record<(typeof COLUMN_ORDER)[number], string> = {
|
||||||
queued: 'Очередь',
|
queued: 'В очереди',
|
||||||
running: 'Выполняются',
|
running: 'Выполняются',
|
||||||
succeeded: 'Успешные',
|
succeeded: 'Успешные',
|
||||||
failed: 'Ошибки',
|
failed: 'Ошибки',
|
||||||
@@ -78,9 +87,9 @@ export function OperationsJobsKanban({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="text-sm font-medium">{COLUMN_LABELS[columnId]}</p>
|
<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}
|
{columns[columnId]?.length ?? 0}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<KanbanColumnContent value={columnId}>
|
<KanbanColumnContent value={columnId}>
|
||||||
{(columns[columnId] ?? []).map((job) => (
|
{(columns[columnId] ?? []).map((job) => (
|
||||||
@@ -88,24 +97,35 @@ export function OperationsJobsKanban({
|
|||||||
key={job.job_id}
|
key={job.job_id}
|
||||||
value={job.job_id}
|
value={job.job_id}
|
||||||
disabled
|
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">
|
<Item variant="outline" size="sm">
|
||||||
{jobKindRu(job.kind)}
|
<ItemContent>
|
||||||
</p>
|
<ItemTitle>{jobKindRu(job.kind)}</ItemTitle>
|
||||||
<StatusBadge status={job.status} />
|
<StatusBadge status={job.status} />
|
||||||
{job.meta?.module_id ? (
|
{job.meta?.module_id ? (
|
||||||
|
<ItemDescription>
|
||||||
<DataGridMutedCell>
|
<DataGridMutedCell>
|
||||||
{nameById.get(String(job.meta.module_id)) ??
|
{nameById.get(String(job.meta.module_id)) ??
|
||||||
String(job.meta.module_id)}
|
String(job.meta.module_id)}
|
||||||
</DataGridMutedCell>
|
</DataGridMutedCell>
|
||||||
|
</ItemDescription>
|
||||||
) : null}
|
) : null}
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
</KanbanItem>
|
</KanbanItem>
|
||||||
))}
|
))}
|
||||||
</KanbanColumnContent>
|
</KanbanColumnContent>
|
||||||
</KanbanColumn>
|
</KanbanColumn>
|
||||||
))}
|
))}
|
||||||
</KanbanBoard>
|
</KanbanBoard>
|
||||||
|
<KanbanOverlay>
|
||||||
|
<Item variant="outline" size="sm">
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>Задача</ItemTitle>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
</KanbanOverlay>
|
||||||
</Kanban>
|
</Kanban>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|||||||
@@ -56,9 +56,10 @@ export function OperationsRevisionsGrid({
|
|||||||
accessorFn: (row) => row.id,
|
accessorFn: (row) => row.id,
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridNameCell icon={GitCommitHorizontal} title={`${row.original.id.slice(0, 12)}…`} />
|
<DataGridNameCell icon={GitCommitHorizontal} title={row.original.id} />
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'ID' },
|
meta: { headerTitle: 'ID' },
|
||||||
|
size: 340,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'created_at',
|
accessorKey: 'created_at',
|
||||||
@@ -69,6 +70,7 @@ export function OperationsRevisionsGrid({
|
|||||||
</DataGridMutedCell>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Создана' },
|
meta: { headerTitle: 'Создана' },
|
||||||
|
size: 210,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'materialized_prefix_count',
|
accessorKey: 'materialized_prefix_count',
|
||||||
@@ -79,6 +81,7 @@ export function OperationsRevisionsGrid({
|
|||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Префиксов' },
|
meta: { headerTitle: 'Префиксов' },
|
||||||
|
size: 150,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
@@ -91,7 +94,7 @@ export function OperationsRevisionsGrid({
|
|||||||
<RefreshCw className="size-3.5" />
|
<RefreshCw className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
|
title={`Откатиться к ревизии ${row.original.id}?`}
|
||||||
description="Будет создана новая ревизия на основе выбранной. Требуется роль оператора."
|
description="Будет создана новая ревизия на основе выбранной. Требуется роль оператора."
|
||||||
confirmLabel="Откатить"
|
confirmLabel="Откатить"
|
||||||
destructive
|
destructive
|
||||||
|
|||||||
@@ -1,93 +1,11 @@
|
|||||||
import type { ReactNode } from 'react'
|
export {
|
||||||
|
FrameSection,
|
||||||
import {
|
FrameSection as PanelCard,
|
||||||
Frame,
|
panelCardClassName,
|
||||||
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,
|
panelCardHeaderClassName,
|
||||||
'flex-row items-start justify-between gap-3',
|
panelCardContentFlushClassName,
|
||||||
headerClassName,
|
panelCardInsetClassName,
|
||||||
)}
|
panelCardFooterClassName,
|
||||||
>
|
type FrameSectionProps,
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
type FrameSectionProps as PanelCardProps,
|
||||||
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
} from '@/components/reui-kit/frame-section'
|
||||||
{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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
import { IconStack } from '@/components/reui/icon-stack'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import {
|
|
||||||
Frame,
|
|
||||||
FramePanel,
|
|
||||||
} from '@/components/reui/frame'
|
|
||||||
import {
|
|
||||||
Empty,
|
|
||||||
EmptyDescription,
|
|
||||||
EmptyHeader,
|
|
||||||
EmptyMedia,
|
|
||||||
EmptyTitle,
|
|
||||||
} from '@evobgp/ui/components/empty'
|
|
||||||
|
|
||||||
/** empty-state-14: Frame + IconStack. Preview: https://reui.io/preview/base/empty-state-14 */
|
/** empty-state-14: Frame + IconStack. Preview: https://reui.io/preview/base/empty-state-14 */
|
||||||
export function IllustratedEmptyState({
|
export function IllustratedEmptyState({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
icon: Icon,
|
icon,
|
||||||
action,
|
action,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
@@ -27,23 +16,13 @@ export function IllustratedEmptyState({
|
|||||||
action?: ReactNode
|
action?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Frame dense spacing="sm" className="w-full">
|
<EmptyState
|
||||||
<FramePanel className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
|
framed
|
||||||
{action ? <div className="w-full self-end">{action}</div> : null}
|
centered={false}
|
||||||
<Empty className="max-w-md gap-5 bg-transparent p-0">
|
icon={icon}
|
||||||
<EmptyHeader className="items-center gap-5 text-center">
|
title={title}
|
||||||
<EmptyMedia className="mb-0">
|
description={description}
|
||||||
<IconStack aria-hidden>
|
action={action}
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export { DonutBreakdownCard } from './donut-breakdown-card'
|
export { DonutBreakdownCard } from './donut-breakdown-card'
|
||||||
export { IllustratedEmptyState } from './illustrated-empty-state'
|
export { IllustratedEmptyState } from './illustrated-empty-state'
|
||||||
export { KpiSparklineCard, type KpiSparklineMetric } from './kpi-sparkline-card'
|
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 { PanelCorners } from './panel-corners'
|
||||||
export { ProjectsEmptyState } from './projects-empty-state'
|
export { ProjectsEmptyState } from './projects-empty-state'
|
||||||
export { SegmentedProgressCard, type SegmentStat } from './segmented-progress-card'
|
export { SegmentedProgressCard, type SegmentStat } from './segmented-progress-card'
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Boxes, Plus } from 'lucide-react'
|
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'
|
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({
|
export function ProjectsEmptyState({
|
||||||
canCreate = true,
|
canCreate = true,
|
||||||
onCreate,
|
onCreate,
|
||||||
@@ -12,7 +12,9 @@ export function ProjectsEmptyState({
|
|||||||
onCreate?: () => void
|
onCreate?: () => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<IllustratedEmptyState
|
<EmptyState
|
||||||
|
framed
|
||||||
|
centered={false}
|
||||||
icon={Boxes}
|
icon={Boxes}
|
||||||
title="Создайте первый модуль"
|
title="Создайте первый модуль"
|
||||||
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
|
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
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', () => {
|
describe('kitDataGridTableLayout', () => {
|
||||||
it('filtering-2 defaults: dense, headerBackground false, width fixed', () => {
|
it('filtering-2 defaults: dense, headerBackground false, width fixed', () => {
|
||||||
@@ -15,6 +21,9 @@ describe('kitDataGridTableLayout', () => {
|
|||||||
|
|
||||||
it('kit tableClassNames совпадает с filtering-2 edgeCell', () => {
|
it('kit tableClassNames совпадает с filtering-2 edgeCell', () => {
|
||||||
expect(kitDataGridTableClassNames.edgeCell).toBe('first:ps-3 last:pe-3')
|
expect(kitDataGridTableClassNames.edgeCell).toBe('first:ps-3 last:pe-3')
|
||||||
|
expect(kitDataGridTableClassNames.base).toBe(
|
||||||
|
'[&_[data-pinned]]:bg-(--frame-panel-bg)',
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('именованные opts: auto + pin', () => {
|
it('именованные opts: auto + pin', () => {
|
||||||
@@ -27,3 +36,63 @@ describe('kitDataGridTableLayout', () => {
|
|||||||
expect(layout.headerBackground).toBe(false)
|
expect(layout.headerBackground).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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,
|
createFilterQuery,
|
||||||
createFilterRule,
|
createFilterRule,
|
||||||
flattenFilterConditions,
|
flattenFilterConditions,
|
||||||
|
flattenFilterRules,
|
||||||
|
isFilterRule,
|
||||||
|
updateFilterRule,
|
||||||
type FilterCondition,
|
type FilterCondition,
|
||||||
} from '@/components/reui/filters/filters-query'
|
} 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. */
|
/** Operators that take no value, so an empty `values` list is expected. */
|
||||||
const VALUELESS_OPERATORS = new Set(['empty', 'not_empty'])
|
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[]) {
|
export function renderSelectedCount(values: unknown[]) {
|
||||||
if (values.length === 0) return 'Выберите…'
|
if (values.length === 0) return 'Выберите…'
|
||||||
if (values.length > 1) return `${values.length} выбрано`
|
if (values.length > 1) return `${values.length} выбрано`
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ import {
|
|||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
import { EmptyState } from '@/components/empty-state'
|
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>
|
export type DataGridColumnDef<TData extends object> = ColumnDef<DataGridFeatures, TData>
|
||||||
|
|
||||||
@@ -81,11 +81,87 @@ export function kitDataGridTableLayout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** filtering-2 edge inset — единственный tableClassNames в kit, не в domain. */
|
/** filtering-2 edge inset + Frame-surface pinned cells (not page --background). */
|
||||||
export const kitDataGridTableClassNames = {
|
export const kitDataGridTableClassNames = {
|
||||||
|
base: '[&_[data-pinned]]:bg-(--frame-panel-bg)',
|
||||||
edgeCell: 'first:ps-3 last:pe-3',
|
edgeCell: 'first:ps-3 last:pe-3',
|
||||||
} as const
|
} 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 {
|
function loadStoredColumnVisibility(key: string): ColumnVisibilityState | undefined {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(key)
|
const raw = localStorage.getItem(key)
|
||||||
@@ -222,6 +298,7 @@ function FrameDataGridBody<TData extends object>({
|
|||||||
recordCount={data.length}
|
recordCount={data.length}
|
||||||
onRowClick={onRowClick}
|
onRowClick={onRowClick}
|
||||||
emptyMessage={emptyTitle}
|
emptyMessage={emptyTitle}
|
||||||
|
i18n={DATA_GRID_I18N_RU}
|
||||||
tableLayout={kitDataGridTableLayout({
|
tableLayout={kitDataGridTableLayout({
|
||||||
dense,
|
dense,
|
||||||
width: tableWidth,
|
width: tableWidth,
|
||||||
@@ -241,7 +318,7 @@ function FrameDataGridBody<TData extends object>({
|
|||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<FrameFooter>
|
<FrameFooter>
|
||||||
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
|
<DataGridPagination sizes={[10, 25, 50]} />
|
||||||
</FrameFooter>
|
</FrameFooter>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -280,6 +357,7 @@ export function FrameDataGrid<TData extends object>({
|
|||||||
expandedContent,
|
expandedContent,
|
||||||
getRowCanExpand,
|
getRowCanExpand,
|
||||||
pinLeftColumnIds,
|
pinLeftColumnIds,
|
||||||
|
horizontalScroll = false,
|
||||||
tableWidth = 'fixed',
|
tableWidth = 'fixed',
|
||||||
columnPinControls = false,
|
columnPinControls = false,
|
||||||
}: FrameDataGridProps<TData>) {
|
}: FrameDataGridProps<TData>) {
|
||||||
@@ -344,21 +422,23 @@ export function FrameDataGrid<TData extends object>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tableColumns: DataGridColumnDef<TData>[] = applyColumnPinControls(
|
const tableColumns: DataGridColumnDef<TData>[] = applyColumnPinControls(
|
||||||
|
applyKitActionColumn(
|
||||||
[
|
[
|
||||||
...(expandedContent ? [expandColumn] : []),
|
...(expandedContent ? [expandColumn] : []),
|
||||||
...(enableRowSelection ? [selectColumn] : []),
|
...(enableRowSelection ? [selectColumn] : []),
|
||||||
...columns,
|
...columns,
|
||||||
],
|
],
|
||||||
|
{ pinLastColumn },
|
||||||
|
),
|
||||||
columnPinControls,
|
columnPinControls,
|
||||||
)
|
)
|
||||||
|
|
||||||
const lastColId = pinLastColumn ? (tableColumns[tableColumns.length - 1]?.id ?? '') : ''
|
const { enablePinning, columnPinning } = kitColumnPinning({
|
||||||
const pinLeft = pinLeftColumnIds ?? []
|
pinLastColumn,
|
||||||
const enablePinning = pinLastColumn || pinLeft.length > 0
|
horizontalScroll,
|
||||||
const columnPinning = {
|
lastColId: lastColumnId(tableColumns),
|
||||||
start: pinLeft,
|
pinLeftColumnIds,
|
||||||
end: pinLastColumn && lastColId ? [lastColId] : [],
|
})
|
||||||
}
|
|
||||||
|
|
||||||
const table = useTable({
|
const table = useTable({
|
||||||
features: dataGridFeatures,
|
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 { ExpandableResourceGrid } from './expandable-resource-grid'
|
||||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||||
export { SettingsShell } from './settings-shell'
|
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 { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { Progress } from '@evobgp/ui/components/progress'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
import { kpiCols } from './kpi-cols'
|
import { kpiCols } from './kpi-cols'
|
||||||
import { IconTile } from '@/components/reui/icon-tile'
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
@@ -29,6 +30,8 @@ export type KpiStatItem = {
|
|||||||
iconClassName?: string
|
iconClassName?: string
|
||||||
variant?: KpiStatVariant
|
variant?: KpiStatVariant
|
||||||
footer?: ReactNode
|
footer?: ReactNode
|
||||||
|
/** 0–100 completion bar under the value (stats-4). Omit to hide. */
|
||||||
|
progress?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** CFDM-compatible card shape (id required). */
|
/** CFDM-compatible card shape (id required). */
|
||||||
@@ -48,6 +51,17 @@ const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
|
|||||||
destructive: 'text-destructive',
|
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>) {
|
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -67,7 +81,7 @@ function resolveFooter(item: KpiStatItem): ReactNode {
|
|||||||
if (item.footer) return item.footer
|
if (item.footer) return item.footer
|
||||||
if (typeof item.hint === 'string') {
|
if (typeof item.hint === 'string') {
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" size="sm">
|
<Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
|
||||||
{item.hint}
|
{item.hint}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
@@ -81,30 +95,53 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
|||||||
const valueVariant = item.variant ?? 'default'
|
const valueVariant = item.variant ?? 'default'
|
||||||
|
|
||||||
return (
|
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 ? (
|
{item.icon ? (
|
||||||
<IconTile
|
<IconTile
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
aria-hidden="true"
|
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}
|
{item.icon}
|
||||||
</IconTile>
|
</IconTile>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||||
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
|
<div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
|
||||||
{footer ? <div className="shrink-0">{footer}</div> : null}
|
{item.label}
|
||||||
|
</div>
|
||||||
|
{footer ? (
|
||||||
|
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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],
|
VALUE_VARIANT_CLASS[valueVariant],
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.value}
|
{item.value}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -116,7 +153,7 @@ function panelClassName(item: KpiStatItem, className?: string) {
|
|||||||
const selected = isSelected(item)
|
const selected = isSelected(item)
|
||||||
|
|
||||||
return cn(
|
return cn(
|
||||||
'relative isolate flex h-full flex-col',
|
'relative isolate flex h-full min-w-0 flex-col',
|
||||||
clickable &&
|
clickable &&
|
||||||
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
|
'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',
|
selected && 'ring-primary/30 bg-muted/30 ring-1',
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface OpsDashboardProps {
|
|||||||
/** Slot after KPI (QuickActionGrid). Preview: stats-12 · card-12 */
|
/** Slot after KPI (QuickActionGrid). Preview: stats-12 · card-12 */
|
||||||
afterKpi?: ReactNode
|
afterKpi?: ReactNode
|
||||||
charts: ReactNode
|
charts: ReactNode
|
||||||
queue: ReactNode
|
queue?: ReactNode
|
||||||
queueTitle?: string
|
queueTitle?: string
|
||||||
queueDescription?: string
|
queueDescription?: string
|
||||||
headerActions?: ReactNode
|
headerActions?: ReactNode
|
||||||
@@ -31,10 +31,12 @@ function OpsDashboardSkeleton() {
|
|||||||
</header>
|
</header>
|
||||||
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
|
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
|
||||||
<div className="flex min-w-0 flex-col gap-4">
|
<div className="flex min-w-0 flex-col gap-4">
|
||||||
<Skeleton className="h-64 w-full rounded-xl" />
|
<Skeleton className="h-40 w-full rounded-xl" />
|
||||||
<Skeleton className="h-64 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>
|
</div>
|
||||||
<Skeleton className="h-48 w-full rounded-xl" />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -76,6 +78,7 @@ export function OpsDashboard({
|
|||||||
{charts}
|
{charts}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{queue ? (
|
||||||
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
|
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
|
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
|
||||||
@@ -85,6 +88,7 @@ export function OpsDashboard({
|
|||||||
</div>
|
</div>
|
||||||
{queue}
|
{queue}
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { useCallback, useMemo, useState, type ReactNode } from 'react'
|
import { useCallback, useMemo, useState, type ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
useTable,
|
useTable,
|
||||||
|
type ExpandedState,
|
||||||
type PaginationState,
|
type PaginationState,
|
||||||
type RowSelectionState,
|
type RowSelectionState,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table'
|
||||||
import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import { CircleAlertIcon, FilterIcon, FilterXIcon, SearchIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
|
||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
import {
|
||||||
|
StatusToggleGroup,
|
||||||
|
type StatusToggleTone,
|
||||||
|
} from '@/components/status-toggle-group'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import {
|
import {
|
||||||
DataGrid,
|
DataGrid,
|
||||||
@@ -17,7 +22,9 @@ import {
|
|||||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||||
|
import { DataGridTableRowExpand } from '@/components/reui/data-grid/data-grid-table'
|
||||||
import { Filters } from '@/components/reui/filters/filters'
|
import { Filters } from '@/components/reui/filters/filters'
|
||||||
|
import { flattenFilterConditions } from '@/components/reui/filters/filters-query'
|
||||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||||
import {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
@@ -29,13 +36,31 @@ import {
|
|||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupInput,
|
||||||
|
} from '@evobgp/ui/components/input-group'
|
||||||
import { Separator } from '@evobgp/ui/components/separator'
|
import { Separator } from '@evobgp/ui/components/separator'
|
||||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||||
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
|
import { DATA_GRID_I18N_RU } from '@/lib/reui-i18n-ru'
|
||||||
import { FILTERS_LABELS_RU, FILTERS_OPERATOR_LABELS_RU } from '@/lib/filters-i18n'
|
import { FILTERS_LABELS_RU, FILTERS_OPERATOR_LABELS_RU } from '@/lib/filters-i18n'
|
||||||
import { applyFiltersToData, createEmptyFilterQuery } from './filter-utils'
|
import {
|
||||||
|
applyFiltersToData,
|
||||||
|
createEmptyFilterQuery,
|
||||||
|
createTextFilterQuery,
|
||||||
|
getActiveFilters,
|
||||||
|
getExtraFilterFields,
|
||||||
|
getFilterTextValue,
|
||||||
|
getPrimaryTextField,
|
||||||
|
mergeFieldRules,
|
||||||
|
setFilterTextValue,
|
||||||
|
stripFieldFromQuery,
|
||||||
|
} from './filter-utils'
|
||||||
import {
|
import {
|
||||||
FrameDataGrid,
|
FrameDataGrid,
|
||||||
|
applyKitActionColumn,
|
||||||
|
kitColumnPinning,
|
||||||
kitDataGridTableClassNames,
|
kitDataGridTableClassNames,
|
||||||
kitDataGridTableLayout,
|
kitDataGridTableLayout,
|
||||||
type DataGridColumnDef,
|
type DataGridColumnDef,
|
||||||
@@ -46,6 +71,8 @@ export interface ResourcePageTab {
|
|||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
count?: number
|
count?: number
|
||||||
|
icon?: LucideIcon
|
||||||
|
tone?: StatusToggleTone
|
||||||
}
|
}
|
||||||
|
|
||||||
type SimpleGridPassthrough<T extends object> = Pick<
|
type SimpleGridPassthrough<T extends object> = Pick<
|
||||||
@@ -69,6 +96,8 @@ type SimpleGridPassthrough<T extends object> = Pick<
|
|||||||
| 'horizontalScroll'
|
| 'horizontalScroll'
|
||||||
| 'pinLeftColumnIds'
|
| 'pinLeftColumnIds'
|
||||||
| 'columnPinControls'
|
| 'columnPinControls'
|
||||||
|
| 'expandedContent'
|
||||||
|
| 'getRowCanExpand'
|
||||||
>
|
>
|
||||||
|
|
||||||
export interface ResourcePageProps<T extends object> extends SimpleGridPassthrough<T> {
|
export interface ResourcePageProps<T extends object> extends SimpleGridPassthrough<T> {
|
||||||
@@ -193,6 +222,8 @@ function ResourcePageSimple<T extends object>({
|
|||||||
horizontalScroll,
|
horizontalScroll,
|
||||||
pinLeftColumnIds,
|
pinLeftColumnIds,
|
||||||
columnPinControls,
|
columnPinControls,
|
||||||
|
expandedContent,
|
||||||
|
getRowCanExpand,
|
||||||
}: ResourcePageProps<T>) {
|
}: ResourcePageProps<T>) {
|
||||||
if (isLoading) return <ResourcePageSkeleton />
|
if (isLoading) return <ResourcePageSkeleton />
|
||||||
if (isError) return <ResourceLoadError error={error} onRetry={onRetry} />
|
if (isError) return <ResourceLoadError error={error} onRetry={onRetry} />
|
||||||
@@ -231,6 +262,8 @@ function ResourcePageSimple<T extends object>({
|
|||||||
height={height}
|
height={height}
|
||||||
onRowSelectionChange={onRowSelectionChange}
|
onRowSelectionChange={onRowSelectionChange}
|
||||||
enableColumnVisibility={enableColumnVisibility}
|
enableColumnVisibility={enableColumnVisibility}
|
||||||
|
expandedContent={expandedContent}
|
||||||
|
getRowCanExpand={getRowCanExpand}
|
||||||
columnVisibility={columnVisibility}
|
columnVisibility={columnVisibility}
|
||||||
onColumnVisibilityChange={onColumnVisibilityChange}
|
onColumnVisibilityChange={onColumnVisibilityChange}
|
||||||
columnVisibilityTrigger={columnVisibilityTrigger}
|
columnVisibilityTrigger={columnVisibilityTrigger}
|
||||||
@@ -278,19 +311,45 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
onRowClick,
|
onRowClick,
|
||||||
virtualization = false,
|
virtualization = false,
|
||||||
height = 480,
|
height = 480,
|
||||||
|
horizontalScroll = false,
|
||||||
|
expandedContent,
|
||||||
|
getRowCanExpand,
|
||||||
}: ResourcePageProps<T>) {
|
}: ResourcePageProps<T>) {
|
||||||
const headerActions = primaryAction ?? actions
|
const headerActions = primaryAction ?? actions
|
||||||
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
|
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
|
||||||
const activeTab = controlledTab ?? internalTab
|
const activeTab = controlledTab ?? internalTab
|
||||||
const showFilters = filterFields.length > 0
|
const showFilters = filterFields.length > 0
|
||||||
|
const primaryTextField = useMemo(() => getPrimaryTextField(filterFields), [filterFields])
|
||||||
|
const extraFilterFields = useMemo(
|
||||||
|
() => getExtraFilterFields(filterFields, primaryTextField?.id),
|
||||||
|
[filterFields, primaryTextField],
|
||||||
|
)
|
||||||
|
const showSearch = Boolean(primaryTextField)
|
||||||
|
const showFiltersPopover = extraFilterFields.length > 0
|
||||||
|
|
||||||
const [internalQuery, setInternalQuery] = useState<FilterQuery>(createEmptyFilterQuery)
|
const [internalQuery, setInternalQuery] = useState<FilterQuery>(createEmptyFilterQuery)
|
||||||
const isQueryControlled = controlledQuery !== undefined
|
const isQueryControlled = controlledQuery !== undefined
|
||||||
const filterQuery = isQueryControlled ? controlledQuery : internalQuery
|
const filterQuery = isQueryControlled ? controlledQuery : internalQuery
|
||||||
const setFilterQuery = isQueryControlled ? onFilterQueryChange : setInternalQuery
|
const setFilterQuery = isQueryControlled ? onFilterQueryChange : setInternalQuery
|
||||||
|
const searchText = primaryTextField
|
||||||
|
? getFilterTextValue(filterQuery, primaryTextField.id)
|
||||||
|
: ''
|
||||||
|
const extraQuery = useMemo(
|
||||||
|
() =>
|
||||||
|
primaryTextField
|
||||||
|
? stripFieldFromQuery(filterQuery, primaryTextField.id)
|
||||||
|
: filterQuery,
|
||||||
|
[filterQuery, primaryTextField],
|
||||||
|
)
|
||||||
|
const extraActiveCount = useMemo(
|
||||||
|
() => getActiveFilters(flattenFilterConditions(extraQuery)).length,
|
||||||
|
[extraQuery],
|
||||||
|
)
|
||||||
|
const hasDirtyFilters = searchText.trim() !== '' || extraActiveCount > 0
|
||||||
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([])
|
const [sorting, setSorting] = useState<SortingState>([])
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
const [expanded, setExpanded] = useState<ExpandedState>({})
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -328,12 +387,34 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
|
|
||||||
const selectedCount = selectedIds.length
|
const selectedCount = selectedIds.length
|
||||||
|
|
||||||
const lastColId = pinLastColumn ? (columns[columns.length - 1]?.id ?? '') : ''
|
// Колонка раскрытия — тот же приём, что во FrameDataGrid: рендерер
|
||||||
const enablePinning = Boolean(pinLastColumn && lastColId)
|
// подстроки живёт в meta колонки, DataGridTable читает его оттуда.
|
||||||
const columnPinning = {
|
const tableColumns = useMemo(
|
||||||
start: [] as string[],
|
() =>
|
||||||
end: enablePinning ? [lastColId] : [],
|
applyKitActionColumn(
|
||||||
}
|
expandedContent
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'expand',
|
||||||
|
header: () => null,
|
||||||
|
cell: ({ row }) => <DataGridTableRowExpand row={row} />,
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
size: 40,
|
||||||
|
meta: { cellClassName: 'w-10', expandedContent },
|
||||||
|
},
|
||||||
|
...columns,
|
||||||
|
]
|
||||||
|
: columns,
|
||||||
|
{ pinLastColumn },
|
||||||
|
),
|
||||||
|
[columns, pinLastColumn, expandedContent],
|
||||||
|
)
|
||||||
|
const { enablePinning, columnPinning } = kitColumnPinning({
|
||||||
|
pinLastColumn,
|
||||||
|
horizontalScroll,
|
||||||
|
lastColId: tableColumns[tableColumns.length - 1]?.id ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
const clearSelection = useCallback(() => {
|
const clearSelection = useCallback(() => {
|
||||||
setRowSelection({})
|
setRowSelection({})
|
||||||
@@ -342,12 +423,13 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
const table = useTable({
|
const table = useTable({
|
||||||
features: dataGridFeatures,
|
features: dataGridFeatures,
|
||||||
data: filteredData,
|
data: filteredData,
|
||||||
columns,
|
columns: tableColumns,
|
||||||
getRowId: (row, index) => getRowId(row, index),
|
getRowId: (row, index) => getRowId(row, index),
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
rowSelection,
|
rowSelection,
|
||||||
pagination,
|
pagination,
|
||||||
|
expanded,
|
||||||
...(enablePinning ? { columnPinning } : {}),
|
...(enablePinning ? { columnPinning } : {}),
|
||||||
},
|
},
|
||||||
initialState: enablePinning ? { columnPinning } : undefined,
|
initialState: enablePinning ? { columnPinning } : undefined,
|
||||||
@@ -355,6 +437,10 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
|
onExpandedChange: setExpanded,
|
||||||
|
getRowCanExpand: expandedContent
|
||||||
|
? (row) => (getRowCanExpand ? getRowCanExpand(row.original) : true)
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleTabChange = useCallback(
|
const handleTabChange = useCallback(
|
||||||
@@ -366,19 +452,38 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
[onTabChange, resetPagination],
|
[onTabChange, resetPagination],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleFiltersChange = useCallback(
|
const handleSearchChange = useCallback(
|
||||||
(next: FilterQuery) => {
|
(value: string) => {
|
||||||
setFilterQuery(next)
|
if (!primaryTextField) return
|
||||||
|
setFilterQuery(setFilterTextValue(filterQuery, primaryTextField.id, value))
|
||||||
resetPagination()
|
resetPagination()
|
||||||
},
|
},
|
||||||
[setFilterQuery, resetPagination],
|
[filterQuery, primaryTextField, setFilterQuery, resetPagination],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleFiltersChange = useCallback(
|
||||||
|
(next: FilterQuery) => {
|
||||||
|
setFilterQuery(
|
||||||
|
primaryTextField
|
||||||
|
? mergeFieldRules(next, filterQuery, primaryTextField.id)
|
||||||
|
: next,
|
||||||
|
)
|
||||||
|
resetPagination()
|
||||||
|
},
|
||||||
|
[filterQuery, primaryTextField, setFilterQuery, resetPagination],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleClear = useCallback(() => {
|
const handleClear = useCallback(() => {
|
||||||
onClearFilters?.()
|
onClearFilters?.()
|
||||||
if (!isQueryControlled) setInternalQuery(createEmptyFilterQuery())
|
if (!isQueryControlled) {
|
||||||
|
setInternalQuery(
|
||||||
|
primaryTextField
|
||||||
|
? createTextFilterQuery(primaryTextField.id)
|
||||||
|
: createEmptyFilterQuery(),
|
||||||
|
)
|
||||||
|
}
|
||||||
resetPagination()
|
resetPagination()
|
||||||
}, [onClearFilters, isQueryControlled, resetPagination])
|
}, [onClearFilters, isQueryControlled, primaryTextField, resetPagination])
|
||||||
|
|
||||||
const countedTabs = useMemo(
|
const countedTabs = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -386,6 +491,8 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
id: tab.id,
|
id: tab.id,
|
||||||
label: tab.label,
|
label: tab.label,
|
||||||
count: tabCounts[tab.id] ?? tab.count ?? 0,
|
count: tabCounts[tab.id] ?? tab.count ?? 0,
|
||||||
|
icon: tab.icon,
|
||||||
|
tone: tab.tone,
|
||||||
})),
|
})),
|
||||||
[tabs, tabCounts],
|
[tabs, tabCounts],
|
||||||
)
|
)
|
||||||
@@ -423,6 +530,7 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
recordCount={filteredData.length}
|
recordCount={filteredData.length}
|
||||||
emptyMessage="Нет записей по выбранным фильтрам."
|
emptyMessage="Нет записей по выбранным фильтрам."
|
||||||
onRowClick={onRowClick}
|
onRowClick={onRowClick}
|
||||||
|
i18n={DATA_GRID_I18N_RU}
|
||||||
tableLayout={kitDataGridTableLayout({
|
tableLayout={kitDataGridTableLayout({
|
||||||
dense: true,
|
dense: true,
|
||||||
width: 'fixed',
|
width: 'fixed',
|
||||||
@@ -459,25 +567,28 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<FramePanel className="p-0 shadow-none!">
|
<FramePanel className="p-0 shadow-none!">
|
||||||
{countedTabs.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
|
|
||||||
<CountedLineTabs
|
|
||||||
tabs={countedTabs}
|
|
||||||
value={activeTab}
|
|
||||||
onValueChange={handleTabChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-2.5">
|
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-2.5">
|
||||||
{showFilters ? (
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||||
|
{showSearch && primaryTextField ? (
|
||||||
|
<InputGroup className="h-8 w-full min-w-[12rem] max-w-sm">
|
||||||
|
<InputGroupAddon>
|
||||||
|
<SearchIcon aria-hidden="true" />
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
|
value={searchText}
|
||||||
|
onChange={(event) => handleSearchChange(event.target.value)}
|
||||||
|
placeholder={primaryTextField.placeholder ?? 'Поиск…'}
|
||||||
|
aria-label={primaryTextField.label || 'Поиск'}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
) : null}
|
||||||
|
{showFiltersPopover ? (
|
||||||
<Filters
|
<Filters
|
||||||
query={filterQuery}
|
query={extraQuery}
|
||||||
fields={filterFields}
|
fields={extraFilterFields}
|
||||||
onQueryChange={handleFiltersChange}
|
onQueryChange={handleFiltersChange}
|
||||||
|
variant="advanced"
|
||||||
|
advancedMode="popover"
|
||||||
size="default"
|
size="default"
|
||||||
labels={FILTERS_LABELS_RU}
|
labels={FILTERS_LABELS_RU}
|
||||||
operatorLabels={FILTERS_OPERATOR_LABELS_RU}
|
operatorLabels={FILTERS_OPERATOR_LABELS_RU}
|
||||||
@@ -485,20 +596,35 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||||
<FilterIcon className="size-4" aria-hidden="true" />
|
<FilterIcon className="size-4" aria-hidden="true" />
|
||||||
Фильтры
|
Фильтры
|
||||||
|
{extraActiveCount > 0 ? (
|
||||||
|
<Badge size="xs" variant="secondary" radius="full">
|
||||||
|
{extraActiveCount}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : null}
|
||||||
|
{!showSearch && !showFiltersPopover && countedTabs.length === 0 ? (
|
||||||
<div />
|
<div />
|
||||||
)}
|
) : null}
|
||||||
|
</div>
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
{countedTabs.length > 0 ? (
|
||||||
|
<StatusToggleGroup
|
||||||
|
items={countedTabs}
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={handleTabChange}
|
||||||
|
aria-label="Фильтр по статусу"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{toolbarExtra}
|
{toolbarExtra}
|
||||||
{selectedCount > 0 ? (
|
{selectedCount > 0 ? (
|
||||||
<Badge size="sm" variant="secondary">
|
<Badge size="sm" variant="secondary">
|
||||||
{selectedCount} выбрано
|
{selectedCount} выбрано
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
{showFilters ? (
|
{hasDirtyFilters ? (
|
||||||
<Button type="button" variant="outline" onClick={handleClear}>
|
<Button type="button" variant="outline" onClick={handleClear}>
|
||||||
<FilterXIcon className="size-4" aria-hidden="true" />
|
<FilterXIcon className="size-4" aria-hidden="true" />
|
||||||
Сбросить
|
Сбросить
|
||||||
@@ -520,7 +646,7 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<FrameFooter>
|
<FrameFooter>
|
||||||
<DataGridPagination {...DATA_GRID_PAGINATION_RU} sizes={[5, 10, 20, 50]} />
|
<DataGridPagination sizes={[5, 10, 20, 50]} />
|
||||||
</FrameFooter>
|
</FrameFooter>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import {
|
import {
|
||||||
useCascaderActions,
|
useCascaderActions,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { CascaderColumnPanel } from "@/components/reui/cascader/cascader-columns"
|
import { CascaderColumnPanel } from "@/components/reui/cascader/cascader-columns"
|
||||||
import {
|
import {
|
||||||
@@ -81,7 +79,8 @@ export function useCascaderVirtualizer({
|
|||||||
|
|
||||||
const measureEstimate = React.useCallback(() => estimateSize, [estimateSize])
|
const measureEstimate = React.useCallback(() => estimateSize, [estimateSize])
|
||||||
|
|
||||||
// React Compiler bails on `useVirtualizer`; harmless, rows memoise one by one.
|
// React Compiler bails on `useVirtualizer` HERE, and only here - the bail does not
|
||||||
|
// propagate to the components that call this hook. They opt out themselves.
|
||||||
const virtualizer = useVirtualizer<HTMLElement, HTMLElement>({
|
const virtualizer = useVirtualizer<HTMLElement, HTMLElement>({
|
||||||
count,
|
count,
|
||||||
getScrollElement,
|
getScrollElement,
|
||||||
@@ -225,6 +224,11 @@ function CascaderVirtualRows({
|
|||||||
estimateSize,
|
estimateSize,
|
||||||
overscan,
|
overscan,
|
||||||
}: CascaderVirtualItemsProps) {
|
}: CascaderVirtualItemsProps) {
|
||||||
|
/* The `useCascaderVirtualizer` bail does NOT reach here: the compiler caches
|
||||||
|
`getVirtualItems()` on the virtualizer, whose identity never changes, so the
|
||||||
|
window freezes on scroll. Inert where no compiler runs. */
|
||||||
|
"use no memo"
|
||||||
|
|
||||||
const {
|
const {
|
||||||
estimateRowSize,
|
estimateRowSize,
|
||||||
overscan: rootOverscan,
|
overscan: rootOverscan,
|
||||||
@@ -375,6 +379,11 @@ function CascaderVirtualColumnRows({
|
|||||||
overscan,
|
overscan,
|
||||||
activeIndex,
|
activeIndex,
|
||||||
}: CascaderVirtualColumnProps & { activeIndex: number }) {
|
}: CascaderVirtualColumnProps & { activeIndex: number }) {
|
||||||
|
/* The `useCascaderVirtualizer` bail does NOT reach here: the compiler caches
|
||||||
|
`getVirtualItems()` on the virtualizer, whose identity never changes, so the
|
||||||
|
window freezes on scroll. Inert where no compiler runs. */
|
||||||
|
"use no memo"
|
||||||
|
|
||||||
const {
|
const {
|
||||||
estimateRowSize,
|
estimateRowSize,
|
||||||
overscan: rootOverscan,
|
overscan: rootOverscan,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import {
|
import {
|
||||||
useCascaderLoader,
|
useCascaderLoader,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { Badge } from "@/components/reui/badge"
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||||
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||||
import type { Column } from "@tanstack/react-table"
|
import type { Column } from "@tanstack/react-table"
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ function DataGridColumnFilter<TData extends object, TValue>({
|
|||||||
title,
|
title,
|
||||||
options,
|
options,
|
||||||
}: DataGridColumnFilterProps<TData, TValue>) {
|
}: DataGridColumnFilterProps<TData, TValue>) {
|
||||||
|
const { i18n } = useDataGrid()
|
||||||
const facets = column?.getFacetedUniqueValues()
|
const facets = column?.getFacetedUniqueValues()
|
||||||
const filterValue = column?.getFilterValue()
|
const filterValue = column?.getFilterValue()
|
||||||
const selectedValues = new Set(
|
const selectedValues = new Set(
|
||||||
@@ -64,7 +66,7 @@ function DataGridColumnFilter<TData extends object, TValue>({
|
|||||||
<div className="hidden space-x-1 lg:flex">
|
<div className="hidden space-x-1 lg:flex">
|
||||||
{selectedValues.size > 2 ? (
|
{selectedValues.size > 2 ? (
|
||||||
<Badge variant="secondary" className="px-1 font-normal">
|
<Badge variant="secondary" className="px-1 font-normal">
|
||||||
{selectedValues.size} selected
|
{i18n.labels.filterSelectedCount(selectedValues.size)}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
options
|
options
|
||||||
@@ -97,7 +99,7 @@ function DataGridColumnFilter<TData extends object, TValue>({
|
|||||||
<div className="max-h-[300px] overflow-y-auto">
|
<div className="max-h-[300px] overflow-y-auto">
|
||||||
{filteredOptions.length === 0 ? (
|
{filteredOptions.length === 0 ? (
|
||||||
<div className="text-muted-foreground py-6 text-center text-sm">
|
<div className="text-muted-foreground py-6 text-center text-sm">
|
||||||
No results found.
|
{i18n.labels.filterNoResults}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-1">
|
<div className="p-1">
|
||||||
@@ -173,7 +175,7 @@ function DataGridColumnFilter<TData extends object, TValue>({
|
|||||||
}}
|
}}
|
||||||
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
|
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
|
||||||
>
|
>
|
||||||
Clear filters
|
{i18n.labels.filterClear}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -47,16 +47,22 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
|||||||
filter,
|
filter,
|
||||||
visibility = false,
|
visibility = false,
|
||||||
}: DataGridColumnHeaderProps<TData, TValue>) {
|
}: DataGridColumnHeaderProps<TData, TValue>) {
|
||||||
const { isLoading, table, props } = useDataGrid()
|
const { i18n, isLoading, table, props } = useDataGrid()
|
||||||
const resolvedTitle = title ?? getColumnHeaderLabel(column)
|
const resolvedTitle = title ?? getColumnHeaderLabel(column)
|
||||||
|
|
||||||
// TanStack's columnOrder defaults to [] until a consumer seeds it; fall
|
// The order a move rewrites: the consumer's columnOrder (TanStack defaults it
|
||||||
// back to the definition order so Move Left/Right work out of the box.
|
// to []), then every leaf it leaves out, in definition order - the same
|
||||||
|
// completion TanStack applies when rendering, so a rendered neighbour is
|
||||||
|
// always present to re-seat beside, even after columns are added later.
|
||||||
const columnOrderState = table.state.columnOrder
|
const columnOrderState = table.state.columnOrder
|
||||||
const columnOrder =
|
const definitionOrder = table
|
||||||
columnOrderState.length > 0
|
.getAllColumns()
|
||||||
? columnOrderState
|
.flatMap((topColumn) => topColumn.getLeafColumns())
|
||||||
: table.getAllLeafColumns().map((leafColumn) => leafColumn.id)
|
.map((leafColumn) => leafColumn.id)
|
||||||
|
const columnOrder = [
|
||||||
|
...columnOrderState,
|
||||||
|
...definitionOrder.filter((id) => !columnOrderState.includes(id)),
|
||||||
|
]
|
||||||
const columnVisibilityKey =
|
const columnVisibilityKey =
|
||||||
props.tableLayout?.columnsVisibility && visibility
|
props.tableLayout?.columnsVisibility && visibility
|
||||||
? JSON.stringify(table.state.columnVisibility)
|
? JSON.stringify(table.state.columnVisibility)
|
||||||
@@ -67,9 +73,46 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
|||||||
const canPin = column.getCanPin()
|
const canPin = column.getCanPin()
|
||||||
const canResize = column.getCanResize()
|
const canResize = column.getCanResize()
|
||||||
|
|
||||||
const columnIndex = columnOrder.indexOf(column.id)
|
// Move neighbours come from what is RENDERED: the column's own pin bucket,
|
||||||
const canMoveLeft = columnIndex > 0
|
// visible columns only. Stepping through the raw columnOrder would trade
|
||||||
const canMoveRight = columnIndex < columnOrder.length - 1
|
// places with a hidden or pinned column - an enabled click that moves nothing.
|
||||||
|
// With grouping in TanStack's default "reorder" mode, grouped columns render
|
||||||
|
// first whatever columnOrder says: they neither move nor serve as a target.
|
||||||
|
const groupedColumnMode = (
|
||||||
|
table.options as { groupedColumnMode?: false | "reorder" | "remove" }
|
||||||
|
).groupedColumnMode
|
||||||
|
const isHoistedByGrouping = (target: object) =>
|
||||||
|
groupedColumnMode !== false &&
|
||||||
|
typeof (target as { getIsGrouped?: unknown }).getIsGrouped === "function" &&
|
||||||
|
(target as { getIsGrouped: () => boolean }).getIsGrouped()
|
||||||
|
const renderedPeers = (
|
||||||
|
isPinned === "start"
|
||||||
|
? table.getStartVisibleLeafColumns()
|
||||||
|
: isPinned === "end"
|
||||||
|
? table.getEndVisibleLeafColumns()
|
||||||
|
: table.getCenterVisibleLeafColumns()
|
||||||
|
)
|
||||||
|
.filter((leafColumn) => !isHoistedByGrouping(leafColumn))
|
||||||
|
.map((leafColumn) => leafColumn.id)
|
||||||
|
const renderedIndex = renderedPeers.indexOf(column.id)
|
||||||
|
const leftNeighbour =
|
||||||
|
renderedIndex > 0 ? renderedPeers[renderedIndex - 1] : undefined
|
||||||
|
const rightNeighbour =
|
||||||
|
renderedIndex !== -1 && renderedIndex < renderedPeers.length - 1
|
||||||
|
? renderedPeers[renderedIndex + 1]
|
||||||
|
: undefined
|
||||||
|
const canMoveLeft = leftNeighbour !== undefined
|
||||||
|
const canMoveRight = rightNeighbour !== undefined
|
||||||
|
|
||||||
|
/** Re-seats this column beside a rendered neighbour; every other column,
|
||||||
|
* hidden or pinned ones included, keeps its place in the full order. */
|
||||||
|
const moveBeside = (neighbourId: string, side: "before" | "after") => {
|
||||||
|
const newOrder = columnOrder.filter((id) => id !== column.id)
|
||||||
|
const at = newOrder.indexOf(neighbourId)
|
||||||
|
if (at === -1) return
|
||||||
|
newOrder.splice(side === "before" ? at : at + 1, 0, column.id)
|
||||||
|
table.setColumnOrder(newOrder)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSort = () => {
|
const handleSort = () => {
|
||||||
if (isSorted === "asc") {
|
if (isSorted === "asc") {
|
||||||
@@ -139,7 +182,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
|||||||
disabled={!canSort}
|
disabled={!canSort}
|
||||||
>
|
>
|
||||||
<ArrowUpIcon className="size-3.5!" />
|
<ArrowUpIcon className="size-3.5!" />
|
||||||
<span className="grow">Asc</span>
|
<span className="grow">{i18n.labels.sortAscending}</span>
|
||||||
{isSorted === "asc" && (
|
{isSorted === "asc" && (
|
||||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||||
)}
|
)}
|
||||||
@@ -156,7 +199,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
|||||||
disabled={!canSort}
|
disabled={!canSort}
|
||||||
>
|
>
|
||||||
<ArrowDownIcon className="size-3.5!" />
|
<ArrowDownIcon className="size-3.5!" />
|
||||||
<span className="grow">Desc</span>
|
<span className="grow">{i18n.labels.sortDescending}</span>
|
||||||
{isSorted === "desc" && (
|
{isSorted === "desc" && (
|
||||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||||
)}
|
)}
|
||||||
@@ -176,7 +219,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
|||||||
onClick={() => column.pin(isPinned === "start" ? false : "start")}
|
onClick={() => column.pin(isPinned === "start" ? false : "start")}
|
||||||
>
|
>
|
||||||
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
|
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||||
<span className="grow">Pin to left</span>
|
<span className="grow">{i18n.labels.pinColumnStart}</span>
|
||||||
{isPinned === "start" && (
|
{isPinned === "start" && (
|
||||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||||
)}
|
)}
|
||||||
@@ -186,7 +229,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
|||||||
onClick={() => column.pin(isPinned === "end" ? false : "end")}
|
onClick={() => column.pin(isPinned === "end" ? false : "end")}
|
||||||
>
|
>
|
||||||
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
|
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||||
<span className="grow">Pin to right</span>
|
<span className="grow">{i18n.labels.pinColumnEnd}</span>
|
||||||
{isPinned === "end" && (
|
{isPinned === "end" && (
|
||||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||||
)}
|
)}
|
||||||
@@ -204,32 +247,22 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key="move-left"
|
key="move-left"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (columnIndex > 0) {
|
if (leftNeighbour) moveBeside(leftNeighbour, "before")
|
||||||
const newOrder = [...columnOrder]
|
|
||||||
const [movedColumn] = newOrder.splice(columnIndex, 1)
|
|
||||||
newOrder.splice(columnIndex - 1, 0, movedColumn)
|
|
||||||
table.setColumnOrder(newOrder)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
disabled={!canMoveLeft || isPinned !== false}
|
disabled={!canMoveLeft || isPinned !== false}
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
|
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
|
||||||
<span>Move to Left</span>
|
<span>{i18n.labels.moveColumnStart}</span>
|
||||||
</DropdownMenuItem>,
|
</DropdownMenuItem>,
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key="move-right"
|
key="move-right"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (columnIndex < columnOrder.length - 1) {
|
if (rightNeighbour) moveBeside(rightNeighbour, "after")
|
||||||
const newOrder = [...columnOrder]
|
|
||||||
const [movedColumn] = newOrder.splice(columnIndex, 1)
|
|
||||||
newOrder.splice(columnIndex + 1, 0, movedColumn)
|
|
||||||
table.setColumnOrder(newOrder)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
disabled={!canMoveRight || isPinned !== false}
|
disabled={!canMoveRight || isPinned !== false}
|
||||||
>
|
>
|
||||||
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
|
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
|
||||||
<span>Move to Right</span>
|
<span>{i18n.labels.moveColumnEnd}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)
|
)
|
||||||
hasPreviousSection = true
|
hasPreviousSection = true
|
||||||
@@ -244,7 +277,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
|||||||
<DropdownMenuSub key="visibility">
|
<DropdownMenuSub key="visibility">
|
||||||
<DropdownMenuSubTrigger>
|
<DropdownMenuSubTrigger>
|
||||||
<Settings2Icon className="size-3.5!" />
|
<Settings2Icon className="size-3.5!" />
|
||||||
<span>Columns</span>
|
<span>{i18n.labels.columnsMenu}</span>
|
||||||
</DropdownMenuSubTrigger>
|
</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuSubContent side="right">
|
<DropdownMenuSubContent side="right">
|
||||||
{table
|
{table
|
||||||
@@ -282,7 +315,8 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
|||||||
canMoveRight,
|
canMoveRight,
|
||||||
visibility,
|
visibility,
|
||||||
table,
|
table,
|
||||||
columnIndex,
|
leftNeighbour,
|
||||||
|
rightNeighbour,
|
||||||
columnOrder,
|
columnOrder,
|
||||||
columnVisibilityKey, // Needed to update checkbox states when visibility changes
|
columnVisibilityKey, // Needed to update checkbox states when visibility changes
|
||||||
])
|
])
|
||||||
@@ -314,8 +348,8 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="rounded-lg -me-1 size-7"
|
className="rounded-lg -me-1 size-7"
|
||||||
onClick={() => column.pin(false)}
|
onClick={() => column.pin(false)}
|
||||||
aria-label={`Unpin ${resolvedTitle} column`}
|
aria-label={i18n.labels.unpinColumn(resolvedTitle)}
|
||||||
title={`Unpin ${resolvedTitle} column`}
|
title={i18n.labels.unpinColumn(resolvedTitle)}
|
||||||
>
|
>
|
||||||
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
|
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type { ReactElement } from "react"
|
import type { ReactElement } from "react"
|
||||||
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
|
import {
|
||||||
|
getColumnHeaderLabel,
|
||||||
|
useDataGrid,
|
||||||
|
} from "@/components/reui/data-grid/data-grid"
|
||||||
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||||
import type { Table } from "@tanstack/react-table"
|
import type { Table } from "@tanstack/react-table"
|
||||||
|
|
||||||
@@ -21,13 +24,15 @@ function DataGridColumnVisibility<TData extends object>({
|
|||||||
table: Table<DataGridFeatures, TData>
|
table: Table<DataGridFeatures, TData>
|
||||||
trigger: ReactElement<Record<string, unknown>>
|
trigger: ReactElement<Record<string, unknown>>
|
||||||
}) {
|
}) {
|
||||||
|
const { i18n } = useDataGrid()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger render={trigger} />
|
<DropdownMenuTrigger render={trigger} />
|
||||||
<DropdownMenuContent align="end" className="min-w-[150px]">
|
<DropdownMenuContent align="end" className="min-w-[150px]">
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuLabel className="font-medium">
|
<DropdownMenuLabel className="font-medium">
|
||||||
Toggle Columns
|
{i18n.labels.toggleColumns}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
{table
|
{table
|
||||||
.getAllColumns()
|
.getAllColumns()
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
export interface DataGridI18nLabels {
|
||||||
|
/* The column header menu. */
|
||||||
|
sortAscending: string
|
||||||
|
sortDescending: string
|
||||||
|
pinColumnStart: string
|
||||||
|
pinColumnEnd: string
|
||||||
|
moveColumnStart: string
|
||||||
|
moveColumnEnd: string
|
||||||
|
columnsMenu: string
|
||||||
|
unpinColumn: (title: string) => string
|
||||||
|
toggleColumns: string
|
||||||
|
/* Row and cell affordances. */
|
||||||
|
rowCreate: string
|
||||||
|
pinRow: string
|
||||||
|
unpinRow: string
|
||||||
|
selectRow: string
|
||||||
|
selectAll: string
|
||||||
|
expandRow: string
|
||||||
|
collapseRow: string
|
||||||
|
dragToReorder: string
|
||||||
|
dragToReorderRow: string
|
||||||
|
reorderingUnavailable: string
|
||||||
|
/* Grid states. */
|
||||||
|
loading: string
|
||||||
|
empty: string
|
||||||
|
allRowsLoaded: string
|
||||||
|
/* Pagination. */
|
||||||
|
rowsPerPage: string
|
||||||
|
paginationInfo: (info: { from: number; to: number; count: number }) => string
|
||||||
|
previousPage: string
|
||||||
|
nextPage: string
|
||||||
|
goToPage: (page: number) => string
|
||||||
|
paginationEllipsis: string
|
||||||
|
/* The faceted column filter. */
|
||||||
|
filterSelectedCount: (count: number) => string
|
||||||
|
filterNoResults: string
|
||||||
|
filterClear: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataGridI18nConfig {
|
||||||
|
labels: DataGridI18nLabels
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DataGridI18nOverrides = {
|
||||||
|
labels?: Partial<DataGridI18nLabels>
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_DATA_GRID_LABELS: DataGridI18nLabels = {
|
||||||
|
sortAscending: "Asc",
|
||||||
|
sortDescending: "Desc",
|
||||||
|
pinColumnStart: "Pin to left",
|
||||||
|
pinColumnEnd: "Pin to right",
|
||||||
|
moveColumnStart: "Move to left",
|
||||||
|
moveColumnEnd: "Move to right",
|
||||||
|
columnsMenu: "Columns",
|
||||||
|
unpinColumn: (title) => `Unpin ${title} column`,
|
||||||
|
toggleColumns: "Toggle Columns",
|
||||||
|
rowCreate: "Add row",
|
||||||
|
pinRow: "Pin row",
|
||||||
|
unpinRow: "Unpin row",
|
||||||
|
selectRow: "Select row",
|
||||||
|
selectAll: "Select all",
|
||||||
|
expandRow: "Expand row",
|
||||||
|
collapseRow: "Collapse row",
|
||||||
|
dragToReorder: "Drag to reorder",
|
||||||
|
dragToReorderRow: "Drag to reorder row",
|
||||||
|
reorderingUnavailable: "Reordering unavailable",
|
||||||
|
loading: "Loading...",
|
||||||
|
empty: "No data available",
|
||||||
|
allRowsLoaded: "All records loaded",
|
||||||
|
rowsPerPage: "Rows per page",
|
||||||
|
paginationInfo: ({ from, to, count }) => `${from} - ${to} of ${count}`,
|
||||||
|
previousPage: "Go to previous page",
|
||||||
|
nextPage: "Go to next page",
|
||||||
|
goToPage: (page) => `Go to page ${page}`,
|
||||||
|
paginationEllipsis: "...",
|
||||||
|
filterSelectedCount: (count) => `${count} selected`,
|
||||||
|
filterNoResults: "No results found.",
|
||||||
|
filterClear: "Clear filters",
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_DATA_GRID_I18N: DataGridI18nConfig = Object.freeze({
|
||||||
|
labels: Object.freeze(DEFAULT_DATA_GRID_LABELS),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A shallow merge per section, deliberately: a deep merge would leak a
|
||||||
|
* default back into a function-valued label the consumer replaced. With no
|
||||||
|
* overrides the frozen default is returned as-is, so the merge is free to
|
||||||
|
* run on every render without producing a new identity.
|
||||||
|
*/
|
||||||
|
export function mergeDataGridI18n(
|
||||||
|
overrides?: DataGridI18nOverrides
|
||||||
|
): DataGridI18nConfig {
|
||||||
|
if (!overrides?.labels) return DEFAULT_DATA_GRID_I18N
|
||||||
|
return {
|
||||||
|
labels: { ...DEFAULT_DATA_GRID_LABELS, ...overrides.labels },
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ interface DataGridPaginationProps {
|
|||||||
sizesDescription?: string
|
sizesDescription?: string
|
||||||
sizesSkeleton?: ReactNode
|
sizesSkeleton?: ReactNode
|
||||||
more?: boolean
|
more?: boolean
|
||||||
|
/** Page buttons the truncated middle shows; the row adds first, last and
|
||||||
|
* an ellipsis for each hidden stretch. Clamped to a minimum of 3. */
|
||||||
moreLimit?: number
|
moreLimit?: number
|
||||||
info?: string
|
info?: string
|
||||||
infoSkeleton?: ReactNode
|
infoSkeleton?: ReactNode
|
||||||
@@ -30,19 +32,113 @@ interface DataGridPaginationProps {
|
|||||||
ellipsisText?: string
|
ellipsisText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One slot in the page row: a page button, or an ellipsis that jumps over the
|
||||||
|
* pages it stands for.
|
||||||
|
*/
|
||||||
|
type DataGridPaginationItem =
|
||||||
|
| { type: "page"; index: number }
|
||||||
|
| { type: "ellipsis"; direction: "previous"; target: number }
|
||||||
|
| { type: "ellipsis"; direction: "next"; target: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The adaptive window: first page, the run around the current one, last page,
|
||||||
|
* with an ellipsis standing in for each hidden stretch. It replaced fixed
|
||||||
|
* BLOCKS (1-5, then 6-10) because a block never shows the last page, so on a
|
||||||
|
* 50-page grid there was no way to reach the end - the primitive wires only
|
||||||
|
* `previousPage()` / `nextPage()`, so there is no last-page arrow either.
|
||||||
|
*
|
||||||
|
* `limit` is `moreLimit`: how many page buttons the truncated middle shows.
|
||||||
|
* At the default of 5 this emits exactly the same rows as the block version
|
||||||
|
* did at `moreLimit: 5`, so the prop keeps both its meaning and its default.
|
||||||
|
* An ellipsis never stands for a single page, because the early return covers
|
||||||
|
* every count a full row could hold.
|
||||||
|
*/
|
||||||
|
function getDataGridPaginationItems(
|
||||||
|
pageIndex: number,
|
||||||
|
pageCount: number,
|
||||||
|
limit: number
|
||||||
|
): DataGridPaginationItem[] {
|
||||||
|
// Pages flanking the current one, per side. The run is rebuilt from it so
|
||||||
|
// the count is always odd: an even `limit` would make the head and tail
|
||||||
|
// rows one slot wider than the middle, and the footer would twitch as the
|
||||||
|
// user pages through.
|
||||||
|
const sibling = Math.max(0, Math.floor(((Math.floor(limit) || 3) - 3) / 2))
|
||||||
|
const pages = sibling * 2 + 3
|
||||||
|
|
||||||
|
// Two ellipses cost the width of two pages, so below this there is nothing
|
||||||
|
// to gain by hiding any.
|
||||||
|
if (pageCount <= pages + 2) {
|
||||||
|
return Array.from({ length: pageCount }, (_, index) => ({
|
||||||
|
type: "page" as const,
|
||||||
|
index,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageIndex <= sibling + 2) {
|
||||||
|
return [
|
||||||
|
...Array.from({ length: pages }, (_, index) => ({
|
||||||
|
type: "page" as const,
|
||||||
|
index,
|
||||||
|
})),
|
||||||
|
{ type: "ellipsis", direction: "next", target: pages },
|
||||||
|
{ type: "page", index: pageCount - 1 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageIndex >= pageCount - sibling - 3) {
|
||||||
|
return [
|
||||||
|
{ type: "page", index: 0 },
|
||||||
|
{
|
||||||
|
type: "ellipsis",
|
||||||
|
direction: "previous",
|
||||||
|
target: pageCount - pages - 1,
|
||||||
|
},
|
||||||
|
...Array.from({ length: pages }, (_, offset) => ({
|
||||||
|
type: "page" as const,
|
||||||
|
index: pageCount - pages + offset,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ type: "page", index: 0 },
|
||||||
|
{
|
||||||
|
type: "ellipsis",
|
||||||
|
direction: "previous",
|
||||||
|
target: pageIndex - sibling - 1,
|
||||||
|
},
|
||||||
|
...Array.from({ length: sibling * 2 + 1 }, (_, offset) => ({
|
||||||
|
type: "page" as const,
|
||||||
|
index: pageIndex - sibling + offset,
|
||||||
|
})),
|
||||||
|
{ type: "ellipsis", direction: "next", target: pageIndex + sibling + 1 },
|
||||||
|
{ type: "page", index: pageCount - 1 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A page button is square at one digit and grows from there, so the row does
|
||||||
|
* not reflow when the count crosses 10 or 100. The floor is each style's own
|
||||||
|
* `icon-sm` square, which is also its `sm` HEIGHT, so the numbers stay square
|
||||||
|
* and keep the arrows' height. Hardcoding one value would be right in three
|
||||||
|
* styles and wrong in five.
|
||||||
|
*/
|
||||||
|
const PAGE_BUTTON_WIDTH_CLASS =
|
||||||
|
"min-w-7"
|
||||||
|
|
||||||
function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
||||||
const { table, recordCount, isLoading } = useDataGrid()
|
const { i18n, table, recordCount, isLoading } = useDataGrid()
|
||||||
|
|
||||||
const defaultProps: Partial<DataGridPaginationProps> = {
|
const defaultProps: Partial<DataGridPaginationProps> = {
|
||||||
sizes: [5, 10, 25, 50, 100],
|
sizes: [5, 10, 25, 50, 100],
|
||||||
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
||||||
moreLimit: 5,
|
moreLimit: 5,
|
||||||
info: "{from} - {to} of {count}",
|
|
||||||
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
||||||
rowsPerPageLabel: "Rows per page",
|
rowsPerPageLabel: i18n.labels.rowsPerPage,
|
||||||
previousPageLabel: "Go to previous page",
|
previousPageLabel: i18n.labels.previousPage,
|
||||||
nextPageLabel: "Go to next page",
|
nextPageLabel: i18n.labels.nextPage,
|
||||||
ellipsisText: "...",
|
ellipsisText: i18n.labels.paginationEllipsis,
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
|
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
|
||||||
@@ -55,84 +151,21 @@ function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
|||||||
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
|
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
|
||||||
const pageCount = table.getPageCount()
|
const pageCount = table.getPageCount()
|
||||||
|
|
||||||
// Replace placeholders in paginationInfo
|
// A supplied `info` keeps its placeholder-template contract; the default
|
||||||
|
// routes through the i18n label function, where word order is free.
|
||||||
const paginationInfo = mergedProps.info
|
const paginationInfo = mergedProps.info
|
||||||
? mergedProps.info
|
? mergedProps.info
|
||||||
.replaceAll("{from}", from.toString())
|
.replaceAll("{from}", from.toString())
|
||||||
.replaceAll("{to}", to.toString())
|
.replaceAll("{to}", to.toString())
|
||||||
.replaceAll("{count}", recordCount.toString())
|
.replaceAll("{count}", recordCount.toString())
|
||||||
: `${from} - ${to} of ${recordCount}`
|
: i18n.labels.paginationInfo({ from, to, count: recordCount })
|
||||||
|
|
||||||
// Pagination limit logic
|
const paginationItems = getDataGridPaginationItems(
|
||||||
const paginationMoreLimit = mergedProps.moreLimit || 5
|
pageIndex,
|
||||||
|
pageCount,
|
||||||
// Determine the start and end of the pagination group
|
mergedProps.moreLimit ?? 5
|
||||||
const currentGroupStart =
|
|
||||||
Math.floor(pageIndex / paginationMoreLimit) * paginationMoreLimit
|
|
||||||
const currentGroupEnd = Math.min(
|
|
||||||
currentGroupStart + paginationMoreLimit,
|
|
||||||
pageCount
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Render page buttons based on the current group
|
|
||||||
const renderPageButtons = () => {
|
|
||||||
const buttons = []
|
|
||||||
for (let i = currentGroupStart; i < currentGroupEnd; i++) {
|
|
||||||
buttons.push(
|
|
||||||
<Button
|
|
||||||
key={i}
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(btnBaseClasses, "text-muted-foreground", {
|
|
||||||
"bg-accent text-accent-foreground": pageIndex === i,
|
|
||||||
})}
|
|
||||||
onClick={() => {
|
|
||||||
if (pageIndex !== i) {
|
|
||||||
table.setPageIndex(i)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i + 1}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return buttons
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render a "previous" ellipsis button if there are previous pages to show
|
|
||||||
const renderEllipsisPrevButton = () => {
|
|
||||||
if (currentGroupStart > 0) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
className={btnBaseClasses}
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => table.setPageIndex(currentGroupStart - 1)}
|
|
||||||
>
|
|
||||||
{mergedProps.ellipsisText}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render a "next" ellipsis button if there are more pages to show after the current group
|
|
||||||
const renderEllipsisNextButton = () => {
|
|
||||||
if (currentGroupEnd < pageCount) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
className={btnBaseClasses}
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
onClick={() => table.setPageIndex(currentGroupEnd)}
|
|
||||||
>
|
|
||||||
{mergedProps.ellipsisText}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="data-grid-pagination"
|
data-slot="data-grid-pagination"
|
||||||
@@ -156,7 +189,15 @@ function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
|||||||
table.setPageSize(newPageSize)
|
table.setPageSize(newPageSize)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-16" size="sm">
|
{/* w-fit with a min, never a fixed width: a fixed w-16 clipped
|
||||||
|
the value "100" by 1px at nova's paddings, while fit-content
|
||||||
|
grows the trigger for 3-digit sizes and the min keeps the
|
||||||
|
1-2 digit ones from collapsing narrower than 64px. */}
|
||||||
|
<SelectTrigger
|
||||||
|
aria-label={mergedProps.rowsPerPageLabel}
|
||||||
|
className="w-fit min-w-16"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent
|
<SelectContent
|
||||||
@@ -183,7 +224,7 @@ function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
|||||||
{paginationInfo}
|
{paginationInfo}
|
||||||
</div>
|
</div>
|
||||||
{pageCount > 1 && (
|
{pageCount > 1 && (
|
||||||
<div className="order-1 flex items-center space-x-1">
|
<div className="order-1 flex flex-wrap items-center justify-center gap-1 sm:flex-nowrap">
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -197,11 +238,49 @@ function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
|||||||
<ChevronLeftIcon className="size-4" />
|
<ChevronLeftIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{renderEllipsisPrevButton()}
|
{paginationItems.map((item) =>
|
||||||
|
item.type === "page" ? (
|
||||||
{renderPageButtons()}
|
<Button
|
||||||
|
key={`page-${item.index}`}
|
||||||
{renderEllipsisNextButton()}
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={i18n.labels.goToPage(item.index + 1)}
|
||||||
|
aria-current={
|
||||||
|
pageIndex === item.index ? "page" : undefined
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
PAGE_BUTTON_WIDTH_CLASS,
|
||||||
|
"px-1.5 text-sm",
|
||||||
|
"text-muted-foreground",
|
||||||
|
{
|
||||||
|
"bg-accent text-accent-foreground":
|
||||||
|
pageIndex === item.index,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (pageIndex !== item.index) {
|
||||||
|
table.setPageIndex(item.index)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.index + 1}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
/* Clickable, unlike the shadcn PaginationEllipsis, which is
|
||||||
|
an aria-hidden span. This one MOVES the user, so it needs
|
||||||
|
a name saying where. */
|
||||||
|
<Button
|
||||||
|
key={`ellipsis-${item.direction}`}
|
||||||
|
size="icon-sm"
|
||||||
|
className={btnBaseClasses}
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={i18n.labels.goToPage(item.target + 1)}
|
||||||
|
onClick={() => table.setPageIndex(item.target)}
|
||||||
|
>
|
||||||
|
{mergedProps.ellipsisText}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ const INITIAL_METRICS = {
|
|||||||
trackHeight: 0,
|
trackHeight: 0,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
// Track footprint, measured: horizontal is 8px tall and vertical 6px wide, and
|
||||||
|
// each loses 1px to its transparent border plus 2px to p-px, so the thumbs land
|
||||||
|
// at 5px and 3px. Shrink these further and the thumb stops being a grab target.
|
||||||
const SCROLLBAR_CLASSNAME =
|
const SCROLLBAR_CLASSNAME =
|
||||||
"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||||
|
|
||||||
const SCROLLBAR_THUMB_CLASSNAME = "bg-border rounded-full relative flex-1"
|
const SCROLLBAR_THUMB_CLASSNAME = "bg-border rounded-full relative flex-1"
|
||||||
|
|
||||||
@@ -454,12 +457,12 @@ function DataGridScrollArea({
|
|||||||
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
|
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="pointer-events-auto relative h-full w-2 touch-none p-px"
|
className="pointer-events-auto relative h-full w-1.5 touch-none p-px"
|
||||||
onPointerDown={handleTrackPointerDown}
|
onPointerDown={handleTrackPointerDown}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-border absolute end-px w-2",
|
"bg-border absolute end-px w-1.5",
|
||||||
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
|
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
|
||||||
"rounded-full"
|
"rounded-full"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
memo,
|
memo,
|
||||||
@@ -105,7 +107,7 @@ type DataGridTableDndRowDecoration<TData extends object> = (context: {
|
|||||||
function DataGridTableDndRowHandle({
|
function DataGridTableDndRowHandle({
|
||||||
className,
|
className,
|
||||||
disabled,
|
disabled,
|
||||||
disabledLabel = "Reordering unavailable",
|
disabledLabel,
|
||||||
}: {
|
}: {
|
||||||
className?: string
|
className?: string
|
||||||
/**
|
/**
|
||||||
@@ -118,7 +120,10 @@ function DataGridTableDndRowHandle({
|
|||||||
/** Announced and shown on hover in place of the drag affordance. */
|
/** Announced and shown on hover in place of the drag affordance. */
|
||||||
disabledLabel?: string
|
disabledLabel?: string
|
||||||
}) {
|
}) {
|
||||||
|
const { i18n } = useDataGrid()
|
||||||
const context = useContext(SortableRowContext)
|
const context = useContext(SortableRowContext)
|
||||||
|
const resolvedDisabledLabel =
|
||||||
|
disabledLabel ?? i18n.labels.reorderingUnavailable
|
||||||
|
|
||||||
if (!context || disabled) {
|
if (!context || disabled) {
|
||||||
return (
|
return (
|
||||||
@@ -133,8 +138,8 @@ function DataGridTableDndRowHandle({
|
|||||||
disabled && "cursor-not-allowed",
|
disabled && "cursor-not-allowed",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
aria-label={disabled ? disabledLabel : "Drag to reorder row"}
|
aria-label={disabled ? resolvedDisabledLabel : i18n.labels.dragToReorderRow}
|
||||||
title={disabled ? disabledLabel : undefined}
|
title={disabled ? resolvedDisabledLabel : undefined}
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
<GripHorizontalIcon aria-hidden="true" />
|
<GripHorizontalIcon aria-hidden="true" />
|
||||||
@@ -150,7 +155,7 @@ function DataGridTableDndRowHandle({
|
|||||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
aria-label="Drag to reorder row"
|
aria-label={i18n.labels.dragToReorderRow}
|
||||||
{...context.attributes}
|
{...context.attributes}
|
||||||
{...context.listeners}
|
{...context.listeners}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Fragment,
|
Fragment,
|
||||||
memo,
|
memo,
|
||||||
@@ -69,7 +67,7 @@ function DataGridTableDndHeader<TData extends object>({
|
|||||||
}: {
|
}: {
|
||||||
header: Header<DataGridFeatures, TData, unknown>
|
header: Header<DataGridFeatures, TData, unknown>
|
||||||
}) {
|
}) {
|
||||||
const { props } = useDataGrid()
|
const { i18n, props } = useDataGrid()
|
||||||
const { column } = header
|
const { column } = header
|
||||||
|
|
||||||
// Check if column ordering is enabled for this column
|
// Check if column ordering is enabled for this column
|
||||||
@@ -115,7 +113,7 @@ function DataGridTableDndHeader<TData extends object>({
|
|||||||
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
aria-label="Drag to reorder"
|
aria-label={i18n.labels.dragToReorder}
|
||||||
>
|
>
|
||||||
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useEffect, useRef, useState } from "react"
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import type { CSSProperties, ReactNode } from "react"
|
import type { CSSProperties, ReactNode } from "react"
|
||||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||||
import type {
|
import type {
|
||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
DataGridTableInstance,
|
DataGridTableInstance,
|
||||||
} from "@/components/reui/data-grid/data-grid"
|
} from "@/components/reui/data-grid/data-grid"
|
||||||
import {
|
import {
|
||||||
|
DataGridTableAddRow,
|
||||||
DataGridTableBase,
|
DataGridTableBase,
|
||||||
DataGridTableBody,
|
DataGridTableBody,
|
||||||
DataGridTableEmpty,
|
DataGridTableEmpty,
|
||||||
@@ -49,6 +50,33 @@ type DataGridTableVirtualizerInstance = Virtualizer<
|
|||||||
|
|
||||||
type DataGridTableVirtualScrollAlignment = "auto" | "center" | "start" | "end"
|
type DataGridTableVirtualScrollAlignment = "auto" | "center" | "start" | "end"
|
||||||
|
|
||||||
|
interface DataGridTableColumnVirtualizerOptions {
|
||||||
|
/** Off unless explicitly true; a column window is opt-in. */
|
||||||
|
enabled?: boolean
|
||||||
|
overscan?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataGridTableColumnScrollRequest = {
|
||||||
|
align: DataGridTableVirtualScrollAlignment
|
||||||
|
behavior: ScrollBehavior
|
||||||
|
columnId: string | undefined
|
||||||
|
columnIndex: number
|
||||||
|
scrollElement: HTMLElement
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameDataGridTableColumnScrollRequest(
|
||||||
|
previous: DataGridTableColumnScrollRequest | null,
|
||||||
|
next: DataGridTableColumnScrollRequest
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
previous?.align === next.align &&
|
||||||
|
previous.behavior === next.behavior &&
|
||||||
|
previous.columnId === next.columnId &&
|
||||||
|
previous.columnIndex === next.columnIndex &&
|
||||||
|
previous.scrollElement === next.scrollElement
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type DataGridTableVirtualScrollRequest = {
|
type DataGridTableVirtualScrollRequest = {
|
||||||
align: DataGridTableVirtualScrollAlignment
|
align: DataGridTableVirtualScrollAlignment
|
||||||
behavior: ScrollBehavior
|
behavior: ScrollBehavior
|
||||||
@@ -267,6 +295,17 @@ interface DataGridTableVirtualProps<TData extends object> {
|
|||||||
scrollToRowAlign?: DataGridTableVirtualScrollAlignment
|
scrollToRowAlign?: DataGridTableVirtualScrollAlignment
|
||||||
/** Index within the center (non-pinned) row section to reveal. */
|
/** Index within the center (non-pinned) row section to reveal. */
|
||||||
scrollToRowIndex?: number
|
scrollToRowIndex?: number
|
||||||
|
/**
|
||||||
|
* Opt-in horizontal virtualization of the CENTER columns. Activates only
|
||||||
|
* under a fixed table layout with a single ungrouped header row; pinned
|
||||||
|
* columns stay mounted, and anything else falls back to full-column
|
||||||
|
* rendering. `scrollBehavior` is shared with controlled row scrolling.
|
||||||
|
*/
|
||||||
|
columnVirtualizerOptions?: DataGridTableColumnVirtualizerOptions
|
||||||
|
/** Alignment used when revealing a controlled target column. */
|
||||||
|
scrollToColumnAlign?: DataGridTableVirtualScrollAlignment
|
||||||
|
/** Index within the center (non-pinned) visible leaf columns to reveal. */
|
||||||
|
scrollToColumnIndex?: number
|
||||||
footerContent?: ReactNode
|
footerContent?: ReactNode
|
||||||
renderHeader?: boolean
|
renderHeader?: boolean
|
||||||
onFetchMore?: () => void
|
onFetchMore?: () => void
|
||||||
@@ -290,6 +329,7 @@ interface VirtualBodyProps<TData extends object> {
|
|||||||
loadingMoreMessage: ReactNode
|
loadingMoreMessage: ReactNode
|
||||||
allRowsLoadedMessage: ReactNode
|
allRowsLoadedMessage: ReactNode
|
||||||
measureRowRef?: (element: HTMLTableRowElement | null) => void
|
measureRowRef?: (element: HTMLTableRowElement | null) => void
|
||||||
|
centerColumnWindow?: { start: number; end: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
function DataGridTableVirtualPinnedPlaceholderCell<TData extends object>({
|
function DataGridTableVirtualPinnedPlaceholderCell<TData extends object>({
|
||||||
@@ -424,6 +464,24 @@ function DataGridTableVirtualStatusRow<TData extends object>({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A scroll frame only shifts the window, so every surviving row's inputs are
|
||||||
|
* identical and its last render is reused; the per-frame cost is the rows
|
||||||
|
* entering the window, not all mounted rows. Cell-level state (selection,
|
||||||
|
* focus, row checks) repaints through each cell's own Subscribe, and any real
|
||||||
|
* data change rebuilds the row wrappers, so identity comparison is safe.
|
||||||
|
*/
|
||||||
|
const MemoizedRenderedRow = memo(
|
||||||
|
DataGridTableRenderedRow,
|
||||||
|
(prev, next) =>
|
||||||
|
prev.row === next.row &&
|
||||||
|
prev.rowIndex === next.rowIndex &&
|
||||||
|
prev.rowRef === next.rowRef &&
|
||||||
|
prev.pinnedBoundary === next.pinnedBoundary &&
|
||||||
|
prev.centerWindow?.start === next.centerWindow?.start &&
|
||||||
|
prev.centerWindow?.end === next.centerWindow?.end
|
||||||
|
) as typeof DataGridTableRenderedRow
|
||||||
|
|
||||||
function DataGridTableVirtualBody<TData extends object>({
|
function DataGridTableVirtualBody<TData extends object>({
|
||||||
table,
|
table,
|
||||||
topRows,
|
topRows,
|
||||||
@@ -438,6 +496,7 @@ function DataGridTableVirtualBody<TData extends object>({
|
|||||||
loadingMoreMessage,
|
loadingMoreMessage,
|
||||||
allRowsLoadedMessage,
|
allRowsLoadedMessage,
|
||||||
measureRowRef,
|
measureRowRef,
|
||||||
|
centerColumnWindow,
|
||||||
}: VirtualBodyProps<TData>) {
|
}: VirtualBodyProps<TData>) {
|
||||||
const { isLoading } = useDataGrid()
|
const { isLoading } = useDataGrid()
|
||||||
const totalRows = topRows.length + centerRows.length + bottomRows.length
|
const totalRows = topRows.length + centerRows.length + bottomRows.length
|
||||||
@@ -479,9 +538,10 @@ function DataGridTableVirtualBody<TData extends object>({
|
|||||||
|
|
||||||
topRows.forEach((row, index) => {
|
topRows.forEach((row, index) => {
|
||||||
renderedRows.push(
|
renderedRows.push(
|
||||||
<DataGridTableRenderedRow
|
<MemoizedRenderedRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
row={row}
|
row={row}
|
||||||
|
centerWindow={centerColumnWindow}
|
||||||
pinnedBoundary={
|
pinnedBoundary={
|
||||||
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
|
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
|
||||||
}
|
}
|
||||||
@@ -506,11 +566,12 @@ function DataGridTableVirtualBody<TData extends object>({
|
|||||||
if (!row) return
|
if (!row) return
|
||||||
|
|
||||||
renderedRows.push(
|
renderedRows.push(
|
||||||
<DataGridTableRenderedRow
|
<MemoizedRenderedRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
row={row}
|
row={row}
|
||||||
rowRef={measureRowRef}
|
rowRef={measureRowRef}
|
||||||
rowIndex={virtualRow.index}
|
rowIndex={virtualRow.index}
|
||||||
|
centerWindow={centerColumnWindow}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -527,7 +588,12 @@ function DataGridTableVirtualBody<TData extends object>({
|
|||||||
} else {
|
} else {
|
||||||
centerRows.forEach((row, rowIndex) => {
|
centerRows.forEach((row, rowIndex) => {
|
||||||
renderedRows.push(
|
renderedRows.push(
|
||||||
<DataGridTableRenderedRow key={row.id} row={row} rowIndex={rowIndex} />
|
<MemoizedRenderedRow
|
||||||
|
key={row.id}
|
||||||
|
row={row}
|
||||||
|
rowIndex={rowIndex}
|
||||||
|
centerWindow={centerColumnWindow}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -557,9 +623,10 @@ function DataGridTableVirtualBody<TData extends object>({
|
|||||||
|
|
||||||
bottomRows.forEach((row, index) => {
|
bottomRows.forEach((row, index) => {
|
||||||
renderedRows.push(
|
renderedRows.push(
|
||||||
<DataGridTableRenderedRow
|
<MemoizedRenderedRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
row={row}
|
row={row}
|
||||||
|
centerWindow={centerColumnWindow}
|
||||||
pinnedBoundary={
|
pinnedBoundary={
|
||||||
index === 0 && (topRows.length > 0 || hasMiddleSection)
|
index === 0 && (topRows.length > 0 || hasMiddleSection)
|
||||||
? "bottom"
|
? "bottom"
|
||||||
@@ -576,10 +643,15 @@ function DataGridTableVirtualBody<TData extends object>({
|
|||||||
* Memoized virtual body: skip re-renders during active column resize.
|
* Memoized virtual body: skip re-renders during active column resize.
|
||||||
* Column widths update via CSS variables on the <table> element,
|
* Column widths update via CSS variables on the <table> element,
|
||||||
* so the browser handles width changes without React re-renders.
|
* so the browser handles width changes without React re-renders.
|
||||||
|
* A cell-selection drag gets the same treatment: painting goes through each
|
||||||
|
* cell's own Subscribe, and the virtualizer re-renders itself from inside the
|
||||||
|
* memo boundary, so parent-driven reconciliation during the drag is waste.
|
||||||
*/
|
*/
|
||||||
const MemoizedVirtualBody = memo(
|
const MemoizedVirtualBody = memo(
|
||||||
DataGridTableVirtualBody,
|
DataGridTableVirtualBody,
|
||||||
(_prev, next) => !!next.table.state.columnResizing.isResizingColumn
|
(_prev, next) =>
|
||||||
|
!!next.table.state.columnResizing.isResizingColumn ||
|
||||||
|
next.table._isSelectingCells === true
|
||||||
) as typeof DataGridTableVirtualBody
|
) as typeof DataGridTableVirtualBody
|
||||||
|
|
||||||
function DataGridTableVirtual<TData extends object>({
|
function DataGridTableVirtual<TData extends object>({
|
||||||
@@ -589,6 +661,9 @@ function DataGridTableVirtual<TData extends object>({
|
|||||||
scrollBehavior = "auto",
|
scrollBehavior = "auto",
|
||||||
scrollToRowAlign = "auto",
|
scrollToRowAlign = "auto",
|
||||||
scrollToRowIndex,
|
scrollToRowIndex,
|
||||||
|
columnVirtualizerOptions,
|
||||||
|
scrollToColumnAlign = "auto",
|
||||||
|
scrollToColumnIndex,
|
||||||
footerContent,
|
footerContent,
|
||||||
renderHeader = true,
|
renderHeader = true,
|
||||||
onFetchMore,
|
onFetchMore,
|
||||||
@@ -597,9 +672,22 @@ function DataGridTableVirtual<TData extends object>({
|
|||||||
fetchMoreOffset = 0,
|
fetchMoreOffset = 0,
|
||||||
virtualizerOptions,
|
virtualizerOptions,
|
||||||
}: DataGridTableVirtualProps<TData>) {
|
}: DataGridTableVirtualProps<TData>) {
|
||||||
const { table, props } = useDataGrid<TData>()
|
const { i18n, table, props } = useDataGrid<TData>()
|
||||||
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
|
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
|
||||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
||||||
|
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
|
||||||
|
// Column windows only where the geometry is provable: a fixed table
|
||||||
|
// layout (undefined defaults to fixed; the colgroup then owns every
|
||||||
|
// width, so colSpan spacers cannot drift) and a single ungrouped header
|
||||||
|
// row (a colSpan group cannot be windowed). Anything else falls back to
|
||||||
|
// full-column rendering silently, the same posture as row virtualization
|
||||||
|
// toward unsupported shapes.
|
||||||
|
const columnVirtualizationActive =
|
||||||
|
columnVirtualizerOptions?.enabled === true &&
|
||||||
|
props.tableLayout?.width !== "auto" &&
|
||||||
|
mergedHeaderGroups.length === 1 &&
|
||||||
|
(mergedHeaderGroups[0]?.headers.every((header) => header.colSpan <= 1) ??
|
||||||
|
false)
|
||||||
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
|
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
|
||||||
table,
|
table,
|
||||||
props.tableLayout?.rowsPinnable
|
props.tableLayout?.rowsPinnable
|
||||||
@@ -622,9 +710,9 @@ function DataGridTableVirtual<TData extends object>({
|
|||||||
|
|
||||||
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
||||||
const loadingMoreMessage =
|
const loadingMoreMessage =
|
||||||
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
|
props.fetchingMoreMessage || props.loadingMessage || i18n.labels.loading
|
||||||
const allRowsLoadedMessage =
|
const allRowsLoadedMessage =
|
||||||
props.allRowsLoadedMessage || "All records loaded"
|
props.allRowsLoadedMessage || i18n.labels.allRowsLoaded
|
||||||
|
|
||||||
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
||||||
setViewportElements({
|
setViewportElements({
|
||||||
@@ -679,6 +767,69 @@ function DataGridTableVirtual<TData extends object>({
|
|||||||
...virtualizerOptionsRest,
|
...virtualizerOptionsRest,
|
||||||
}) as DataGridTableVirtualizerInstance
|
}) as DataGridTableVirtualizerInstance
|
||||||
|
|
||||||
|
// Horizontal offsets invert in RTL; the virtualizer must be told.
|
||||||
|
const isRtl = useMemo(
|
||||||
|
() =>
|
||||||
|
viewportElements.containerElement
|
||||||
|
? getComputedStyle(viewportElements.containerElement).direction ===
|
||||||
|
"rtl"
|
||||||
|
: false,
|
||||||
|
[viewportElements.containerElement]
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolveColumnKey = useCallback(
|
||||||
|
(index: number) => centerVisibleColumns[index]?.id ?? index,
|
||||||
|
[centerVisibleColumns]
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolveColumnEstimateSize = useCallback(
|
||||||
|
(index: number) => centerVisibleColumns[index]?.getSize() ?? 0,
|
||||||
|
[centerVisibleColumns]
|
||||||
|
)
|
||||||
|
|
||||||
|
const columnVirtualizer = useVirtualizer({
|
||||||
|
horizontal: true,
|
||||||
|
isRtl,
|
||||||
|
count: columnVirtualizationActive ? centerVisibleColumns.length : 0,
|
||||||
|
getScrollElement: resolveScrollElement,
|
||||||
|
getItemKey: resolveColumnKey,
|
||||||
|
estimateSize: resolveColumnEstimateSize,
|
||||||
|
overscan: columnVirtualizerOptions?.overscan ?? 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Column sizes are exact reads of getSize(), never DOM-measured, so a
|
||||||
|
// resize commit or a visibility/order/pinning change must resync the
|
||||||
|
// virtualizer's cached sizes by hand.
|
||||||
|
useEffect(() => {
|
||||||
|
if (columnVirtualizationActive) columnVirtualizer.measure()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [
|
||||||
|
columnVirtualizationActive,
|
||||||
|
columnVirtualizer,
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
table.state.columnSizing,
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
table.state.columnVisibility,
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
table.state.columnOrder,
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
table.state.columnPinning,
|
||||||
|
])
|
||||||
|
|
||||||
|
const virtualColumns = columnVirtualizationActive
|
||||||
|
? columnVirtualizer.getVirtualItems()
|
||||||
|
: []
|
||||||
|
// The window is a contiguous inclusive [start, end] over the center
|
||||||
|
// columns; before the scroll element resolves the item list is empty and
|
||||||
|
// the grid renders full-width for that first frame.
|
||||||
|
const centerColumnWindow =
|
||||||
|
columnVirtualizationActive && virtualColumns.length > 0
|
||||||
|
? {
|
||||||
|
start: virtualColumns[0]!.index,
|
||||||
|
end: virtualColumns[virtualColumns.length - 1]!.index,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
const virtualItems = isVirtualizationEnabled
|
const virtualItems = isVirtualizationEnabled
|
||||||
? virtualizer.getVirtualItems()
|
? virtualizer.getVirtualItems()
|
||||||
: []
|
: []
|
||||||
@@ -705,6 +856,51 @@ function DataGridTableVirtual<TData extends object>({
|
|||||||
// before the consumer flips isFetchingMore, and loops at end-of-data when
|
// before the consumer flips isFetchingMore, and loops at end-of-data when
|
||||||
// hasMore is never set.
|
// hasMore is never set.
|
||||||
const fetchMoreFiredAtCountRef = useRef<number | null>(null)
|
const fetchMoreFiredAtCountRef = useRef<number | null>(null)
|
||||||
|
const lastColumnScrollRequestRef =
|
||||||
|
useRef<DataGridTableColumnScrollRequest | null>(null)
|
||||||
|
|
||||||
|
// Controlled column reveal: same dedupe posture as the row request - the
|
||||||
|
// signature keeps ordinary renders from re-scrolling, and exact column
|
||||||
|
// sizes mean no post-measure follow-up pass is needed.
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!columnVirtualizationActive ||
|
||||||
|
scrollToColumnIndex === undefined ||
|
||||||
|
scrollToColumnIndex < 0 ||
|
||||||
|
scrollToColumnIndex >= centerVisibleColumns.length ||
|
||||||
|
!viewportElements.scrollElement
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const request: DataGridTableColumnScrollRequest = {
|
||||||
|
align: scrollToColumnAlign,
|
||||||
|
behavior: scrollBehavior,
|
||||||
|
columnId: centerVisibleColumns[scrollToColumnIndex]?.id,
|
||||||
|
columnIndex: scrollToColumnIndex,
|
||||||
|
scrollElement: viewportElements.scrollElement,
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isSameDataGridTableColumnScrollRequest(
|
||||||
|
lastColumnScrollRequestRef.current,
|
||||||
|
request
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastColumnScrollRequestRef.current = request
|
||||||
|
columnVirtualizer.scrollToIndex(scrollToColumnIndex, {
|
||||||
|
align: scrollToColumnAlign,
|
||||||
|
behavior: scrollBehavior === "smooth" ? "smooth" : "auto",
|
||||||
|
})
|
||||||
|
}, [
|
||||||
|
columnVirtualizationActive,
|
||||||
|
columnVirtualizer,
|
||||||
|
centerVisibleColumns,
|
||||||
|
scrollToColumnAlign,
|
||||||
|
scrollToColumnIndex,
|
||||||
|
scrollBehavior,
|
||||||
|
viewportElements.scrollElement,
|
||||||
|
])
|
||||||
|
|
||||||
// Resolve after every commit so a stable getter can expose a replaced ref;
|
// Resolve after every commit so a stable getter can expose a replaced ref;
|
||||||
// the request signature prevents duplicate scrolling on ordinary renders.
|
// the request signature prevents duplicate scrolling on ordinary renders.
|
||||||
@@ -847,30 +1043,79 @@ function DataGridTableVirtual<TData extends object>({
|
|||||||
virtualItems,
|
virtualItems,
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
// The header re-renders only when its real inputs move: the table
|
||||||
<DataGridTableViewport
|
// wrapper (any table state change recreates it), the layout props, and
|
||||||
viewportRef={handleViewportRef}
|
// the column window. A scroll frame changes none of them, so the whole
|
||||||
className={!usesExternalScrollArea ? "block" : undefined}
|
// sortable-header subtree is reused instead of rebuilt per frame.
|
||||||
style={
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
usesExternalScrollArea
|
const headerNode = useMemo(
|
||||||
? undefined
|
() =>
|
||||||
: {
|
renderHeader && (
|
||||||
height,
|
|
||||||
overflow: "auto",
|
|
||||||
position: "relative",
|
|
||||||
// Standalone mode: this node IS the scroll container, so it
|
|
||||||
// must stay at its parent's width (not the resizable table
|
|
||||||
// width) or horizontal scrolling becomes impossible.
|
|
||||||
width: "auto",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DataGridTableBase>
|
|
||||||
{renderHeader && (
|
|
||||||
<DataGridTableHead>
|
<DataGridTableHead>
|
||||||
{mergedHeaderGroups.map((headerGroup) => (
|
{mergedHeaderGroups.map((headerGroup) => (
|
||||||
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
|
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
|
||||||
{headerGroup.headers
|
{/* Under an active column window the single ungrouped header
|
||||||
|
row is bucketed start / windowed center / end, with each
|
||||||
|
off-window flank one colSpan spacer sized by the intact
|
||||||
|
colgroup - the same shape the body rows take. */}
|
||||||
|
{centerColumnWindow
|
||||||
|
? headerGroup.headers
|
||||||
|
.filter((header) => header.column.getIsPinned() === "start")
|
||||||
|
.map((header) => (
|
||||||
|
<DataGridTableHeadRowCell header={header} key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
{props.tableLayout?.columnsResizable &&
|
||||||
|
header.column.getCanResize() && (
|
||||||
|
<DataGridTableHeadRowCellResize header={header} />
|
||||||
|
)}
|
||||||
|
</DataGridTableHeadRowCell>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
{centerColumnWindow && centerColumnWindow.start > 0 ? (
|
||||||
|
<th
|
||||||
|
aria-hidden="true"
|
||||||
|
data-slot="data-grid-table-virtual-col-spacer"
|
||||||
|
colSpan={centerColumnWindow.start}
|
||||||
|
className="p-0"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{centerColumnWindow
|
||||||
|
? headerGroup.headers
|
||||||
|
.filter((header) => !header.column.getIsPinned())
|
||||||
|
.slice(centerColumnWindow.start, centerColumnWindow.end + 1)
|
||||||
|
.map((header) => (
|
||||||
|
<DataGridTableHeadRowCell header={header} key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
{props.tableLayout?.columnsResizable &&
|
||||||
|
header.column.getCanResize() && (
|
||||||
|
<DataGridTableHeadRowCellResize header={header} />
|
||||||
|
)}
|
||||||
|
</DataGridTableHeadRowCell>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
{centerColumnWindow &&
|
||||||
|
centerVisibleColumns.length - 1 - centerColumnWindow.end > 0 ? (
|
||||||
|
<th
|
||||||
|
aria-hidden="true"
|
||||||
|
data-slot="data-grid-table-virtual-col-spacer"
|
||||||
|
colSpan={
|
||||||
|
centerVisibleColumns.length - 1 - centerColumnWindow.end
|
||||||
|
}
|
||||||
|
className="p-0"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!centerColumnWindow &&
|
||||||
|
headerGroup.headers
|
||||||
.filter((header) => header.column.getIsPinned() !== "end")
|
.filter((header) => header.column.getIsPinned() !== "end")
|
||||||
.map((header) => {
|
.map((header) => {
|
||||||
const { column } = header
|
const { column } = header
|
||||||
@@ -890,8 +1135,7 @@ function DataGridTableVirtual<TData extends object>({
|
|||||||
</DataGridTableHeadRowCell>
|
</DataGridTableHeadRowCell>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{props.tableLayout?.columnsResizable &&
|
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
|
||||||
hasRightPinnedColumns ? (
|
|
||||||
<DataGridTableFillHeadCell />
|
<DataGridTableFillHeadCell />
|
||||||
) : null}
|
) : null}
|
||||||
{headerGroup.headers
|
{headerGroup.headers
|
||||||
@@ -914,14 +1158,43 @@ function DataGridTableVirtual<TData extends object>({
|
|||||||
</DataGridTableHeadRowCell>
|
</DataGridTableHeadRowCell>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{props.tableLayout?.columnsResizable &&
|
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
|
||||||
!hasRightPinnedColumns ? (
|
|
||||||
<DataGridTableFillHeadCell />
|
<DataGridTableFillHeadCell />
|
||||||
) : null}
|
) : null}
|
||||||
</DataGridTableHeadRow>
|
</DataGridTableHeadRow>
|
||||||
))}
|
))}
|
||||||
</DataGridTableHead>
|
</DataGridTableHead>
|
||||||
)}
|
),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[
|
||||||
|
renderHeader,
|
||||||
|
table,
|
||||||
|
props.tableLayout,
|
||||||
|
centerColumnWindow,
|
||||||
|
hasRightPinnedColumns,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridTableViewport
|
||||||
|
viewportRef={handleViewportRef}
|
||||||
|
className={!usesExternalScrollArea ? "block" : undefined}
|
||||||
|
style={
|
||||||
|
usesExternalScrollArea
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
height,
|
||||||
|
overflow: "auto",
|
||||||
|
position: "relative",
|
||||||
|
// Standalone mode: this node IS the scroll container, so it
|
||||||
|
// must stay at its parent's width (not the resizable table
|
||||||
|
// width) or horizontal scrolling becomes impossible.
|
||||||
|
width: "auto",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DataGridTableBase>
|
||||||
|
{headerNode}
|
||||||
|
|
||||||
{renderHeader &&
|
{renderHeader &&
|
||||||
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||||
@@ -943,7 +1216,12 @@ function DataGridTableVirtual<TData extends object>({
|
|||||||
loadingMoreMessage={loadingMoreMessage}
|
loadingMoreMessage={loadingMoreMessage}
|
||||||
allRowsLoadedMessage={allRowsLoadedMessage}
|
allRowsLoadedMessage={allRowsLoadedMessage}
|
||||||
measureRowRef={measureRowRef}
|
measureRowRef={measureRowRef}
|
||||||
|
centerColumnWindow={centerColumnWindow}
|
||||||
/>
|
/>
|
||||||
|
{/* Same appended region as the standard body, so onRowCreate and
|
||||||
|
a consumer draft work in the virtual layout too. */}
|
||||||
|
{props.appendRow}
|
||||||
|
<DataGridTableAddRow />
|
||||||
</DataGridTableBody>
|
</DataGridTableBody>
|
||||||
|
|
||||||
{footerContent && (
|
{footerContent && (
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,19 @@
|
|||||||
import { createContext, useContext, useEffect, useMemo, useRef } from "react"
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
} from "react"
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import {
|
import {
|
||||||
|
mergeDataGridI18n,
|
||||||
|
type DataGridI18nConfig,
|
||||||
|
type DataGridI18nOverrides,
|
||||||
|
} from "@/components/reui/data-grid/data-grid-i18n"
|
||||||
|
import {
|
||||||
|
cellSelectionFeature,
|
||||||
columnFacetingFeature,
|
columnFacetingFeature,
|
||||||
columnFilteringFeature,
|
columnFilteringFeature,
|
||||||
columnOrderingFeature,
|
columnOrderingFeature,
|
||||||
@@ -30,6 +43,7 @@ import {
|
|||||||
tableFeatures,
|
tableFeatures,
|
||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
import type {
|
import type {
|
||||||
|
Cell,
|
||||||
Column,
|
Column,
|
||||||
ColumnFiltersState,
|
ColumnFiltersState,
|
||||||
ReactTable,
|
ReactTable,
|
||||||
@@ -49,6 +63,39 @@ import { cn } from "@evobgp/ui/lib/utils"
|
|||||||
* installing the data grid no longer widens `ColumnMeta` for every other
|
* installing the data grid no longer widens `ColumnMeta` for every other
|
||||||
* table in the consuming app.
|
* table in the consuming app.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Per-column write contract for the spreadsheet operations (paste, fill,
|
||||||
|
* clear, cut). Presence marks the column writable; every write still flows
|
||||||
|
* through `onCellsChange`, so the consumer's state stays the only data owner.
|
||||||
|
*/
|
||||||
|
export interface DataGridColumnCellEdit<TData> {
|
||||||
|
/**
|
||||||
|
* Set false for a column that formats clipboard output but is never
|
||||||
|
* written; a function decides per row, so locked rows (archived, another
|
||||||
|
* user's, a totals row) reject like any read-only cell. Defaults to true.
|
||||||
|
*/
|
||||||
|
editable?: boolean | ((row: TData) => boolean)
|
||||||
|
/**
|
||||||
|
* Clipboard or fill string to typed value. Return undefined to reject the
|
||||||
|
* cell into `rejected`. Without it the raw string passes through unchanged -
|
||||||
|
* a deliberate non-coercion, so a number column receives strings only when
|
||||||
|
* the consumer chose not to parse.
|
||||||
|
*/
|
||||||
|
parse?: (raw: string, row: TData) => unknown
|
||||||
|
/** Typed value to clipboard string. Fallback: String(value ?? ""). */
|
||||||
|
format?: (value: unknown, row: TData) => string
|
||||||
|
/** Value dispatched by Delete/Backspace and cut. Defaults to null. */
|
||||||
|
clearValue?: unknown
|
||||||
|
/**
|
||||||
|
* Opt into the grid's built-in free-text editor: an overlay input (or
|
||||||
|
* auto-growing textarea) flush over the focused cell, opened by Enter, F2,
|
||||||
|
* typing, or double-click, committing through `onCellsChange` as source
|
||||||
|
* `"edit"` with `parse` applied. Columns without it stay consumer-edited
|
||||||
|
* via `onCellEditRequest` or their own cell renderers.
|
||||||
|
*/
|
||||||
|
control?: "text" | "textarea"
|
||||||
|
}
|
||||||
|
|
||||||
export interface DataGridColumnMeta<TData> {
|
export interface DataGridColumnMeta<TData> {
|
||||||
headerTitle?: string
|
headerTitle?: string
|
||||||
headerClassName?: string
|
headerClassName?: string
|
||||||
@@ -56,6 +103,15 @@ export interface DataGridColumnMeta<TData> {
|
|||||||
skeleton?: ReactNode
|
skeleton?: ReactNode
|
||||||
expandedContent?: (row: TData) => ReactNode
|
expandedContent?: (row: TData) => ReactNode
|
||||||
autoSize?: boolean
|
autoSize?: boolean
|
||||||
|
cellEdit?: DataGridColumnCellEdit<TData>
|
||||||
|
/**
|
||||||
|
* Under `columnsResizable`, this column absorbs the free space the filler
|
||||||
|
* strip would otherwise hold, so the grid always reads full-width and the
|
||||||
|
* built-in editor covers the whole cell. One column per grid; while free
|
||||||
|
* space remains, manual resizing of this column is visually a no-op (the
|
||||||
|
* absorbed space compensates), the flex-column trade-off.
|
||||||
|
*/
|
||||||
|
fillWidth?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,6 +161,10 @@ export const dataGridFeatures = tableFeatures({
|
|||||||
rowSelectionFeature,
|
rowSelectionFeature,
|
||||||
rowExpandingFeature,
|
rowExpandingFeature,
|
||||||
rowPinningFeature,
|
rowPinningFeature,
|
||||||
|
// Registration alone is inert: it seeds a `cellSelection: []` slice and
|
||||||
|
// prototype methods but binds no DOM handlers, so grids without
|
||||||
|
// `tableLayout.cellSelection` render and behave exactly as before.
|
||||||
|
cellSelectionFeature,
|
||||||
sortedRowModel: createSortedRowModel(),
|
sortedRowModel: createSortedRowModel(),
|
||||||
filteredRowModel: createFilteredRowModel(),
|
filteredRowModel: createFilteredRowModel(),
|
||||||
paginatedRowModel: createPaginatedRowModel(),
|
paginatedRowModel: createPaginatedRowModel(),
|
||||||
@@ -156,6 +216,131 @@ export function getColumnHeaderLabel<TData extends RowData, TValue>(
|
|||||||
return String(column.id)
|
return String(column.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The td contract for spreadsheet selection. Call inside a
|
||||||
|
* `Subscribe source={table.atoms.cellSelection}` render only: the reads are
|
||||||
|
* builder calls whose state dependency React Compiler cannot see, exactly the
|
||||||
|
* trap the row-selection checkbox documents.
|
||||||
|
*/
|
||||||
|
export function getDataGridCellSelectionCellAttrs<TData extends object>(
|
||||||
|
cell: Cell<DataGridFeatures, TData, unknown>
|
||||||
|
): {
|
||||||
|
"aria-selected": boolean
|
||||||
|
"data-col-id": string
|
||||||
|
"data-cell-selected": true | undefined
|
||||||
|
"data-cell-focused": true | undefined
|
||||||
|
"data-cell-edge-top": true | undefined
|
||||||
|
"data-cell-edge-right": true | undefined
|
||||||
|
"data-cell-edge-bottom": true | undefined
|
||||||
|
"data-cell-edge-left": true | undefined
|
||||||
|
} {
|
||||||
|
const selected = cell.getIsSelected()
|
||||||
|
const edges = cell.getSelectionEdges()
|
||||||
|
return {
|
||||||
|
"aria-selected": selected,
|
||||||
|
"data-col-id": cell.column.id,
|
||||||
|
"data-cell-selected": selected || undefined,
|
||||||
|
"data-cell-focused": cell.getIsFocused() || undefined,
|
||||||
|
"data-cell-edge-top": edges.top || undefined,
|
||||||
|
"data-cell-edge-right": edges.right || undefined,
|
||||||
|
"data-cell-edge-bottom": edges.bottom || undefined,
|
||||||
|
"data-cell-edge-left": edges.left || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selection chrome for a body td, activated by the data attributes above plus
|
||||||
|
* the imperatively toggled `data-cell-fill-target` (fill-drag preview, written
|
||||||
|
* outside React like the resize indicator). A ::before overlay, not outline
|
||||||
|
* or box-shadow: an outline ring hugs the cell box and cannot line up with
|
||||||
|
* the gridlines the perimeter paints on, and the pinned-column dividers
|
||||||
|
* already own the cell's shadow slot (tailwind-merge would collapse a second
|
||||||
|
* shadow-[...] into it).
|
||||||
|
*/
|
||||||
|
export const dataGridCellSelectionCellClasses = cn(
|
||||||
|
"relative select-none",
|
||||||
|
// A light tint: the selection must read as a range without drowning the
|
||||||
|
// gridlines under a heavy fill. The focused cell stays unfilled. Pinned
|
||||||
|
// cells must stay OPAQUE (they hide scrolled content), so they get the
|
||||||
|
// tint pre-mixed over the background, and the focused pinned cell keeps
|
||||||
|
// a solid background instead of turning transparent.
|
||||||
|
"data-[cell-selected]:bg-primary/4",
|
||||||
|
"data-[cell-selected]:data-pinned:bg-[color-mix(in_oklab,var(--primary)_4%,var(--background))]",
|
||||||
|
"data-[cell-focused]:bg-transparent!",
|
||||||
|
"data-[cell-focused]:data-pinned:bg-background!",
|
||||||
|
// Every selection line is one layout-free ::before overlay per cell. It
|
||||||
|
// reaches 1px BEYOND the cell so each line paints exactly ON the shared
|
||||||
|
// gridline it replaces: the range perimeter and the interior dividers
|
||||||
|
// share this geometry, so where they meet they coincide instead of
|
||||||
|
// stacking into a wider edge, every side stays exactly 1px, and
|
||||||
|
// selecting never adds real borders that shift layout. At the table's
|
||||||
|
// own boundary there is no shared gridline and the overshoot leaves the
|
||||||
|
// scrollable content box, where it is CLIPPED - the perimeter simply
|
||||||
|
// vanished on the first column, last column and last row. So every
|
||||||
|
// overshoot that can hit a boundary goes through a variable the
|
||||||
|
// boundary cells zero out. Longhand inset utilities only, logical
|
||||||
|
// start/end
|
||||||
|
// so RTL mirrors for free; a separate override rule cannot do this,
|
||||||
|
// equal-specificity variants leave shorthand-vs-longhand to sort order.
|
||||||
|
"data-[cell-selected]:before:pointer-events-none data-[cell-selected]:before:absolute data-[cell-selected]:before:top-[var(--data-grid-overlay-top,-1px)] data-[cell-selected]:before:start-[var(--data-grid-overlay-start,-1px)] data-[cell-selected]:before:end-[var(--data-grid-overlay-end,-1px)] data-[cell-selected]:before:bottom-[var(--data-grid-overlay-bottom,-1px)] data-[cell-selected]:before:border-primary data-[cell-selected]:before:content-['']",
|
||||||
|
"data-[cell-focused]:before:pointer-events-none data-[cell-focused]:before:absolute data-[cell-focused]:before:top-[var(--data-grid-overlay-top,-1px)] data-[cell-focused]:before:start-[var(--data-grid-overlay-start,-1px)] data-[cell-focused]:before:end-[var(--data-grid-overlay-end,-1px)] data-[cell-focused]:before:bottom-[var(--data-grid-overlay-bottom,-1px)] data-[cell-focused]:before:border-primary data-[cell-focused]:before:content-['']",
|
||||||
|
// Interior dividers: selected cells repaint their own end and bottom
|
||||||
|
// gridlines on the overlay, so gridline-less grids still divide a range.
|
||||||
|
// The gray color is guarded by not- variants (higher specificity), so a
|
||||||
|
// side that belongs to the range perimeter deterministically stays
|
||||||
|
// primary.
|
||||||
|
"data-[cell-selected]:before:border-e data-[cell-selected]:before:border-b",
|
||||||
|
"data-[cell-selected]:not-data-[cell-edge-right]:before:border-e-border",
|
||||||
|
"data-[cell-selected]:not-data-[cell-edge-bottom]:before:border-b-border",
|
||||||
|
// The Sheets model: a single selected cell IS its own range, so the four
|
||||||
|
// edge attributes below already draw its full primary box; inside a
|
||||||
|
// larger range the anchor reads by its unfilled background alone. The
|
||||||
|
// explicit ring only covers a focused cell with no selection at all.
|
||||||
|
// Edge sides are logical (border-e/border-s): the feature derives edges
|
||||||
|
// from display-order column indexes, which mirror in RTL.
|
||||||
|
"data-[cell-focused]:not-data-[cell-selected]:before:border",
|
||||||
|
"data-[cell-edge-top]:before:border-t data-[cell-edge-right]:before:border-e data-[cell-edge-bottom]:before:border-b data-[cell-edge-left]:before:border-s",
|
||||||
|
// While a fill drag is live the preview border is the ONE painter for
|
||||||
|
// the whole pending region, so the source cells' own chrome rests -
|
||||||
|
// otherwise its lines double against the region border at every shared
|
||||||
|
// edge. display is the no-cascade-fight switch (see below).
|
||||||
|
"in-data-[cell-filling]:data-[cell-selected]:before:hidden",
|
||||||
|
"in-data-[cell-filling]:data-[cell-focused]:before:hidden",
|
||||||
|
// While the built-in editor is open its outline is the one border; the
|
||||||
|
// edited cell's own overlay chrome hides so nothing peeks around the
|
||||||
|
// flush editor. display is the switch because no other before: rule
|
||||||
|
// sets it, so there is no cascade order to lose.
|
||||||
|
"in-data-[cell-editing]:data-[cell-focused]:before:hidden",
|
||||||
|
// Clamped to the cell edge at the grid's own boundary, the overlay's
|
||||||
|
// lines sit flush against the container border instead of being clipped
|
||||||
|
// away outside the scrollable content. A custom property has no cascade
|
||||||
|
// fight to lose. First/last-child cover the boundary columns: when a
|
||||||
|
// filler td follows the last data column, that cell's overshoot lands
|
||||||
|
// INSIDE the table and needs no clamp, and the td:first/last-child of a
|
||||||
|
// row are its logical start/end in RTL too.
|
||||||
|
"in-[tr:last-child]:[--data-grid-overlay-bottom:0px]",
|
||||||
|
// The top overshoot lands under a sticky header, which is positioned and
|
||||||
|
// paints over it - so the first row's perimeter lost its top line. Clamp
|
||||||
|
// inside the cell there, exactly like the other three boundaries.
|
||||||
|
"in-[tr:first-child]:[--data-grid-overlay-top:0px]",
|
||||||
|
"first:[--data-grid-overlay-start:0px]",
|
||||||
|
"last:[--data-grid-overlay-end:0px]",
|
||||||
|
// A pinned column is a boundary like the grid's own edges: the sticky
|
||||||
|
// neighbor would cover an on-gridline overlay line, and re-painting it
|
||||||
|
// from the pinned side doubles into a 2px blur whenever subpixel
|
||||||
|
// positions disagree (browser zoom, fractional fill widths). One
|
||||||
|
// painter instead: the overlay clamps inside its own cell there, the
|
||||||
|
// same custom-property clamp the first and last cells use.
|
||||||
|
"[&:has(+td[data-pinned])]:[--data-grid-overlay-end:0px]",
|
||||||
|
"[&:has(+[data-slot=data-grid-table-fill-body-cell]:last-child)]:[--data-grid-overlay-end:0px]",
|
||||||
|
"[&:has(+[data-slot=data-grid-table-fill-body-cell]+td[data-pinned])]:[--data-grid-overlay-end:0px]",
|
||||||
|
"[td[data-pinned]+&]:[--data-grid-overlay-start:0px]",
|
||||||
|
// Tint only: the dashed region outline is one overlay element drawn by
|
||||||
|
// the fill session, so neighboring target cells never double their edges.
|
||||||
|
"data-[cell-fill-target]:bg-primary/4",
|
||||||
|
"data-[cell-fill-target]:data-pinned:bg-[color-mix(in_oklab,var(--primary)_4%,var(--background))]"
|
||||||
|
)
|
||||||
|
|
||||||
export type DataGridApiFetchParams = {
|
export type DataGridApiFetchParams = {
|
||||||
pageIndex: number
|
pageIndex: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
@@ -188,6 +373,10 @@ export interface DataGridContextProps<TData extends object> {
|
|||||||
table: DataGridTableInstance<TData>
|
table: DataGridTableInstance<TData>
|
||||||
recordCount: number
|
recordCount: number
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
|
/** Stable per-grid prefix for the DOM ids ARIA relations point at. */
|
||||||
|
gridId: string
|
||||||
|
/** The merged label set; components read `i18n.labels.<key>`. */
|
||||||
|
i18n: DataGridI18nConfig
|
||||||
/**
|
/**
|
||||||
* Internal coordinator for `meta.autoSize` columns. Lives at the core level
|
* Internal coordinator for `meta.autoSize` columns. Lives at the core level
|
||||||
* so every table variant and viewport instance shares one application state.
|
* so every table variant and viewport instance shares one application state.
|
||||||
@@ -195,13 +384,66 @@ export interface DataGridContextProps<TData extends object> {
|
|||||||
autoSize?: DataGridAutoSizeController
|
autoSize?: DataGridAutoSizeController
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row and column ids are consumer strings; DOM ids cannot carry spaces or
|
||||||
|
* quotes. One shared sanitizer so `aria-activedescendant` writers and the id
|
||||||
|
* renderers always agree on the same encoding.
|
||||||
|
*/
|
||||||
|
export function toDataGridDomId(value: string): string {
|
||||||
|
return value.replace(/[^\w-]/g, "_")
|
||||||
|
}
|
||||||
|
|
||||||
export type DataGridAutoSizeController = {
|
export type DataGridAutoSizeController = {
|
||||||
/**
|
/**
|
||||||
* Grows the first visible `meta.autoSize` column by the given free space.
|
* Feeds the controller one viewport measurement: the SIGNED free space
|
||||||
* Applies at most once per column id; safe to call from every viewport
|
* between the scroll container and the table, negative while the table
|
||||||
* measurement. Returns true when a sizing update was dispatched.
|
* overflows. The first sight of a `meta.autoSize` column absorbs the
|
||||||
|
* free space into its width; after that, every measurement REFLOWS the
|
||||||
|
* same growth - a wider window widens the column, a narrower one gives
|
||||||
|
* the space back down to the column's `minSize` (or its starting width
|
||||||
|
* when none is set) - so the grid tracks its container live. Commits
|
||||||
|
* are coalesced for performance: sub-pixel deltas are dropped, a
|
||||||
|
* streaming resize gets one leading commit plus one trailing commit at
|
||||||
|
* settle, and a width the user dragged is never touched. Safe to call
|
||||||
|
* from every viewport measurement; returns true when a sizing update
|
||||||
|
* was dispatched.
|
||||||
*/
|
*/
|
||||||
apply: (fillWidth: number) => boolean
|
apply: (freeSpace: number) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataGridAutoSizeReflowState = {
|
||||||
|
applied: { columnId: string; base: number; grown: number } | null
|
||||||
|
settleTimer: ReturnType<typeof setTimeout> | null
|
||||||
|
pendingFreeSpace: number
|
||||||
|
lastCommitAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bookkeeping keyed on the TABLE STORE, never on a React instance: dev
|
||||||
|
* StrictMode double-mounts the tree, so two controller instances serve one
|
||||||
|
* page and only the first would remember what it absorbed - the second
|
||||||
|
* reads the grown width as a user drag and freezes the column. The store
|
||||||
|
* object is the one identity that provably survives that dance (the
|
||||||
|
* absorbed sizing rides inside it), and the WeakMap lets a discarded
|
||||||
|
* table take its bookkeeping with it.
|
||||||
|
*/
|
||||||
|
const dataGridAutoSizeStates = new WeakMap<
|
||||||
|
object,
|
||||||
|
DataGridAutoSizeReflowState
|
||||||
|
>()
|
||||||
|
|
||||||
|
function getDataGridAutoSizeState(store: object): DataGridAutoSizeReflowState {
|
||||||
|
let state = dataGridAutoSizeStates.get(store)
|
||||||
|
if (!state) {
|
||||||
|
state = {
|
||||||
|
applied: null,
|
||||||
|
settleTimer: null,
|
||||||
|
pendingFreeSpace: 0,
|
||||||
|
lastCommitAt: 0,
|
||||||
|
}
|
||||||
|
dataGridAutoSizeStates.set(store, state)
|
||||||
|
}
|
||||||
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDataGridAutoSizeController<TData extends object>(
|
function createDataGridAutoSizeController<TData extends object>(
|
||||||
@@ -217,29 +459,95 @@ function createDataGridAutoSizeController<TData extends object>(
|
|||||||
*/
|
*/
|
||||||
getTable: () => DataGridTableInstance<TData>
|
getTable: () => DataGridTableInstance<TData>
|
||||||
): DataGridAutoSizeController {
|
): DataGridAutoSizeController {
|
||||||
let applied: { columnId: string; base: number; grown: number } | null = null
|
// Reflow coalescing: a live window drag streams a measurement per frame,
|
||||||
|
// and every accepted commit re-renders the table. One leading commit
|
||||||
|
// keeps single snaps (a maximize) instant; the rest of the stream parks
|
||||||
|
// its latest value behind one trailing timer, so a continuous drag costs
|
||||||
|
// two commits instead of sixty a second. The trailing timer may fire
|
||||||
|
// after the grid unmounts; it re-reads the table then, and a sizing
|
||||||
|
// write to the consumer's store is inert once nothing renders it.
|
||||||
|
const SETTLE_MS = 150
|
||||||
|
|
||||||
|
const reflow = (freeSpace: number): boolean => {
|
||||||
|
const table = getTable()
|
||||||
|
const state = getDataGridAutoSizeState(table.store)
|
||||||
|
// LIVE state, never the render snapshot: a measurement can run between
|
||||||
|
// our own sizing commit and React's re-render, and the stale snapshot
|
||||||
|
// would hide the width this controller just wrote.
|
||||||
|
const columnSizing =
|
||||||
|
table.atoms.columnSizing?.get() ?? table.state.columnSizing
|
||||||
|
if (!state.applied) return false
|
||||||
|
const autoSizeColumn = table
|
||||||
|
.getVisibleLeafColumns()
|
||||||
|
.find(
|
||||||
|
(column) => column.columnDef.meta?.autoSize && column.getCanResize()
|
||||||
|
)
|
||||||
|
if (!autoSizeColumn || autoSizeColumn.id !== state.applied.columnId)
|
||||||
|
return false
|
||||||
|
// A width this coordinator did not write belongs to the user's drag;
|
||||||
|
// reflow stands down until a reset re-arms the column.
|
||||||
|
const currentSize = columnSizing[state.applied.columnId]
|
||||||
|
if (currentSize !== undefined && currentSize !== state.applied.grown) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// The free space as it would measure with our growth removed; the
|
||||||
|
// target re-absorbs exactly that, floored at minSize so a narrow
|
||||||
|
// window hands space back without ever crushing the column (no
|
||||||
|
// explicit minSize floors at the starting width instead).
|
||||||
|
const freeAtBase = freeSpace + (state.applied.grown - state.applied.base)
|
||||||
|
const floor = autoSizeColumn.columnDef.minSize ?? state.applied.base
|
||||||
|
const target = Math.max(floor, state.applied.base + freeAtBase)
|
||||||
|
if (Math.abs(target - state.applied.grown) < 1) return false
|
||||||
|
state.applied = { ...state.applied, grown: target }
|
||||||
|
table.setColumnSizing((old) => ({
|
||||||
|
...old,
|
||||||
|
[state.applied!.columnId]: target,
|
||||||
|
}))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
apply(fillWidth: number) {
|
apply(freeSpace: number) {
|
||||||
const table = getTable()
|
const table = getTable()
|
||||||
const columnSizing = table.state.columnSizing
|
const state = getDataGridAutoSizeState(table.store)
|
||||||
|
// Same live read as `reflow`; the snapshot race is what made the
|
||||||
|
// re-arm check clear the bookkeeping right after the first absorb.
|
||||||
|
const columnSizing =
|
||||||
|
table.atoms.columnSizing?.get() ?? table.state.columnSizing
|
||||||
|
|
||||||
// Re-arm after reset flows (double-click resetSize, resetColumnSizing,
|
// Re-arm after reset flows (double-click resetSize, resetColumnSizing,
|
||||||
// controlled state replacement) so the column re-fills instead of
|
// controlled state replacement) so the column re-fills instead of
|
||||||
// leaving a dead blank strip.
|
// leaving a dead blank strip.
|
||||||
if (applied && columnSizing[applied.columnId] === undefined) {
|
if (state.applied && columnSizing[state.applied.columnId] === undefined) {
|
||||||
applied = null
|
state.applied = null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fillWidth <= 0) return false
|
|
||||||
|
|
||||||
const autoSizeColumn = table
|
const autoSizeColumn = table
|
||||||
.getVisibleLeafColumns()
|
.getVisibleLeafColumns()
|
||||||
.find(
|
.find(
|
||||||
(column) => column.columnDef.meta?.autoSize && column.getCanResize()
|
(column) => column.columnDef.meta?.autoSize && column.getCanResize()
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!autoSizeColumn || applied?.columnId === autoSizeColumn.id) {
|
if (autoSizeColumn && state.applied?.columnId === autoSizeColumn.id) {
|
||||||
|
state.pendingFreeSpace = freeSpace
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - state.lastCommitAt > SETTLE_MS) {
|
||||||
|
state.lastCommitAt = now
|
||||||
|
return reflow(freeSpace)
|
||||||
|
}
|
||||||
|
if (state.settleTimer !== null) clearTimeout(state.settleTimer)
|
||||||
|
state.settleTimer = setTimeout(() => {
|
||||||
|
state.settleTimer = null
|
||||||
|
state.lastCommitAt = Date.now()
|
||||||
|
reflow(state.pendingFreeSpace)
|
||||||
|
}, SETTLE_MS)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const fillWidth = Math.max(0, freeSpace)
|
||||||
|
if (fillWidth <= 0) return false
|
||||||
|
|
||||||
|
if (!autoSizeColumn) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,13 +557,13 @@ function createDataGridAutoSizeController<TData extends object>(
|
|||||||
// un-resizable: the drag committed, the next viewport measurement
|
// un-resizable: the drag committed, the next viewport measurement
|
||||||
// stamped the fill back on top, and the column snapped to its old width.
|
// stamped the fill back on top, and the column snapped to its old width.
|
||||||
//
|
//
|
||||||
// Deliberately keyed on observed state rather than on `applied`, which
|
// Deliberately keyed on observed state rather than on `state.applied`, which
|
||||||
// is per-coordinator memory: anything that rebuilds the coordinator
|
// is per-coordinator memory: anything that rebuilds the coordinator
|
||||||
// (a remount, a new table store) forgets what it did, and the guard has
|
// (a remount, a new table store) forgets what it did, and the guard has
|
||||||
// to survive that. An explicit reset clears the entry and re-arms the
|
// to survive that. An explicit reset clears the entry and re-arms the
|
||||||
// fill, which is what makes double-click-to-reset still work.
|
// fill, which is what makes double-click-to-reset still work.
|
||||||
const currentSize = columnSizing[autoSizeColumn.id]
|
const currentSize = columnSizing[autoSizeColumn.id]
|
||||||
if (currentSize !== undefined && currentSize !== applied?.grown) {
|
if (currentSize !== undefined && currentSize !== state.applied?.grown) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,13 +572,14 @@ function createDataGridAutoSizeController<TData extends object>(
|
|||||||
// user hasn't manually resized that column since, so visibility
|
// user hasn't manually resized that column since, so visibility
|
||||||
// toggles cannot ratchet the table wider than its container forever.
|
// toggles cannot ratchet the table wider than its container forever.
|
||||||
const revert =
|
const revert =
|
||||||
applied && columnSizing[applied.columnId] === applied.grown
|
state.applied &&
|
||||||
? applied
|
columnSizing[state.applied.columnId] === state.applied.grown
|
||||||
|
? state.applied
|
||||||
: null
|
: null
|
||||||
const base = columnSizing[autoSizeColumn.id] ?? autoSizeColumn.getSize()
|
const base = columnSizing[autoSizeColumn.id] ?? autoSizeColumn.getSize()
|
||||||
const grown = base + fillWidth
|
const grown = base + fillWidth
|
||||||
|
|
||||||
applied = { columnId: autoSizeColumn.id, base, grown }
|
state.applied = { columnId: autoSizeColumn.id, base, grown }
|
||||||
table.setColumnSizing((old) => {
|
table.setColumnSizing((old) => {
|
||||||
const next = { ...old, [autoSizeColumn.id]: grown }
|
const next = { ...old, [autoSizeColumn.id]: grown }
|
||||||
if (revert && next[revert.columnId] === revert.grown) {
|
if (revert && next[revert.columnId] === revert.grown) {
|
||||||
@@ -291,6 +600,104 @@ export type DataGridRequestParams = {
|
|||||||
columnFilters?: ColumnFiltersState
|
columnFilters?: ColumnFiltersState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which spreadsheet operation produced a cell change batch. */
|
||||||
|
export type DataGridCellsChangeSource =
|
||||||
|
| "paste"
|
||||||
|
| "cut"
|
||||||
|
| "clear"
|
||||||
|
| "fill"
|
||||||
|
| "edit"
|
||||||
|
|
||||||
|
export interface DataGridCellChange<TData> {
|
||||||
|
rowId: string
|
||||||
|
columnId: string
|
||||||
|
/** The consumer's row object, so immutable write-back needs no second lookup. */
|
||||||
|
row: TData
|
||||||
|
/** Accessor value at dispatch time; enables consumer-side undo stacks. */
|
||||||
|
previousValue: unknown
|
||||||
|
/** Typed value produced by `meta.cellEdit.parse`, or `clearValue` for clear/cut. */
|
||||||
|
value: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What onCellsCopy receives after the grid writes the clipboard. */
|
||||||
|
export interface DataGridCopyDetails {
|
||||||
|
/** The TSV text that reached the clipboard. */
|
||||||
|
text: string
|
||||||
|
/** The same content as a row-major grid of formatted fields. */
|
||||||
|
grid: string[][]
|
||||||
|
/** True when the write came from a cut, which clears the region next. */
|
||||||
|
cut: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One resolved positive region, in pre-paginated display indexes. */
|
||||||
|
export interface DataGridCellSelectionBound {
|
||||||
|
minRowIndex: number
|
||||||
|
maxRowIndex: number
|
||||||
|
minColumnIndex: number
|
||||||
|
maxColumnIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What onCellSelectionChange receives, resolved from the live selection. */
|
||||||
|
export interface DataGridCellSelectionSnapshot {
|
||||||
|
/** The virtually focused cell, the active range's anchor. Null when clear. */
|
||||||
|
focused: { rowId: string; columnId: string } | null
|
||||||
|
/** Every positive region, geometric order. */
|
||||||
|
bounds: DataGridCellSelectionBound[]
|
||||||
|
/** The most recent region, the one copy and paste act on. */
|
||||||
|
activeBound: DataGridCellSelectionBound | null
|
||||||
|
/**
|
||||||
|
* Cells of the selection the view can show - the count that matches the
|
||||||
|
* painted range and what a batch will touch.
|
||||||
|
*/
|
||||||
|
visibleCellCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataGridCellRejection {
|
||||||
|
rowId: string
|
||||||
|
columnId: string
|
||||||
|
raw: string
|
||||||
|
reason: "readonly" | "invalid"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle of a row for the optional CRUD indications: "new" and "dirty"
|
||||||
|
* tint the row, "deleted" mutes and strikes it. Purely presentational - the
|
||||||
|
* consumer's state stays the only owner of what the statuses mean.
|
||||||
|
*/
|
||||||
|
export type DataGridRowStatus = "new" | "dirty" | "deleted"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle of a cell for the optional CRUD indications: a corner mark in
|
||||||
|
* the amber tone for "dirty", the destructive tone for "invalid".
|
||||||
|
*/
|
||||||
|
export type DataGridCellStatus = "dirty" | "invalid"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the consumer to open its editor for the focused cell: Enter or F2, or
|
||||||
|
* a typed printable character (then `initialText` carries it, the
|
||||||
|
* Notion/Airtable type-to-replace flow). Dispatched only for cells whose
|
||||||
|
* column is writable via `meta.cellEdit`.
|
||||||
|
*/
|
||||||
|
export interface DataGridCellEditRequest<TData> {
|
||||||
|
rowId: string
|
||||||
|
columnId: string
|
||||||
|
row: TData
|
||||||
|
previousValue: unknown
|
||||||
|
/** The typed character that started the edit; absent for Enter and F2. */
|
||||||
|
initialText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One batch per operation, however many cells it spans: a 10k-cell paste is
|
||||||
|
* one callback, one consumer setState, one table rebuild. The grid never
|
||||||
|
* writes data itself.
|
||||||
|
*/
|
||||||
|
export interface DataGridCellsChangeDetails<TData> {
|
||||||
|
source: DataGridCellsChangeSource
|
||||||
|
changes: DataGridCellChange<TData>[]
|
||||||
|
rejected: DataGridCellRejection[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface DataGridProps<
|
export interface DataGridProps<
|
||||||
TFeatures extends TableFeatures,
|
TFeatures extends TableFeatures,
|
||||||
TData extends object,
|
TData extends object,
|
||||||
@@ -300,7 +707,62 @@ export interface DataGridProps<
|
|||||||
recordCount: number
|
recordCount: number
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
onRowClick?: (row: TData) => void
|
onRowClick?: (row: TData) => void
|
||||||
|
/**
|
||||||
|
* Receives every spreadsheet write batch (paste, cut, clear, fill, edit).
|
||||||
|
* Served through the props getter like `onRowClick`, so an inline identity
|
||||||
|
* never republishes the context. Absent: copy still works, writes no-op.
|
||||||
|
*/
|
||||||
|
onCellsChange?: (details: DataGridCellsChangeDetails<TData>) => void
|
||||||
|
/**
|
||||||
|
* Opens the consumer's editor from the keyboard: Enter, F2, or typing on
|
||||||
|
* the focused cell. Absent: Enter keeps moving down, typing does nothing.
|
||||||
|
*/
|
||||||
|
onCellEditRequest?: (request: DataGridCellEditRequest<TData>) => void
|
||||||
|
/**
|
||||||
|
* Fires on every cell-selection change (focus moves, ranges grow or
|
||||||
|
* clear) with a resolved snapshot, so a selection count, formula bar or
|
||||||
|
* context panel needs no reach into TanStack internals.
|
||||||
|
*/
|
||||||
|
onCellSelectionChange?: (snapshot: DataGridCellSelectionSnapshot) => void
|
||||||
|
/**
|
||||||
|
* Fires after the grid writes the clipboard (Cmd/Ctrl+C or X, or the
|
||||||
|
* native copy and cut events), e.g. to confirm with a toast.
|
||||||
|
*/
|
||||||
|
onCellsCopy?: (details: DataGridCopyDetails) => void
|
||||||
|
/**
|
||||||
|
* Renders an "Add row" affordance as the table body's last row; clicking
|
||||||
|
* it is the consumer's cue to start creating a row. Absent: no row.
|
||||||
|
*/
|
||||||
|
onRowCreate?: () => void
|
||||||
|
/** Label of the onRowCreate affordance. Defaults to "Add row". */
|
||||||
|
rowCreateLabel?: ReactNode
|
||||||
|
/**
|
||||||
|
* Rendered inside the table body after the data rows: the slot a DRAFT
|
||||||
|
* row lives in while it is being created, before it joins `data` and the
|
||||||
|
* pagination math. Supply a `<tr>` of cells; the colgroup sizes them.
|
||||||
|
*/
|
||||||
|
appendRow?: ReactNode
|
||||||
|
/**
|
||||||
|
* Optional CRUD indication per row. Return undefined for no indication;
|
||||||
|
* omit the prop entirely to render none anywhere.
|
||||||
|
*/
|
||||||
|
getRowStatus?: (row: TData) => DataGridRowStatus | undefined
|
||||||
|
/**
|
||||||
|
* Optional CRUD indication per cell: the classic corner mark on cells the
|
||||||
|
* consumer tracks as edited or invalid.
|
||||||
|
*/
|
||||||
|
getCellStatus?: (
|
||||||
|
row: TData,
|
||||||
|
columnId: string
|
||||||
|
) => DataGridCellStatus | undefined
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
|
/**
|
||||||
|
* Overrides for every built-in string: menu items, aria labels,
|
||||||
|
* pagination copy, state messages. Merged over the defaults; a more
|
||||||
|
* specific component prop (`rowCreateLabel`, `loadingMessage`, the
|
||||||
|
* pagination label props) still wins over its `i18n` counterpart.
|
||||||
|
*/
|
||||||
|
i18n?: DataGridI18nOverrides
|
||||||
loadingMode?: "skeleton" | "spinner"
|
loadingMode?: "skeleton" | "spinner"
|
||||||
loadingMessage?: ReactNode | string
|
loadingMessage?: ReactNode | string
|
||||||
fetchingMoreMessage?: ReactNode | string
|
fetchingMoreMessage?: ReactNode | string
|
||||||
@@ -325,6 +787,36 @@ export interface DataGridProps<
|
|||||||
columnsDraggable?: boolean
|
columnsDraggable?: boolean
|
||||||
rowsDraggable?: boolean
|
rowsDraggable?: boolean
|
||||||
rowsPinnable?: boolean
|
rowsPinnable?: boolean
|
||||||
|
/** Spreadsheet cell range selection: mouse ranges, keyboard, clipboard chrome. */
|
||||||
|
cellSelection?: boolean
|
||||||
|
/**
|
||||||
|
* "range" (default): drag, Shift and Ctrl/Cmd gestures and Shift+keys
|
||||||
|
* grow multi-cell regions. "single": one focused cell only - every
|
||||||
|
* gesture collapses to it and Ctrl/Cmd+A is inert.
|
||||||
|
*/
|
||||||
|
cellSelectionMode?: "single" | "range"
|
||||||
|
/** Drag-to-fill handle on the selection corner. Requires cellSelection. */
|
||||||
|
cellFillHandle?: boolean
|
||||||
|
/**
|
||||||
|
* How a cell opens its editor from the mouse: "dblclick" (default,
|
||||||
|
* the spreadsheet standard - first click focuses, second edits) or
|
||||||
|
* "click", opening the editor as soon as a plain click lands on the
|
||||||
|
* focused cell; modifier clicks and drags still select.
|
||||||
|
*/
|
||||||
|
cellEditMode?: "dblclick" | "click"
|
||||||
|
/**
|
||||||
|
* When true, committing an edit with Enter moves focus down (Shift+Enter
|
||||||
|
* up), the Sheets and Excel convention. Off by default: the commit keeps
|
||||||
|
* focus on the edited cell. Tab always commits and moves across.
|
||||||
|
*/
|
||||||
|
cellEditEnterAdvance?: boolean
|
||||||
|
/**
|
||||||
|
* The handle's look: "dot" is a solid primary dot kept inside the
|
||||||
|
* cell corner; "ring" is the Sheets-style hollow circle riding the
|
||||||
|
* corner point, clamped at the grid's own boundary; "square" is the
|
||||||
|
* Excel-style solid square inside the corner. Default "dot".
|
||||||
|
*/
|
||||||
|
cellFillHandleVariant?: "dot" | "ring" | "square"
|
||||||
}
|
}
|
||||||
tableClassNames?: {
|
tableClassNames?: {
|
||||||
base?: string
|
base?: string
|
||||||
@@ -335,6 +827,20 @@ export interface DataGridProps<
|
|||||||
bodyRow?: string
|
bodyRow?: string
|
||||||
footer?: string
|
footer?: string
|
||||||
edgeCell?: string
|
edgeCell?: string
|
||||||
|
/** Override the getRowStatus tints; defaults are the warning-muted set. */
|
||||||
|
rowNew?: string
|
||||||
|
rowDirty?: string
|
||||||
|
rowDeleted?: string
|
||||||
|
/**
|
||||||
|
* Override the pinned-row chrome (muted tint, boundary shadow). A grid
|
||||||
|
* that pins a draft row for quick-create wants it to read as an active
|
||||||
|
* row, not a muted one.
|
||||||
|
*/
|
||||||
|
rowPinned?: string
|
||||||
|
/** The onRowCreate affordance row, e.g. to match the grid's row height. */
|
||||||
|
rowCreate?: string
|
||||||
|
/** The fill handle, e.g. to restyle or resize it beyond the built-in variants. */
|
||||||
|
cellFillHandle?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,11 +878,16 @@ function DataGridProvider<TData extends object>({
|
|||||||
}) {
|
}) {
|
||||||
// Latest-props ref: context reads always resolve fresh props through the
|
// Latest-props ref: context reads always resolve fresh props through the
|
||||||
// getter below without the memoized context value depending on unstable
|
// getter below without the memoized context value depending on unstable
|
||||||
|
// Stable for the grid's lifetime; ARIA ids and relations hang off it.
|
||||||
|
const gridId = useId()
|
||||||
// ReactNode/function prop identities (inline emptyMessage/onRowClick would
|
// ReactNode/function prop identities (inline emptyMessage/onRowClick would
|
||||||
// otherwise publish a new context value on every consumer render - at
|
// otherwise publish a new context value on every consumer render - at
|
||||||
// mousemove rate during a resize drag, piercing the body-rows memo).
|
// mousemove rate during a resize drag, piercing the body-rows memo).
|
||||||
const propsRef = useRef(props)
|
const propsRef = useRef(props)
|
||||||
propsRef.current = props
|
propsRef.current = props
|
||||||
|
const i18n = mergeDataGridI18n(props.i18n)
|
||||||
|
const i18nRef = useRef(i18n)
|
||||||
|
i18nRef.current = i18n
|
||||||
|
|
||||||
// Same treatment for the table itself, which v9 - unlike v8 - re-creates on
|
// Same treatment for the table itself, which v9 - unlike v8 - re-creates on
|
||||||
// every state change. Depending on it directly would republish the context
|
// every state change. Depending on it directly would republish the context
|
||||||
@@ -401,6 +912,32 @@ function DataGridProvider<TData extends object>({
|
|||||||
table.setOptions((old) => ({ ...old, columnResizeMode: resizeMode }))
|
table.setOptions((old) => ({ ...old, columnResizeMode: resizeMode }))
|
||||||
}, [table, resizeMode])
|
}, [table, resizeMode])
|
||||||
|
|
||||||
|
// With the selection UI on, every paste/fill/clear batch replaces `data`,
|
||||||
|
// and the feature's default autoResetCellSelection would wipe the selection
|
||||||
|
// after each write. Ranges are row-id based and unresolvable ids drop out
|
||||||
|
// of the bounds safely, so keeping state across data edits is the correct
|
||||||
|
// default here.
|
||||||
|
const cellSelectionOn = !!props.tableLayout?.cellSelection
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cellSelectionOn || table.atoms.cellSelection == null) return
|
||||||
|
if (table.options.autoResetCellSelection === false) return
|
||||||
|
table.setOptions((old) => ({ ...old, autoResetCellSelection: false }))
|
||||||
|
}, [table, cellSelectionOn])
|
||||||
|
|
||||||
|
// Rendered row indexes re-base per page, so a range kept across a page flip
|
||||||
|
// re-lights the same positions over different rows. A page change clears
|
||||||
|
// the selection instead - the rule Excel-family grids apply to paginated
|
||||||
|
// range selection.
|
||||||
|
const pageKey = `${table.state.pagination?.pageIndex}:${table.state.pagination?.pageSize}`
|
||||||
|
const previousPageKeyRef = useRef(pageKey)
|
||||||
|
useEffect(() => {
|
||||||
|
if (previousPageKeyRef.current === pageKey) return
|
||||||
|
previousPageKeyRef.current = pageKey
|
||||||
|
if (!cellSelectionOn || table.atoms.cellSelection == null) return
|
||||||
|
tableRef.current.resetCellSelection(true)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [pageKey, cellSelectionOn])
|
||||||
|
|
||||||
// One autoSize coordinator per table instance so split header/body viewports
|
// One autoSize coordinator per table instance so split header/body viewports
|
||||||
// cannot apply the growth twice. Keyed on `table.store`, which v9 keeps
|
// cannot apply the growth twice. Keyed on `table.store`, which v9 keeps
|
||||||
// stable for the life of the table, rather than on `table` itself: the
|
// stable for the life of the table, rather than on `table` itself: the
|
||||||
@@ -419,6 +956,9 @@ function DataGridProvider<TData extends object>({
|
|||||||
// ReactNode/function props (messages, onRowClick) are also excluded: they
|
// ReactNode/function props (messages, onRowClick) are also excluded: they
|
||||||
// are served fresh through the props getter, so unstable inline identities
|
// are served fresh through the props getter, so unstable inline identities
|
||||||
// cannot invalidate the context value.
|
// cannot invalidate the context value.
|
||||||
|
// `cellSelection` state is excluded on purpose too: a drag writes it once
|
||||||
|
// per cell crossed, and nothing reads it through context - cells and the
|
||||||
|
// selection bar subscribe to `table.atoms.cellSelection` directly.
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
get props() {
|
get props() {
|
||||||
@@ -427,8 +967,12 @@ function DataGridProvider<TData extends object>({
|
|||||||
get table() {
|
get table() {
|
||||||
return tableRef.current
|
return tableRef.current
|
||||||
},
|
},
|
||||||
|
get i18n() {
|
||||||
|
return i18nRef.current
|
||||||
|
},
|
||||||
recordCount: props.recordCount,
|
recordCount: props.recordCount,
|
||||||
isLoading: props.isLoading || false,
|
isLoading: props.isLoading || false,
|
||||||
|
gridId,
|
||||||
autoSize,
|
autoSize,
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -495,6 +1039,8 @@ function DataGrid<TFeatures extends TableFeatures, TData extends object>({
|
|||||||
columnsDraggable: false,
|
columnsDraggable: false,
|
||||||
rowsDraggable: false,
|
rowsDraggable: false,
|
||||||
rowsPinnable: false,
|
rowsPinnable: false,
|
||||||
|
cellSelection: false,
|
||||||
|
cellFillHandle: false,
|
||||||
},
|
},
|
||||||
tableClassNames: {
|
tableClassNames: {
|
||||||
base: "",
|
base: "",
|
||||||
@@ -555,7 +1101,9 @@ function DataGridContainer({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="data-grid"
|
data-slot="data-grid"
|
||||||
className={cn("w-full overflow-hidden", className)}
|
// relative: anchors floating chrome composed inside the container,
|
||||||
|
// like the fill-drag preview outline.
|
||||||
|
className={cn("relative w-full overflow-hidden", className)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,237 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useMemo } from "react"
|
|
||||||
import {
|
|
||||||
EventCalendarViewContext,
|
|
||||||
useEventCalendar,
|
|
||||||
useEventCalendarSelector,
|
|
||||||
useEventCalendarSettings,
|
|
||||||
useEventCalendarViewConfig,
|
|
||||||
} from "@/components/reui/event-calendar/event-calendar"
|
|
||||||
import { EventCalendarEvent } from "@/components/reui/event-calendar/event-calendar-event"
|
|
||||||
import {
|
|
||||||
getDayKey,
|
|
||||||
getRangeKey,
|
|
||||||
toZoned,
|
|
||||||
zonedStartOfDay,
|
|
||||||
} from "@/components/reui/event-calendar/event-calendar-lib"
|
|
||||||
import type {
|
|
||||||
EventCalendarDateRange,
|
|
||||||
EventCalendarSegment,
|
|
||||||
} from "@/components/reui/event-calendar/event-calendar-types"
|
|
||||||
import { IconStack } from "@/components/reui/icon-stack"
|
|
||||||
import { mergeProps } from "@base-ui/react/merge-props"
|
|
||||||
import { useRender } from "@base-ui/react/use-render"
|
|
||||||
import { addDays, format } from "date-fns"
|
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
import { ScrollArea } from "@evobgp/ui/components/scroll-area"
|
|
||||||
import { CalendarIcon } from "lucide-react"
|
|
||||||
|
|
||||||
// The agenda window length is the agendaDayCount SETTING (the store derives
|
|
||||||
// visibleRange from it); a per-view prop here would silently disagree.
|
|
||||||
type EventCalendarAgendaViewProps = useRender.ComponentProps<"div">
|
|
||||||
|
|
||||||
function EventCalendarAgendaView({
|
|
||||||
className,
|
|
||||||
render,
|
|
||||||
...props
|
|
||||||
}: EventCalendarAgendaViewProps) {
|
|
||||||
const instance = useEventCalendar()
|
|
||||||
const settings = useEventCalendarSettings()
|
|
||||||
const viewConfig = useEventCalendarViewConfig()
|
|
||||||
const visibleRange = useEventCalendarSelector<
|
|
||||||
unknown,
|
|
||||||
EventCalendarDateRange
|
|
||||||
>((state) => state.visibleRange, {
|
|
||||||
isEqual: (a, b) => getRangeKey(a) === getRangeKey(b),
|
|
||||||
})
|
|
||||||
// Subscribe to event changes via the day-bucket content of the whole range
|
|
||||||
useEventCalendarSelector((state) => state.events)
|
|
||||||
|
|
||||||
const days = useMemo(() => {
|
|
||||||
const result: Date[] = []
|
|
||||||
let cursor = zonedStartOfDay(visibleRange.start, settings.timeZone)
|
|
||||||
while (cursor < visibleRange.end) {
|
|
||||||
result.push(cursor)
|
|
||||||
cursor = zonedStartOfDay(
|
|
||||||
addDays(toZoned(cursor, settings.timeZone), 1),
|
|
||||||
settings.timeZone
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}, [visibleRange, settings.timeZone])
|
|
||||||
|
|
||||||
const index = instance.internals.getIndex()
|
|
||||||
const groups = days
|
|
||||||
.map((day) => ({
|
|
||||||
day,
|
|
||||||
bucket: index.byDay.get(getDayKey(day, settings.timeZone)),
|
|
||||||
}))
|
|
||||||
.filter((group) => {
|
|
||||||
const total =
|
|
||||||
(group.bucket?.allDay.length ?? 0) + (group.bucket?.timed.length ?? 0)
|
|
||||||
return total > 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const isToday = (day: Date) =>
|
|
||||||
getDayKey(day, settings.timeZone) ===
|
|
||||||
getDayKey(new Date(), settings.timeZone)
|
|
||||||
|
|
||||||
const native = viewConfig.scrollbars === "native"
|
|
||||||
|
|
||||||
const body = (
|
|
||||||
<>
|
|
||||||
{groups.length === 0 ? (
|
|
||||||
<div
|
|
||||||
data-slot="event-calendar-no-events"
|
|
||||||
className={cn(
|
|
||||||
"flex min-h-72 flex-col items-center justify-center gap-4 py-16",
|
|
||||||
viewConfig.classNames?.noEvents
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{viewConfig.renderNoEvents?.() ?? (
|
|
||||||
<>
|
|
||||||
<IconStack>
|
|
||||||
<CalendarIcon className="size-5" aria-hidden="true" />
|
|
||||||
</IconStack>
|
|
||||||
<span className="text-muted-foreground text-sm">
|
|
||||||
{settings.i18n.labels.noEvents}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// Drop the very last row's bottom border so it does not double up with
|
|
||||||
// the calendar container's own bottom border. Targets the last day
|
|
||||||
// group's last child (its last agenda item); per-item `border-b` is
|
|
||||||
// kept everywhere else, including each day's internal rows.
|
|
||||||
<div className="flex flex-col [&>*:last-child>*:last-child]:border-b-0">
|
|
||||||
{groups.map(({ day, bucket }) => {
|
|
||||||
const items = [...(bucket?.allDay ?? []), ...(bucket?.timed ?? [])]
|
|
||||||
const zoned = toZoned(day, settings.timeZone)
|
|
||||||
const weekday = format(zoned, "EEEE", { locale: settings.locale })
|
|
||||||
const dayDate = format(zoned, "MMMM d, yyyy", {
|
|
||||||
locale: settings.locale,
|
|
||||||
})
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={day.getTime()}
|
|
||||||
data-slot="event-calendar-agenda-day"
|
|
||||||
data-today={isToday(day) || undefined}
|
|
||||||
// A named group per day so a screen reader can step day by day
|
|
||||||
// (and hear how full one is) instead of arrowing every row.
|
|
||||||
role="group"
|
|
||||||
aria-label={`${weekday}, ${dayDate}, ${settings.i18n.labels.events(items.length)}`}
|
|
||||||
>
|
|
||||||
{/* Group header: weekday (leading) + full date (trailing) */}
|
|
||||||
<div
|
|
||||||
data-slot="event-calendar-agenda-day-header"
|
|
||||||
// The day bar is the agenda's only structure, so give it a
|
|
||||||
// heading level: the H key and the rotor can jump between
|
|
||||||
// days, which is the whole point of a long agenda.
|
|
||||||
role="heading"
|
|
||||||
aria-level={3}
|
|
||||||
className={cn(
|
|
||||||
"bg-muted/60 sticky top-0 z-10 flex items-baseline justify-between gap-4 border-b px-4 py-2",
|
|
||||||
// The custom ScrollArea's overlay scrollbar (w-2.5 = 10px)
|
|
||||||
// is painted UNDER this sticky, z-10, opaque header, so the
|
|
||||||
// thumb vanishes behind the day bar at the top of the view.
|
|
||||||
// Inset the header by the scrollbar lane so its background
|
|
||||||
// stops before the scrollbar instead of covering it. Native
|
|
||||||
// scrollbars already sit outside the content box, so this
|
|
||||||
// only applies to the custom-scrollbar path.
|
|
||||||
!native && "me-2.5",
|
|
||||||
viewConfig.classNames?.agendaDayHeader
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-foreground font-semibold",
|
|
||||||
isToday(day) && "text-primary"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{weekday}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground font-medium tabular-nums">
|
|
||||||
{dayDate}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{items.map((segment) => (
|
|
||||||
<EventCalendarAgendaItem
|
|
||||||
key={segment.occurrence.key}
|
|
||||||
segment={segment}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
"data-slot": "event-calendar-agenda-view",
|
|
||||||
"data-view": "agenda",
|
|
||||||
// Unlike the grid views the agenda has no row/column semantics to carry a
|
|
||||||
// name, so label the region with the day range it covers - through
|
|
||||||
// formatDayRange, so a consumer override reaches it.
|
|
||||||
role: "group",
|
|
||||||
"aria-label": settings.i18n.functions.formatDayRange(visibleRange, {
|
|
||||||
locale: settings.locale,
|
|
||||||
}),
|
|
||||||
className: cn(
|
|
||||||
"flex min-h-0 flex-1 flex-col overflow-hidden border-t",
|
|
||||||
viewConfig.classNames?.agendaView,
|
|
||||||
className
|
|
||||||
),
|
|
||||||
children: native ? (
|
|
||||||
<div
|
|
||||||
data-slot="scroll-area-viewport"
|
|
||||||
data-ec-native-scroll=""
|
|
||||||
className="h-full overflow-y-auto"
|
|
||||||
>
|
|
||||||
{body}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ScrollArea className="h-full">{body}</ScrollArea>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EventCalendarViewContext.Provider value={{ view: "agenda" }}>
|
|
||||||
{useRender({
|
|
||||||
defaultTagName: "div",
|
|
||||||
render,
|
|
||||||
props: mergeProps<"div">(defaultProps, props),
|
|
||||||
})}
|
|
||||||
</EventCalendarViewContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One agenda row: a full-width, selectable table row - time column, color dot,
|
|
||||||
* and title (all replaceable via renderAgendaEvent). Clicking selects the
|
|
||||||
* event (drag/resize stay off in the agenda).
|
|
||||||
*/
|
|
||||||
function EventCalendarAgendaItem({
|
|
||||||
segment,
|
|
||||||
}: {
|
|
||||||
segment: EventCalendarSegment
|
|
||||||
}) {
|
|
||||||
const viewConfig = useEventCalendarViewConfig()
|
|
||||||
return (
|
|
||||||
<EventCalendarEvent
|
|
||||||
segment={segment}
|
|
||||||
className={cn(
|
|
||||||
// read-only list: hover only, no selected/focused styling on click
|
|
||||||
"hover:bg-accent/40 gap-3 rounded-none border-b px-4 py-2.5 transition-colors",
|
|
||||||
viewConfig.classNames?.agendaItem
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { EventCalendarAgendaView }
|
|
||||||
export type { EventCalendarAgendaViewProps }
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { type ComponentType, type ReactNode } from "react"
|
|
||||||
import {
|
|
||||||
useEventCalendarSelector,
|
|
||||||
useEventCalendarViewConfig,
|
|
||||||
} from "@/components/reui/event-calendar/event-calendar"
|
|
||||||
import { EventCalendarAgendaView } from "@/components/reui/event-calendar/event-calendar-agenda-view"
|
|
||||||
import { EventCalendarMonthView } from "@/components/reui/event-calendar/event-calendar-month-view"
|
|
||||||
import { EventCalendarResourceView } from "@/components/reui/event-calendar/event-calendar-resource-view"
|
|
||||||
import {
|
|
||||||
EventCalendarDaysView,
|
|
||||||
EventCalendarDayView,
|
|
||||||
EventCalendarWeekView,
|
|
||||||
} from "@/components/reui/event-calendar/event-calendar-time-grid"
|
|
||||||
import type { CalendarView } from "@/components/reui/event-calendar/event-calendar-types"
|
|
||||||
import { mergeProps } from "@base-ui/react/merge-props"
|
|
||||||
import { useRender } from "@base-ui/react/use-render"
|
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
|
|
||||||
const DEFAULT_VIEW_COMPONENTS: Record<CalendarView, ComponentType> = {
|
|
||||||
month: EventCalendarMonthView,
|
|
||||||
week: EventCalendarWeekView,
|
|
||||||
day: EventCalendarDayView,
|
|
||||||
days: EventCalendarDaysView,
|
|
||||||
agenda: EventCalendarAgendaView,
|
|
||||||
resource: EventCalendarResourceView,
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EventCalendarContentProps extends Omit<
|
|
||||||
useRender.ComponentProps<"div">,
|
|
||||||
"children"
|
|
||||||
> {
|
|
||||||
/** Swap individual view implementations. */
|
|
||||||
components?: Partial<Record<CalendarView, ComponentType>>
|
|
||||||
/** Replaces the switchboard entirely; read useEventCalendarView() inside. */
|
|
||||||
children?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
function EventCalendarContent({
|
|
||||||
className,
|
|
||||||
render,
|
|
||||||
components,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: EventCalendarContentProps) {
|
|
||||||
const viewConfig = useEventCalendarViewConfig()
|
|
||||||
const view = useEventCalendarSelector((state) => state.view)
|
|
||||||
const loading = useEventCalendarSelector((state) => state.loading)
|
|
||||||
|
|
||||||
const resolved = {
|
|
||||||
...DEFAULT_VIEW_COMPONENTS,
|
|
||||||
...viewConfig.components,
|
|
||||||
...components,
|
|
||||||
}
|
|
||||||
// A spread copies keys that hold `undefined`, so `components={{ month: isPro
|
|
||||||
// ? ProMonth : undefined }}` would erase the default and render <undefined />.
|
|
||||||
const ActiveView = resolved[view] ?? DEFAULT_VIEW_COMPONENTS[view]
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
"data-slot": "event-calendar-content",
|
|
||||||
"data-view": view,
|
|
||||||
"data-loading": loading || undefined,
|
|
||||||
className: cn(
|
|
||||||
"relative flex min-h-0 min-w-0 flex-1 flex-col",
|
|
||||||
"data-loading:pointer-events-none data-loading:opacity-60",
|
|
||||||
viewConfig.classNames?.content,
|
|
||||||
className
|
|
||||||
),
|
|
||||||
children: children ?? <ActiveView />,
|
|
||||||
}
|
|
||||||
|
|
||||||
return useRender({
|
|
||||||
defaultTagName: "div",
|
|
||||||
render,
|
|
||||||
props: mergeProps<"div">(defaultProps, props),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DEFAULT_VIEW_COMPONENTS, EventCalendarContent }
|
|
||||||
export type { EventCalendarContentProps }
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,555 +0,0 @@
|
|||||||
import {
|
|
||||||
createContext,
|
|
||||||
useContext,
|
|
||||||
useMemo,
|
|
||||||
type CSSProperties,
|
|
||||||
type ReactNode,
|
|
||||||
} from "react"
|
|
||||||
import {
|
|
||||||
useEventCalendar,
|
|
||||||
useEventCalendarSelector,
|
|
||||||
useEventCalendarViewConfig,
|
|
||||||
useEventCalendarViewContext,
|
|
||||||
} from "@/components/reui/event-calendar/event-calendar"
|
|
||||||
import {
|
|
||||||
markChipPress,
|
|
||||||
useEventCalendarGestures,
|
|
||||||
wasRecentDrag,
|
|
||||||
} from "@/components/reui/event-calendar/event-calendar-dnd"
|
|
||||||
import {
|
|
||||||
spansMultipleDays,
|
|
||||||
toZoned,
|
|
||||||
zonedStartOfDay,
|
|
||||||
} from "@/components/reui/event-calendar/event-calendar-lib"
|
|
||||||
import type {
|
|
||||||
EventCalendarOccurrence,
|
|
||||||
EventCalendarSegment,
|
|
||||||
} from "@/components/reui/event-calendar/event-calendar-types"
|
|
||||||
import { mergeProps } from "@base-ui/react/merge-props"
|
|
||||||
import { useRender } from "@base-ui/react/use-render"
|
|
||||||
import { addDays, format } from "date-fns"
|
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@evobgp/ui/components/tooltip"
|
|
||||||
import { RepeatIcon } from "lucide-react"
|
|
||||||
|
|
||||||
/** Event color presets; each stays legible on light and dark surfaces. */
|
|
||||||
const EVENT_CALENDAR_COLORS: Array<{ name: string; value: string }> = [
|
|
||||||
{ name: "Blue", value: "var(--color-blue-500)" },
|
|
||||||
{ name: "Emerald", value: "var(--color-emerald-500)" },
|
|
||||||
{ name: "Violet", value: "var(--color-violet-500)" },
|
|
||||||
{ name: "Rose", value: "var(--color-rose-500)" },
|
|
||||||
{ name: "Amber", value: "var(--color-amber-500)" },
|
|
||||||
{ name: "Cyan", value: "var(--color-cyan-500)" },
|
|
||||||
{ name: "Orange", value: "var(--color-orange-500)" },
|
|
||||||
{ name: "Pink", value: "var(--color-pink-500)" },
|
|
||||||
{ name: "Teal", value: "var(--color-teal-500)" },
|
|
||||||
{ name: "Indigo", value: "var(--color-indigo-500)" },
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drag-ghost surfaces, shared verbatim by every view. A move CARRIES the
|
|
||||||
* event: the dnd engine attaches a full clone to the cursor
|
|
||||||
* (data-slot=event-calendar-drag-carry), so this in-grid ghost is only the
|
|
||||||
* dashed placeholder for the snapped drop slot. A resize STRETCHES instead:
|
|
||||||
* the chip itself at the proposed extent, dashed rather than solid.
|
|
||||||
*/
|
|
||||||
const EVENT_CALENDAR_GHOST = {
|
|
||||||
move: "rounded-sm border border-dashed border-(--ec-event-color)/50 bg-(--ec-event-color)/8",
|
|
||||||
resize:
|
|
||||||
"rounded-sm border border-dashed border-(--ec-event-color)/70 overflow-hidden",
|
|
||||||
invalid: "border-destructive/70 bg-destructive/10",
|
|
||||||
invalidResize: "border-destructive/70",
|
|
||||||
/** Applied to the clone inside an invalid resize ghost. */
|
|
||||||
invalidContent: "opacity-60",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fade-out truncation for stacked timed blocks, where squeezed cascade
|
|
||||||
* columns clip titles into a mash of glyphs; a right-edge mask fade reads
|
|
||||||
* cleaner than an ellipsis at those widths. Masked ONLY below a 10rem
|
|
||||||
* container: mask-image forces text off subpixel antialiasing, so masking
|
|
||||||
* wide chips makes the whole grid read bolder and shimmer while resizing.
|
|
||||||
* Wide chips keep the plain ellipsis. Exported for consumer renderEvent.
|
|
||||||
*/
|
|
||||||
const EVENT_CALENDAR_FADE_TRUNCATE =
|
|
||||||
"w-full truncate @max-[10rem]:text-clip @max-[10rem]:[mask-image:linear-gradient(to_right,#000_calc(100%-0.75rem),transparent)] @max-[10rem]:rtl:[mask-image:linear-gradient(to_left,#000_calc(100%-0.75rem),transparent)]"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The drag-to-create selection, shared by every view: a dashed primary
|
|
||||||
* outline over a faint wash with the range printed inside. `box` is the timed
|
|
||||||
* grid's single minute-positioned rectangle; `segment` is one day-cell slice
|
|
||||||
* of a multi-cell draft, with side borders and rounding only on the run's two
|
|
||||||
* ends so it reads as one dashed box rather than a row of them.
|
|
||||||
*/
|
|
||||||
const EVENT_CALENDAR_SLOT_DRAFT = {
|
|
||||||
box: "rounded-sm border border-dashed border-primary/40 bg-primary/5",
|
|
||||||
segment: "border-y border-dashed border-primary/40",
|
|
||||||
segmentStart: "rounded-s-sm border-s",
|
|
||||||
segmentEnd: "rounded-e-sm border-e",
|
|
||||||
/**
|
|
||||||
* The wash for segmented views, on the CELL not the dashed overlay: the
|
|
||||||
* overlay stacks above the chips, so tinting it would wash them instead.
|
|
||||||
*/
|
|
||||||
surface: "bg-primary/5",
|
|
||||||
/**
|
|
||||||
* The range readout. `leading-none` is load-bearing: the shortest timed
|
|
||||||
* draft is one snap interval tall (16px at the default 15-minute snap and
|
|
||||||
* 4rem hour height), and looser leading renders ~18px, clipped by the
|
|
||||||
* draft's own overflow-hidden.
|
|
||||||
*/
|
|
||||||
label:
|
|
||||||
"text-primary truncate px-1 py-0.5 text-[0.6875rem] leading-none font-medium",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
interface EventCalendarChipContextValue<TData = unknown> {
|
|
||||||
occurrence: EventCalendarOccurrence<TData>
|
|
||||||
segment: EventCalendarSegment<TData>
|
|
||||||
isDragging: boolean
|
|
||||||
isSelected: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const EventCalendarChipContext =
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
createContext<EventCalendarChipContextValue<any> | null>(null)
|
|
||||||
|
|
||||||
/** The chip's subject; usable inside renderEvent content and chip children. */
|
|
||||||
function useEventCalendarEventChip<
|
|
||||||
TData = unknown,
|
|
||||||
>(): EventCalendarChipContextValue<TData> {
|
|
||||||
const ctx = useContext(EventCalendarChipContext)
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error(
|
|
||||||
"useEventCalendarEventChip must be used within <EventCalendarEvent>"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return ctx as EventCalendarChipContextValue<TData>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EventCalendarEventProps<TData = unknown> extends Omit<
|
|
||||||
useRender.ComponentProps<"button">,
|
|
||||||
"children"
|
|
||||||
> {
|
|
||||||
segment: EventCalendarSegment<TData>
|
|
||||||
/** Replaces the default chip CONTENT; the wrapper stays calendar-owned. */
|
|
||||||
children?: ReactNode
|
|
||||||
/**
|
|
||||||
* Static drag clone: the chip as-is but inert - no gestures, resize
|
|
||||||
* handles, selection/drag state, focus or pointer events.
|
|
||||||
*/
|
|
||||||
preview?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The one interactive event element in every view. The wrapper owns a11y,
|
|
||||||
* data attributes, selection and drag/resize wiring; content comes from
|
|
||||||
* children, else renderEvent, else the built-in default.
|
|
||||||
*/
|
|
||||||
function EventCalendarEvent<TData = unknown>({
|
|
||||||
segment,
|
|
||||||
className,
|
|
||||||
render,
|
|
||||||
children,
|
|
||||||
preview = false,
|
|
||||||
...props
|
|
||||||
}: EventCalendarEventProps<TData>) {
|
|
||||||
const instance = useEventCalendar<TData>()
|
|
||||||
const viewConfig = useEventCalendarViewConfig<TData>()
|
|
||||||
const { view } = useEventCalendarViewContext()
|
|
||||||
const gestures = useEventCalendarGestures<TData>()
|
|
||||||
const { settings } = instance
|
|
||||||
const occurrence = segment.occurrence
|
|
||||||
const event = occurrence.event
|
|
||||||
|
|
||||||
const isSelectedRaw = useEventCalendarSelector<TData, boolean>(
|
|
||||||
(state) => state.selection.eventKeys.includes(occurrence.key),
|
|
||||||
{ calendar: instance }
|
|
||||||
)
|
|
||||||
const isDraggingRaw = useEventCalendarSelector<TData, boolean>(
|
|
||||||
(state) => state.drag?.occurrence.key === occurrence.key,
|
|
||||||
{ calendar: instance }
|
|
||||||
)
|
|
||||||
// reactive, unlike gestures.canResize: api.setInteractions({ resize })
|
|
||||||
// must add/remove the handles without waiting for an unrelated re-render
|
|
||||||
const resizeOn = useEventCalendarSelector<TData, boolean>(
|
|
||||||
(state) => state.interactions.resize,
|
|
||||||
{ calendar: instance }
|
|
||||||
)
|
|
||||||
// A preview clone must never inherit the source's selected/dragging state
|
|
||||||
// (the drag key matches, which would dim the clone itself).
|
|
||||||
const isSelected = preview ? false : isSelectedRaw
|
|
||||||
const isDragging = preview ? false : isDraggingRaw
|
|
||||||
|
|
||||||
const isBar =
|
|
||||||
occurrence.allDay || spansMultipleDays(occurrence, settings.timeZone)
|
|
||||||
const inTimeGrid =
|
|
||||||
view === "week" || view === "day" || view === "days" || view === "resource"
|
|
||||||
const interactive = view !== "agenda" && !preview
|
|
||||||
const timedBlock = inTimeGrid && !isBar
|
|
||||||
const horizontalBar = isBar && !inTimeGrid
|
|
||||||
// >= compactEventMinutes renders the stacked (title over time) layout, where
|
|
||||||
// squeezed cascade columns fade-truncate instead of clipping into neighbors
|
|
||||||
const stackedBlock =
|
|
||||||
timedBlock &&
|
|
||||||
(segment.endMin ?? 0) - (segment.startMin ?? 0) >=
|
|
||||||
viewConfig.compactEventMinutes
|
|
||||||
|
|
||||||
const defaultContent = (
|
|
||||||
<>
|
|
||||||
{/* leading dot for single-row chips (month cells, all-day bars); a
|
|
||||||
time-grid block takes its color from the tinted surface instead, and
|
|
||||||
in its stacked layout a dot would sit alone on the first line */}
|
|
||||||
{!timedBlock && (
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
data-slot="event-calendar-event-dot"
|
|
||||||
// -me-0.5 tightens only the dot-to-title gap; the chip keeps gap-1.5
|
|
||||||
className="-me-0.5 size-1.5 shrink-0 rounded-full bg-(--ec-event-color)"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{occurrence.isRecurring && (
|
|
||||||
<RepeatIcon className="size-2.5 shrink-0 opacity-70" aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"font-medium",
|
|
||||||
stackedBlock ? EVENT_CALENDAR_FADE_TRUNCATE : "truncate"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{event.title}
|
|
||||||
</span>
|
|
||||||
{/* month cells are narrow: a compact never-shrinking start time keeps
|
|
||||||
the title readable; grids show the full range */}
|
|
||||||
{!occurrence.allDay &&
|
|
||||||
segment.isStart &&
|
|
||||||
(view === "month" ? (
|
|
||||||
<span className="text-muted-foreground shrink-0">
|
|
||||||
{format(
|
|
||||||
toZoned(occurrence.start, settings.timeZone),
|
|
||||||
settings.i18n.formats.eventTime,
|
|
||||||
{ locale: settings.locale }
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground hidden @[8rem]:inline",
|
|
||||||
stackedBlock ? EVENT_CALENDAR_FADE_TRUNCATE : "truncate"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{settings.i18n.functions.formatEventTime(
|
|
||||||
toZoned(occurrence.start, settings.timeZone),
|
|
||||||
toZoned(occurrence.end, settings.timeZone),
|
|
||||||
occurrence.allDay,
|
|
||||||
{ locale: settings.locale }
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Per-day time text for a multi-day event: "From 9:00 AM", "All day",
|
|
||||||
// "Until 5:00 PM". Boundaries come from the occurrence vs segment.day, never
|
|
||||||
// the packing flags - lane merging rewrites those on shared segments.
|
|
||||||
const agendaTimeText = (() => {
|
|
||||||
if (view !== "agenda") return ""
|
|
||||||
if (occurrence.allDay) return settings.i18n.labels.allDay
|
|
||||||
const dayStart = zonedStartOfDay(segment.day, settings.timeZone)
|
|
||||||
const dayEnd = addDays(toZoned(dayStart, settings.timeZone), 1)
|
|
||||||
const startsBefore = occurrence.start < dayStart
|
|
||||||
const endsAfter = occurrence.end > dayEnd
|
|
||||||
if (startsBefore && endsAfter) return settings.i18n.labels.allDay
|
|
||||||
if (endsAfter) {
|
|
||||||
return settings.i18n.labels.timeFrom(
|
|
||||||
format(
|
|
||||||
toZoned(occurrence.start, settings.timeZone),
|
|
||||||
settings.i18n.formats.eventTime,
|
|
||||||
{ locale: settings.locale }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (startsBefore) {
|
|
||||||
return settings.i18n.labels.timeUntil(
|
|
||||||
format(
|
|
||||||
toZoned(occurrence.end, settings.timeZone),
|
|
||||||
settings.i18n.formats.eventTime,
|
|
||||||
{ locale: settings.locale }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return settings.i18n.functions.formatEventTime(
|
|
||||||
toZoned(occurrence.start, settings.timeZone),
|
|
||||||
toZoned(occurrence.end, settings.timeZone),
|
|
||||||
false,
|
|
||||||
{ locale: settings.locale }
|
|
||||||
)
|
|
||||||
})()
|
|
||||||
|
|
||||||
// Agenda default row: time column, color-dot badge, plain title
|
|
||||||
const agendaDefaultContent = (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground w-40 shrink-0 truncate tabular-nums">
|
|
||||||
{agendaTimeText}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
data-slot="event-calendar-agenda-dot"
|
|
||||||
className="size-2 shrink-0 rounded-full bg-(--ec-event-color)"
|
|
||||||
/>
|
|
||||||
<span className="truncate text-sm">{event.title}</span>
|
|
||||||
{occurrence.isRecurring && (
|
|
||||||
<RepeatIcon className="text-muted-foreground size-2.5 shrink-0" aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Memoized so a drag - which re-renders the lane on every pointer move -
|
|
||||||
// never re-invokes the consumer's renderEvent per frame: a referentially
|
|
||||||
// stable element lets React skip the custom subtree instead of flickering it.
|
|
||||||
// The render fns are deps, so an inline arrow from the consumer defeats it.
|
|
||||||
const customContent = useMemo(() => {
|
|
||||||
const renderProps = { occurrence, segment, view, isDragging, isSelected }
|
|
||||||
return view === "agenda"
|
|
||||||
? viewConfig.renderAgendaEvent?.(renderProps)
|
|
||||||
: viewConfig.renderEvent?.(renderProps)
|
|
||||||
}, [
|
|
||||||
occurrence,
|
|
||||||
segment,
|
|
||||||
view,
|
|
||||||
isDragging,
|
|
||||||
isSelected,
|
|
||||||
viewConfig.renderAgendaEvent,
|
|
||||||
viewConfig.renderEvent,
|
|
||||||
])
|
|
||||||
const content =
|
|
||||||
children ??
|
|
||||||
customContent ??
|
|
||||||
(view === "agenda" ? agendaDefaultContent : defaultContent)
|
|
||||||
|
|
||||||
const timeLabel = settings.i18n.functions.formatEventTime(
|
|
||||||
toZoned(occurrence.start, settings.timeZone),
|
|
||||||
toZoned(occurrence.end, settings.timeZone),
|
|
||||||
occurrence.allDay,
|
|
||||||
{ locale: settings.locale }
|
|
||||||
)
|
|
||||||
// native hover tooltip text; a formatter returning undefined drops the title
|
|
||||||
const label = settings.i18n.functions.formatEventLabel
|
|
||||||
? settings.i18n.functions.formatEventLabel(event.title, timeLabel)
|
|
||||||
: `${event.title}, ${timeLabel}`
|
|
||||||
|
|
||||||
// Optional styled tooltip (viewConfig.eventTooltip, default off). It replaces
|
|
||||||
// the native title so the two never stack, and a preview never gets one. A
|
|
||||||
// falsy renderEventTooltip result (including the false/"" of `cond && <node>`)
|
|
||||||
// falls back to the label; an empty label leaves no content and skips it.
|
|
||||||
const tooltipOpts =
|
|
||||||
typeof viewConfig.eventTooltip === "object" ? viewConfig.eventTooltip : null
|
|
||||||
const tooltipContent =
|
|
||||||
!preview && viewConfig.eventTooltip
|
|
||||||
? viewConfig.renderEventTooltip?.({
|
|
||||||
occurrence,
|
|
||||||
segment,
|
|
||||||
view,
|
|
||||||
label,
|
|
||||||
}) || label
|
|
||||||
: null
|
|
||||||
const tooltipOn = Boolean(tooltipContent)
|
|
||||||
|
|
||||||
const showResize =
|
|
||||||
interactive && resizeOn && !event.readOnly && event.resizable !== false
|
|
||||||
// Hover grip pill (mirrors the gantt bars) marking the resize direction.
|
|
||||||
// Shown on compact sub-compactEventMinutes blocks too: the 1.5rem chip
|
|
||||||
// min-height leaves edge room without colliding with the centered title.
|
|
||||||
const grip = (
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
data-slot="event-calendar-resize-grip"
|
|
||||||
className={cn(
|
|
||||||
"bg-foreground/40 rounded-full",
|
|
||||||
timedBlock ? "h-0.5 w-2.5" : "h-2.5 w-0.5",
|
|
||||||
viewConfig.classNames?.resizeGrip
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
const resizeHandles = showResize && (
|
|
||||||
<>
|
|
||||||
{timedBlock && segment.isStart && (
|
|
||||||
<span
|
|
||||||
data-slot="event-calendar-resize-handle"
|
|
||||||
data-edge="start"
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-x-1 top-0 flex h-1.5 cursor-ns-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100",
|
|
||||||
viewConfig.classNames?.resizeHandle
|
|
||||||
)}
|
|
||||||
onPointerDown={(e) => gestures.beginResize(e, segment, "start")}
|
|
||||||
>
|
|
||||||
{grip}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{timedBlock && segment.isEnd && (
|
|
||||||
<span
|
|
||||||
data-slot="event-calendar-resize-handle"
|
|
||||||
data-edge="end"
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-x-1 bottom-0 flex h-1.5 cursor-ns-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100",
|
|
||||||
viewConfig.classNames?.resizeHandle
|
|
||||||
)}
|
|
||||||
onPointerDown={(e) => gestures.beginResize(e, segment, "end")}
|
|
||||||
>
|
|
||||||
{grip}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(horizontalBar || (isBar && inTimeGrid)) && segment.isStart && (
|
|
||||||
<span
|
|
||||||
data-slot="event-calendar-resize-handle"
|
|
||||||
data-edge="start"
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-y-0 start-0 flex w-2 cursor-ew-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100",
|
|
||||||
viewConfig.classNames?.resizeHandle
|
|
||||||
)}
|
|
||||||
onPointerDown={(e) => gestures.beginResize(e, segment, "start")}
|
|
||||||
>
|
|
||||||
{grip}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(horizontalBar || (isBar && inTimeGrid)) && segment.isEnd && (
|
|
||||||
<span
|
|
||||||
data-slot="event-calendar-resize-handle"
|
|
||||||
data-edge="end"
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-y-0 end-0 flex w-2 cursor-ew-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100",
|
|
||||||
viewConfig.classNames?.resizeHandle
|
|
||||||
)}
|
|
||||||
onPointerDown={(e) => gestures.beginResize(e, segment, "end")}
|
|
||||||
>
|
|
||||||
{grip}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
type: "button" as const,
|
|
||||||
"data-slot": "event-calendar-event",
|
|
||||||
"data-view": view,
|
|
||||||
"data-all-day": occurrence.allDay || undefined,
|
|
||||||
"data-recurring": occurrence.isRecurring || undefined,
|
|
||||||
"data-selected": isSelected || undefined,
|
|
||||||
"data-dragging": isDragging || undefined,
|
|
||||||
"data-preview": preview || undefined,
|
|
||||||
"data-past": occurrence.end.getTime() < Date.now() || undefined,
|
|
||||||
title: preview || tooltipOn ? undefined : label,
|
|
||||||
"aria-label":
|
|
||||||
settings.i18n.functions.formatEventAriaLabel?.(
|
|
||||||
event.title,
|
|
||||||
timeLabel,
|
|
||||||
segment.continuesBefore || segment.continuesAfter
|
|
||||||
) ??
|
|
||||||
`${event.title}, ${timeLabel}${
|
|
||||||
segment.continuesBefore || segment.continuesAfter
|
|
||||||
? `, ${settings.i18n.labels.continues}`
|
|
||||||
: ""
|
|
||||||
}`,
|
|
||||||
// A background tint alone conveys selection, so the chip is a real toggle
|
|
||||||
// wherever it is interactive (agenda rows never select, previews are inert).
|
|
||||||
"aria-pressed": interactive ? isSelected : undefined,
|
|
||||||
"aria-hidden": preview || undefined,
|
|
||||||
tabIndex: preview ? -1 : undefined,
|
|
||||||
style: {
|
|
||||||
"--ec-event-color": event.color ?? "var(--color-primary)",
|
|
||||||
} as CSSProperties,
|
|
||||||
onPointerDown: (e: React.PointerEvent) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
// suppress the trailing slot-create click when this press yields no drag
|
|
||||||
markChipPress()
|
|
||||||
if (interactive) gestures.beginMove(e, segment)
|
|
||||||
},
|
|
||||||
onClick: (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (wasRecentDrag()) return
|
|
||||||
// consumer first: e.preventDefault() opts out of built-in selection
|
|
||||||
settings.onEventClick?.(occurrence, e)
|
|
||||||
// the agenda is a read-only list: a click never selects/focuses a row
|
|
||||||
if (e.defaultPrevented || view === "agenda") return
|
|
||||||
instance.api.selectEvent(occurrence.key)
|
|
||||||
},
|
|
||||||
onDoubleClick: (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
settings.onEventDoubleClick?.(occurrence, e)
|
|
||||||
},
|
|
||||||
className: cn(
|
|
||||||
"group/ec-event text-foreground relative flex w-full min-w-0 cursor-pointer touch-none items-center overflow-hidden text-start select-none",
|
|
||||||
"focus-visible:ring-ring/50 outline-none focus-visible:ring-2",
|
|
||||||
preview && "pointer-events-none",
|
|
||||||
view === "agenda"
|
|
||||||
? // plain list row: color lives in the dot badge, not a tinted pill;
|
|
||||||
// hover AND selection surfaces are owned by the agenda row wrapper
|
|
||||||
"gap-3 rounded-md text-sm"
|
|
||||||
: cn(
|
|
||||||
// @container removes intrinsic sizing; only grid chips are containers
|
|
||||||
// py-1: room above/below inline badges (attendee pill etc.)
|
|
||||||
"@container gap-1.5 rounded-sm px-1.5 py-1 leading-normal",
|
|
||||||
// soft tint + inset ring, not an accent border: legible on both themes
|
|
||||||
"bg-(--ec-event-color)/15 hover:bg-(--ec-event-color)/25",
|
|
||||||
// a flat tint reads darker on a dark surface, so lift it there
|
|
||||||
"dark:bg-(--ec-event-color)/20 dark:hover:bg-(--ec-event-color)/30",
|
|
||||||
"inset-ring inset-ring-(--ec-event-color)/15",
|
|
||||||
"transition-[background-color,box-shadow] duration-150",
|
|
||||||
"data-dragging:opacity-40",
|
|
||||||
"data-selected:bg-(--ec-event-color)/30 data-selected:inset-ring-(--ec-event-color)/40",
|
|
||||||
segment.continuesBefore && "rounded-s-none",
|
|
||||||
segment.continuesAfter && "rounded-e-none"
|
|
||||||
),
|
|
||||||
viewConfig.classNames?.event,
|
|
||||||
className
|
|
||||||
),
|
|
||||||
children: (
|
|
||||||
<>
|
|
||||||
{content}
|
|
||||||
{resizeHandles}
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
const chip = useRender({
|
|
||||||
defaultTagName: "button",
|
|
||||||
render,
|
|
||||||
props: mergeProps<"button">(defaultProps, props),
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EventCalendarChipContext.Provider
|
|
||||||
value={{ occurrence, segment, isDragging, isSelected }}
|
|
||||||
>
|
|
||||||
{tooltipOn ? (
|
|
||||||
<TooltipProvider delay={tooltipOpts?.delay ?? 600}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger render={chip} />
|
|
||||||
<TooltipContent
|
|
||||||
side={tooltipOpts?.side ?? "top"}
|
|
||||||
className={viewConfig.classNames?.eventTooltip}
|
|
||||||
>
|
|
||||||
{tooltipContent}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
) : (
|
|
||||||
chip
|
|
||||||
)}
|
|
||||||
</EventCalendarChipContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
EVENT_CALENDAR_COLORS,
|
|
||||||
EVENT_CALENDAR_FADE_TRUNCATE,
|
|
||||||
EVENT_CALENDAR_GHOST,
|
|
||||||
EVENT_CALENDAR_SLOT_DRAFT,
|
|
||||||
EventCalendarEvent,
|
|
||||||
useEventCalendarEventChip,
|
|
||||||
}
|
|
||||||
export type { EventCalendarChipContextValue, EventCalendarEventProps }
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
import type {
|
|
||||||
CalendarView,
|
|
||||||
EventCalendarDateRange,
|
|
||||||
} from "@/components/reui/event-calendar/event-calendar-types"
|
|
||||||
import {
|
|
||||||
format,
|
|
||||||
isSameMonth,
|
|
||||||
isSameYear,
|
|
||||||
subMilliseconds,
|
|
||||||
type Locale,
|
|
||||||
} from "date-fns"
|
|
||||||
|
|
||||||
interface EventCalendarI18nConfig {
|
|
||||||
labels: {
|
|
||||||
today: string
|
|
||||||
previous: string
|
|
||||||
next: string
|
|
||||||
addEvent: string
|
|
||||||
allDay: string
|
|
||||||
more: (count: number) => string
|
|
||||||
noEvents: string
|
|
||||||
loading: string
|
|
||||||
event: string
|
|
||||||
events: (count: number) => string
|
|
||||||
selectView: string
|
|
||||||
week: (weekNumber: number) => string
|
|
||||||
resources: string
|
|
||||||
goToDate: string
|
|
||||||
/** Cursor hint while a drag/resize hovers a rejected position. */
|
|
||||||
dropNotAllowed: string
|
|
||||||
/** Aria-label suffix on chip segments that continue past the cell. */
|
|
||||||
continues: string
|
|
||||||
/** Agenda label for the first day of a multi-day event. */
|
|
||||||
timeFrom: (time: string) => string
|
|
||||||
/** Agenda label for the last day of a multi-day event. */
|
|
||||||
timeUntil: (time: string) => string
|
|
||||||
/** View-switcher shortcut hint characters, per view. */
|
|
||||||
viewShortcuts: Record<CalendarView, string>
|
|
||||||
/** Aria-label of the agenda day collapse/expand toggle. */
|
|
||||||
toggleDayEvents: (count: number, expanded: boolean) => string
|
|
||||||
/** Aria-label of the agenda event details toggle. */
|
|
||||||
eventDetails: (title: string) => string
|
|
||||||
/** Compact "+N" overflow (agenda summary dot stack). */
|
|
||||||
moreCompact: (count: number) => string
|
|
||||||
/** Joins a bounded from-to time span. */
|
|
||||||
timeRange: (from: string, to: string) => string
|
|
||||||
}
|
|
||||||
viewNames: {
|
|
||||||
month: string
|
|
||||||
week: string
|
|
||||||
day: string
|
|
||||||
days: (count: number) => string
|
|
||||||
agenda: string
|
|
||||||
resource: string
|
|
||||||
}
|
|
||||||
/** date-fns format strings, applied with the calendar `locale`. */
|
|
||||||
formats: {
|
|
||||||
monthTitle: string
|
|
||||||
/** Undefined = smart cross-month range via functions.formatTitle. */
|
|
||||||
weekTitle?: string
|
|
||||||
dayTitle: string
|
|
||||||
/** Undefined = smart range label via functions.formatTitle. */
|
|
||||||
agendaTitle?: string
|
|
||||||
monthDayHeader: string
|
|
||||||
/** Narrow variant used by the month view below the compact breakpoint. */
|
|
||||||
monthDayHeaderNarrow: string
|
|
||||||
timeGridDayHeader: string
|
|
||||||
agendaDayHeader: string
|
|
||||||
/** Agenda date-gutter day number. */
|
|
||||||
agendaDayNumber: string
|
|
||||||
/** Agenda date-gutter weekday label. */
|
|
||||||
agendaWeekday: string
|
|
||||||
/** "+N more" popover day header. */
|
|
||||||
moreDayHeader: string
|
|
||||||
/** Month cell aria-label date. */
|
|
||||||
monthCellAriaLabel: string
|
|
||||||
/** Time-grid day column aria-label date. */
|
|
||||||
dayAria: string
|
|
||||||
/** Undefined = the resource view title falls back to dayTitle. */
|
|
||||||
resourceTitle?: string
|
|
||||||
timeGutter: string
|
|
||||||
/** Sub-hour gutter labels (interval below 60 minutes). */
|
|
||||||
timeGutterMinute: string
|
|
||||||
eventTime: string
|
|
||||||
monthCellDay: string
|
|
||||||
}
|
|
||||||
functions: {
|
|
||||||
formatTitle: (
|
|
||||||
view: CalendarView,
|
|
||||||
ctx: {
|
|
||||||
date: Date
|
|
||||||
activeRange: EventCalendarDateRange
|
|
||||||
visibleRange: EventCalendarDateRange
|
|
||||||
locale?: Locale
|
|
||||||
}
|
|
||||||
) => string
|
|
||||||
formatEventTime: (
|
|
||||||
start: Date,
|
|
||||||
end: Date,
|
|
||||||
allDay: boolean,
|
|
||||||
/** date-fns options (the calendar `locale`); trailing so a 3-arg override still fits. */
|
|
||||||
opts?: { locale?: Locale }
|
|
||||||
) => string
|
|
||||||
formatDayRange: (
|
|
||||||
range: EventCalendarDateRange,
|
|
||||||
opts?: { locale?: Locale }
|
|
||||||
) => string
|
|
||||||
/** Chip native tooltip text; return undefined to drop the attribute. */
|
|
||||||
formatEventLabel?: (title: string, timeLabel: string) => string | undefined
|
|
||||||
/** Chip aria-label composition. */
|
|
||||||
formatEventAriaLabel?: (
|
|
||||||
title: string,
|
|
||||||
timeLabel: string,
|
|
||||||
continues: boolean
|
|
||||||
) => string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_LABELS: EventCalendarI18nConfig["labels"] = {
|
|
||||||
today: "Today",
|
|
||||||
previous: "Previous",
|
|
||||||
next: "Next",
|
|
||||||
addEvent: "Add event",
|
|
||||||
allDay: "All day",
|
|
||||||
more: (count) => `+${count} more`,
|
|
||||||
noEvents: "No events",
|
|
||||||
loading: "Loading events",
|
|
||||||
event: "event",
|
|
||||||
events: (count) => (count === 1 ? "1 event" : `${count} events`),
|
|
||||||
selectView: "Select view",
|
|
||||||
week: (weekNumber) => `W${weekNumber}`,
|
|
||||||
resources: "Resources",
|
|
||||||
goToDate: "Go to date",
|
|
||||||
dropNotAllowed: "Can't place here",
|
|
||||||
continues: "continues",
|
|
||||||
timeFrom: (time) => `From ${time}`,
|
|
||||||
timeUntil: (time) => `Until ${time}`,
|
|
||||||
viewShortcuts: {
|
|
||||||
month: "M",
|
|
||||||
week: "W",
|
|
||||||
day: "D",
|
|
||||||
days: "5",
|
|
||||||
agenda: "A",
|
|
||||||
resource: "G",
|
|
||||||
},
|
|
||||||
toggleDayEvents: (count) => (count === 1 ? "1 event" : `${count} events`),
|
|
||||||
eventDetails: (title) => title,
|
|
||||||
moreCompact: (count) => `+${count}`,
|
|
||||||
timeRange: (from, to) => `${from} - ${to}`,
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_VIEW_NAMES: EventCalendarI18nConfig["viewNames"] = {
|
|
||||||
month: "Month",
|
|
||||||
week: "Week",
|
|
||||||
day: "Day",
|
|
||||||
days: (count) => (count === 1 ? "1 day" : `${count} days`),
|
|
||||||
agenda: "Agenda",
|
|
||||||
resource: "Time Grid",
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_FORMATS: EventCalendarI18nConfig["formats"] = {
|
|
||||||
monthTitle: "MMMM yyyy",
|
|
||||||
weekTitle: undefined,
|
|
||||||
dayTitle: "EEEE, MMMM d, yyyy",
|
|
||||||
agendaTitle: undefined,
|
|
||||||
monthDayHeader: "EEE",
|
|
||||||
monthDayHeaderNarrow: "EEEEE",
|
|
||||||
timeGridDayHeader: "EEE d",
|
|
||||||
agendaDayHeader: "EEEE, MMMM d",
|
|
||||||
agendaDayNumber: "d",
|
|
||||||
agendaWeekday: "EEE",
|
|
||||||
moreDayHeader: "EEEE, MMMM d",
|
|
||||||
monthCellAriaLabel: "PPPP",
|
|
||||||
dayAria: "PPPP",
|
|
||||||
resourceTitle: undefined,
|
|
||||||
timeGutter: "h a",
|
|
||||||
timeGutterMinute: "h:mm a",
|
|
||||||
eventTime: "h:mm a",
|
|
||||||
monthCellDay: "d",
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default formatting functions BOUND to a config's labels/formats, so that
|
|
||||||
* `formats` overrides flow into the default renderers (a consumer overriding
|
|
||||||
* formats.monthTitle without replacing formatTitle still sees it applied).
|
|
||||||
*/
|
|
||||||
function makeDefaultFunctions(
|
|
||||||
cfg: Pick<EventCalendarI18nConfig, "labels" | "formats">
|
|
||||||
): EventCalendarI18nConfig["functions"] {
|
|
||||||
return {
|
|
||||||
formatTitle: (view, { date, activeRange, locale }) => {
|
|
||||||
const opts = { locale }
|
|
||||||
if (view === "month") {
|
|
||||||
return format(date, cfg.formats.monthTitle, opts)
|
|
||||||
}
|
|
||||||
if (view === "resource") {
|
|
||||||
return format(
|
|
||||||
date,
|
|
||||||
cfg.formats.resourceTitle ?? cfg.formats.dayTitle,
|
|
||||||
opts
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (view === "day") {
|
|
||||||
return format(date, cfg.formats.dayTitle, opts)
|
|
||||||
}
|
|
||||||
if (view === "week" && cfg.formats.weekTitle) {
|
|
||||||
return format(date, cfg.formats.weekTitle, opts)
|
|
||||||
}
|
|
||||||
if (view === "agenda" && cfg.formats.agendaTitle) {
|
|
||||||
return format(date, cfg.formats.agendaTitle, opts)
|
|
||||||
}
|
|
||||||
// week / days / agenda: smart range label, last day is activeRange.end - 1ms.
|
|
||||||
// subMilliseconds keeps the zoned date type (a plain new Date(ms)
|
|
||||||
// would flip the label to the machine zone near midnight)
|
|
||||||
const rangeEnd = subMilliseconds(activeRange.end, 1)
|
|
||||||
const start = activeRange.start
|
|
||||||
if (isSameMonth(start, rangeEnd)) {
|
|
||||||
return `${format(start, "MMMM d", opts)} - ${format(rangeEnd, "d, yyyy", opts)}`
|
|
||||||
}
|
|
||||||
if (isSameYear(start, rangeEnd)) {
|
|
||||||
return `${format(start, "MMM d", opts)} - ${format(rangeEnd, "MMM d, yyyy", opts)}`
|
|
||||||
}
|
|
||||||
return `${format(start, "MMM d, yyyy", opts)} - ${format(rangeEnd, "MMM d, yyyy", opts)}`
|
|
||||||
},
|
|
||||||
formatEventTime: (start, end, allDay, opts) => {
|
|
||||||
if (allDay) return cfg.labels.allDay
|
|
||||||
const fmt = cfg.formats.eventTime
|
|
||||||
// Multi-day timed events carry the date on both sides. Compare calendar
|
|
||||||
// days off the last rendered instant (end is exclusive, so a 14:00 to
|
|
||||||
// midnight event still ends on the start day). Elapsed ms would miss an
|
|
||||||
// exactly-24h event and a DST day that only runs 23 hours.
|
|
||||||
const lastInstant =
|
|
||||||
end.getTime() - 1 >= start.getTime() ? subMilliseconds(end, 1) : start
|
|
||||||
if (format(start, "yyyy-MM-dd") !== format(lastInstant, "yyyy-MM-dd")) {
|
|
||||||
return `${format(start, `MMM d, ${fmt}`, opts)} - ${format(end, `MMM d, ${fmt}`, opts)}`
|
|
||||||
}
|
|
||||||
return `${format(start, fmt, opts)} - ${format(end, fmt, opts)}`
|
|
||||||
},
|
|
||||||
formatDayRange: (range, opts) => {
|
|
||||||
// subMilliseconds keeps the zoned date type, same reason as formatTitle
|
|
||||||
const rangeEnd = subMilliseconds(range.end, 1)
|
|
||||||
return `${format(range.start, "MMM d", opts)} - ${format(rangeEnd, "MMM d", opts)}`
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_EVENT_CALENDAR_I18N: EventCalendarI18nConfig = {
|
|
||||||
labels: DEFAULT_LABELS,
|
|
||||||
viewNames: DEFAULT_VIEW_NAMES,
|
|
||||||
formats: DEFAULT_FORMATS,
|
|
||||||
functions: makeDefaultFunctions({
|
|
||||||
labels: DEFAULT_LABELS,
|
|
||||||
formats: DEFAULT_FORMATS,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One level deeper than `Partial`, because the merge below is per nested
|
|
||||||
* section: overriding a single label must not force a consumer to restate the
|
|
||||||
* other 22. Unknown keys are still rejected by the excess property check.
|
|
||||||
*/
|
|
||||||
type EventCalendarI18nOverrides = {
|
|
||||||
[K in keyof EventCalendarI18nConfig]?: Partial<EventCalendarI18nConfig[K]>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shallow merge per nested object, matching the filters.tsx i18n contract:
|
|
||||||
* a partial override replaces individual keys, never whole sections. Default
|
|
||||||
* functions are re-bound to the MERGED labels/formats; explicit `functions`
|
|
||||||
* overrides still win.
|
|
||||||
*/
|
|
||||||
function mergeEventCalendarI18n(
|
|
||||||
overrides?: EventCalendarI18nOverrides
|
|
||||||
): EventCalendarI18nConfig {
|
|
||||||
if (!overrides) return DEFAULT_EVENT_CALENDAR_I18N
|
|
||||||
const labels = { ...DEFAULT_LABELS, ...overrides.labels }
|
|
||||||
const viewNames = { ...DEFAULT_VIEW_NAMES, ...overrides.viewNames }
|
|
||||||
const formats = { ...DEFAULT_FORMATS, ...overrides.formats }
|
|
||||||
return {
|
|
||||||
labels,
|
|
||||||
viewNames,
|
|
||||||
formats,
|
|
||||||
functions: {
|
|
||||||
...makeDefaultFunctions({ labels, formats }),
|
|
||||||
...overrides.functions,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DEFAULT_EVENT_CALENDAR_I18N, mergeEventCalendarI18n }
|
|
||||||
export type { EventCalendarI18nConfig, EventCalendarI18nOverrides }
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user