Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc803bcb34 | ||
|
|
e469c421ca | ||
|
|
094f75913b | ||
|
|
764a4e5b4e | ||
|
|
2d29a892f9 | ||
|
|
d9bec85c02 |
@@ -46,28 +46,31 @@ pnpm dlx shadcn@latest add @reui/data-grid # ReUI enterprise → apps/web/src/co
|
||||
|--------|----------|--------|
|
||||
| Button, Card, Sheet, Field, Sidebar | `@shadcn` | `@evobgp/ui/components/*` |
|
||||
| Blocks (sidebar-07, dashboard-01) | `@shadcn` | blocks → `apps/web/src/components/` |
|
||||
| Data Grid (sort, pagination, virtual) | `@reui` | `@/components/reui/data-grid/*` → обёртка `DataGridCard` |
|
||||
| Data Grid (sort, pagination, virtual) | `@reui` | `@/components/reui/data-grid/*` → `reui-kit/ResourcePage` |
|
||||
| Мультифильтры | `@reui` | `@/components/reui/filters` |
|
||||
| Number field со stepper | `@reui` | `@/components/reui/number-field` |
|
||||
| Autocomplete | `@reui` | `@/components/reui/autocomplete` → `AutoCompleteInput` |
|
||||
| Date selector / range | `@reui` | `@/components/reui/date-selector` |
|
||||
| Semantic badge (success/info/warning) | `@reui` | `@/components/reui/badge` или `StatusBadge` |
|
||||
|
||||
**Простые списки** — shadcn `Table`. **Сложные data-списки** — ReUI data-grid (через `DataGridCard`), не shadcn Data Table.
|
||||
**Простые списки** — shadcn `Table`. **Сложные data-списки** — ReUI data-grid через `ResourcePage`, не shadcn Data Table.
|
||||
|
||||
## Уже установленные shared-обёртки
|
||||
|
||||
В `apps/web/src/components/`:
|
||||
- `PageHeader`, `PageShell` — заголовки и обёртки страниц
|
||||
- `QueryState` — обёртка loading/error/empty для TanStack Query
|
||||
- `EmptyState` — пустые списки
|
||||
- `EmptyState` — пустые списки (empty-state-14 DNA)
|
||||
- `ConfirmDialog` — подтверждения (не `window.confirm`)
|
||||
- `LoadingButton` — кнопка с loading-состоянием
|
||||
- `StatusBadge` — статусные бейджи
|
||||
- `SectionCards` — сетка KPI-карточек
|
||||
- `Skeletons` (`TableSkeleton`, `SectionCardsSkeleton`) — скелетоны
|
||||
- `LoadingButton` — SoT кнопка с loading (`Button` + `Spinner`)
|
||||
- `StatusBadge` / `CategoryBadge` / `ModeBadge` — semantic ReUI Badge
|
||||
- `SegmentedTabs` — секции страницы (c-tabs-9)
|
||||
- `StatusToggleGroup` — статус грида (c-toggle-group-5)
|
||||
- `FormDrawer` — Sheet overlay (sheet-8 / form-7)
|
||||
- `Skeletons` (`TableSkeleton`, `KpiStatGridSkeleton`) — скелетоны
|
||||
- `TruncatedText` — текст с тултипом
|
||||
- `ModeToggle` — переключатель темы
|
||||
|
||||
Kit: `reui-kit/ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `SettingsShell`, `DetailPanel`, `FrameSection`.
|
||||
|
||||
Перед созданием новой обёртки — проверить существующие через Codegraph.
|
||||
|
||||
|
||||
@@ -70,14 +70,14 @@ alwaysApply: true
|
||||
|------|------|--------|
|
||||
| shadcn | `packages/ui/src/components/` | `@evobgp/ui/components/*` |
|
||||
| ReUI CLI | `apps/web/src/components/reui/` | `@/components/reui/*` |
|
||||
| PRO blocks (reference) | `apps/web/src/components/blocks/` | adapt into kit, не копипаст в routes |
|
||||
| PRO blocks (reference) | — | CLI → adapt into kit; не держать demo-деревья в `src/components/blocks/` |
|
||||
| Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` |
|
||||
|
||||
## Установленные ReUI (apps/web)
|
||||
|
||||
**Components:** `frame`, `data-grid/*`, `filters`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`, `icon-tile`, `stepper`
|
||||
|
||||
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell`
|
||||
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell`, `FrameSection`
|
||||
|
||||
**Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
|
||||
|
||||
|
||||
@@ -23,10 +23,10 @@ alwaysApply: false
|
||||
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
|
||||
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
|
||||
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, LoadingButton |
|
||||
| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, QuickActionGrid, OpsDashboard, SettingsShell |
|
||||
| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, QuickActionGrid, OpsDashboard, SettingsShell, DetailPanel, FrameSection |
|
||||
| Роуты | `apps/web/src/routes/` | TanStack Router (file-based) |
|
||||
|
||||
**Design contract:** [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Surface: **frame**. KPI hybrid SoT: [stats-12](https://reui.io/preview/base/stats-12). Lists: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2). Quick Actions: `QuickActionGrid`.
|
||||
**Design contract:** [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Surface: **frame**. KPI hybrid SoT: [stats-12](https://reui.io/preview/base/stats-12). Lists: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2). Page tabs: [c-tabs-9](https://reui.io/preview/base/components/c-tabs-9). Grid status: [c-toggle-group-5](https://reui.io/preview/base/components/c-toggle-group-5). Quick Actions: `QuickActionGrid`.
|
||||
|
||||
Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`.
|
||||
|
||||
@@ -48,9 +48,16 @@ alwaysApply: false
|
||||
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
|
||||
*Проверка:* https://ui.shadcn.com/docs/components/form
|
||||
|
||||
**WEB-06** | MUST | Сложные data-списки — Frame + ReUI DataGrid через `reui-kit/ResourcePage` или `DataGridSection` (не Card shell, не shadcn Data Table). Surface lock: `UI_SURFACE = 'frame'` (`lib/ui-surface.ts`). Простые списки — shadcn `Table`.
|
||||
**WEB-06** | MUST | Сложные data-списки — Frame + ReUI DataGrid через `reui-kit/ResourcePage` (не Card shell, не shadcn Data Table). Surface lock: `UI_SURFACE = 'frame'` (`lib/ui-surface.ts`). Простые списки — shadcn `Table`.
|
||||
*Проверка:* `@/components/reui-kit`, `@/components/reui/data-grid`, `docs/ui-design-contract.md`.
|
||||
|
||||
**WEB-06a** | MUST | Переключатели по роли UX:
|
||||
- секции страницы (Сеть / Операции / Справочники) — native `Tabs` default list (`SegmentedTabs`, [c-tabs-9](https://reui.io/preview/base/components/c-tabs-9));
|
||||
- статус грида (Все/Вкл) — `ToggleGroup` в toolbar (`StatusToggleGroup`, [c-toggle-group-5](https://reui.io/preview/base/components/c-toggle-group-5));
|
||||
- настройки — вертикальный Tabs rail ([settings-3](https://reui.io/preview/base/settings-3)).
|
||||
Не `variant="line"` для page/in-grid tabs.
|
||||
*Проверка:* `@/components/segmented-tabs`, `@/components/status-toggle-group`.
|
||||
|
||||
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
|
||||
*Проверка:* https://ui.shadcn.com/docs/components/sonner
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ Runner: `ubuntu-latest`, **bird2** из apt, Docker для **docker-check** (PR)
|
||||
|
||||
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `github.token`. Push OCI — **только PAT** (у `GITEA_TOKEN` нет права packages).
|
||||
|
||||
**`docker_hub_token`**: PAT Docker Hub (Settings → Actions → Secrets, можно на уровне организации). Логин перед зеркалом `docker.io` и `bake`, чтобы не ловить anonymous 429. Логин: `docker_hub_username` (если задан) или `gitea.actor`.
|
||||
|
||||
### Теги образов
|
||||
|
||||
```text
|
||||
|
||||
@@ -17,6 +17,8 @@ jobs:
|
||||
allow_registry_login: false
|
||||
secrets:
|
||||
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||
docker_hub_token: ${{ secrets.docker_hub_token }}
|
||||
docker_hub_username: ${{ secrets.docker_hub_username }}
|
||||
|
||||
publish:
|
||||
needs: [quality]
|
||||
@@ -107,6 +109,12 @@ jobs:
|
||||
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
||||
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
- name: Log in to Docker Hub
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
username: ${{ secrets.docker_hub_username || gitea.actor }}
|
||||
password: ${{ secrets.docker_hub_token }}
|
||||
- name: Log in to Gitea Registry
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
|
||||
@@ -21,3 +21,5 @@ jobs:
|
||||
allow_registry_login: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
secrets:
|
||||
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||
docker_hub_token: ${{ secrets.docker_hub_token }}
|
||||
docker_hub_username: ${{ secrets.docker_hub_username }}
|
||||
|
||||
@@ -26,6 +26,10 @@ on:
|
||||
secrets:
|
||||
ACTIONS_PAT:
|
||||
required: false
|
||||
docker_hub_token:
|
||||
required: false
|
||||
docker_hub_username:
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -397,6 +401,12 @@ jobs:
|
||||
name: evobgp
|
||||
driver: docker-container
|
||||
cleanup: false
|
||||
- name: Log in to Docker Hub
|
||||
if: secrets.docker_hub_token != ''
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
username: ${{ secrets.docker_hub_username || gitea.actor }}
|
||||
password: ${{ secrets.docker_hub_token }}
|
||||
- name: Log in to Gitea Registry
|
||||
if: inputs.allow_registry_login
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
| Конфиг BIRD, `birdc` | `internal/birdfmt/`, `internal/birddeploy/` |
|
||||
| Бандлы и подписи | `internal/bundle/`, `internal/signing/` |
|
||||
| Точки входа процессов | `cmd/*/` |
|
||||
| Веб (SvelteKit) | `web/` |
|
||||
| Веб (React + ReUI) | `apps/web/` |
|
||||
| Compose, деплой | `deploy/compose/` |
|
||||
|
||||
Точки входа бинарников и их роли — в таблице в начале [docs/architecture.md](docs/architecture.md).
|
||||
|
||||
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import globals from 'globals'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'src/routeTree.gen.ts', 'src/components/blocks/**'] },
|
||||
{ ignores: ['dist', 'src/routeTree.gen.ts'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"recharts": "3.8.0",
|
||||
"shadcn": "^4.19.0",
|
||||
"sonner": "^1.7.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
|
||||
@@ -98,6 +98,7 @@ export function AccessApiKeysGrid({
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
size: 88,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const k = row.original
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@evobgp/ui/components/tooltip'
|
||||
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { FrameSection } from '@/components/reui-kit'
|
||||
|
||||
/** Chart Frame shell — chart-1 spacing, not Card.
|
||||
* @see https://reui.io/preview/base/chart-1
|
||||
@@ -51,7 +51,7 @@ export function AnalyticsCardShell({
|
||||
)
|
||||
|
||||
return (
|
||||
<PanelCard
|
||||
<FrameSection
|
||||
title={titleNode}
|
||||
description={description}
|
||||
actions={actions}
|
||||
@@ -61,6 +61,6 @@ export function AnalyticsCardShell({
|
||||
footerClassName={footer ? 'gap-2 px-5 py-4' : undefined}
|
||||
>
|
||||
{children}
|
||||
</PanelCard>
|
||||
</FrameSection>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ export function DashboardPlatformCard({
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => navigate({ to: '/monitoring', search: { tab: 'system' } })}
|
||||
onClick={() => navigate({ to: '/monitoring' })}
|
||||
>
|
||||
Мониторинг
|
||||
</Button>
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
import { type ComponentProps } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { type DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
|
||||
import { type ColumnDef } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import { type ModuleRecord, type ModuleStatus } from "./data"
|
||||
import { CalendarDaysIcon, FileTextIcon, StarIcon, MoreHorizontalIcon, CopyIcon, ArchiveIcon } from "lucide-react"
|
||||
|
||||
export type ModuleRowAction = "open" | "favorite" | "duplicate" | "archive"
|
||||
|
||||
const moduleStatusVariant: Record<
|
||||
ModuleStatus,
|
||||
ComponentProps<typeof Badge>["variant"]
|
||||
> = {
|
||||
Planned: "info-outline",
|
||||
Backlog: "outline",
|
||||
"In Progress": "warning-outline",
|
||||
}
|
||||
|
||||
const moduleStatusDotClass: Record<ModuleStatus, string> = {
|
||||
Planned: "bg-sky-500 dark:bg-sky-400",
|
||||
Backlog: "bg-muted-foreground/50",
|
||||
"In Progress": "bg-amber-500 dark:bg-amber-400",
|
||||
}
|
||||
|
||||
function DotSeparator() {
|
||||
return (
|
||||
<span
|
||||
className="bg-muted-foreground/45 size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function getProgressToneClass(value: number) {
|
||||
if (value >= 75) return "text-emerald-500 dark:text-emerald-400"
|
||||
if (value >= 40) return "text-amber-500 dark:text-amber-400"
|
||||
if (value > 0) return "text-sky-500 dark:text-sky-400"
|
||||
|
||||
return "text-muted-foreground/35"
|
||||
}
|
||||
|
||||
function getWindowDurationLabel(module: ModuleRecord) {
|
||||
const start = new Date(module.dateStart).getTime()
|
||||
const end = new Date(module.dateEnd).getTime()
|
||||
const dayMs = 24 * 60 * 60 * 1000
|
||||
const days = Math.max(1, Math.round((end - start) / dayMs))
|
||||
|
||||
return `${days}-day window`
|
||||
}
|
||||
|
||||
function getCompactDateRange(module: ModuleRecord) {
|
||||
return module.dateRange.replace(/, 2026/g, "")
|
||||
}
|
||||
|
||||
function ModuleProgress({ module }: { module: ModuleRecord }) {
|
||||
const value = module.progress
|
||||
const radius = 18
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const dashOffset = circumference - (value / 100) * circumference
|
||||
const progressClassName = getProgressToneClass(value)
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="relative size-10 shrink-0">
|
||||
<svg
|
||||
viewBox="0 0 44 44"
|
||||
className="absolute inset-0 size-10 -rotate-90"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="22"
|
||||
cy="22"
|
||||
r={radius}
|
||||
fill="none"
|
||||
className="stroke-muted-foreground/20"
|
||||
strokeWidth="3.25"
|
||||
/>
|
||||
<circle
|
||||
cx="22"
|
||||
cy="22"
|
||||
r={radius}
|
||||
fill="none"
|
||||
className={cn("stroke-current", progressClassName)}
|
||||
strokeWidth="3.25"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-muted-foreground absolute inset-0 flex items-center justify-center text-[9px] leading-none font-medium tabular-nums">
|
||||
{value}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-foreground text-sm font-medium tabular-nums">
|
||||
{value}% ready
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs tabular-nums">
|
||||
{module.tasksCompleted}/{module.tasksTotal} tasks
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModuleNameCell({ module }: { module: ModuleRecord }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="text-foreground truncate text-sm leading-5 font-medium">
|
||||
{module.name}
|
||||
</span>
|
||||
<div className="text-muted-foreground flex min-w-0 flex-wrap items-center gap-1.5 text-xs">
|
||||
<span className="shrink-0">{module.kind}</span>
|
||||
<DotSeparator />
|
||||
<span className="truncate">{module.owner.name}</span>
|
||||
<DotSeparator />
|
||||
<span className="truncate font-mono tracking-wide uppercase">
|
||||
{module.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModuleDateCell({ module }: { module: ModuleRecord }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-foreground truncate text-sm font-medium tabular-nums">
|
||||
{getCompactDateRange(module)}
|
||||
</span>
|
||||
<span className="text-muted-foreground inline-flex min-w-0 items-center gap-1.5 truncate text-xs">
|
||||
<CalendarDaysIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{getWindowDurationLabel(module)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModuleStatusCell({ module }: { module: ModuleRecord }) {
|
||||
return (
|
||||
<Badge variant={moduleStatusVariant[module.status]}>
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full!",
|
||||
moduleStatusDotClass[module.status]
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{module.status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function ModuleActions({
|
||||
module,
|
||||
onAction,
|
||||
}: {
|
||||
module: ModuleRecord
|
||||
onAction: (action: ModuleRowAction, module: ModuleRecord) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`Open ${module.name}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onAction("open", module)
|
||||
}}
|
||||
>
|
||||
<FileTextIcon aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`${module.favorite ? "Unfavorite" : "Favorite"} ${
|
||||
module.name
|
||||
}`}
|
||||
className={cn(module.favorite && "text-amber-500")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onAction("favorite", module)
|
||||
}}
|
||||
>
|
||||
<StarIcon aria-hidden="true" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`More actions for ${module.name}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => onAction("duplicate", module)}>
|
||||
<CopyIcon aria-hidden="true" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onAction("archive", module)}
|
||||
>
|
||||
<ArchiveIcon aria-hidden="true" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function createModuleGridColumns({
|
||||
onAction,
|
||||
}: {
|
||||
onAction: (action: ModuleRowAction, module: ModuleRecord) => void
|
||||
}): ColumnDef<DataGridFeatures, ModuleRecord>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: "progress",
|
||||
id: "progress",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <ModuleProgress module={row.original} />,
|
||||
size: 210,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
headerTitle: "Progress",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
id: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <ModuleNameCell module={row.original} />,
|
||||
minSize: 300,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
autoSize: true,
|
||||
headerTitle: "Module",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "dateStart",
|
||||
id: "dateStart",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <ModuleDateCell module={row.original} />,
|
||||
sortFn: (rowA, rowB) =>
|
||||
new Date(rowA.original.dateStart).getTime() -
|
||||
new Date(rowB.original.dateStart).getTime(),
|
||||
size: 180,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
headerTitle: "Window",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
id: "status",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <ModuleStatusCell module={row.original} />,
|
||||
size: 126,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
headerTitle: "Status",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<ModuleActions module={row.original} onAction={onAction} />
|
||||
),
|
||||
size: 104,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
headerTitle: "Actions",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
DataGrid,
|
||||
dataGridFeatures,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
|
||||
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
|
||||
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
useTable,
|
||||
type PaginationState,
|
||||
type SortingState,
|
||||
} from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@evobgp/ui/components/input-group"
|
||||
import { createModuleGridColumns, type ModuleRowAction } from "./columns"
|
||||
import {
|
||||
MODULE_RECORDS,
|
||||
MODULE_STATUS_OPTIONS,
|
||||
type ModuleRecord,
|
||||
type ModuleStatus,
|
||||
} from "./data"
|
||||
import { FlagIcon, ChevronRightIcon, PackageIcon, SearchIcon, XIcon, ArrowUpDownIcon, ChevronDownIcon, FilterIcon } from "lucide-react"
|
||||
|
||||
type ModuleSort = "name" | "dateStart" | "progress" | "status"
|
||||
|
||||
const sortLabels: Record<ModuleSort, string> = {
|
||||
name: "Name",
|
||||
dateStart: "Window",
|
||||
progress: "Progress",
|
||||
status: "Status",
|
||||
}
|
||||
|
||||
function buildSorting(sortBy: ModuleSort): SortingState {
|
||||
return [{ id: sortBy, desc: false }]
|
||||
}
|
||||
|
||||
function getModuleSearchBlob(module: ModuleRecord) {
|
||||
return [
|
||||
module.name,
|
||||
module.id,
|
||||
module.kind,
|
||||
module.owner.name,
|
||||
module.owner.role,
|
||||
module.health,
|
||||
module.status,
|
||||
module.dateRange,
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
export function ModulesDataGridView() {
|
||||
const [modules, setModules] = useState<ModuleRecord[]>(MODULE_RECORDS)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<ModuleStatus[]>([])
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 5,
|
||||
})
|
||||
const [sortBy, setSortBy] = useState<ModuleSort>("name")
|
||||
const [sorting, setSorting] = useState<SortingState>(() =>
|
||||
buildSorting("name")
|
||||
)
|
||||
|
||||
const filteredModules = useMemo(() => {
|
||||
const normalizedSearchQuery = searchQuery.trim().toLowerCase()
|
||||
|
||||
return modules.filter((module) => {
|
||||
const matchesSearch =
|
||||
normalizedSearchQuery.length === 0 ||
|
||||
getModuleSearchBlob(module).includes(normalizedSearchQuery)
|
||||
const matchesStatus =
|
||||
selectedStatuses.length === 0 ||
|
||||
selectedStatuses.includes(module.status)
|
||||
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
}, [modules, searchQuery, selectedStatuses])
|
||||
|
||||
const activeFilterCount = selectedStatuses.length
|
||||
|
||||
const resetPagination = useCallback(() => {
|
||||
setPagination((current) => ({
|
||||
...current,
|
||||
pageIndex: 0,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearchQuery(value)
|
||||
resetPagination()
|
||||
},
|
||||
[resetPagination]
|
||||
)
|
||||
|
||||
const handleStatusToggle = useCallback(
|
||||
(status: ModuleStatus, checked: boolean) => {
|
||||
setSelectedStatuses((current) => {
|
||||
if (checked) {
|
||||
return current.includes(status) ? current : [...current, status]
|
||||
}
|
||||
|
||||
return current.filter((item) => item !== status)
|
||||
})
|
||||
resetPagination()
|
||||
},
|
||||
[resetPagination]
|
||||
)
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(value: string) => {
|
||||
const nextSort = value as ModuleSort
|
||||
setSortBy(nextSort)
|
||||
setSorting(buildSorting(nextSort))
|
||||
resetPagination()
|
||||
},
|
||||
[resetPagination]
|
||||
)
|
||||
|
||||
const handleModuleAction = useCallback(
|
||||
(action: ModuleRowAction, module: ModuleRecord) => {
|
||||
if (action === "favorite") {
|
||||
setModules((current) =>
|
||||
current.map((item) =>
|
||||
item.id === module.id
|
||||
? {
|
||||
...item,
|
||||
favorite: !item.favorite,
|
||||
}
|
||||
: item
|
||||
)
|
||||
)
|
||||
toast.success(module.favorite ? "Removed favorite" : "Module starred", {
|
||||
description: module.name,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (action === "open") {
|
||||
toast.info("Open module", {
|
||||
description: `${module.name} (${module.kind})`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toast.message(
|
||||
action === "duplicate" ? "Duplicate module" : "Archive module",
|
||||
{
|
||||
description: `Connect this action to your ${module.name} module flow.`,
|
||||
}
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleAddModule = () => {
|
||||
toast.success("Add module", {
|
||||
description: "Open your module creation dialog from this primary action.",
|
||||
})
|
||||
}
|
||||
|
||||
const columns = useMemo(
|
||||
() => createModuleGridColumns({ onAction: handleModuleAction }),
|
||||
[handleModuleAction]
|
||||
)
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
data: filteredModules,
|
||||
columns,
|
||||
pageCount: Math.ceil(filteredModules.length / pagination.pageSize),
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
},
|
||||
onPaginationChange: setPagination,
|
||||
onSortingChange: setSorting,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filteredModules.length}
|
||||
emptyMessage="No modules match the selected filters."
|
||||
tableLayout={{
|
||||
dense: true,
|
||||
rowBorder: true,
|
||||
headerSticky: false,
|
||||
columnsVisibility: false,
|
||||
columnsResizable: false,
|
||||
columnsMovable: false,
|
||||
width: "fixed",
|
||||
}}
|
||||
tableClassNames={{
|
||||
bodyRow: "group/module-row [&>td]:h-16",
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full max-w-6xl flex-col">
|
||||
<div className="flex flex-col gap-3 border-b px-0 py-3 lg:min-h-14 lg:flex-row lg:items-center lg:gap-4 lg:py-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FlagIcon className="size-3.5 shrink-0 fill-amber-400 text-amber-400" aria-hidden="true" />
|
||||
<span className="text-muted-foreground truncate text-sm">
|
||||
Keenthemes
|
||||
</span>
|
||||
<ChevronRightIcon className="text-muted-foreground size-3.5 shrink-0" aria-hidden="true" />
|
||||
<PackageIcon className="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
|
||||
<h2 className="text-foreground truncate text-sm font-medium">
|
||||
Modules
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 lg:ml-auto lg:flex-nowrap">
|
||||
<InputGroup className="w-full min-w-40 sm:w-48">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon className="text-muted-foreground size-4" aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={searchQuery}
|
||||
onChange={(event) => handleSearchChange(event.target.value)}
|
||||
placeholder="Search..."
|
||||
aria-label="Search modules"
|
||||
/>
|
||||
{searchQuery.length > 0 ? (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label="Clear search"
|
||||
onClick={() => handleSearchChange("")}
|
||||
>
|
||||
<XIcon className="size-4" aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
) : null}
|
||||
</InputGroup>
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button type="button" variant="outline">
|
||||
<ArrowUpDownIcon data-icon="inline-start" aria-hidden="true" />
|
||||
{sortLabels[sortBy]}
|
||||
<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="min-w-44">
|
||||
<DropdownMenuGroup>
|
||||
{(["name", "dateStart", "progress", "status"] as const).map(
|
||||
(value) => (
|
||||
<DropdownMenuItem
|
||||
key={value}
|
||||
onClick={() => handleSortChange(value)}
|
||||
>
|
||||
{sortLabels[value]}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button type="button" variant="outline">
|
||||
<FilterIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Filters
|
||||
{activeFilterCount > 0 ? (
|
||||
<Badge variant="outline" radius="full">
|
||||
{activeFilterCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="min-w-48">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Status</DropdownMenuLabel>
|
||||
{MODULE_STATUS_OPTIONS.map((status) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={status}
|
||||
checked={selectedStatuses.includes(status)}
|
||||
closeOnClick={false}
|
||||
onCheckedChange={(checked) =>
|
||||
handleStatusToggle(status, checked === true)
|
||||
}
|
||||
>
|
||||
{status}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{activeFilterCount > 0 ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
closeOnClick={false}
|
||||
onClick={() => {
|
||||
setSelectedStatuses([])
|
||||
resetPagination()
|
||||
}}
|
||||
>
|
||||
Reset filters
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button type="button" onClick={handleAddModule}>
|
||||
Add Module
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
|
||||
<div className="border-t px-0 py-3">
|
||||
{filteredModules.length > 0 ? (
|
||||
<DataGridPagination
|
||||
sizes={[5, 10, 15]}
|
||||
info="{from} - {to} of {count} modules"
|
||||
className="py-0"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">0 modules</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
export type ModuleStatus = "Planned" | "Backlog" | "In Progress"
|
||||
|
||||
export type ModuleKind = "System" | "Feature" | "Area"
|
||||
|
||||
export type ModuleHealth = "On Track" | "Watch" | "Blocked"
|
||||
|
||||
export interface ModuleOwner {
|
||||
name: string
|
||||
initials: string
|
||||
role: string
|
||||
avatarSrc: string
|
||||
}
|
||||
|
||||
export interface ModuleRecord {
|
||||
id: string
|
||||
name: string
|
||||
kind: ModuleKind
|
||||
owner: ModuleOwner
|
||||
progress: number
|
||||
tasksCompleted: number
|
||||
tasksTotal: number
|
||||
contributors: number
|
||||
blockers: number
|
||||
health: ModuleHealth
|
||||
dateStart: string
|
||||
dateEnd: string
|
||||
dateRange: string
|
||||
status: ModuleStatus
|
||||
favorite: boolean
|
||||
}
|
||||
|
||||
export const MODULE_STATUS_OPTIONS: ModuleStatus[] = [
|
||||
"Planned",
|
||||
"Backlog",
|
||||
"In Progress",
|
||||
]
|
||||
|
||||
const moduleOwners = {
|
||||
maya: {
|
||||
name: "Maya Patel",
|
||||
initials: "MP",
|
||||
role: "Release lead",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=80&h=80&dpr=2&q=80",
|
||||
},
|
||||
jonah: {
|
||||
name: "Jonah Lee",
|
||||
initials: "JL",
|
||||
role: "Product ops",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=80&h=80&dpr=2&q=80",
|
||||
},
|
||||
nina: {
|
||||
name: "Nina Santos",
|
||||
initials: "NS",
|
||||
role: "Workspace admin",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=80&h=80&dpr=2&q=80",
|
||||
},
|
||||
elijah: {
|
||||
name: "Elijah Morgan",
|
||||
initials: "EM",
|
||||
role: "Billing lead",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=80&h=80&dpr=2&q=80",
|
||||
},
|
||||
priya: {
|
||||
name: "Priya Shah",
|
||||
initials: "PS",
|
||||
role: "Lifecycle PM",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1517841905240-472988babdf9?w=80&h=80&dpr=2&q=80",
|
||||
},
|
||||
omar: {
|
||||
name: "Omar Haddad",
|
||||
initials: "OH",
|
||||
role: "Security owner",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=80&h=80&dpr=2&q=80",
|
||||
},
|
||||
sofia: {
|
||||
name: "Sofia Romero",
|
||||
initials: "SR",
|
||||
role: "Content lead",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=80&h=80&dpr=2&q=80",
|
||||
},
|
||||
kenji: {
|
||||
name: "Kenji Tan",
|
||||
initials: "KT",
|
||||
role: "Platform lead",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=80&h=80&dpr=2&q=80",
|
||||
},
|
||||
lena: {
|
||||
name: "Lena Brooks",
|
||||
initials: "LB",
|
||||
role: "Developer tools",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1551836022-d5d88e9218df?w=80&h=80&dpr=2&q=80",
|
||||
},
|
||||
} satisfies Record<string, ModuleOwner>
|
||||
|
||||
export const MODULE_RECORDS: ModuleRecord[] = [
|
||||
{
|
||||
id: "core-workflow",
|
||||
name: "Core Workflow",
|
||||
kind: "System",
|
||||
owner: moduleOwners.maya,
|
||||
progress: 25,
|
||||
tasksCompleted: 8,
|
||||
tasksTotal: 32,
|
||||
contributors: 6,
|
||||
blockers: 0,
|
||||
health: "On Track",
|
||||
dateStart: "2026-04-17",
|
||||
dateEnd: "2026-05-01",
|
||||
dateRange: "Apr 17 - May 01, 2026",
|
||||
status: "Planned",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "onboarding-flow",
|
||||
name: "Onboarding Flow",
|
||||
kind: "Feature",
|
||||
owner: moduleOwners.jonah,
|
||||
progress: 0,
|
||||
tasksCompleted: 0,
|
||||
tasksTotal: 18,
|
||||
contributors: 4,
|
||||
blockers: 0,
|
||||
health: "Watch",
|
||||
dateStart: "2026-04-19",
|
||||
dateEnd: "2026-05-03",
|
||||
dateRange: "Apr 19 - May 03, 2026",
|
||||
status: "Backlog",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "workspace-setup",
|
||||
name: "Workspace Setup",
|
||||
kind: "Area",
|
||||
owner: moduleOwners.nina,
|
||||
progress: 0,
|
||||
tasksCompleted: 2,
|
||||
tasksTotal: 14,
|
||||
contributors: 3,
|
||||
blockers: 1,
|
||||
health: "Blocked",
|
||||
dateStart: "2026-04-21",
|
||||
dateEnd: "2026-05-05",
|
||||
dateRange: "Apr 21 - May 05, 2026",
|
||||
status: "In Progress",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "permission-matrix",
|
||||
name: "Permission Matrix",
|
||||
kind: "System",
|
||||
owner: moduleOwners.maya,
|
||||
progress: 42,
|
||||
tasksCompleted: 11,
|
||||
tasksTotal: 26,
|
||||
contributors: 5,
|
||||
blockers: 0,
|
||||
health: "Watch",
|
||||
dateStart: "2026-04-22",
|
||||
dateEnd: "2026-05-06",
|
||||
dateRange: "Apr 22 - May 06, 2026",
|
||||
status: "In Progress",
|
||||
favorite: true,
|
||||
},
|
||||
{
|
||||
id: "billing-rules",
|
||||
name: "Billing Rules",
|
||||
kind: "Feature",
|
||||
owner: moduleOwners.elijah,
|
||||
progress: 64,
|
||||
tasksCompleted: 21,
|
||||
tasksTotal: 33,
|
||||
contributors: 7,
|
||||
blockers: 0,
|
||||
health: "On Track",
|
||||
dateStart: "2026-04-18",
|
||||
dateEnd: "2026-05-02",
|
||||
dateRange: "Apr 18 - May 02, 2026",
|
||||
status: "In Progress",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "notification-center",
|
||||
name: "Notification Center",
|
||||
kind: "Area",
|
||||
owner: moduleOwners.priya,
|
||||
progress: 18,
|
||||
tasksCompleted: 5,
|
||||
tasksTotal: 28,
|
||||
contributors: 4,
|
||||
blockers: 2,
|
||||
health: "Blocked",
|
||||
dateStart: "2026-04-23",
|
||||
dateEnd: "2026-05-09",
|
||||
dateRange: "Apr 23 - May 09, 2026",
|
||||
status: "Backlog",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "audit-trail",
|
||||
name: "Audit Trail",
|
||||
kind: "System",
|
||||
owner: moduleOwners.omar,
|
||||
progress: 76,
|
||||
tasksCompleted: 19,
|
||||
tasksTotal: 25,
|
||||
contributors: 5,
|
||||
blockers: 0,
|
||||
health: "On Track",
|
||||
dateStart: "2026-04-15",
|
||||
dateEnd: "2026-04-30",
|
||||
dateRange: "Apr 15 - Apr 30, 2026",
|
||||
status: "In Progress",
|
||||
favorite: true,
|
||||
},
|
||||
{
|
||||
id: "template-library",
|
||||
name: "Template Library",
|
||||
kind: "Feature",
|
||||
owner: moduleOwners.sofia,
|
||||
progress: 33,
|
||||
tasksCompleted: 10,
|
||||
tasksTotal: 30,
|
||||
contributors: 6,
|
||||
blockers: 0,
|
||||
health: "Watch",
|
||||
dateStart: "2026-04-24",
|
||||
dateEnd: "2026-05-10",
|
||||
dateRange: "Apr 24 - May 10, 2026",
|
||||
status: "Planned",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "integration-hub",
|
||||
name: "Integration Hub",
|
||||
kind: "Area",
|
||||
owner: moduleOwners.kenji,
|
||||
progress: 58,
|
||||
tasksCompleted: 14,
|
||||
tasksTotal: 24,
|
||||
contributors: 8,
|
||||
blockers: 1,
|
||||
health: "Watch",
|
||||
dateStart: "2026-04-20",
|
||||
dateEnd: "2026-05-04",
|
||||
dateRange: "Apr 20 - May 04, 2026",
|
||||
status: "In Progress",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "api-console",
|
||||
name: "API Console",
|
||||
kind: "Feature",
|
||||
owner: moduleOwners.lena,
|
||||
progress: 91,
|
||||
tasksCompleted: 29,
|
||||
tasksTotal: 32,
|
||||
contributors: 4,
|
||||
blockers: 0,
|
||||
health: "On Track",
|
||||
dateStart: "2026-04-12",
|
||||
dateEnd: "2026-04-26",
|
||||
dateRange: "Apr 12 - Apr 26, 2026",
|
||||
status: "In Progress",
|
||||
favorite: true,
|
||||
},
|
||||
{
|
||||
id: "role-automation",
|
||||
name: "Role Automation",
|
||||
kind: "System",
|
||||
owner: moduleOwners.maya,
|
||||
progress: 12,
|
||||
tasksCompleted: 3,
|
||||
tasksTotal: 25,
|
||||
contributors: 3,
|
||||
blockers: 1,
|
||||
health: "Blocked",
|
||||
dateStart: "2026-04-25",
|
||||
dateEnd: "2026-05-12",
|
||||
dateRange: "Apr 25 - May 12, 2026",
|
||||
status: "Backlog",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "workspace-invites",
|
||||
name: "Workspace Invites",
|
||||
kind: "Feature",
|
||||
owner: moduleOwners.jonah,
|
||||
progress: 47,
|
||||
tasksCompleted: 15,
|
||||
tasksTotal: 32,
|
||||
contributors: 5,
|
||||
blockers: 0,
|
||||
health: "Watch",
|
||||
dateStart: "2026-04-19",
|
||||
dateEnd: "2026-05-06",
|
||||
dateRange: "Apr 19 - May 06, 2026",
|
||||
status: "Planned",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "release-checklist",
|
||||
name: "Release Checklist",
|
||||
kind: "Area",
|
||||
owner: moduleOwners.nina,
|
||||
progress: 84,
|
||||
tasksCompleted: 26,
|
||||
tasksTotal: 31,
|
||||
contributors: 7,
|
||||
blockers: 0,
|
||||
health: "On Track",
|
||||
dateStart: "2026-04-16",
|
||||
dateEnd: "2026-05-01",
|
||||
dateRange: "Apr 16 - May 01, 2026",
|
||||
status: "In Progress",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "reporting-digest",
|
||||
name: "Reporting Digest",
|
||||
kind: "Feature",
|
||||
owner: moduleOwners.priya,
|
||||
progress: 5,
|
||||
tasksCompleted: 2,
|
||||
tasksTotal: 38,
|
||||
contributors: 3,
|
||||
blockers: 0,
|
||||
health: "Watch",
|
||||
dateStart: "2026-04-28",
|
||||
dateEnd: "2026-05-16",
|
||||
dateRange: "Apr 28 - May 16, 2026",
|
||||
status: "Backlog",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "security-review",
|
||||
name: "Security Review",
|
||||
kind: "System",
|
||||
owner: moduleOwners.omar,
|
||||
progress: 69,
|
||||
tasksCompleted: 18,
|
||||
tasksTotal: 26,
|
||||
contributors: 6,
|
||||
blockers: 2,
|
||||
health: "Blocked",
|
||||
dateStart: "2026-04-18",
|
||||
dateEnd: "2026-05-07",
|
||||
dateRange: "Apr 18 - May 07, 2026",
|
||||
status: "In Progress",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "help-center",
|
||||
name: "Help Center",
|
||||
kind: "Area",
|
||||
owner: moduleOwners.sofia,
|
||||
progress: 39,
|
||||
tasksCompleted: 9,
|
||||
tasksTotal: 23,
|
||||
contributors: 4,
|
||||
blockers: 0,
|
||||
health: "On Track",
|
||||
dateStart: "2026-04-23",
|
||||
dateEnd: "2026-05-11",
|
||||
dateRange: "Apr 23 - May 11, 2026",
|
||||
status: "Planned",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "data-retention",
|
||||
name: "Data Retention",
|
||||
kind: "System",
|
||||
owner: moduleOwners.kenji,
|
||||
progress: 22,
|
||||
tasksCompleted: 7,
|
||||
tasksTotal: 32,
|
||||
contributors: 5,
|
||||
blockers: 1,
|
||||
health: "Watch",
|
||||
dateStart: "2026-04-27",
|
||||
dateEnd: "2026-05-14",
|
||||
dateRange: "Apr 27 - May 14, 2026",
|
||||
status: "Backlog",
|
||||
favorite: false,
|
||||
},
|
||||
]
|
||||
@@ -1,15 +0,0 @@
|
||||
import { ModulesDataGridView } from "./components/data-grid-view"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main
|
||||
className="mx-auto flex min-h-svh w-full max-w-6xl items-start justify-center p-4 pt-8 sm:p-8 sm:pt-12"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Modules data grid
|
||||
</h1>
|
||||
<ModulesDataGridView />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { type ReactNode } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { type DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
|
||||
import {
|
||||
DataGridTableRowSelect,
|
||||
DataGridTableRowSelectAll,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import { Rating } from "@/components/reui/rating"
|
||||
import { type ColumnDef } from "@tanstack/react-table"
|
||||
import { format, parseISO } from "date-fns"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
import { Switch } from "@evobgp/ui/components/switch"
|
||||
import {
|
||||
type AutomationKind,
|
||||
type AutomationOwnerAvailability,
|
||||
type AutomationState,
|
||||
type IAutomationRecord,
|
||||
} from "./data"
|
||||
import { GitBranchIcon, RouteIcon, SparklesIcon, MailIcon, BellRingIcon, EllipsisVerticalIcon, PencilIcon, EyeIcon, CircleCheckIcon, ArchiveIcon } from "lucide-react"
|
||||
|
||||
export type AutomationAction = "edit" | "open" | "archive"
|
||||
|
||||
const automationKindStyles: Record<
|
||||
AutomationKind,
|
||||
{ chipClassName: string; icon: ReactNode }
|
||||
> = {
|
||||
sequence: {
|
||||
chipClassName: "text-sky-600 dark:text-sky-300",
|
||||
icon: (
|
||||
<GitBranchIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
routing: {
|
||||
chipClassName: "text-violet-600 dark:text-violet-300",
|
||||
icon: (
|
||||
<RouteIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
enrichment: {
|
||||
chipClassName: "text-emerald-600 dark:text-emerald-300",
|
||||
icon: (
|
||||
<SparklesIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
digest: {
|
||||
chipClassName: "text-amber-600 dark:text-amber-300",
|
||||
icon: (
|
||||
<MailIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
escalation: {
|
||||
chipClassName: "text-rose-600 dark:text-rose-300",
|
||||
icon: (
|
||||
<BellRingIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
const stateBadgeStyles: Record<
|
||||
AutomationState,
|
||||
{ label: string; dotClassName?: string }
|
||||
> = {
|
||||
live: {
|
||||
label: "Live",
|
||||
dotClassName: "bg-emerald-500",
|
||||
},
|
||||
review: {
|
||||
label: "Needs approval",
|
||||
dotClassName: "bg-amber-500",
|
||||
},
|
||||
drafts: {
|
||||
label: "Draft",
|
||||
dotClassName: "bg-slate-400 dark:bg-slate-300",
|
||||
},
|
||||
paused: {
|
||||
label: "Paused",
|
||||
dotClassName: "bg-zinc-400 dark:bg-zinc-300",
|
||||
},
|
||||
}
|
||||
|
||||
const updatedBucketLabel: Record<IAutomationRecord["updatedBucket"], string> = {
|
||||
today: "Today",
|
||||
"this-week": "This week",
|
||||
older: "Older",
|
||||
}
|
||||
|
||||
const availabilityColor: Record<AutomationOwnerAvailability, string> = {
|
||||
online: "bg-green-500",
|
||||
away: "bg-yellow-400",
|
||||
busy: "bg-red-500",
|
||||
offline: "bg-gray-500",
|
||||
}
|
||||
|
||||
function AutomationKindChip({ kind }: { kind: AutomationKind }) {
|
||||
const style = automationKindStyles[kind]
|
||||
|
||||
return (
|
||||
<Item
|
||||
render={<span />}
|
||||
className={cn(
|
||||
"p-0",
|
||||
"border-background bg-muted flex size-9 items-center justify-center border-2 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4",
|
||||
style.chipClassName
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{style.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
function AutomationNameCell({ automation }: { automation: IAutomationRecord }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<AutomationKindChip kind={automation.kind} />
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<span className="text-foreground truncate text-sm font-medium">
|
||||
{automation.title}
|
||||
</span>
|
||||
<div className="text-muted-foreground flex min-w-0 items-center gap-1.5 text-xs">
|
||||
<span className="truncate">{automation.runWindowLabel}</span>
|
||||
<span
|
||||
className="bg-input size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{automation.audienceLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OwnerCell({ automation }: { automation: IAutomationRecord }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative shrink-0">
|
||||
<Avatar className="size-8">
|
||||
{automation.owner.avatar ? (
|
||||
<AvatarImage
|
||||
src={automation.owner.avatar}
|
||||
alt={automation.owner.name}
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback>{automation.owner.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
className={cn(
|
||||
"ring-background absolute right-0 bottom-0.5 size-2 rounded-full ring-2",
|
||||
availabilityColor[automation.owner.availability]
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-foreground line-clamp-1 font-medium">
|
||||
{automation.owner.name}
|
||||
</div>
|
||||
<div
|
||||
className="text-muted-foreground line-clamp-1 text-xs"
|
||||
title={automation.owner.email}
|
||||
>
|
||||
{automation.owner.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StateCell({ automation }: { automation: IAutomationRecord }) {
|
||||
const stateStyle = stateBadgeStyles[automation.state]
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center">
|
||||
<Badge variant="outline" className="gap-1.5">
|
||||
{stateStyle.dotClassName ? (
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
stateStyle.dotClassName
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{stateStyle.label}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UpdatedCell({ automation }: { automation: IAutomationRecord }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<span className="text-foreground text-sm">
|
||||
{format(parseISO(automation.updatedAt), "MMM d, yyyy")}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{updatedBucketLabel[automation.updatedBucket]}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RatingCell({ automation }: { automation: IAutomationRecord }) {
|
||||
return <Rating rating={automation.rating} size="sm" showValue={true} />
|
||||
}
|
||||
|
||||
function AutomationActionsCell({
|
||||
automation,
|
||||
onAction,
|
||||
onToggleEnabled,
|
||||
}: {
|
||||
automation: IAutomationRecord
|
||||
onAction: (action: AutomationAction, automation: IAutomationRecord) => void
|
||||
onToggleEnabled: (automation: IAutomationRecord, nextValue: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Open actions for ${automation.title}`}
|
||||
>
|
||||
<EllipsisVerticalIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{/* Content */}
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => onAction("edit", automation)}>
|
||||
<PencilIcon className="size-4" aria-hidden="true" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onAction("open", automation)}>
|
||||
<EyeIcon className="size-4" aria-hidden="true" />
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
closeOnClick={false}
|
||||
onClick={(event) => {
|
||||
// The Switch toggles itself and its click bubbles here; skip it
|
||||
// so item-level activation (row click, Enter/Space) toggles once.
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
event.target.closest('[data-slot="switch"]')
|
||||
) {
|
||||
return
|
||||
}
|
||||
onToggleEnabled(automation, !automation.enabled)
|
||||
}}
|
||||
className="justify-between gap-4"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<CircleCheckIcon className="size-4" aria-hidden="true" />
|
||||
Enabled
|
||||
</span>
|
||||
<Switch
|
||||
size="sm"
|
||||
aria-label={`Toggle ${automation.title}`}
|
||||
checked={automation.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleEnabled(automation, checked)
|
||||
}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onAction("archive", automation)}
|
||||
>
|
||||
<ArchiveIcon className="size-4" aria-hidden="true" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function createAutomationColumns({
|
||||
onAction,
|
||||
onToggleEnabled,
|
||||
}: {
|
||||
onAction: (action: AutomationAction, automation: IAutomationRecord) => void
|
||||
onToggleEnabled: (automation: IAutomationRecord, nextValue: boolean) => void
|
||||
}): ColumnDef<DataGridFeatures, IAutomationRecord>[] {
|
||||
return [
|
||||
{
|
||||
id: "select",
|
||||
header: () => <DataGridTableRowSelectAll />,
|
||||
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
|
||||
size: 30,
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.title,
|
||||
id: "workflow",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Workflow" column={column} />
|
||||
),
|
||||
cell: ({ row }) => <AutomationNameCell automation={row.original} />,
|
||||
size: 340,
|
||||
minSize: 200,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
meta: {
|
||||
autoSize: true,
|
||||
headerClassName: "pl-3!",
|
||||
cellClassName: "pl-3!",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.owner.name,
|
||||
id: "owner",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Owner" column={column} />
|
||||
),
|
||||
cell: ({ row }) => <OwnerCell automation={row.original} />,
|
||||
size: 175,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.rating,
|
||||
id: "rating",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Score" column={column} />
|
||||
),
|
||||
cell: ({ row }) => <RatingCell automation={row.original} />,
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.state,
|
||||
id: "state",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="State" column={column} />
|
||||
),
|
||||
cell: ({ row }) => <StateCell automation={row.original} />,
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => parseISO(row.updatedAt).getTime(),
|
||||
id: "updatedAt",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Last updated" column={column} />
|
||||
),
|
||||
cell: ({ row }) => <UpdatedCell automation={row.original} />,
|
||||
size: 125,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => null,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end">
|
||||
<AutomationActionsCell
|
||||
automation={row.original}
|
||||
onAction={onAction}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
size: 56,
|
||||
enableSorting: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,679 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
DataGrid,
|
||||
dataGridFeatures,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
|
||||
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
|
||||
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
|
||||
import { Filters } from "@/components/reui/filters/filters"
|
||||
import {
|
||||
createFilterQuery,
|
||||
createFilterRule,
|
||||
flattenFilterConditions,
|
||||
} from "@/components/reui/filters/filters-query"
|
||||
import type { FilterCondition } from "@/components/reui/filters/filters-query"
|
||||
import type {
|
||||
FilterField,
|
||||
FilterQuery,
|
||||
} from "@/components/reui/filters/filters-types"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import {
|
||||
useTable,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
} from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@evobgp/ui/components/alert-dialog"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@evobgp/ui/components/tabs"
|
||||
import { createAutomationColumns } from "./columns"
|
||||
import {
|
||||
AUTOMATION_TABS,
|
||||
AUTOMATIONS,
|
||||
DELIVERY_FILTER_OPTIONS,
|
||||
getAutomationTabFromState,
|
||||
OWNER_FILTER_OPTIONS,
|
||||
UPDATED_FILTER_OPTIONS,
|
||||
type AutomationState,
|
||||
type AutomationTab,
|
||||
type IAutomationRecord,
|
||||
} from "./data"
|
||||
import { SearchIcon, UsersIcon, RouteIcon, ClockIcon, PlusIcon, FilterIcon, FunnelXIcon } from "lucide-react"
|
||||
|
||||
type ToastTone = "success" | "neutral" | "destructive"
|
||||
|
||||
const toneStyles: Record<ToastTone, { dot: string }> = {
|
||||
success: { dot: "bg-emerald-500" },
|
||||
neutral: { dot: "bg-sky-500" },
|
||||
destructive: { dot: "bg-rose-500" },
|
||||
}
|
||||
|
||||
function showAutomationToast({
|
||||
tone,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
tone: ToastTone
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
toast.custom((id) => (
|
||||
<div className="bg-popover text-popover-foreground border-border flex w-[356px] flex-col gap-3 rounded-md border p-4 shadow-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1 flex size-2 shrink-0 rounded-full",
|
||||
toneStyles[tone].dot
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<p className="text-sm font-semibold">{title}</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed text-pretty">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="xs" variant="outline" onClick={() => toast.dismiss(id)}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
function getAutomationSearchBlob(automation: IAutomationRecord) {
|
||||
return [
|
||||
automation.title,
|
||||
automation.kind,
|
||||
automation.state,
|
||||
automation.deliveryMode,
|
||||
automation.audienceLabel,
|
||||
automation.runWindowLabel,
|
||||
automation.owner.name,
|
||||
automation.owner.email,
|
||||
automation.owner.teamLabel,
|
||||
automation.approvalRequired ? "approval required" : "auto-approved",
|
||||
automation.enabled ? "enabled" : "disabled",
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
/** Operators that take no value, so an empty `values` list is expected. */
|
||||
const VALUELESS_OPERATORS = new Set(["empty", "not_empty"])
|
||||
|
||||
function getActiveFilters(filters: FilterCondition[]) {
|
||||
return filters.filter((filter) => {
|
||||
const { operator, values } = filter
|
||||
if (VALUELESS_OPERATORS.has(operator)) return true
|
||||
if (!values || values.length === 0) return false
|
||||
if (
|
||||
values.every((value) => typeof value === "string" && value.trim() === "")
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (values.every((value) => value === null || value === undefined)) {
|
||||
return false
|
||||
}
|
||||
if (values.every((value) => Array.isArray(value) && value.length === 0)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function renderSelectedCount(values: unknown[]) {
|
||||
if (values.length === 0) return "Select..."
|
||||
if (values.length > 1) return `${values.length} selected`
|
||||
return null
|
||||
}
|
||||
|
||||
function renderSingleSelectedLabel(
|
||||
values: unknown[],
|
||||
options: { value: string; label: string }[]
|
||||
) {
|
||||
const state = renderSelectedCount(values)
|
||||
if (state) return state
|
||||
|
||||
const option = options.find((item) => item.value === values[0])
|
||||
return option?.label ?? String(values[0])
|
||||
}
|
||||
|
||||
function filterFieldValue(
|
||||
automation: IAutomationRecord,
|
||||
field: string
|
||||
): unknown {
|
||||
switch (field) {
|
||||
case "workflow":
|
||||
return getAutomationSearchBlob(automation)
|
||||
case "ownerTeam":
|
||||
return automation.owner.team
|
||||
case "deliveryMode":
|
||||
return automation.deliveryMode
|
||||
case "updatedBucket":
|
||||
return automation.updatedBucket
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function matchesFilterCondition(
|
||||
fieldValue: unknown,
|
||||
operator: string,
|
||||
values: unknown[]
|
||||
): boolean {
|
||||
switch (operator) {
|
||||
case "is":
|
||||
return values.includes(fieldValue)
|
||||
case "is_not":
|
||||
return !values.includes(fieldValue)
|
||||
case "is_any_of":
|
||||
return values.some((value) => fieldValue === value)
|
||||
case "is_none_of":
|
||||
return !values.some((value) => fieldValue === value)
|
||||
case "contains": {
|
||||
const tokens = values.map((value) => String(value).trim()).filter(Boolean)
|
||||
if (tokens.length === 0) return true
|
||||
return tokens.some((token) =>
|
||||
String(fieldValue).toLowerCase().includes(token.toLowerCase())
|
||||
)
|
||||
}
|
||||
case "not_contains":
|
||||
return !values.some((value) =>
|
||||
String(fieldValue).toLowerCase().includes(String(value).toLowerCase())
|
||||
)
|
||||
case "starts_with":
|
||||
return values.some((value) =>
|
||||
String(fieldValue).toLowerCase().startsWith(String(value).toLowerCase())
|
||||
)
|
||||
case "ends_with":
|
||||
return values.some((value) =>
|
||||
String(fieldValue).toLowerCase().endsWith(String(value).toLowerCase())
|
||||
)
|
||||
case "empty":
|
||||
return fieldValue === "" || fieldValue == null
|
||||
case "not_empty":
|
||||
return fieldValue !== "" && fieldValue != null
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function applyFiltersToData(
|
||||
data: IAutomationRecord[],
|
||||
filters: FilterCondition[]
|
||||
): IAutomationRecord[] {
|
||||
const active = getActiveFilters(filters)
|
||||
let result = [...data]
|
||||
|
||||
active.forEach((filter) => {
|
||||
const { field, operator, values, negated } = filter
|
||||
|
||||
result = result.filter((item) => {
|
||||
const raw = filterFieldValue(item, field)
|
||||
const fieldValue = raw != null ? raw : ""
|
||||
const matches = matchesFilterCondition(fieldValue, operator, values)
|
||||
|
||||
// A chip negated from its menu keeps its operator and flips its meaning.
|
||||
return negated ? !matches : matches
|
||||
})
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const OWNER_TEAM_FILTER_OPTIONS = OWNER_FILTER_OPTIONS.filter(
|
||||
(option) => option.value !== "everyone"
|
||||
).map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}))
|
||||
|
||||
const DELIVERY_MODE_FILTER_OPTIONS = DELIVERY_FILTER_OPTIONS.filter(
|
||||
(option) => option.value !== "any"
|
||||
).map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}))
|
||||
|
||||
const UPDATED_BUCKET_FILTER_OPTIONS = UPDATED_FILTER_OPTIONS.filter(
|
||||
(option) => option.value !== "any"
|
||||
).map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}))
|
||||
|
||||
const filterFields: FilterField[] = [
|
||||
{
|
||||
id: "workflow",
|
||||
label: "Workflow",
|
||||
icon: (
|
||||
<SearchIcon className="size-3.5" aria-hidden />
|
||||
),
|
||||
type: "text",
|
||||
placeholder: "Search...",
|
||||
},
|
||||
{
|
||||
id: "ownerTeam",
|
||||
label: "Owner team",
|
||||
icon: (
|
||||
<UsersIcon className="size-3.5" aria-hidden />
|
||||
),
|
||||
type: "select",
|
||||
searchable: false,
|
||||
options: OWNER_TEAM_FILTER_OPTIONS,
|
||||
renderValue: ({ values }) =>
|
||||
renderSingleSelectedLabel(values, OWNER_TEAM_FILTER_OPTIONS),
|
||||
},
|
||||
{
|
||||
id: "deliveryMode",
|
||||
label: "Delivery mode",
|
||||
icon: (
|
||||
<RouteIcon className="size-3.5" aria-hidden />
|
||||
),
|
||||
type: "select",
|
||||
searchable: false,
|
||||
options: DELIVERY_MODE_FILTER_OPTIONS,
|
||||
renderValue: ({ values }) =>
|
||||
renderSingleSelectedLabel(values, DELIVERY_MODE_FILTER_OPTIONS),
|
||||
},
|
||||
{
|
||||
id: "updatedBucket",
|
||||
label: "Last updated",
|
||||
icon: (
|
||||
<ClockIcon className="size-3.5" aria-hidden />
|
||||
),
|
||||
type: "select",
|
||||
searchable: false,
|
||||
options: UPDATED_BUCKET_FILTER_OPTIONS,
|
||||
renderValue: ({ values }) =>
|
||||
renderSingleSelectedLabel(values, UPDATED_BUCKET_FILTER_OPTIONS),
|
||||
},
|
||||
]
|
||||
|
||||
function createDefaultAutomationFilters(): FilterQuery {
|
||||
return createFilterQuery([
|
||||
createFilterRule({
|
||||
id: "workflow-1",
|
||||
path: ["workflow"],
|
||||
operator: "contains",
|
||||
value: "",
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
function getTabCounts(records: IAutomationRecord[]) {
|
||||
return {
|
||||
all: records.length,
|
||||
live: records.filter((record) => record.state === "live").length,
|
||||
review: records.filter((record) => record.state === "review").length,
|
||||
drafts: records.filter((record) => record.state === "drafts").length,
|
||||
paused: records.filter((record) => record.state === "paused").length,
|
||||
} satisfies Record<AutomationTab, number>
|
||||
}
|
||||
|
||||
export function AutomationLibraryGridView() {
|
||||
const [automations, setAutomations] =
|
||||
useState<IAutomationRecord[]>(AUTOMATIONS)
|
||||
const [activeTab, setActiveTab] = useState<AutomationTab>("all")
|
||||
const [filterQuery, setFilterQuery] = useState<FilterQuery>(
|
||||
createDefaultAutomationFilters
|
||||
)
|
||||
const filters = useMemo(
|
||||
() => flattenFilterConditions(filterQuery),
|
||||
[filterQuery]
|
||||
)
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "updatedAt", desc: true },
|
||||
])
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 5,
|
||||
})
|
||||
const [automationPendingArchive, setAutomationPendingArchive] =
|
||||
useState<IAutomationRecord | null>(null)
|
||||
|
||||
const resetPagination = useCallback(() => {
|
||||
setPagination((current) =>
|
||||
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }
|
||||
)
|
||||
}, [])
|
||||
|
||||
const filteredBaseAutomations = useMemo(() => {
|
||||
return applyFiltersToData(automations, filters)
|
||||
}, [automations, filters])
|
||||
|
||||
const filteredAutomations = useMemo(
|
||||
() =>
|
||||
filteredBaseAutomations.filter((automation) =>
|
||||
activeTab === "all"
|
||||
? true
|
||||
: getAutomationTabFromState(automation.state) === activeTab
|
||||
),
|
||||
[activeTab, filteredBaseAutomations]
|
||||
)
|
||||
|
||||
const tabCounts = useMemo(
|
||||
() => getTabCounts(filteredBaseAutomations),
|
||||
[filteredBaseAutomations]
|
||||
)
|
||||
|
||||
const filteredLiveCount = useMemo(
|
||||
() =>
|
||||
filteredAutomations.filter((automation) => automation.state === "live")
|
||||
.length,
|
||||
[filteredAutomations]
|
||||
)
|
||||
|
||||
const filteredReviewCount = useMemo(
|
||||
() =>
|
||||
filteredAutomations.filter((automation) => automation.state === "review")
|
||||
.length,
|
||||
[filteredAutomations]
|
||||
)
|
||||
|
||||
const selectedCount = useMemo(
|
||||
() => Object.keys(rowSelection).length,
|
||||
[rowSelection]
|
||||
)
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
createAutomationColumns({
|
||||
onAction: (action, automation) => {
|
||||
if (action === "archive") {
|
||||
setAutomationPendingArchive(automation)
|
||||
return
|
||||
}
|
||||
|
||||
if (action === "edit") {
|
||||
showAutomationToast({
|
||||
tone: "neutral",
|
||||
title: "Workflow editor",
|
||||
description: `Connect "${automation.title}" to your builder, side panel, or automation step editor.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
showAutomationToast({
|
||||
tone: "success",
|
||||
title: "Workflow details",
|
||||
description: `"${automation.title}" is ready for a detail route, run history drawer, or audit panel.`,
|
||||
})
|
||||
},
|
||||
onToggleEnabled: (automation, nextValue) => {
|
||||
const nextState: AutomationState =
|
||||
nextValue && automation.state === "paused"
|
||||
? "live"
|
||||
: nextValue && automation.state === "drafts"
|
||||
? "review"
|
||||
: !nextValue && automation.state === "live"
|
||||
? "paused"
|
||||
: automation.state
|
||||
|
||||
setAutomations((current) =>
|
||||
current.map((item) =>
|
||||
item.id === automation.id
|
||||
? {
|
||||
...item,
|
||||
enabled: nextValue,
|
||||
state: nextState,
|
||||
}
|
||||
: item
|
||||
)
|
||||
)
|
||||
|
||||
showAutomationToast({
|
||||
tone: nextValue ? "success" : "neutral",
|
||||
title: nextValue ? "Workflow enabled" : "Workflow paused",
|
||||
description: nextValue
|
||||
? `"${automation.title}" is ready to run in the ${nextState === "review" ? "review" : "live"} queue.`
|
||||
: `"${automation.title}" will stay available but will not continue running until resumed.`,
|
||||
})
|
||||
},
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
data: filteredAutomations,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
state: {
|
||||
sorting,
|
||||
rowSelection,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onPaginationChange: setPagination,
|
||||
})
|
||||
|
||||
const handleClearControls = useCallback(() => {
|
||||
setFilterQuery(createDefaultAutomationFilters())
|
||||
resetPagination()
|
||||
}, [resetPagination])
|
||||
|
||||
const handleFiltersChange = useCallback(
|
||||
(nextQuery: FilterQuery) => {
|
||||
setFilterQuery(nextQuery)
|
||||
resetPagination()
|
||||
},
|
||||
[resetPagination]
|
||||
)
|
||||
|
||||
const handleArchiveAutomation = useCallback(() => {
|
||||
if (!automationPendingArchive) return
|
||||
|
||||
const automationToArchive = automationPendingArchive
|
||||
|
||||
setAutomations((current) =>
|
||||
current.filter(
|
||||
(automation) => automation.id !== automationPendingArchive.id
|
||||
)
|
||||
)
|
||||
setRowSelection((current) => {
|
||||
const next = { ...current }
|
||||
delete next[automationPendingArchive.id]
|
||||
return next
|
||||
})
|
||||
setAutomationPendingArchive(null)
|
||||
resetPagination()
|
||||
|
||||
showAutomationToast({
|
||||
tone: "destructive",
|
||||
title: "Workflow archived",
|
||||
description: `"${automationToArchive.title}" was removed from this automation library.`,
|
||||
})
|
||||
}, [automationPendingArchive, resetPagination])
|
||||
|
||||
const emptyMessage =
|
||||
"No workflows match this automation slice. Switch tabs or clear the filters."
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Table */}
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filteredAutomations.length}
|
||||
emptyMessage={emptyMessage}
|
||||
tableLayout={{
|
||||
dense: true,
|
||||
}}
|
||||
tableClassNames={{
|
||||
edgeCell: "first:ps-3 last:pe-3",
|
||||
}}
|
||||
>
|
||||
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||
<FrameHeader className="flex-row items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-px">
|
||||
<FrameTitle className="text-balance">
|
||||
Automation Library
|
||||
</FrameTitle>
|
||||
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
|
||||
<span>
|
||||
{filteredAutomations.length} workflow
|
||||
{filteredAutomations.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
<span
|
||||
className="bg-input size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{filteredLiveCount} live</span>
|
||||
<span
|
||||
className="bg-input size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{filteredReviewCount} review</span>
|
||||
{selectedCount > 0 ? (
|
||||
<>
|
||||
<span
|
||||
className="bg-input size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{selectedCount} selected</span>
|
||||
</>
|
||||
) : null}
|
||||
</FrameDescription>
|
||||
</div>
|
||||
|
||||
<Button type="button" className="shrink-0">
|
||||
<PlusIcon className="size-4" aria-hidden="true" />
|
||||
New workflow
|
||||
</Button>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="p-0 shadow-none!">
|
||||
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => {
|
||||
setActiveTab(value as AutomationTab)
|
||||
resetPagination()
|
||||
}}
|
||||
>
|
||||
<TabsList variant="line" className="gap-5">
|
||||
{AUTOMATION_TABS.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className="gap-2 px-0 pt-0 pb-(--frame-panel-header-py) text-sm"
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
|
||||
{tabCounts[tab.value]}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-2.5">
|
||||
<Filters
|
||||
query={filterQuery}
|
||||
fields={filterFields}
|
||||
onQueryChange={handleFiltersChange}
|
||||
size="default"
|
||||
trigger={
|
||||
<Button variant="outline" aria-label="Filters">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
Filters
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{selectedCount > 0 ? (
|
||||
<Badge size="sm" variant="secondary">
|
||||
{selectedCount} selected
|
||||
</Badge>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClearControls}
|
||||
>
|
||||
<FunnelXIcon className="size-4" aria-hidden="true" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
|
||||
<Separator />
|
||||
|
||||
<FrameFooter>
|
||||
<DataGridPagination />
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</DataGrid>
|
||||
|
||||
<AlertDialog
|
||||
open={automationPendingArchive != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setAutomationPendingArchive(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Archive workflow?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{automationPendingArchive
|
||||
? `Archive "${automationPendingArchive.title}" from this automation library. Run history and ownership context can stay available in your backend, but this row will disappear from the grid preview.`
|
||||
: "Archive this workflow from the automation library."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleArchiveAutomation}
|
||||
render={
|
||||
<Button type="button" variant="destructive">
|
||||
Archive
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
export type AutomationTab = "all" | "live" | "review" | "drafts" | "paused"
|
||||
|
||||
export type AutomationKind =
|
||||
| "sequence"
|
||||
| "routing"
|
||||
| "enrichment"
|
||||
| "digest"
|
||||
| "escalation"
|
||||
|
||||
export type OwnerFilter =
|
||||
| "everyone"
|
||||
| "product"
|
||||
| "engineering"
|
||||
| "operations"
|
||||
| "revenue"
|
||||
| "support"
|
||||
|
||||
export type DeliveryFilter =
|
||||
| "any"
|
||||
| "scheduled"
|
||||
| "event-driven"
|
||||
| "manual"
|
||||
| "hybrid"
|
||||
|
||||
export type UpdatedFilter = "any" | "today" | "this-week" | "older"
|
||||
|
||||
export type AutomationState = Exclude<AutomationTab, "all">
|
||||
export type AutomationOwnerAvailability = "online" | "away" | "busy" | "offline"
|
||||
|
||||
export interface IAutomationOwner {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
initials: string
|
||||
avatar?: string
|
||||
availability: AutomationOwnerAvailability
|
||||
team: Exclude<OwnerFilter, "everyone">
|
||||
teamLabel: string
|
||||
}
|
||||
|
||||
export interface IAutomationRecord {
|
||||
id: string
|
||||
title: string
|
||||
kind: AutomationKind
|
||||
state: AutomationState
|
||||
rating: number
|
||||
deliveryMode: Exclude<DeliveryFilter, "any">
|
||||
owner: IAutomationOwner
|
||||
updatedAt: string
|
||||
updatedBucket: Exclude<UpdatedFilter, "any">
|
||||
enabled: boolean
|
||||
approvalRequired: boolean
|
||||
audienceLabel: string
|
||||
runWindowLabel: string
|
||||
}
|
||||
|
||||
const OWNERS: Record<string, IAutomationOwner> = {
|
||||
maya: {
|
||||
id: "maya-patel",
|
||||
name: "Maya Patel",
|
||||
email: "[email protected]",
|
||||
initials: "MP",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||
availability: "online",
|
||||
team: "product",
|
||||
teamLabel: "Product",
|
||||
},
|
||||
jonas: {
|
||||
id: "jonas-reed",
|
||||
name: "Jonas Reed",
|
||||
email: "[email protected]",
|
||||
initials: "JR",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
availability: "busy",
|
||||
team: "engineering",
|
||||
teamLabel: "Engineering",
|
||||
},
|
||||
priya: {
|
||||
id: "priya-nair",
|
||||
name: "Priya Nair",
|
||||
email: "[email protected]",
|
||||
initials: "PN",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1517841905240-472988babdf9?w=96&h=96&dpr=2&q=80",
|
||||
availability: "away",
|
||||
team: "operations",
|
||||
teamLabel: "Operations",
|
||||
},
|
||||
emil: {
|
||||
id: "emil-novak",
|
||||
name: "Emil Novak",
|
||||
email: "[email protected]",
|
||||
initials: "EN",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1560250097-0b93528c311a?w=96&h=96&dpr=2&q=80",
|
||||
availability: "offline",
|
||||
team: "revenue",
|
||||
teamLabel: "Revenue",
|
||||
},
|
||||
nora: {
|
||||
id: "nora-ibrahim",
|
||||
name: "Nora Ibrahim",
|
||||
email: "[email protected]",
|
||||
initials: "NI",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=96&h=96&dpr=2&q=80",
|
||||
availability: "online",
|
||||
team: "support",
|
||||
teamLabel: "Support",
|
||||
},
|
||||
}
|
||||
|
||||
function automation(
|
||||
input: Omit<IAutomationRecord, "owner"> & {
|
||||
owner: keyof typeof OWNERS
|
||||
}
|
||||
): IAutomationRecord {
|
||||
return {
|
||||
...input,
|
||||
owner: OWNERS[input.owner],
|
||||
}
|
||||
}
|
||||
|
||||
export const AUTOMATION_TABS: { value: AutomationTab; label: string }[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "live", label: "Live" },
|
||||
{ value: "review", label: "Needs Review" },
|
||||
{ value: "drafts", label: "Drafts" },
|
||||
{ value: "paused", label: "Paused" },
|
||||
]
|
||||
|
||||
export const OWNER_FILTER_OPTIONS: {
|
||||
value: OwnerFilter
|
||||
label: string
|
||||
}[] = [
|
||||
{ value: "everyone", label: "Owner team" },
|
||||
{ value: "product", label: "Product" },
|
||||
{ value: "engineering", label: "Engineering" },
|
||||
{ value: "operations", label: "Operations" },
|
||||
{ value: "revenue", label: "Revenue" },
|
||||
{ value: "support", label: "Support" },
|
||||
]
|
||||
|
||||
export const DELIVERY_FILTER_OPTIONS: {
|
||||
value: DeliveryFilter
|
||||
label: string
|
||||
}[] = [
|
||||
{ value: "any", label: "Delivery mode" },
|
||||
{ value: "scheduled", label: "Scheduled" },
|
||||
{ value: "event-driven", label: "Event-driven" },
|
||||
{ value: "manual", label: "Manual" },
|
||||
{ value: "hybrid", label: "Hybrid" },
|
||||
]
|
||||
|
||||
export const UPDATED_FILTER_OPTIONS: {
|
||||
value: UpdatedFilter
|
||||
label: string
|
||||
}[] = [
|
||||
{ value: "any", label: "Last updated" },
|
||||
{ value: "today", label: "Today" },
|
||||
{ value: "this-week", label: "This week" },
|
||||
{ value: "older", label: "Older" },
|
||||
]
|
||||
|
||||
export function getAutomationTabFromState(
|
||||
state: AutomationState
|
||||
): Exclude<AutomationTab, "all"> {
|
||||
return state
|
||||
}
|
||||
|
||||
export const AUTOMATIONS: IAutomationRecord[] = [
|
||||
automation({
|
||||
id: "renewal-touchpoint-orchestration",
|
||||
title: "Renewal touchpoint orchestration",
|
||||
kind: "sequence",
|
||||
state: "live",
|
||||
rating: 4.8,
|
||||
deliveryMode: "scheduled",
|
||||
owner: "emil",
|
||||
updatedAt: "2026-04-11",
|
||||
updatedBucket: "today",
|
||||
enabled: true,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Renewal accounts",
|
||||
runWindowLabel: "Weekdays 09:00",
|
||||
}),
|
||||
automation({
|
||||
id: "delegated-sender-review-route",
|
||||
title: "Delegated sender review route",
|
||||
kind: "routing",
|
||||
state: "review",
|
||||
rating: 4.2,
|
||||
deliveryMode: "manual",
|
||||
owner: "priya",
|
||||
updatedAt: "2026-04-11",
|
||||
updatedBucket: "today",
|
||||
enabled: false,
|
||||
approvalRequired: true,
|
||||
audienceLabel: "Delegated senders",
|
||||
runWindowLabel: "Queue-based release",
|
||||
}),
|
||||
automation({
|
||||
id: "launch-handoff-digest",
|
||||
title: "Launch handoff digest",
|
||||
kind: "digest",
|
||||
state: "live",
|
||||
rating: 4.7,
|
||||
deliveryMode: "scheduled",
|
||||
owner: "maya",
|
||||
updatedAt: "2026-04-10",
|
||||
updatedBucket: "this-week",
|
||||
enabled: true,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Launch squad",
|
||||
runWindowLabel: "Daily 08:30",
|
||||
}),
|
||||
automation({
|
||||
id: "sla-escalation-watch",
|
||||
title: "SLA escalation watch",
|
||||
kind: "escalation",
|
||||
state: "live",
|
||||
rating: 4.9,
|
||||
deliveryMode: "event-driven",
|
||||
owner: "nora",
|
||||
updatedAt: "2026-04-10",
|
||||
updatedBucket: "this-week",
|
||||
enabled: true,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Priority tickets",
|
||||
runWindowLabel: "On trigger",
|
||||
}),
|
||||
automation({
|
||||
id: "lead-enrichment-pass",
|
||||
title: "Lead enrichment pass",
|
||||
kind: "enrichment",
|
||||
state: "paused",
|
||||
rating: 3.9,
|
||||
deliveryMode: "hybrid",
|
||||
owner: "jonas",
|
||||
updatedAt: "2026-04-09",
|
||||
updatedBucket: "this-week",
|
||||
enabled: false,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Inbound pipeline",
|
||||
runWindowLabel: "Hourly batch",
|
||||
}),
|
||||
automation({
|
||||
id: "sandbox-onboarding-sequence",
|
||||
title: "Sandbox onboarding sequence",
|
||||
kind: "sequence",
|
||||
state: "drafts",
|
||||
rating: 4.1,
|
||||
deliveryMode: "scheduled",
|
||||
owner: "priya",
|
||||
updatedAt: "2026-04-08",
|
||||
updatedBucket: "this-week",
|
||||
enabled: false,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Trial workspaces",
|
||||
runWindowLabel: "Pending QA",
|
||||
}),
|
||||
automation({
|
||||
id: "partner-routing-fallback",
|
||||
title: "Partner routing fallback",
|
||||
kind: "routing",
|
||||
state: "live",
|
||||
rating: 4.4,
|
||||
deliveryMode: "hybrid",
|
||||
owner: "emil",
|
||||
updatedAt: "2026-04-07",
|
||||
updatedBucket: "this-week",
|
||||
enabled: true,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Partner renewals",
|
||||
runWindowLabel: "Live + nightly",
|
||||
}),
|
||||
automation({
|
||||
id: "weekly-adoption-digest",
|
||||
title: "Weekly adoption digest",
|
||||
kind: "digest",
|
||||
state: "paused",
|
||||
rating: 3.8,
|
||||
deliveryMode: "scheduled",
|
||||
owner: "maya",
|
||||
updatedAt: "2026-04-06",
|
||||
updatedBucket: "this-week",
|
||||
enabled: false,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Workspace champions",
|
||||
runWindowLabel: "Fridays 16:00",
|
||||
}),
|
||||
automation({
|
||||
id: "enterprise-risk-escalation",
|
||||
title: "Enterprise risk escalation",
|
||||
kind: "escalation",
|
||||
state: "review",
|
||||
rating: 4.3,
|
||||
deliveryMode: "manual",
|
||||
owner: "nora",
|
||||
updatedAt: "2026-04-05",
|
||||
updatedBucket: "older",
|
||||
enabled: false,
|
||||
approvalRequired: true,
|
||||
audienceLabel: "Enterprise accounts",
|
||||
runWindowLabel: "Manual release",
|
||||
}),
|
||||
automation({
|
||||
id: "crm-enrichment-backfill",
|
||||
title: "CRM enrichment backfill",
|
||||
kind: "enrichment",
|
||||
state: "live",
|
||||
rating: 4.6,
|
||||
deliveryMode: "scheduled",
|
||||
owner: "jonas",
|
||||
updatedAt: "2026-04-04",
|
||||
updatedBucket: "older",
|
||||
enabled: true,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Open opportunities",
|
||||
runWindowLabel: "Nightly 01:00",
|
||||
}),
|
||||
automation({
|
||||
id: "trial-conversion-follow-up",
|
||||
title: "Trial conversion follow-up",
|
||||
kind: "sequence",
|
||||
state: "drafts",
|
||||
rating: 4.0,
|
||||
deliveryMode: "scheduled",
|
||||
owner: "emil",
|
||||
updatedAt: "2026-04-03",
|
||||
updatedBucket: "older",
|
||||
enabled: false,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Product-led signups",
|
||||
runWindowLabel: "Awaiting copy",
|
||||
}),
|
||||
automation({
|
||||
id: "owner-assignment-router",
|
||||
title: "Owner assignment router",
|
||||
kind: "routing",
|
||||
state: "live",
|
||||
rating: 4.5,
|
||||
deliveryMode: "event-driven",
|
||||
owner: "priya",
|
||||
updatedAt: "2026-04-02",
|
||||
updatedBucket: "older",
|
||||
enabled: true,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Workspace requests",
|
||||
runWindowLabel: "Immediate",
|
||||
}),
|
||||
automation({
|
||||
id: "ops-exception-digest",
|
||||
title: "Ops exception digest",
|
||||
kind: "digest",
|
||||
state: "review",
|
||||
rating: 4.1,
|
||||
deliveryMode: "scheduled",
|
||||
owner: "priya",
|
||||
updatedAt: "2026-04-11",
|
||||
updatedBucket: "today",
|
||||
enabled: false,
|
||||
approvalRequired: true,
|
||||
audienceLabel: "Ops leadership",
|
||||
runWindowLabel: "Daily 18:00",
|
||||
}),
|
||||
automation({
|
||||
id: "billing-retry-escalation",
|
||||
title: "Billing retry escalation",
|
||||
kind: "escalation",
|
||||
state: "live",
|
||||
rating: 4.7,
|
||||
deliveryMode: "event-driven",
|
||||
owner: "nora",
|
||||
updatedAt: "2026-04-01",
|
||||
updatedBucket: "older",
|
||||
enabled: true,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Recovery queue",
|
||||
runWindowLabel: "On failure",
|
||||
}),
|
||||
]
|
||||
@@ -1,15 +0,0 @@
|
||||
import { AutomationLibraryGridView } from "./components/data-grid-view"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main
|
||||
className="mx-auto flex min-h-svh w-full max-w-7xl items-start justify-center p-8 pt-12"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Automation library data grid
|
||||
</h1>
|
||||
<AutomationLibraryGridView />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { type CSSProperties } from "react"
|
||||
import {
|
||||
Frame,
|
||||
FrameFooter,
|
||||
FramePanel,
|
||||
} from "@/components/reui/frame"
|
||||
import { IconStack } from "@/components/reui/icon-stack"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@evobgp/ui/components/empty"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@evobgp/ui/components/tooltip"
|
||||
import { Building2Icon, InfoIcon } from "lucide-react"
|
||||
|
||||
export function EmptyState() {
|
||||
return (
|
||||
<section
|
||||
className="flex w-full max-w-5xl flex-col gap-8"
|
||||
aria-labelledby="organizations-heading"
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<h2
|
||||
id="organizations-heading"
|
||||
className="text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
Organizations
|
||||
</h2>
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<TooltipProvider delay={200}>
|
||||
<Frame spacing="xs" className="w-full" aria-label="Organizations setup">
|
||||
<FramePanel className="flex min-h-[274px] items-center justify-center px-6 py-12">
|
||||
<Empty className="max-w-md flex-none bg-transparent p-0">
|
||||
<EmptyHeader className="gap-5">
|
||||
<EmptyMedia className="mb-0">
|
||||
<IconStack
|
||||
aria-hidden="true"
|
||||
className="h-20 w-18"
|
||||
style={
|
||||
{
|
||||
"--icon-stack-content-x": "70%",
|
||||
"--icon-stack-content-y": "57%",
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<Building2Icon className="size-4.5" strokeWidth="1.8" aria-hidden="true" />
|
||||
</IconStack>
|
||||
</EmptyMedia>
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<EmptyTitle className="text-base font-semibold tracking-tight">
|
||||
Enable organizations
|
||||
</EmptyTitle>
|
||||
<EmptyDescription className="max-w-sm text-sm/relaxed">
|
||||
Enable your users to create organizations, manage team
|
||||
members, and control access with role-based permissions.
|
||||
</EmptyDescription>
|
||||
</div>
|
||||
</EmptyHeader>
|
||||
|
||||
<EmptyContent className="mt-5 max-w-none gap-0">
|
||||
<Button type="button">Enable organizations</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
</FramePanel>
|
||||
|
||||
<FrameFooter className="flex-row items-center justify-start">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<a
|
||||
href="#organizations-docs"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-sm text-xs font-medium transition-colors outline-none focus-visible:ring-[3px]"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoIcon className="size-3.5" aria-hidden="true" />
|
||||
<span>
|
||||
Learn more about{" "}
|
||||
<span className="text-primary">Organizations</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-64">
|
||||
Organizations group users, teams, roles, and permissions under
|
||||
one account boundary.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
</TooltipProvider>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { EmptyState } from "./components/empty-state"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main
|
||||
className="flex min-h-svh w-full items-start justify-center p-4 sm:p-8 md:p-10"
|
||||
aria-labelledby="organizations-heading"
|
||||
>
|
||||
<EmptyState />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type { BadgeProps } from "@/components/reui/badge"
|
||||
|
||||
import { ClaudeAiIcon } from "@evobgp/ui/components/svgs/claudeAiIcon"
|
||||
import { CursorDark } from "@evobgp/ui/components/svgs/cursorDark"
|
||||
import { CursorLight } from "@evobgp/ui/components/svgs/cursorLight"
|
||||
import { GoogleCalendar } from "@evobgp/ui/components/svgs/googleCalendar"
|
||||
import { ResendIconBlack } from "@evobgp/ui/components/svgs/resendIconBlack"
|
||||
import { ResendIconWhite } from "@evobgp/ui/components/svgs/resendIconWhite"
|
||||
import { Slack } from "@evobgp/ui/components/svgs/slack"
|
||||
import { Stripe } from "@evobgp/ui/components/svgs/stripe"
|
||||
import { Supabase } from "@evobgp/ui/components/svgs/supabase"
|
||||
|
||||
export type IntegrationStatus = "connected" | "available" | "preview"
|
||||
|
||||
export interface IntegrationStatusMeta {
|
||||
label: string
|
||||
variant: BadgeProps["variant"]
|
||||
}
|
||||
|
||||
export interface IntegrationApp {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
logo: ReactNode
|
||||
status: IntegrationStatus
|
||||
actionLabel: string
|
||||
}
|
||||
|
||||
export const INTEGRATION_STATUS_META: Record<
|
||||
IntegrationStatus,
|
||||
IntegrationStatusMeta
|
||||
> = {
|
||||
connected: {
|
||||
label: "Connected",
|
||||
variant: "success-light",
|
||||
},
|
||||
available: {
|
||||
label: "Available",
|
||||
variant: "secondary",
|
||||
},
|
||||
preview: {
|
||||
label: "Preview",
|
||||
variant: "info-light",
|
||||
},
|
||||
}
|
||||
|
||||
const logoClassName = "block size-5"
|
||||
|
||||
function ThemeLogo({ light, dark }: { light: ReactNode; dark: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-5 items-center justify-center leading-none dark:hidden"
|
||||
>
|
||||
{light}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="hidden size-5 items-center justify-center leading-none dark:flex"
|
||||
>
|
||||
{dark}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const CURSOR_LOGO = (
|
||||
<ThemeLogo
|
||||
light={<CursorLight className={logoClassName} />}
|
||||
dark={<CursorDark className={logoClassName} />}
|
||||
/>
|
||||
)
|
||||
|
||||
const RESEND_LOGO = (
|
||||
<ThemeLogo
|
||||
light={<ResendIconBlack className={logoClassName} />}
|
||||
dark={<ResendIconWhite className={logoClassName} />}
|
||||
/>
|
||||
)
|
||||
|
||||
export const INTEGRATION_APPS: IntegrationApp[] = [
|
||||
{
|
||||
id: "supabase",
|
||||
name: "Supabase",
|
||||
description:
|
||||
"Sync workspace records, owners, and lifecycle events from your product database.",
|
||||
logo: <Supabase className={logoClassName} aria-hidden="true" />,
|
||||
status: "connected",
|
||||
actionLabel: "Configure",
|
||||
},
|
||||
{
|
||||
id: "slack",
|
||||
name: "Slack",
|
||||
description:
|
||||
"Route approvals, launch notes, and incident updates into selected team channels.",
|
||||
logo: <Slack className={logoClassName} aria-hidden="true" />,
|
||||
status: "connected",
|
||||
actionLabel: "Configure",
|
||||
},
|
||||
{
|
||||
id: "stripe",
|
||||
name: "Stripe",
|
||||
description:
|
||||
"Mirror subscription, invoice, and renewal signals into account reviews.",
|
||||
logo: <Stripe className={logoClassName} aria-hidden="true" />,
|
||||
status: "connected",
|
||||
actionLabel: "Configure",
|
||||
},
|
||||
{
|
||||
id: "google-calendar",
|
||||
name: "Google Calendar",
|
||||
description:
|
||||
"Create planning reviews from milestones, customer dates, and release windows.",
|
||||
logo: <GoogleCalendar className={logoClassName} aria-hidden="true" />,
|
||||
status: "available",
|
||||
actionLabel: "Install",
|
||||
},
|
||||
{
|
||||
id: "resend",
|
||||
name: "Resend",
|
||||
description:
|
||||
"Send branded transactional updates when workflows change status or ownership.",
|
||||
logo: RESEND_LOGO,
|
||||
status: "available",
|
||||
actionLabel: "Install",
|
||||
},
|
||||
{
|
||||
id: "cursor",
|
||||
name: "Cursor",
|
||||
description:
|
||||
"Attach implementation context and AI coding notes to active product tasks.",
|
||||
logo: CURSOR_LOGO,
|
||||
status: "preview",
|
||||
actionLabel: "Configure",
|
||||
},
|
||||
{
|
||||
id: "claude",
|
||||
name: "Claude",
|
||||
description:
|
||||
"Draft summaries, support notes, and rollout briefs from approved workspace data.",
|
||||
logo: <ClaudeAiIcon className={logoClassName} aria-hidden="true" />,
|
||||
status: "preview",
|
||||
actionLabel: "Install",
|
||||
},
|
||||
]
|
||||
@@ -1,109 +0,0 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemGroup,
|
||||
ItemMedia,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
} from "@evobgp/ui/components/item"
|
||||
|
||||
import {
|
||||
INTEGRATION_APPS,
|
||||
INTEGRATION_STATUS_META,
|
||||
type IntegrationApp,
|
||||
} from "./data"
|
||||
|
||||
function IntegrationLogo({ app }: { app: IntegrationApp }) {
|
||||
return (
|
||||
<Item className="bg-muted/60 border-background flex size-10 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:block [&_svg]:size-5">
|
||||
<ItemMedia variant="icon" className="size-auto translate-y-0! self-center!">
|
||||
{app.logo}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
function IntegrationRow({ app }: { app: IntegrationApp }) {
|
||||
const status = INTEGRATION_STATUS_META[app.status]
|
||||
|
||||
return (
|
||||
<Item role="listitem" className="items-center px-3.5 py-2.5 sm:px-4">
|
||||
{/* Media */}
|
||||
<ItemMedia className="translate-y-0! self-center!">
|
||||
<IntegrationLogo app={app} />
|
||||
</ItemMedia>
|
||||
|
||||
{/* Content */}
|
||||
<ItemContent className="min-w-0 gap-0">
|
||||
<ItemTitle className="w-full min-w-0 gap-2">
|
||||
<span className="truncate">{app.name}</span>
|
||||
<Badge variant={status.variant}>{status.label}</Badge>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="line-clamp-1">
|
||||
{app.description}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
|
||||
{/* Actions */}
|
||||
<ItemActions className="ml-auto shrink-0 justify-end gap-2">
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
{app.actionLabel}
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
export function IntegrationSettings() {
|
||||
return (
|
||||
<div className="flex w-full max-w-4xl flex-col gap-5">
|
||||
{/* Section */}
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-sm font-semibold">Connected Apps</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Manage source systems for automation, scheduling, support, and AI
|
||||
workflows.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button type="button" size="sm" className="w-full sm:w-auto">
|
||||
Build Connector
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Frame>
|
||||
<FrameHeader className="sr-only">
|
||||
<FrameTitle>Integration Apps</FrameTitle>
|
||||
<FrameDescription>
|
||||
Available workspace apps and configuration actions.
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0!">
|
||||
<ItemGroup className="gap-0">
|
||||
{INTEGRATION_APPS.map((app, index) => (
|
||||
<div key={app.id}>
|
||||
{index > 0 ? <ItemSeparator className="my-0" /> : null}
|
||||
<IntegrationRow app={app} />
|
||||
</div>
|
||||
))}
|
||||
</ItemGroup>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { IntegrationSettings } from "./components/integration-settings"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main
|
||||
className="flex min-h-svh w-full items-start justify-center p-4 sm:p-8 md:p-12"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Integration settings
|
||||
</h1>
|
||||
<IntegrationSettings />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useState } from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { FieldLegend, FieldSet } from "@evobgp/ui/components/field"
|
||||
import { ACCENT_COLORS } from "./data"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
// ── Color Picker ──
|
||||
|
||||
export function ColorPicker() {
|
||||
const [selected, setSelected] = useState(ACCENT_COLORS[0].value)
|
||||
|
||||
return (
|
||||
<FieldSet className="gap-0">
|
||||
<FieldLegend className="sr-only">Accent color</FieldLegend>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
{ACCENT_COLORS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
aria-label={color.name}
|
||||
onClick={() => setSelected(color.value)}
|
||||
className={cn(
|
||||
"flex size-7 items-center justify-center rounded-full transition-shadow",
|
||||
selected === color.value &&
|
||||
"ring-ring ring-offset-background ring-2 ring-offset-2"
|
||||
)}
|
||||
style={{ backgroundColor: color.value }}
|
||||
>
|
||||
{selected === color.value ? (
|
||||
<CheckIcon className="size-3 text-white" aria-hidden="true" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</FieldSet>
|
||||
)
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export type SelectOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type Preference = {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
defaultChecked: boolean
|
||||
}
|
||||
|
||||
export type AccentColorOption = {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type SettingBadgeVariant =
|
||||
| "primary-light"
|
||||
| "destructive-light"
|
||||
| "info-light"
|
||||
|
||||
export type SettingFieldProps = {
|
||||
title: string
|
||||
description: string
|
||||
badge?: { label: string; variant: SettingBadgeVariant }
|
||||
children: ReactNode
|
||||
last?: boolean
|
||||
}
|
||||
|
||||
export type DateFormatOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
// ── Data ──
|
||||
|
||||
export const DAYS: SelectOption[] = [
|
||||
{ value: "monday", label: "Monday" },
|
||||
{ value: "tuesday", label: "Tuesday" },
|
||||
{ value: "wednesday", label: "Wednesday" },
|
||||
{ value: "thursday", label: "Thursday" },
|
||||
{ value: "friday", label: "Friday" },
|
||||
{ value: "saturday", label: "Saturday" },
|
||||
{ value: "sunday", label: "Sunday" },
|
||||
]
|
||||
|
||||
export const CURRENCIES: SelectOption[] = [
|
||||
{ value: "usd", label: "USD · US Dollar" },
|
||||
{ value: "eur", label: "EUR · Euro" },
|
||||
{ value: "gbp", label: "GBP · British Pound" },
|
||||
{ value: "jpy", label: "JPY · Japanese Yen" },
|
||||
]
|
||||
|
||||
export const REGIONS: SelectOption[] = [
|
||||
{ value: "us", label: "United States" },
|
||||
{ value: "eu", label: "European Union" },
|
||||
{ value: "uk", label: "United Kingdom" },
|
||||
{ value: "jp", label: "Japan" },
|
||||
{ value: "au", label: "Australia" },
|
||||
]
|
||||
|
||||
export const ACCENT_COLORS: AccentColorOption[] = [
|
||||
{ name: "Slate", value: "#64748b" },
|
||||
{ name: "Rose", value: "#f43f5e" },
|
||||
{ name: "Orange", value: "#f97316" },
|
||||
{ name: "Violet", value: "#8b5cf6" },
|
||||
{ name: "Emerald", value: "#10b981" },
|
||||
{ name: "Sky", value: "#0ea5e9" },
|
||||
]
|
||||
|
||||
export const PREFERENCES: Preference[] = [
|
||||
{
|
||||
id: "keyboard-shortcuts",
|
||||
label: "Enable keyboard shortcuts.",
|
||||
description: "Use shortcuts to speed up your workflow.",
|
||||
defaultChecked: true,
|
||||
},
|
||||
{
|
||||
id: "spellcheck",
|
||||
label: "Auto spell-check.",
|
||||
description: "Highlight spelling errors in text fields.",
|
||||
defaultChecked: true,
|
||||
},
|
||||
{
|
||||
id: "compact-mode",
|
||||
label: "Compact Mode.",
|
||||
description: "Reduce spacing for a denser interface layout.",
|
||||
defaultChecked: false,
|
||||
},
|
||||
{
|
||||
id: "animations",
|
||||
label: "Reduce animations.",
|
||||
description: "Minimize motion effects throughout the UI.",
|
||||
defaultChecked: false,
|
||||
},
|
||||
]
|
||||
|
||||
export const DATE_FORMAT_OPTIONS: DateFormatOption[] = [
|
||||
{ value: "mdy", label: "MM/DD/YYYY" },
|
||||
{ value: "dmy", label: "DD/MM/YYYY" },
|
||||
{ value: "ymd", label: "YYYY/MM/DD" },
|
||||
]
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useState } from "react"
|
||||
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@evobgp/ui/components/field"
|
||||
import { Switch } from "@evobgp/ui/components/switch"
|
||||
|
||||
import { PREFERENCES } from "./data"
|
||||
|
||||
// ── Editor Preferences ──
|
||||
|
||||
export function EditorPreferences() {
|
||||
const [values, setValues] = useState<Record<string, boolean>>(() =>
|
||||
Object.fromEntries(PREFERENCES.map((p) => [p.id, p.defaultChecked]))
|
||||
)
|
||||
|
||||
return (
|
||||
<FieldSet className="w-full gap-3">
|
||||
<FieldLegend className="sr-only">Editor preferences</FieldLegend>
|
||||
{/* Description */}
|
||||
<FieldDescription className="sr-only">
|
||||
Configure editing behavior and interface density.
|
||||
</FieldDescription>
|
||||
|
||||
{/* List */}
|
||||
<FieldGroup className="gap-3">
|
||||
{PREFERENCES.map((pref) => (
|
||||
<Field key={pref.id} orientation="horizontal" className="gap-3">
|
||||
<Switch
|
||||
id={`settings-3-${pref.id}`}
|
||||
checked={values[pref.id]}
|
||||
onCheckedChange={(value) =>
|
||||
setValues((prev) => ({ ...prev, [pref.id]: value }))
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
|
||||
<FieldContent className="gap-0.5">
|
||||
<FieldLabel htmlFor={`settings-3-${pref.id}`}>
|
||||
{pref.label}
|
||||
</FieldLabel>
|
||||
<FieldDescription className="text-xs">
|
||||
{pref.description}
|
||||
</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
))}
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
)
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@evobgp/ui/components/field"
|
||||
import { Input } from "@evobgp/ui/components/input"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@evobgp/ui/components/input-group"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@evobgp/ui/components/toggle-group"
|
||||
import { ColorPicker } from "./color-picker"
|
||||
import { CURRENCIES, DAYS, REGIONS } from "./data"
|
||||
import { EditorPreferences } from "./editor-preferences"
|
||||
import { SettingField } from "./setting-field"
|
||||
import { PlusIcon, UploadIcon, GlobeIcon, CircleDollarSignIcon } from "lucide-react"
|
||||
|
||||
export function GeneralSettings() {
|
||||
const [dateFormat, setDateFormat] = useState(["mdy"])
|
||||
const [startOfWeek, setStartOfWeek] = useState("monday")
|
||||
const [region, setRegion] = useState("us")
|
||||
const [currency, setCurrency] = useState("usd")
|
||||
const handleStartOfWeekChange = (value: string | null) => {
|
||||
if (value !== null) {
|
||||
setStartOfWeek(value)
|
||||
}
|
||||
}
|
||||
const handleRegionChange = (value: string | null) => {
|
||||
if (value !== null) {
|
||||
setRegion(value)
|
||||
}
|
||||
}
|
||||
const handleCurrencyChange = (value: string | null) => {
|
||||
if (value !== null) {
|
||||
setCurrency(value)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame className="w-full max-w-3xl">
|
||||
<FrameHeader className="px-2! py-2.5!">
|
||||
<FrameTitle>General Settings</FrameTitle>
|
||||
<FrameDescription>Core app preferences.</FrameDescription>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="p-0">
|
||||
<FieldGroup className="gap-0">
|
||||
{/* ── Project name ── */}
|
||||
<SettingField
|
||||
title="Project name"
|
||||
description="The display name for your project across the platform."
|
||||
labelFor="settings-3-project-name"
|
||||
>
|
||||
<Input id="settings-3-project-name" defaultValue="Acme Dashboard" />
|
||||
</SettingField>
|
||||
|
||||
{/* ── API endpoint ── */}
|
||||
<SettingField
|
||||
title="API endpoint"
|
||||
description="Set the base URL for your project API."
|
||||
badge={{ label: "Required", variant: "destructive-light" }}
|
||||
labelFor="settings-3-api-endpoint"
|
||||
>
|
||||
<InputGroup className="w-full">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<InputGroupText>https://</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="settings-3-api-endpoint"
|
||||
defaultValue="api.acme.io"
|
||||
/>
|
||||
</InputGroup>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Start of week ── */}
|
||||
<SettingField
|
||||
title="Start of week"
|
||||
description="Choose which day marks the start of your week."
|
||||
labelFor="settings-3-start-of-week"
|
||||
>
|
||||
<Select value={startOfWeek} onValueChange={handleStartOfWeekChange}>
|
||||
<SelectTrigger id="settings-3-start-of-week" className="w-full">
|
||||
<SelectValue>{getOptionLabel(DAYS, startOfWeek)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{DAYS.map((day) => (
|
||||
<SelectItem key={day.value} value={day.value}>
|
||||
{day.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Allowed origins ── */}
|
||||
<SettingField
|
||||
title="Allowed origins"
|
||||
description="Define the trusted domains for CORS requests."
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Add origin
|
||||
</Button>
|
||||
|
||||
<Button variant="outline">
|
||||
<UploadIcon aria-hidden="true" />
|
||||
Import from file
|
||||
</Button>
|
||||
</div>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Accent color ── */}
|
||||
<SettingField
|
||||
title="Accent color"
|
||||
description="Select a color to represent your brand."
|
||||
badge={{ label: "Upgrade to Pro", variant: "primary-light" }}
|
||||
>
|
||||
<ColorPicker />
|
||||
</SettingField>
|
||||
|
||||
{/* ── Region & currency ── */}
|
||||
<SettingField
|
||||
title="Region & currency"
|
||||
description="Adjust your regional preferences and currency."
|
||||
contentClassName="@md/field-group:w-[22rem]"
|
||||
>
|
||||
<FieldSet className="w-full gap-3">
|
||||
<FieldLegend className="sr-only">Region and currency</FieldLegend>
|
||||
<FieldDescription className="sr-only">
|
||||
Regional and financial formatting preferences.
|
||||
</FieldDescription>
|
||||
|
||||
<FieldGroup className="gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-3-region">Region</FieldLabel>
|
||||
<Select value={region} onValueChange={handleRegionChange}>
|
||||
<SelectTrigger id="settings-3-region" className="w-full">
|
||||
<GlobeIcon aria-hidden="true" />
|
||||
<SelectValue>
|
||||
{getOptionLabel(REGIONS, region)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{REGIONS.map((region) => (
|
||||
<SelectItem key={region.value} value={region.value}>
|
||||
{region.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-3-currency">
|
||||
Currency
|
||||
</FieldLabel>
|
||||
<Select value={currency} onValueChange={handleCurrencyChange}>
|
||||
<SelectTrigger id="settings-3-currency" className="w-full">
|
||||
<CircleDollarSignIcon aria-hidden="true" />
|
||||
<SelectValue>
|
||||
{getOptionLabel(CURRENCIES, currency)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{CURRENCIES.map((currency) => (
|
||||
<SelectItem
|
||||
key={currency.value}
|
||||
value={currency.value}
|
||||
>
|
||||
{currency.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Date display format ── */}
|
||||
<SettingField
|
||||
title="Date display format"
|
||||
description="Choose your preferred format for dates."
|
||||
>
|
||||
<ToggleGroup
|
||||
multiple={false}
|
||||
value={dateFormat}
|
||||
onValueChange={(value) => {
|
||||
if (value.length > 0) setDateFormat(value)
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Date display format"
|
||||
>
|
||||
<ToggleGroupItem value="mdy">MM/DD/YYYY</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dmy">DD/MM/YYYY</ToggleGroupItem>
|
||||
<ToggleGroupItem value="ymd">YYYY/MM/DD</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Editor preferences ── */}
|
||||
<SettingField
|
||||
title="Editor preferences"
|
||||
description="Adjust your editing environment and display options."
|
||||
last
|
||||
contentClassName="@md/field-group:w-[22rem]"
|
||||
>
|
||||
<EditorPreferences />
|
||||
</SettingField>
|
||||
</FieldGroup>
|
||||
</FramePanel>
|
||||
|
||||
<FrameFooter className="flex flex-row justify-end gap-3">
|
||||
<Button variant="outline">Reset</Button>
|
||||
<Button>Save changes</Button>
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helper functions ──
|
||||
|
||||
function getOptionLabel(
|
||||
options: Array<{ value: string; label: string }>,
|
||||
value: string
|
||||
) {
|
||||
return options.find((option) => option.value === value)?.label ?? value
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { type ComponentProps, type ReactNode } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
FieldTitle,
|
||||
} from "@evobgp/ui/components/field"
|
||||
|
||||
interface SettingFieldProps {
|
||||
title: string
|
||||
description: string
|
||||
badge?: {
|
||||
label: string
|
||||
variant: ComponentProps<typeof Badge>["variant"]
|
||||
}
|
||||
children: ReactNode
|
||||
last?: boolean
|
||||
labelFor?: string
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
// ── Setting Field ──
|
||||
|
||||
export function SettingField({
|
||||
title,
|
||||
description,
|
||||
badge,
|
||||
children,
|
||||
last,
|
||||
labelFor,
|
||||
contentClassName,
|
||||
}: SettingFieldProps) {
|
||||
return (
|
||||
<>
|
||||
<Field orientation="responsive" className="gap-4 px-4 py-4">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{labelFor ? (
|
||||
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
||||
) : (
|
||||
<FieldTitle>{title}</FieldTitle>
|
||||
)}
|
||||
|
||||
{badge ? (
|
||||
<Badge variant={badge.variant} size="sm">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<FieldDescription className="text-xs">{description}</FieldDescription>
|
||||
</div>
|
||||
|
||||
<FieldContent
|
||||
className={cn("min-w-0 @md/field-group:w-78", contentClassName)}
|
||||
>
|
||||
{children}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
{!last ? <FieldSeparator /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { GeneralSettings } from "./components/general-settings"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-start justify-center p-8 md:p-16">
|
||||
<GeneralSettings />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
export type WizardStepId = "company" | "ownership" | "documents"
|
||||
export type RegistrationStatus = "registered" | "invited" | "new"
|
||||
export type VendorCategory = "software" | "services" | "data" | "hardware"
|
||||
export type VendorCountry = "us" | "gb" | "de" | "uz" | "sg"
|
||||
export type DocumentId =
|
||||
| "certificate"
|
||||
| "tax-letter"
|
||||
| "ownership-chart"
|
||||
| "bank-letter"
|
||||
|
||||
export interface WizardStep {
|
||||
id: WizardStepId
|
||||
step: number
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface WizardOption<TValue extends string = string> {
|
||||
value: TValue
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface OwnerRecord {
|
||||
id: string
|
||||
name: string
|
||||
address: string
|
||||
share: string
|
||||
}
|
||||
|
||||
export interface DocumentRequirement {
|
||||
id: DocumentId
|
||||
title: string
|
||||
description: string
|
||||
status: "Ready" | "Needs upload" | "Review"
|
||||
}
|
||||
|
||||
export interface WizardValues {
|
||||
companyName: string
|
||||
registrationStatus: RegistrationStatus
|
||||
portalId: string
|
||||
category: VendorCategory
|
||||
country: VendorCountry
|
||||
contactEmail: string
|
||||
website: string
|
||||
owners: OwnerRecord[]
|
||||
taxId: string
|
||||
incorporationDate: string
|
||||
ownershipAttested: boolean
|
||||
documentIds: DocumentId[]
|
||||
submissionNotes: string
|
||||
}
|
||||
|
||||
export const WIZARD_STEPS: WizardStep[] = [
|
||||
{
|
||||
id: "company",
|
||||
step: 1,
|
||||
title: "Company Details",
|
||||
description: "Vendor identity and procurement profile.",
|
||||
},
|
||||
{
|
||||
id: "ownership",
|
||||
step: 2,
|
||||
title: "Compliance",
|
||||
description: "Owners and tax IDs.",
|
||||
},
|
||||
{
|
||||
id: "documents",
|
||||
step: 3,
|
||||
title: "Submit",
|
||||
description: "Review and submit.",
|
||||
},
|
||||
]
|
||||
|
||||
export const REGISTRATION_OPTIONS: WizardOption<RegistrationStatus>[] = [
|
||||
{
|
||||
value: "registered",
|
||||
label: "Yes",
|
||||
description: "Vendor record already exists in the portal.",
|
||||
},
|
||||
{
|
||||
value: "invited",
|
||||
label: "Invited",
|
||||
description: "Invitation sent, registration is pending.",
|
||||
},
|
||||
{
|
||||
value: "new",
|
||||
label: "No",
|
||||
description: "Create a fresh procurement record.",
|
||||
},
|
||||
]
|
||||
|
||||
export const CATEGORY_OPTIONS: WizardOption<VendorCategory>[] = [
|
||||
{
|
||||
value: "software",
|
||||
label: "Software subscription",
|
||||
description: "SaaS, licenses, and usage-based software.",
|
||||
},
|
||||
{
|
||||
value: "services",
|
||||
label: "Professional services",
|
||||
description: "Implementation, advisory, and support work.",
|
||||
},
|
||||
{
|
||||
value: "data",
|
||||
label: "Data provider",
|
||||
description: "Market data, enrichment, and data feeds.",
|
||||
},
|
||||
{
|
||||
value: "hardware",
|
||||
label: "Hardware supplier",
|
||||
description: "Devices, equipment, and physical goods.",
|
||||
},
|
||||
]
|
||||
|
||||
export const COUNTRY_OPTIONS: WizardOption<VendorCountry>[] = [
|
||||
{
|
||||
value: "us",
|
||||
label: "United States",
|
||||
},
|
||||
{
|
||||
value: "gb",
|
||||
label: "United Kingdom",
|
||||
},
|
||||
{
|
||||
value: "de",
|
||||
label: "Germany",
|
||||
},
|
||||
{
|
||||
value: "uz",
|
||||
label: "Uzbekistan",
|
||||
},
|
||||
{
|
||||
value: "sg",
|
||||
label: "Singapore",
|
||||
},
|
||||
]
|
||||
|
||||
export const DOCUMENT_REQUIREMENTS: DocumentRequirement[] = [
|
||||
{
|
||||
id: "certificate",
|
||||
title: "Certificate of incorporation",
|
||||
description: "Legal entity proof.",
|
||||
status: "Ready",
|
||||
},
|
||||
{
|
||||
id: "tax-letter",
|
||||
title: "Tax residency letter",
|
||||
description: "Needed for payment release.",
|
||||
status: "Needs upload",
|
||||
},
|
||||
{
|
||||
id: "ownership-chart",
|
||||
title: "Ownership chart",
|
||||
description: "Confirms ownership threshold.",
|
||||
status: "Review",
|
||||
},
|
||||
{
|
||||
id: "bank-letter",
|
||||
title: "Bank verification letter",
|
||||
description: "Verifies payee details.",
|
||||
status: "Ready",
|
||||
},
|
||||
]
|
||||
|
||||
export const DEFAULT_WIZARD_VALUES: WizardValues = {
|
||||
companyName: "ReUI Labs",
|
||||
registrationStatus: "registered",
|
||||
portalId: "VEN-2048",
|
||||
category: "software",
|
||||
country: "us",
|
||||
contactEmail: "[email protected]",
|
||||
website: "https://reui.dev",
|
||||
owners: [
|
||||
{
|
||||
id: "owner-1",
|
||||
name: "Maya Chen",
|
||||
address: "214 Mission Street, San Francisco, CA",
|
||||
share: "52%",
|
||||
},
|
||||
{
|
||||
id: "owner-2",
|
||||
name: "Kai Morgan",
|
||||
address: "18 King Street, Seattle, WA",
|
||||
share: "28%",
|
||||
},
|
||||
],
|
||||
taxId: "US-82-4917721",
|
||||
incorporationDate: "08/08/2025",
|
||||
ownershipAttested: true,
|
||||
documentIds: ["certificate", "bank-letter"],
|
||||
submissionNotes:
|
||||
"Route final review to finance once tax residency is uploaded.",
|
||||
}
|
||||
@@ -1,646 +0,0 @@
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Checkbox } from "@evobgp/ui/components/checkbox"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldTitle,
|
||||
} from "@evobgp/ui/components/field"
|
||||
import { Input } from "@evobgp/ui/components/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
import { Textarea } from "@evobgp/ui/components/textarea"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@evobgp/ui/components/tooltip"
|
||||
import {
|
||||
CATEGORY_OPTIONS,
|
||||
COUNTRY_OPTIONS,
|
||||
DOCUMENT_REQUIREMENTS,
|
||||
REGISTRATION_OPTIONS,
|
||||
type DocumentId,
|
||||
type OwnerRecord,
|
||||
type VendorCategory,
|
||||
type VendorCountry,
|
||||
type WizardOption,
|
||||
type WizardValues,
|
||||
} from "./data"
|
||||
import { InfoIcon, Trash2Icon, PlusIcon } from "lucide-react"
|
||||
|
||||
export type WizardErrors = Partial<Record<keyof WizardValues, string>>
|
||||
|
||||
type WizardValueChange = (
|
||||
field: keyof WizardValues,
|
||||
value: WizardValues[keyof WizardValues]
|
||||
) => void
|
||||
|
||||
interface WizardSummary {
|
||||
categoryLabel: string
|
||||
countryLabel: string
|
||||
registrationLabel: string
|
||||
}
|
||||
|
||||
function RequiredMark() {
|
||||
return <span className="text-destructive">*</span>
|
||||
}
|
||||
|
||||
function FieldLabelHint({
|
||||
htmlFor,
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
htmlFor?: string
|
||||
label: string
|
||||
hint: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-fit items-center gap-1.5 leading-snug">
|
||||
<FieldLabel htmlFor={htmlFor}>{children}</FieldLabel>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex size-4 shrink-0 items-center justify-center rounded-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
aria-label={`${label} hint`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoIcon className="size-3.5" aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-56 text-xs text-balance">
|
||||
{hint}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WizardSelectField<TValue extends string>({
|
||||
id,
|
||||
value,
|
||||
options,
|
||||
placeholder,
|
||||
onValueChange,
|
||||
}: {
|
||||
id: string
|
||||
value: TValue
|
||||
options: WizardOption<TValue>[]
|
||||
placeholder: string
|
||||
onValueChange: (value: TValue) => void
|
||||
}) {
|
||||
const selectedOption =
|
||||
options.find((option) => option.value === value) ?? null
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={selectedOption}
|
||||
items={options}
|
||||
onValueChange={(nextValue) => {
|
||||
if (nextValue) {
|
||||
onValueChange(nextValue.value)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={id} className="w-full [&_small]:hidden">
|
||||
<SelectValue>
|
||||
{(item: WizardOption<TValue> | null) => item?.label ?? placeholder}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option}>
|
||||
{option.description ? (
|
||||
<span className="flex min-w-0 flex-col items-start gap-px">
|
||||
<span className="truncate font-medium">{option.label}</span>
|
||||
<small className="text-muted-foreground truncate text-xs">
|
||||
{option.description}
|
||||
</small>
|
||||
</span>
|
||||
) : (
|
||||
option.label
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryLine({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-4 text-sm">
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="text-foreground min-w-0 text-right font-medium break-words">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SummarySection({
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={[
|
||||
"border-border/70 flex min-w-0 flex-col gap-3 rounded-md border p-4",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeading({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 id={id} className="text-base font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RemoveRowButton({
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive justify-self-start sm:justify-self-end"
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Trash2Icon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function OwnerRows({
|
||||
owners,
|
||||
error,
|
||||
onOwnerChange,
|
||||
onAddOwner,
|
||||
onRemoveOwner,
|
||||
}: {
|
||||
owners: OwnerRecord[]
|
||||
error?: string
|
||||
onOwnerChange: (id: string, field: keyof OwnerRecord, value: string) => void
|
||||
onAddOwner: () => void
|
||||
onRemoveOwner: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Field data-invalid={Boolean(error)} className="gap-3 sm:col-span-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{owners.map((owner, index) => (
|
||||
<div
|
||||
key={owner.id}
|
||||
className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)_5rem_auto]"
|
||||
>
|
||||
<Field className="gap-1.5">
|
||||
<FieldLabel htmlFor={`${owner.id}-name`} className="sr-only">
|
||||
Owner {index + 1} name
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={`${owner.id}-name`}
|
||||
value={owner.name}
|
||||
onChange={(event) =>
|
||||
onOwnerChange(owner.id, "name", event.target.value)
|
||||
}
|
||||
placeholder="Owner full name"
|
||||
autoComplete="name"
|
||||
/>
|
||||
</Field>
|
||||
<Field className="gap-1.5">
|
||||
<FieldLabel htmlFor={`${owner.id}-address`} className="sr-only">
|
||||
Owner {index + 1} address
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={`${owner.id}-address`}
|
||||
value={owner.address}
|
||||
onChange={(event) =>
|
||||
onOwnerChange(owner.id, "address", event.target.value)
|
||||
}
|
||||
placeholder="Registered address"
|
||||
autoComplete="street-address"
|
||||
/>
|
||||
</Field>
|
||||
<Field className="gap-1.5">
|
||||
<FieldLabel htmlFor={`${owner.id}-share`} className="sr-only">
|
||||
Owner {index + 1} share
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={`${owner.id}-share`}
|
||||
value={owner.share}
|
||||
onChange={(event) =>
|
||||
onOwnerChange(owner.id, "share", event.target.value)
|
||||
}
|
||||
placeholder="Share"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</Field>
|
||||
<RemoveRowButton
|
||||
label={`Remove owner ${index + 1}`}
|
||||
onClick={() => onRemoveOwner(owner.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? <FieldError>{error}</FieldError> : null}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-auto"
|
||||
onClick={onAddOwner}
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Add owner
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
function AttestationCheckbox({
|
||||
id,
|
||||
checked,
|
||||
label,
|
||||
description,
|
||||
error,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
id: string
|
||||
checked: boolean
|
||||
label: string
|
||||
description: string
|
||||
error?: string
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<Field
|
||||
data-invalid={Boolean(error)}
|
||||
orientation="horizontal"
|
||||
className="gap-3"
|
||||
>
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={(nextChecked) => onCheckedChange(nextChecked === true)}
|
||||
aria-invalid={Boolean(error)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
<FieldDescription>{description}</FieldDescription>
|
||||
</FieldContent>
|
||||
{error ? <FieldError>{error}</FieldError> : null}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
export function CompanyStepFields({
|
||||
values,
|
||||
errors,
|
||||
summary,
|
||||
onValueChange,
|
||||
}: {
|
||||
values: WizardValues
|
||||
errors: WizardErrors
|
||||
summary: WizardSummary
|
||||
onValueChange: WizardValueChange
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-6"
|
||||
aria-labelledby="wizard-2-company-title"
|
||||
>
|
||||
<SectionHeading
|
||||
id="wizard-2-company-title"
|
||||
title="Company Details"
|
||||
description="Identify the vendor record before compliance review."
|
||||
/>
|
||||
|
||||
<FieldGroup className="grid gap-5 sm:grid-cols-2">
|
||||
<Field data-invalid={Boolean(errors.companyName)} className="gap-2">
|
||||
<FieldLabelHint
|
||||
htmlFor="wizard-2-company"
|
||||
label="Legal Company Name"
|
||||
hint="Match the registered legal entity name."
|
||||
>
|
||||
Legal Company Name <RequiredMark />
|
||||
</FieldLabelHint>
|
||||
<Input
|
||||
id="wizard-2-company"
|
||||
value={values.companyName}
|
||||
onChange={(event) =>
|
||||
onValueChange("companyName", event.target.value)
|
||||
}
|
||||
aria-invalid={Boolean(errors.companyName)}
|
||||
autoComplete="organization"
|
||||
/>
|
||||
{errors.companyName ? (
|
||||
<FieldError>{errors.companyName}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field className="gap-2">
|
||||
<FieldLabelHint
|
||||
htmlFor="wizard-2-registration"
|
||||
label="Portal Registration"
|
||||
hint="Use the status from the vendor portal."
|
||||
>
|
||||
Portal Registration
|
||||
</FieldLabelHint>
|
||||
<WizardSelectField
|
||||
id="wizard-2-registration"
|
||||
value={values.registrationStatus}
|
||||
options={REGISTRATION_OPTIONS}
|
||||
placeholder="Select status"
|
||||
onValueChange={(value) =>
|
||||
onValueChange("registrationStatus", value)
|
||||
}
|
||||
/>
|
||||
<FieldDescription>{summary.registrationLabel}</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<Field className="gap-2">
|
||||
<FieldLabel htmlFor="wizard-2-category">Category</FieldLabel>
|
||||
<WizardSelectField<VendorCategory>
|
||||
id="wizard-2-category"
|
||||
value={values.category}
|
||||
options={CATEGORY_OPTIONS}
|
||||
placeholder="Select category"
|
||||
onValueChange={(value) => onValueChange("category", value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field className="gap-2">
|
||||
<FieldLabel htmlFor="wizard-2-country">Country</FieldLabel>
|
||||
<WizardSelectField<VendorCountry>
|
||||
id="wizard-2-country"
|
||||
value={values.country}
|
||||
options={COUNTRY_OPTIONS}
|
||||
placeholder="Select country"
|
||||
onValueChange={(value) => onValueChange("country", value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.contactEmail)} className="gap-2">
|
||||
<FieldLabel htmlFor="wizard-2-email">
|
||||
Vendor Email <RequiredMark />
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="wizard-2-email"
|
||||
value={values.contactEmail}
|
||||
onChange={(event) =>
|
||||
onValueChange("contactEmail", event.target.value)
|
||||
}
|
||||
aria-invalid={Boolean(errors.contactEmail)}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
/>
|
||||
{errors.contactEmail ? (
|
||||
<FieldError>{errors.contactEmail}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field className="gap-2">
|
||||
<FieldLabel htmlFor="wizard-2-website">Website</FieldLabel>
|
||||
<Input
|
||||
id="wizard-2-website"
|
||||
value={values.website}
|
||||
onChange={(event) => onValueChange("website", event.target.value)}
|
||||
autoComplete="url"
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function OwnershipStepFields({
|
||||
values,
|
||||
errors,
|
||||
onValueChange,
|
||||
onOwnerChange,
|
||||
onAddOwner,
|
||||
onRemoveOwner,
|
||||
}: {
|
||||
values: WizardValues
|
||||
errors: WizardErrors
|
||||
onValueChange: WizardValueChange
|
||||
onOwnerChange: (id: string, field: keyof OwnerRecord, value: string) => void
|
||||
onRemoveOwner: (id: string) => void
|
||||
onAddOwner: () => void
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-6"
|
||||
aria-labelledby="wizard-2-ownership-title"
|
||||
>
|
||||
<SectionHeading
|
||||
id="wizard-2-ownership-title"
|
||||
title="Compliance"
|
||||
description="Confirm ownership and tax records."
|
||||
/>
|
||||
|
||||
<FieldGroup className="grid gap-5 sm:grid-cols-2">
|
||||
<OwnerRows
|
||||
owners={values.owners}
|
||||
error={errors.owners}
|
||||
onOwnerChange={onOwnerChange}
|
||||
onAddOwner={onAddOwner}
|
||||
onRemoveOwner={onRemoveOwner}
|
||||
/>
|
||||
|
||||
<Field data-invalid={Boolean(errors.taxId)} className="gap-2">
|
||||
<FieldLabelHint
|
||||
htmlFor="wizard-2-tax"
|
||||
label="Tax/VAT ID"
|
||||
hint="Enter the identifier finance will validate."
|
||||
>
|
||||
Tax/VAT ID <RequiredMark />
|
||||
</FieldLabelHint>
|
||||
<Input
|
||||
id="wizard-2-tax"
|
||||
value={values.taxId}
|
||||
onChange={(event) => onValueChange("taxId", event.target.value)}
|
||||
aria-invalid={Boolean(errors.taxId)}
|
||||
placeholder="Enter tax number"
|
||||
/>
|
||||
{errors.taxId ? <FieldError>{errors.taxId}</FieldError> : null}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
data-invalid={Boolean(errors.incorporationDate)}
|
||||
className="gap-2"
|
||||
>
|
||||
<FieldLabelHint
|
||||
htmlFor="wizard-2-incorporated"
|
||||
label="Date Incorporated"
|
||||
hint="Use the date from the registration record."
|
||||
>
|
||||
Date Incorporated <RequiredMark />
|
||||
</FieldLabelHint>
|
||||
<Input
|
||||
id="wizard-2-incorporated"
|
||||
value={values.incorporationDate}
|
||||
onChange={(event) =>
|
||||
onValueChange("incorporationDate", event.target.value)
|
||||
}
|
||||
aria-invalid={Boolean(errors.incorporationDate)}
|
||||
placeholder="MM/DD/YYYY"
|
||||
/>
|
||||
{errors.incorporationDate ? (
|
||||
<FieldError>{errors.incorporationDate}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<AttestationCheckbox
|
||||
id="wizard-2-ownership-attested"
|
||||
checked={values.ownershipAttested}
|
||||
label="Ownership reviewed"
|
||||
description="Records match company documents."
|
||||
error={errors.ownershipAttested}
|
||||
onCheckedChange={(checked) =>
|
||||
onValueChange("ownershipAttested", checked)
|
||||
}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function DocumentsStepFields({
|
||||
values,
|
||||
errors,
|
||||
summary,
|
||||
onValueChange,
|
||||
onDocumentToggle,
|
||||
}: {
|
||||
values: WizardValues
|
||||
errors: WizardErrors
|
||||
summary: WizardSummary
|
||||
onValueChange: WizardValueChange
|
||||
onDocumentToggle: (documentId: DocumentId, checked: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-6"
|
||||
aria-labelledby="wizard-2-documents-title"
|
||||
>
|
||||
<SectionHeading
|
||||
id="wizard-2-documents-title"
|
||||
title="Submit"
|
||||
description="Review files and confirm."
|
||||
/>
|
||||
|
||||
<FieldGroup className="grid gap-6">
|
||||
<Field data-invalid={Boolean(errors.documentIds)} className="gap-3">
|
||||
<FieldLabelHint
|
||||
label="Required Documents"
|
||||
hint="Select files already received from the vendor."
|
||||
>
|
||||
Required Documents
|
||||
</FieldLabelHint>
|
||||
<FieldGroup className="grid gap-2 sm:grid-cols-2">
|
||||
{DOCUMENT_REQUIREMENTS.map((document) => (
|
||||
<FieldLabel key={document.id}>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
checked={values.documentIds.includes(document.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
onDocumentToggle(document.id, checked === true)
|
||||
}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldTitle>{document.title}</FieldTitle>
|
||||
<FieldDescription>{document.description}</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
))}
|
||||
</FieldGroup>
|
||||
{errors.documentIds ? (
|
||||
<FieldError>{errors.documentIds}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field className="gap-2">
|
||||
<FieldLabel htmlFor="wizard-2-notes">Reviewer Notes</FieldLabel>
|
||||
<Textarea
|
||||
id="wizard-2-notes"
|
||||
value={values.submissionNotes}
|
||||
onChange={(event) =>
|
||||
onValueChange("submissionNotes", event.target.value)
|
||||
}
|
||||
className="min-h-24 resize-none"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<SummarySection title="Vendor">
|
||||
<dl className="flex flex-col gap-2.5">
|
||||
<SummaryLine label="Company" value={values.companyName} />
|
||||
<SummaryLine label="Category" value={summary.categoryLabel} />
|
||||
<SummaryLine label="Country" value={summary.countryLabel} />
|
||||
</dl>
|
||||
</SummarySection>
|
||||
|
||||
<SummarySection title="Compliance">
|
||||
<dl className="flex flex-col gap-2.5">
|
||||
<SummaryLine label="Tax/VAT" value={values.taxId} />
|
||||
<SummaryLine label="Owners" value={values.owners.length} />
|
||||
<SummaryLine label="Status" value="Reviewed" />
|
||||
</dl>
|
||||
</SummarySection>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useMemo, useState, type FormEvent } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import {
|
||||
Stepper,
|
||||
StepperContent,
|
||||
StepperIndicator,
|
||||
StepperItem,
|
||||
StepperNav,
|
||||
StepperPanel,
|
||||
StepperSeparator,
|
||||
StepperTitle,
|
||||
StepperTrigger,
|
||||
} from "@/components/reui/stepper"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Spinner } from "@evobgp/ui/components/spinner"
|
||||
import {
|
||||
CATEGORY_OPTIONS,
|
||||
COUNTRY_OPTIONS,
|
||||
DEFAULT_WIZARD_VALUES,
|
||||
REGISTRATION_OPTIONS,
|
||||
WIZARD_STEPS,
|
||||
type DocumentId,
|
||||
type OwnerRecord,
|
||||
type WizardOption,
|
||||
type WizardStepId,
|
||||
type WizardValues,
|
||||
} from "./data"
|
||||
import {
|
||||
CompanyStepFields,
|
||||
DocumentsStepFields,
|
||||
OwnershipStepFields,
|
||||
type WizardErrors,
|
||||
} from "./procurement-vendor-steps"
|
||||
import { CheckIcon, FlagIcon, ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
|
||||
|
||||
function createDefaultValues(): WizardValues {
|
||||
return {
|
||||
...DEFAULT_WIZARD_VALUES,
|
||||
owners: DEFAULT_WIZARD_VALUES.owners.map((owner) => ({ ...owner })),
|
||||
documentIds: [...DEFAULT_WIZARD_VALUES.documentIds],
|
||||
}
|
||||
}
|
||||
|
||||
function isValidEmail(value: string) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
|
||||
}
|
||||
|
||||
function getOptionLabel<TValue extends string>(
|
||||
options: WizardOption<TValue>[],
|
||||
value: TValue
|
||||
) {
|
||||
return options.find((option) => option.value === value)?.label ?? "Not set"
|
||||
}
|
||||
|
||||
function getStepErrors(stepId: WizardStepId, values: WizardValues) {
|
||||
const nextErrors: WizardErrors = {}
|
||||
|
||||
if (stepId === "company") {
|
||||
if (!values.companyName.trim()) {
|
||||
nextErrors.companyName = "Enter the legal company name."
|
||||
}
|
||||
|
||||
if (!isValidEmail(values.contactEmail)) {
|
||||
nextErrors.contactEmail = "Enter a valid procurement email."
|
||||
}
|
||||
}
|
||||
|
||||
if (stepId === "ownership") {
|
||||
if (!values.taxId.trim()) {
|
||||
nextErrors.taxId = "Enter the Tax/VAT ID."
|
||||
}
|
||||
|
||||
if (!values.incorporationDate.trim()) {
|
||||
nextErrors.incorporationDate = "Enter the incorporation date."
|
||||
}
|
||||
|
||||
if (!values.owners.some((owner) => owner.name.trim())) {
|
||||
nextErrors.owners = "Add at least one beneficial owner."
|
||||
}
|
||||
|
||||
if (!values.ownershipAttested) {
|
||||
nextErrors.ownershipAttested = "Confirm beneficial ownership review."
|
||||
}
|
||||
}
|
||||
|
||||
if (stepId === "documents" && values.documentIds.length < 2) {
|
||||
nextErrors.documentIds = "Select at least two received documents."
|
||||
}
|
||||
|
||||
return nextErrors
|
||||
}
|
||||
|
||||
function getAllRequiredErrors(values: WizardValues) {
|
||||
return {
|
||||
...getStepErrors("company", values),
|
||||
...getStepErrors("ownership", values),
|
||||
...getStepErrors("documents", values),
|
||||
}
|
||||
}
|
||||
|
||||
function getFirstInvalidStep(values: WizardValues) {
|
||||
if (Object.keys(getStepErrors("company", values)).length > 0) return 1
|
||||
if (Object.keys(getStepErrors("ownership", values)).length > 0) return 2
|
||||
if (Object.keys(getStepErrors("documents", values)).length > 0) return 3
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function DotSeparator() {
|
||||
return (
|
||||
<span
|
||||
className="size-1 shrink-0 rounded-full bg-gray-400 dark:bg-gray-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProcurementVendorWizard() {
|
||||
const [currentStep, setCurrentStep] = useState(2)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [values, setValues] = useState<WizardValues>(createDefaultValues)
|
||||
const [errors, setErrors] = useState<WizardErrors>({})
|
||||
|
||||
const currentStepConfig = useMemo(
|
||||
() =>
|
||||
WIZARD_STEPS.find((step) => step.step === currentStep) ?? WIZARD_STEPS[0],
|
||||
[currentStep]
|
||||
)
|
||||
|
||||
const summary = useMemo(
|
||||
() => ({
|
||||
categoryLabel: getOptionLabel(CATEGORY_OPTIONS, values.category),
|
||||
countryLabel: getOptionLabel(COUNTRY_OPTIONS, values.country),
|
||||
registrationLabel: getOptionLabel(
|
||||
REGISTRATION_OPTIONS,
|
||||
values.registrationStatus
|
||||
),
|
||||
}),
|
||||
[values.category, values.country, values.registrationStatus]
|
||||
)
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(field: keyof WizardValues, value: WizardValues[keyof WizardValues]) => {
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
[field]: value,
|
||||
}))
|
||||
|
||||
setErrors((current) => {
|
||||
if (!current[field]) {
|
||||
return current
|
||||
}
|
||||
|
||||
const nextErrors = { ...current }
|
||||
delete nextErrors[field]
|
||||
return nextErrors
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleOwnerChange = useCallback(
|
||||
(id: string, field: keyof OwnerRecord, value: string) => {
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
owners: current.owners.map((owner) =>
|
||||
owner.id === id ? { ...owner, [field]: value } : owner
|
||||
),
|
||||
}))
|
||||
|
||||
setErrors((current) => {
|
||||
if (!current.owners) return current
|
||||
const nextErrors = { ...current }
|
||||
delete nextErrors.owners
|
||||
return nextErrors
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleAddOwner = useCallback(() => {
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
owners: [
|
||||
...current.owners,
|
||||
{
|
||||
id: `owner-${Date.now()}`,
|
||||
name: "",
|
||||
address: "",
|
||||
share: "",
|
||||
},
|
||||
],
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const handleRemoveOwner = useCallback((id: string) => {
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
owners: current.owners.filter((owner) => owner.id !== id),
|
||||
}))
|
||||
|
||||
setErrors((current) => {
|
||||
if (!current.owners) return current
|
||||
const nextErrors = { ...current }
|
||||
delete nextErrors.owners
|
||||
return nextErrors
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleDocumentToggle = useCallback(
|
||||
(documentId: DocumentId, checked: boolean) => {
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
documentIds: checked
|
||||
? Array.from(new Set([...current.documentIds, documentId]))
|
||||
: current.documentIds.filter((id) => id !== documentId),
|
||||
}))
|
||||
|
||||
setErrors((current) => {
|
||||
if (!current.documentIds) return current
|
||||
const nextErrors = { ...current }
|
||||
delete nextErrors.documentIds
|
||||
return nextErrors
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setCurrentStep((step) => Math.max(1, step - 1))
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (isSubmitting) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentStepConfig.id !== "documents") {
|
||||
const stepErrors = getStepErrors(currentStepConfig.id, values)
|
||||
|
||||
if (Object.keys(stepErrors).length > 0) {
|
||||
setErrors((current) => ({ ...current, ...stepErrors }))
|
||||
return
|
||||
}
|
||||
|
||||
const nextStep = Math.min(currentStep + 1, WIZARD_STEPS.length)
|
||||
setCurrentStep(nextStep)
|
||||
return
|
||||
}
|
||||
|
||||
const requiredErrors = getAllRequiredErrors(values)
|
||||
const invalidStep = getFirstInvalidStep(values)
|
||||
|
||||
if (Object.keys(requiredErrors).length > 0 && invalidStep !== null) {
|
||||
setErrors(requiredErrors)
|
||||
setCurrentStep(invalidStep)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
window.setTimeout(() => {
|
||||
setIsSubmitting(false)
|
||||
toast.success("Vendor file submitted", {
|
||||
description: `${values.companyName} is ready for procurement review.`,
|
||||
})
|
||||
}, 900)
|
||||
},
|
||||
[currentStep, currentStepConfig.id, isSubmitting, values]
|
||||
)
|
||||
|
||||
const isLastStep = currentStep === WIZARD_STEPS.length
|
||||
const canGoBack = currentStep > 1 && !isSubmitting
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex w-full max-w-2xl flex-col"
|
||||
aria-label="Procurement vendor onboarding wizard"
|
||||
>
|
||||
<Stepper
|
||||
value={currentStep}
|
||||
onValueChange={setCurrentStep}
|
||||
indicators={{
|
||||
completed: (
|
||||
<CheckIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
loading: <Spinner className="size-3.5" />,
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<Frame stacked={true} className="w-full">
|
||||
<FrameHeader>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<FrameTitle>Procurement Vendor Setup</FrameTitle>
|
||||
<FrameDescription className="inline-flex min-w-0 items-center gap-1.5 text-sm">
|
||||
<span className="shrink-0">{values.portalId}</span>
|
||||
<DotSeparator />
|
||||
<span className="truncate">{summary.categoryLabel}</span>
|
||||
</FrameDescription>
|
||||
</div>
|
||||
<Badge variant="warning-light">Review file</Badge>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="flex items-center overflow-x-auto">
|
||||
<StepperNav
|
||||
aria-label="Wizard progress"
|
||||
className="min-w-full items-center"
|
||||
>
|
||||
{WIZARD_STEPS.map((step, index) => (
|
||||
<StepperItem
|
||||
key={step.id}
|
||||
step={step.step}
|
||||
loading={isSubmitting && step.step === currentStep}
|
||||
className="relative min-w-0"
|
||||
>
|
||||
<StepperTrigger
|
||||
type="button"
|
||||
className="min-w-0 justify-start gap-2 disabled:opacity-100"
|
||||
>
|
||||
<StepperIndicator className="data-[state=active]:bg-primary/10 data-[state=active]:text-primary data-[state=completed]:bg-primary data-[state=inactive]:border-border data-[state=inactive]:text-foreground data-[state=active]:before:border-primary relative isolate size-6 overflow-visible rounded-full border before:pointer-events-none before:absolute before:inset-0 before:z-10 before:rounded-full before:border before:border-dashed before:border-transparent before:content-[''] data-[state=active]:border-0 data-[state=active]:before:animate-[spin_8s_linear_infinite] motion-reduce:data-[state=active]:before:animate-none">
|
||||
{index + 1}
|
||||
</StepperIndicator>
|
||||
<StepperTitle className="text-foreground hidden truncate text-sm capitalize sm:block">
|
||||
{step.title}
|
||||
</StepperTitle>
|
||||
</StepperTrigger>
|
||||
|
||||
{WIZARD_STEPS.length > index + 1 ? (
|
||||
<StepperSeparator className="bg-border group-data-[state=completed]/step:bg-primary mx-2" />
|
||||
) : null}
|
||||
</StepperItem>
|
||||
))}
|
||||
</StepperNav>
|
||||
</FramePanel>
|
||||
|
||||
<FramePanel>
|
||||
<StepperPanel className="text-sm">
|
||||
{WIZARD_STEPS.map((step) => (
|
||||
<StepperContent key={step.id} value={step.step}>
|
||||
{step.id === "company" ? (
|
||||
<CompanyStepFields
|
||||
values={values}
|
||||
errors={errors}
|
||||
summary={summary}
|
||||
onValueChange={handleValueChange}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step.id === "ownership" ? (
|
||||
<OwnershipStepFields
|
||||
values={values}
|
||||
errors={errors}
|
||||
onValueChange={handleValueChange}
|
||||
onOwnerChange={handleOwnerChange}
|
||||
onAddOwner={handleAddOwner}
|
||||
onRemoveOwner={handleRemoveOwner}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step.id === "documents" ? (
|
||||
<DocumentsStepFields
|
||||
values={values}
|
||||
errors={errors}
|
||||
summary={summary}
|
||||
onValueChange={handleValueChange}
|
||||
onDocumentToggle={handleDocumentToggle}
|
||||
/>
|
||||
) : null}
|
||||
</StepperContent>
|
||||
))}
|
||||
</StepperPanel>
|
||||
</FramePanel>
|
||||
|
||||
<FrameFooter className="gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<FrameDescription className="hidden min-w-0 items-center gap-1.5 text-sm sm:flex">
|
||||
<FlagIcon className="size-4 shrink-0" aria-hidden="true" />
|
||||
{currentStepConfig.description}
|
||||
</FrameDescription>
|
||||
|
||||
<div className="flex w-full items-center justify-end gap-2 sm:w-auto">
|
||||
{canGoBack ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleBack}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Previous
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="shrink-0"
|
||||
>
|
||||
{isSubmitting ? <Spinner data-icon="inline-start" /> : null}
|
||||
{isLastStep ? "Submit" : "Next Step"}
|
||||
{!isLastStep && !isSubmitting ? (
|
||||
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
|
||||
) : null}
|
||||
</Button>
|
||||
</div>
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
</Stepper>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { ProcurementVendorWizard } from "./components/procurement-vendor-wizard"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-start justify-center p-4 sm:p-6 lg:p-10">
|
||||
<ProcurementVendorWizard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,50 +1 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
|
||||
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
||||
|
||||
const TONE_VARIANT: Record<string, BadgeVariant> = {
|
||||
neutral: 'outline',
|
||||
info: 'info-light',
|
||||
warning: 'warning-light',
|
||||
success: 'success-light',
|
||||
}
|
||||
|
||||
export function ModeBadge({
|
||||
enabled,
|
||||
onLabel = 'включён',
|
||||
offLabel = 'выключен',
|
||||
className,
|
||||
}: {
|
||||
enabled: boolean
|
||||
onLabel?: string
|
||||
offLabel?: string
|
||||
className?: string
|
||||
}) {
|
||||
return enabled ? (
|
||||
<Badge variant="success-light" size="sm" radius="full" className={className}>
|
||||
{onLabel}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" size="sm" radius="full" className={className}>
|
||||
{offLabel}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export function CategoryBadge({
|
||||
children,
|
||||
tone = 'neutral',
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
tone?: keyof typeof TONE_VARIANT
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Badge variant={TONE_VARIANT[tone]} size="sm" radius="full" className={className}>
|
||||
{children}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
export { CategoryBadge, ModeBadge } from '@/components/status-badge'
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export interface CountedLineTab {
|
||||
id: string
|
||||
label: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
interface CountedLineTabsProps {
|
||||
tabs: CountedLineTab[]
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
className?: string
|
||||
listClassName?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/** Line tabs with count pills (c-tabs-2 / data-grid-filtering-2). */
|
||||
export function CountedLineTabs({
|
||||
tabs,
|
||||
value,
|
||||
onValueChange,
|
||||
className,
|
||||
listClassName,
|
||||
children,
|
||||
}: CountedLineTabsProps) {
|
||||
return (
|
||||
<Tabs value={value} onValueChange={onValueChange} className={className}>
|
||||
<TabsList variant="line" className={cn('gap-5', listClassName)}>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
{tab.count !== undefined ? (
|
||||
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
|
||||
{tab.count}
|
||||
</span>
|
||||
) : null}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{children}
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { AlertTriangle, CheckCircle, Info } from 'lucide-react'
|
||||
|
||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
} from '@/components/reui/timeline'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
import { recentPlatformActivity } from '@/lib/metrics'
|
||||
import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
const KIND_META = {
|
||||
job: { icon: Info, className: 'text-info' },
|
||||
revision: { icon: CheckCircle, className: 'text-success' },
|
||||
network: { icon: AlertTriangle, className: 'text-warning' },
|
||||
} as const
|
||||
|
||||
function statusBadgeVariant(status: string) {
|
||||
const s = status.toLowerCase()
|
||||
if (['ok', 'success', 'completed', 'done'].includes(s)) return 'success-light' as const
|
||||
if (['running', 'queued', 'pending'].includes(s)) return 'info-light' as const
|
||||
if (['warning', 'mismatch'].includes(s)) return 'warning-light' as const
|
||||
if (['failed', 'error', 'cancelled'].includes(s)) return 'destructive-light' as const
|
||||
return 'outline' as const
|
||||
}
|
||||
|
||||
export function DashboardActivityTimeline({
|
||||
jobs,
|
||||
revisions,
|
||||
peers,
|
||||
speakers,
|
||||
loading,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
revisions: RevisionRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const items = recentPlatformActivity(jobs, revisions, peers, speakers, 6)
|
||||
|
||||
return (
|
||||
<DashboardFramePanel
|
||||
title="Недавняя активность"
|
||||
description="Задачи, ревизии и сетевые события"
|
||||
className="h-full min-w-0"
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground px-4 py-6 text-sm">Загрузка…</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-muted-foreground px-4 py-6 text-sm">Нет недавних событий</p>
|
||||
) : (
|
||||
<div className="px-4 py-4">
|
||||
<Timeline defaultValue={items.length}>
|
||||
{items.map((item, index) => {
|
||||
const meta = KIND_META[item.kind]
|
||||
const Icon = meta.icon
|
||||
return (
|
||||
<TimelineItem
|
||||
key={item.id}
|
||||
step={index + 1}
|
||||
className="group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=vertical]/timeline:not-last:pb-4"
|
||||
>
|
||||
<TimelineHeader>
|
||||
<TimelineSeparator className="bg-border! group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:top-2 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem)] group-data-[orientation=vertical]/timeline:translate-y-5" />
|
||||
<TimelineIndicator className="border-none bg-transparent group-data-[orientation=vertical]/timeline:-left-6">
|
||||
<span
|
||||
className={cn(
|
||||
'bg-muted/70 flex size-7 items-center justify-center rounded-full',
|
||||
meta.className,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" aria-hidden />
|
||||
</span>
|
||||
</TimelineIndicator>
|
||||
</TimelineHeader>
|
||||
<TimelineContent className="min-w-0 pb-1 text-foreground">
|
||||
<TimelineTitle className="text-sm leading-snug font-normal break-words">
|
||||
{item.message}
|
||||
</TimelineTitle>
|
||||
<div className="mt-2">
|
||||
<Badge variant={statusBadgeVariant(item.status)} size="sm">
|
||||
{item.statusLabel ?? item.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
)
|
||||
})}
|
||||
</Timeline>
|
||||
</div>
|
||||
)}
|
||||
</DashboardFramePanel>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
PanelCard,
|
||||
panelCardContentFlushClassName,
|
||||
} from '@/components/panel-card'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
/** Card-surface panel for dashboard sections (replaces legacy Frame shell). */
|
||||
export function DashboardFramePanel({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
}: {
|
||||
title?: string
|
||||
description?: string
|
||||
actions?: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<PanelCard
|
||||
title={title}
|
||||
description={description}
|
||||
actions={actions}
|
||||
className={cn('h-full', className)}
|
||||
contentClassName={cn(panelCardContentFlushClassName, contentClassName)}
|
||||
>
|
||||
{children}
|
||||
</PanelCard>
|
||||
)
|
||||
}
|
||||
@@ -3,12 +3,10 @@ import {
|
||||
Boxes,
|
||||
ListChecks,
|
||||
Network,
|
||||
ServerCog,
|
||||
Share2,
|
||||
} from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
|
||||
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
|
||||
@@ -16,6 +14,11 @@ import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
type KpiCard = KpiStatItem & { icon: ReactNode }
|
||||
|
||||
function ratioPercent(part: number, total: number): number | undefined {
|
||||
if (total <= 0) return undefined
|
||||
return Math.round((part / total) * 100)
|
||||
}
|
||||
|
||||
function buildKpis({
|
||||
modules,
|
||||
peers,
|
||||
@@ -40,17 +43,34 @@ function buildKpis({
|
||||
).length
|
||||
const offlineSpeakers = Math.max(0, speakers.length - network.speakersOnline)
|
||||
const riskCount = network.peersMismatch + failedJobs + offlineSpeakers
|
||||
const disabledModules = Math.max(0, modules.length - enabledModules)
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'modules',
|
||||
icon: <Boxes aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`,
|
||||
value: loading ? '—' : enabledModules,
|
||||
label: 'Модули активны',
|
||||
progress: loading ? undefined : ratioPercent(enabledModules, modules.length),
|
||||
footer: (
|
||||
<Badge variant="primary-light" size="sm">
|
||||
{loading ? '…' : `${modules.length} всего`}
|
||||
<Badge
|
||||
variant={
|
||||
loading || modules.length === 0
|
||||
? 'outline'
|
||||
: disabledModules === 0
|
||||
? 'success-light'
|
||||
: 'warning-light'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{loading
|
||||
? '…'
|
||||
: modules.length === 0
|
||||
? 'нет модулей'
|
||||
: disabledModules === 0
|
||||
? 'все активны'
|
||||
: `${disabledModules} выкл`}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
@@ -60,6 +80,7 @@ function buildKpis({
|
||||
iconClassName: 'text-info',
|
||||
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
|
||||
label: 'BGP готовность',
|
||||
progress: loading || bgpPct === null ? undefined : bgpPct,
|
||||
footer: (
|
||||
<Badge
|
||||
variant={
|
||||
@@ -73,34 +94,9 @@ function buildKpis({
|
||||
>
|
||||
{loading || bgpPct === null
|
||||
? 'нет включённых пиров'
|
||||
: `${network.peersEstablished} установлено`}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'peers',
|
||||
icon: <Share2 aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
|
||||
label: 'Пиры установлены',
|
||||
footer: (
|
||||
<Badge variant="success-light" size="sm">
|
||||
{loading ? '…' : `${network.peersTotal} в каталоге`}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'speakers',
|
||||
icon: <ServerCog aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
||||
label: 'Спикеры в сети',
|
||||
footer: (
|
||||
<Badge
|
||||
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
|
||||
size="sm"
|
||||
>
|
||||
{loading ? '…' : 'в сети'}
|
||||
: bgpPct >= 90
|
||||
? 'сессии в норме'
|
||||
: `${network.peersEstablished} установлено`}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
@@ -112,7 +108,7 @@ function buildKpis({
|
||||
label: 'Активные задачи',
|
||||
footer: (
|
||||
<Badge variant={running > 0 ? 'info-light' : 'outline'} size="sm">
|
||||
{loading ? '…' : `${jobs.length} в выборке`}
|
||||
{loading ? '…' : running > 0 ? 'выполняются' : 'очередь пуста'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { BoxesIcon, PlusIcon, SearchIcon } from 'lucide-react'
|
||||
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridNameCell } from '@/components/data-grid-cell'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||
import {
|
||||
ResourcePage,
|
||||
createTextFilterQuery,
|
||||
type DataGridColumnDef,
|
||||
} from '@/components/reui-kit'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { moduleTypeRu } from '@/lib/ui-labels'
|
||||
import type { ModuleRow } from '@/types/api'
|
||||
|
||||
const MODULE_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'enabled', label: 'Вкл' },
|
||||
{ id: 'disabled', label: 'Выкл' },
|
||||
]
|
||||
|
||||
const filterFields: FilterField[] = [
|
||||
{
|
||||
id: 'search',
|
||||
label: 'Поиск',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
placeholder: 'Поиск модулей…',
|
||||
},
|
||||
]
|
||||
|
||||
function getFilterFieldValue(item: ModuleRow, field: string): unknown {
|
||||
if (field === 'search') {
|
||||
return `${item.name} ${item.type} ${moduleTypeRu(item.type)}`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function tabFilter(item: ModuleRow, tabId: string): boolean {
|
||||
if (tabId === 'enabled') return item.enabled !== false
|
||||
if (tabId === 'disabled') return item.enabled === false
|
||||
return true
|
||||
}
|
||||
|
||||
export function DashboardModulesGrid({
|
||||
modules,
|
||||
isLoading = false,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||
createTextFilterQuery('search'),
|
||||
)
|
||||
|
||||
const columns = useMemo<DataGridColumnDef<ModuleRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
id: 'name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
|
||||
cell: ({ row }) => <DataGridNameCell icon={BoxesIcon} title={row.original.name} />,
|
||||
minSize: 180,
|
||||
meta: { headerTitle: 'Модуль' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
id: 'type',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => <CategoryBadge>{moduleTypeRu(row.original.type)}</CategoryBadge>,
|
||||
meta: { headerTitle: 'Тип' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'priority',
|
||||
id: 'priority',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Приоритет" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm tabular-nums">{row.original.priority}</span>
|
||||
),
|
||||
size: 88,
|
||||
meta: { headerTitle: 'Приоритет' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<ResourcePage
|
||||
title="Модули"
|
||||
description="Поиск и быстрый переход к настройке"
|
||||
tabs={MODULE_TABS}
|
||||
tabFilter={tabFilter}
|
||||
filterFields={filterFields}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
columns={columns}
|
||||
data={modules}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
primaryAction={
|
||||
<Button variant="outline" size="sm" render={<Link to="/modules" search={{ create: true }} />}>
|
||||
<PlusIcon />
|
||||
Создать
|
||||
</Button>
|
||||
}
|
||||
onRowClick={(row) =>
|
||||
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })
|
||||
}
|
||||
emptyState={{ title: 'Нет модулей по выбранным фильтрам.' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -60,8 +60,28 @@ export function DashboardNetworkHealth({
|
||||
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры в сети',
|
||||
percent: loading ? 0 : utilization,
|
||||
badge: (
|
||||
<Badge variant="outline" radius="full" className="h-6 px-2 text-[10px]">
|
||||
{loading ? '…' : `${established}/${total}`}
|
||||
<Badge
|
||||
variant={
|
||||
loading || total === 0
|
||||
? 'outline'
|
||||
: offline === 0
|
||||
? 'success-light'
|
||||
: 'warning-light'
|
||||
}
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
{loading
|
||||
? '…'
|
||||
: total === 0
|
||||
? 'нет данных'
|
||||
: offline === 0
|
||||
? mode === 'peers'
|
||||
? 'все установлены'
|
||||
: 'все в сети'
|
||||
: mode === 'peers'
|
||||
? `${offline} не установлены`
|
||||
: `${offline} офлайн`}
|
||||
</Badge>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -61,7 +61,6 @@ const ACTIONS: QuickActionItem[] = [
|
||||
title: 'Мониторинг',
|
||||
description: 'Состояние системы, BIRD и PostgreSQL.',
|
||||
to: '/monitoring',
|
||||
search: { tab: 'system' },
|
||||
icon: <Gauge aria-hidden />,
|
||||
iconClassName: 'text-destructive',
|
||||
},
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { ListTodo } from 'lucide-react'
|
||||
|
||||
import { DataGridNameCell } from '@/components/data-grid-cell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { FrameDataGrid, type DataGridColumnDef } from '@/components/reui-kit'
|
||||
import { jobKindRu } from '@/lib/ui-labels'
|
||||
import type { JobRow } from '@/types/api'
|
||||
|
||||
export function DashboardRecentJobsGrid({
|
||||
jobs,
|
||||
nameById,
|
||||
isLoading = false,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
nameById: Map<string, string>
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const data = useMemo(() => jobs.slice(0, 8), [jobs])
|
||||
|
||||
const columns = useMemo<DataGridColumnDef<JobRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'kind',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridNameCell
|
||||
icon={ListTodo}
|
||||
title={jobKindRu(row.original.kind)}
|
||||
subtitle={
|
||||
row.original.meta?.module_id
|
||||
? (nameById.get(String(row.original.meta.module_id)) ?? undefined)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Вид' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
],
|
||||
[nameById],
|
||||
)
|
||||
|
||||
return (
|
||||
<FrameDataGrid
|
||||
title="Недавние задачи"
|
||||
description="Последние фоновые операции"
|
||||
columns={columns}
|
||||
data={data}
|
||||
rowId={(row) => row.job_id}
|
||||
emptyTitle="Нет задач"
|
||||
pagination={false}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { GitCommitHorizontal } from 'lucide-react'
|
||||
|
||||
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { FrameDataGrid, type DataGridColumnDef } from '@/components/reui-kit'
|
||||
import type { RevisionRow } from '@/types/api'
|
||||
|
||||
export function DashboardRecentRevisionsGrid({
|
||||
revisions,
|
||||
isLoading = false,
|
||||
}: {
|
||||
revisions: RevisionRow[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const data = useMemo(() => revisions.slice(0, 8), [revisions])
|
||||
|
||||
const columns = useMemo<DataGridColumnDef<RevisionRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'id',
|
||||
accessorFn: (row) => row.id,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridNameCell icon={GitCommitHorizontal} title={`${row.original.id.slice(0, 10)}…`} />
|
||||
),
|
||||
meta: { headerTitle: 'ID' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>
|
||||
{new Date(row.original.created_at).toLocaleString('ru-RU')}
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
meta: { headerTitle: 'Создана' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<FrameDataGrid
|
||||
title="Последние ревизии"
|
||||
description="История конфигураций"
|
||||
columns={columns}
|
||||
data={data}
|
||||
rowId={(row) => row.id}
|
||||
emptyTitle="Нет ревизий"
|
||||
pagination={false}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,10 @@ import { InboxIcon, type LucideIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { IconStack } from '@/components/reui/icon-stack'
|
||||
import {
|
||||
Frame,
|
||||
FramePanel,
|
||||
} from '@/components/reui/frame'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
@@ -25,6 +29,8 @@ interface EmptyStateProps {
|
||||
className?: string
|
||||
stackedIcon?: boolean
|
||||
centered?: boolean
|
||||
/** Wrap in Frame (empty-state-14). Nested grids should leave this false. */
|
||||
framed?: boolean
|
||||
}
|
||||
|
||||
function isLucideIcon(icon: LucideIcon | ReactNode): icon is LucideIcon {
|
||||
@@ -44,15 +50,16 @@ export function EmptyState({
|
||||
className,
|
||||
stackedIcon = true,
|
||||
centered = true,
|
||||
framed = false,
|
||||
}: EmptyStateProps) {
|
||||
const Icon = isLucideIcon(icon) ? icon : InboxIcon
|
||||
const customIcon = icon && !isLucideIcon(icon) ? icon : null
|
||||
|
||||
const body = (
|
||||
const empty = (
|
||||
<Empty
|
||||
className={cn(
|
||||
'max-w-md flex-none border-0 bg-transparent p-0',
|
||||
!centered && className,
|
||||
!centered && !framed && className,
|
||||
)}
|
||||
>
|
||||
<EmptyHeader className="gap-5 text-center">
|
||||
@@ -88,6 +95,16 @@ export function EmptyState({
|
||||
</Empty>
|
||||
)
|
||||
|
||||
const body = framed ? (
|
||||
<Frame dense spacing="sm" className={cn('w-full', !centered && className)}>
|
||||
<FramePanel className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
|
||||
{empty}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
) : (
|
||||
empty
|
||||
)
|
||||
|
||||
if (!centered) return body
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { Checkbox } from "@evobgp/ui/components/checkbox"
|
||||
import { Field } from "@evobgp/ui/components/field"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@evobgp/ui/components/input-group"
|
||||
import { Label } from "@evobgp/ui/components/label"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import { SearchIcon, XIcon, ListFilterIcon } from "lucide-react"
|
||||
|
||||
const statuses = ["Pending", "Shipped", "Cancelled"] as const
|
||||
|
||||
type Status = (typeof statuses)[number]
|
||||
|
||||
function toggleStatus(values: Status[], value: Status) {
|
||||
return values.includes(value)
|
||||
? values.filter((item) => item !== value)
|
||||
: [...values, value]
|
||||
}
|
||||
|
||||
export function Pattern() {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<Status[]>([])
|
||||
|
||||
return (
|
||||
<Field className="max-w-sm">
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
|
||||
<InputGroupInput
|
||||
placeholder="Search orders..."
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
/>
|
||||
|
||||
<InputGroupAddon align="inline-end" className="gap-1">
|
||||
{searchQuery.length > 0 ? (
|
||||
<InputGroupButton
|
||||
aria-label="Clear search"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
) : null}
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<InputGroupButton
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="gap-1.5"
|
||||
aria-label="Filter order status"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ListFilterIcon className="size-3.5" aria-hidden="true" />
|
||||
Status
|
||||
{selectedStatuses.length > 0 ? (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{selectedStatuses.length}
|
||||
</span>
|
||||
) : null}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-40 p-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
{statuses.map((status) => (
|
||||
<div key={status} className="flex items-center gap-2.5">
|
||||
<Checkbox
|
||||
id={`order-status-${status}`}
|
||||
checked={selectedStatuses.includes(status)}
|
||||
onCheckedChange={() =>
|
||||
setSelectedStatuses((previous) =>
|
||||
toggleStatus(previous, status)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`order-status-${status}`}
|
||||
className="text-sm font-normal"
|
||||
>
|
||||
{status}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Field } from '@evobgp/ui/components/field'
|
||||
|
||||
import { SelectMenu } from '@/components/select-field'
|
||||
|
||||
const items = [
|
||||
{ label: 'Select an item', value: 'placeholder' as const },
|
||||
...Array.from({ length: 100 }).map((_, i) => ({
|
||||
label: `Item ${i}`,
|
||||
value: `item-${i}` as const,
|
||||
})),
|
||||
]
|
||||
|
||||
export function Pattern() {
|
||||
return (
|
||||
<Field className="max-w-xs">
|
||||
<SelectMenu items={items} placeholder="Select an item" />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@evobgp/ui/components/card"
|
||||
import { Input } from "@evobgp/ui/components/input"
|
||||
import { Label } from "@evobgp/ui/components/label"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@evobgp/ui/components/tabs"
|
||||
|
||||
export function Pattern() {
|
||||
return (
|
||||
<div className="flex w-full max-w-xs flex-col gap-6">
|
||||
<Tabs defaultValue="account">
|
||||
<TabsList variant="line" className="mb-3.5 w-full">
|
||||
<TabsTrigger value="account">Account</TabsTrigger>
|
||||
<TabsTrigger value="password">Password</TabsTrigger>
|
||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="account">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Account</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
Update your account information.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="underline-name" className="text-sm">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="underline-name"
|
||||
defaultValue="Alex Chen"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="underline-email" className="text-sm">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="underline-email"
|
||||
type="email"
|
||||
defaultValue="[email protected]"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="pt-3">
|
||||
<Button size="sm">Save changes</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="password">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Password</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
Change your password here.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="underline-current" className="text-sm">
|
||||
Current password
|
||||
</Label>
|
||||
<Input id="underline-current" type="password" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="underline-new" className="text-sm">
|
||||
New password
|
||||
</Label>
|
||||
<Input id="underline-new" type="password" className="h-9" />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="pt-3">
|
||||
<Button size="sm">Update password</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="settings">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Settings</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
Manage your preferences.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="underline-theme" className="text-sm">
|
||||
Theme
|
||||
</Label>
|
||||
<Input
|
||||
id="underline-theme"
|
||||
defaultValue="Light"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="underline-language" className="text-sm">
|
||||
Language
|
||||
</Label>
|
||||
<Input
|
||||
id="underline-language"
|
||||
defaultValue="English"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="pt-3">
|
||||
<Button size="sm">Save settings</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@evobgp/ui/components/tabs"
|
||||
import { LayoutDashboardIcon, BarChart3Icon, SettingsIcon } from "lucide-react"
|
||||
|
||||
export function Pattern() {
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col gap-6">
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="overview">
|
||||
<LayoutDashboardIcon className="size-4" />
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="analytics">
|
||||
<BarChart3Icon className="size-4" />
|
||||
Analytics
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings">
|
||||
<SettingsIcon className="size-4" />
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview">
|
||||
<Card>
|
||||
<CardContent>Overview dashboard content goes here.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="analytics">
|
||||
<Card>
|
||||
<CardContent>Analytics charts and metrics.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="settings">
|
||||
<Card>
|
||||
<CardContent>Application settings and preferences.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@evobgp/ui/components/tabs"
|
||||
|
||||
export function Pattern() {
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col gap-6">
|
||||
<Tabs defaultValue="inbox">
|
||||
<TabsList variant="line" className="mb-3.5 w-full">
|
||||
<TabsTrigger value="inbox" className="gap-2">
|
||||
Inbox
|
||||
<Badge variant="primary-light" size="sm">
|
||||
12
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="drafts" className="gap-2">
|
||||
Drafts
|
||||
<Badge variant="info-light" size="sm">
|
||||
3
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sent" className="gap-2">
|
||||
Sent
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="spam" className="gap-2">
|
||||
Spam
|
||||
<Badge variant="destructive-light" size="sm">
|
||||
24
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="inbox">
|
||||
<Card>
|
||||
<CardContent>12 unread messages in your inbox.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="drafts">
|
||||
<Card>
|
||||
<CardContent>3 drafts waiting to be sent.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="sent">
|
||||
<Card>
|
||||
<CardContent>All sent messages appear here.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="spam">
|
||||
<Card>
|
||||
<CardContent>24 spam messages detected.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { FieldGroup } from '@evobgp/ui/components/field'
|
||||
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -20,7 +22,11 @@ interface FormDrawerProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Create/edit overlay — Sheet DNA (sheet-8 / sheet-1). Preview: https://reui.io/preview/base/sheet-8 */
|
||||
/**
|
||||
* Create/edit overlay — Sheet DNA (sheet-8) + FieldGroup (form-7).
|
||||
* @see https://reui.io/preview/base/sheet-8
|
||||
* @see https://reui.io/preview/base/form-7
|
||||
*/
|
||||
export function FormDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -34,13 +40,17 @@ export function FormDrawer({
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className={cn('w-full gap-0 overflow-hidden p-0 sm:max-w-lg', className)}
|
||||
className={cn('flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-lg', className)}
|
||||
>
|
||||
<SheetHeader className="shrink-0 border-b border-border/50">
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
{description ? <SheetDescription>{description}</SheetDescription> : null}
|
||||
</SheetHeader>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">{children}</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full">
|
||||
<FieldGroup className="p-4">{children}</FieldGroup>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<SheetFooter className="mt-0 shrink-0 border-t border-border/50 bg-muted/50">
|
||||
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||
{footer}
|
||||
|
||||
@@ -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: [
|
||||
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и применение', search: { tab: 'revisions' } },
|
||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание обновления' },
|
||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD', search: { tab: 'system' } },
|
||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Loader2Icon } from 'lucide-react'
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import { Spinner } from '@evobgp/ui/components/spinner'
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
|
||||
type LoadingButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
type LoadingButtonProps = ComponentProps<typeof Button> & {
|
||||
loading?: boolean
|
||||
variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
|
||||
size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/** SoT for async actions — Button + Spinner. */
|
||||
export function LoadingButton({ loading, disabled, children, ...props }: LoadingButtonProps) {
|
||||
return (
|
||||
<Button disabled={disabled || loading} {...props}>
|
||||
{loading ? <Loader2Icon className="animate-spin" data-icon="inline-start" /> : null}
|
||||
{loading ? <Spinner data-icon="inline-start" /> : null}
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -3,8 +3,8 @@ import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Checkbox } from '@evobgp/ui/components/checkbox'
|
||||
import { Field, FieldDescription, FieldLabel } from '@evobgp/ui/components/field'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
|
||||
import { FormDrawer } from '@/components/form-drawer'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
@@ -171,42 +171,42 @@ export function ModuleCreateDialog({
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-create-name">Название</Label>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="mod-create-name">Название</FieldLabel>
|
||||
<Input
|
||||
id="mod-create-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Имя модуля"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<Field orientation="horizontal">
|
||||
<div className="grid min-w-0 flex-1 gap-1 pr-2">
|
||||
<Label htmlFor="mod-create-enabled">Включён</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<FieldLabel htmlFor="mod-create-enabled">Включён</FieldLabel>
|
||||
<FieldDescription>
|
||||
Выключенный модуль не участвует в обновлении и применении.
|
||||
</p>
|
||||
</FieldDescription>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="mod-create-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={(v) => setEnabled(v === true)}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-create-priority">Приоритет</Label>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="mod-create-priority">Приоритет</FieldLabel>
|
||||
<Input
|
||||
id="mod-create-priority"
|
||||
type="number"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-create-interval">Интервал обновления (сек)</Label>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="mod-create-interval">Интервал обновления (сек)</FieldLabel>
|
||||
<Input
|
||||
id="mod-create-interval"
|
||||
type="number"
|
||||
@@ -215,17 +215,17 @@ export function ModuleCreateDialog({
|
||||
value={refreshIntervalSec}
|
||||
onChange={(e) => setRefreshIntervalSec(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-create-cron">Cron (опционально)</Label>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="mod-create-cron">Cron (опционально)</FieldLabel>
|
||||
<Input
|
||||
id="mod-create-cron"
|
||||
placeholder="0 * * * *"
|
||||
value={cronExpr}
|
||||
onChange={(e) => setCronExpr(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<CommunitySelect
|
||||
id="mod-create-community"
|
||||
@@ -248,8 +248,8 @@ export function ModuleCreateDialog({
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>DoH профили</Label>
|
||||
<Field>
|
||||
<FieldLabel>DoH профили</FieldLabel>
|
||||
{dohProfiles.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
|
||||
) : (
|
||||
@@ -281,7 +281,7 @@ export function ModuleCreateDialog({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
</>
|
||||
) : null}
|
||||
</FormDrawer>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Clock, Gauge, Globe, Tags } from 'lucide-react'
|
||||
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { KpiStatGridSkeleton } from '@/components/skeletons'
|
||||
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
|
||||
|
||||
@@ -57,7 +57,7 @@ export const peerColumns: DataGridColumnDef<PeerRow>[] = [
|
||||
export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown {
|
||||
switch (field) {
|
||||
case 'name':
|
||||
return item.name ?? item.neighbor
|
||||
return `${item.name ?? ''} ${item.neighbor} ${item.remote_asn ?? ''}`
|
||||
case 'neighbor':
|
||||
return item.neighbor
|
||||
case 'session_state':
|
||||
|
||||
@@ -68,7 +68,7 @@ export const speakerColumns: DataGridColumnDef<SpeakerRow>[] = [
|
||||
export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown {
|
||||
switch (field) {
|
||||
case 'endpoint':
|
||||
return `${item.endpoint} ${item.agent_domain ?? ''}`
|
||||
return `${item.endpoint} ${item.agent_domain ?? ''} ${item.id}`
|
||||
case 'role':
|
||||
return item.role
|
||||
default:
|
||||
|
||||
@@ -8,13 +8,22 @@ import {
|
||||
KanbanColumn,
|
||||
KanbanColumnContent,
|
||||
KanbanItem,
|
||||
KanbanOverlay,
|
||||
} from '@/components/reui/kanban'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemTitle,
|
||||
} from '@evobgp/ui/components/item'
|
||||
import { jobKindRu } from '@/lib/ui-labels'
|
||||
import type { JobRow } from '@/types/api'
|
||||
|
||||
/**
|
||||
* Operations jobs board view — ReUI Kanban (read-only columns).
|
||||
* Operations jobs board — ReUI Kanban (read-only columns).
|
||||
* @see https://reui.io/preview/base/kanban-board-8
|
||||
* @see https://reui.io/docs/components/base/kanban
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
@@ -78,9 +87,9 @@ export function OperationsJobsKanban({
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">{COLUMN_LABELS[columnId]}</p>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
<Badge variant="outline" size="sm">
|
||||
{columns[columnId]?.length ?? 0}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<KanbanColumnContent value={columnId}>
|
||||
{(columns[columnId] ?? []).map((job) => (
|
||||
@@ -88,24 +97,35 @@ export function OperationsJobsKanban({
|
||||
key={job.job_id}
|
||||
value={job.job_id}
|
||||
disabled
|
||||
className="bg-background flex flex-col gap-1 rounded-md border p-3"
|
||||
className="p-0"
|
||||
>
|
||||
<p className="text-sm leading-tight font-medium">
|
||||
{jobKindRu(job.kind)}
|
||||
</p>
|
||||
<StatusBadge status={job.status} />
|
||||
{job.meta?.module_id ? (
|
||||
<DataGridMutedCell>
|
||||
{nameById.get(String(job.meta.module_id)) ??
|
||||
String(job.meta.module_id)}
|
||||
</DataGridMutedCell>
|
||||
) : null}
|
||||
<Item variant="outline" size="sm">
|
||||
<ItemContent>
|
||||
<ItemTitle>{jobKindRu(job.kind)}</ItemTitle>
|
||||
<StatusBadge status={job.status} />
|
||||
{job.meta?.module_id ? (
|
||||
<ItemDescription>
|
||||
<DataGridMutedCell>
|
||||
{nameById.get(String(job.meta.module_id)) ??
|
||||
String(job.meta.module_id)}
|
||||
</DataGridMutedCell>
|
||||
</ItemDescription>
|
||||
) : null}
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</KanbanItem>
|
||||
))}
|
||||
</KanbanColumnContent>
|
||||
</KanbanColumn>
|
||||
))}
|
||||
</KanbanBoard>
|
||||
<KanbanOverlay>
|
||||
<Item variant="outline" size="sm">
|
||||
<ItemContent>
|
||||
<ItemTitle>Задача</ItemTitle>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</KanbanOverlay>
|
||||
</Kanban>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
@@ -1,93 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
/**
|
||||
* Frame shell for analytics / comparison panels (not list grids).
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
* @see https://reui.io/preview/base/chart-1
|
||||
*/
|
||||
export const panelCardClassName = 'w-full gap-0 p-0'
|
||||
export const panelCardHeaderClassName = 'border-b'
|
||||
/** Flush body inside FramePanel (toolbar / data-grid). */
|
||||
export const panelCardContentFlushClassName = 'p-0'
|
||||
/** Horizontal inset for toolbar / pagination rows. */
|
||||
export const panelCardInsetClassName = 'px-5 py-3'
|
||||
export const panelCardFooterClassName = 'border-t bg-transparent'
|
||||
|
||||
interface PanelCardProps {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
footer?: ReactNode
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
headerClassName?: string
|
||||
contentClassName?: string
|
||||
footerClassName?: string
|
||||
size?: 'default' | 'sm'
|
||||
}
|
||||
|
||||
export function PanelCard({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
footer,
|
||||
children,
|
||||
className,
|
||||
headerClassName,
|
||||
contentClassName,
|
||||
footerClassName,
|
||||
size = 'default',
|
||||
}: PanelCardProps) {
|
||||
const hasHeader = Boolean(title || description || actions)
|
||||
const spacing = size === 'sm' ? 'sm' : 'default'
|
||||
|
||||
return (
|
||||
<Frame
|
||||
dense
|
||||
spacing={spacing}
|
||||
className={cn(panelCardClassName, 'min-w-0', className)}
|
||||
>
|
||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||
{hasHeader ? (
|
||||
<FrameHeader
|
||||
className={cn(
|
||||
panelCardHeaderClassName,
|
||||
'flex-row items-start justify-between gap-3',
|
||||
headerClassName,
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
||||
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||
{description ? (
|
||||
<FrameDescription>{description}</FrameDescription>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
) : null}
|
||||
{children != null && children !== false ? (
|
||||
<div className={cn('min-w-0 flex-1', contentClassName)}>{children}</div>
|
||||
) : null}
|
||||
{footer ? (
|
||||
<FrameFooter className={cn(panelCardFooterClassName, footerClassName)}>
|
||||
{footer}
|
||||
</FrameFooter>
|
||||
) : null}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
export {
|
||||
FrameSection,
|
||||
FrameSection as PanelCard,
|
||||
panelCardClassName,
|
||||
panelCardHeaderClassName,
|
||||
panelCardContentFlushClassName,
|
||||
panelCardInsetClassName,
|
||||
panelCardFooterClassName,
|
||||
type FrameSectionProps,
|
||||
type FrameSectionProps as PanelCardProps,
|
||||
} from '@/components/reui-kit/frame-section'
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { IconStack } from '@/components/reui/icon-stack'
|
||||
import {
|
||||
Frame,
|
||||
FramePanel,
|
||||
} from '@/components/reui/frame'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@evobgp/ui/components/empty'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
|
||||
/** empty-state-14: Frame + IconStack. Preview: https://reui.io/preview/base/empty-state-14 */
|
||||
export function IllustratedEmptyState({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
icon,
|
||||
action,
|
||||
}: {
|
||||
title: string
|
||||
@@ -27,23 +16,13 @@ export function IllustratedEmptyState({
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FramePanel className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
|
||||
{action ? <div className="w-full self-end">{action}</div> : null}
|
||||
<Empty className="max-w-md gap-5 bg-transparent p-0">
|
||||
<EmptyHeader className="items-center gap-5 text-center">
|
||||
<EmptyMedia className="mb-0">
|
||||
<IconStack aria-hidden>
|
||||
<Icon strokeWidth={1.9} aria-hidden />
|
||||
</IconStack>
|
||||
</EmptyMedia>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<EmptyTitle className="text-base font-semibold tracking-tight">{title}</EmptyTitle>
|
||||
<EmptyDescription className="max-w-sm text-sm/relaxed">{description}</EmptyDescription>
|
||||
</div>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
<EmptyState
|
||||
framed
|
||||
centered={false}
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={description}
|
||||
action={action}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { DonutBreakdownCard } from './donut-breakdown-card'
|
||||
export { IllustratedEmptyState } from './illustrated-empty-state'
|
||||
export { KpiSparklineCard, type KpiSparklineMetric } from './kpi-sparkline-card'
|
||||
export { KpiStatGrid, KpiStatCard, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||
export { KpiStatGrid, KpiStatCard, type KpiStatItem } from '@/components/reui-kit'
|
||||
export { PanelCorners } from './panel-corners'
|
||||
export { ProjectsEmptyState } from './projects-empty-state'
|
||||
export { SegmentedProgressCard, type SegmentStat } from './segmented-progress-card'
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Boxes, Plus } from 'lucide-react'
|
||||
|
||||
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
/** empty-state-3 pattern for first module. */
|
||||
/** empty-state-14 for first module. Preview: https://reui.io/preview/base/empty-state-14 */
|
||||
export function ProjectsEmptyState({
|
||||
canCreate = true,
|
||||
onCreate,
|
||||
@@ -12,7 +12,9 @@ export function ProjectsEmptyState({
|
||||
onCreate?: () => void
|
||||
}) {
|
||||
return (
|
||||
<IllustratedEmptyState
|
||||
<EmptyState
|
||||
framed
|
||||
centered={false}
|
||||
icon={Boxes}
|
||||
title="Создайте первый модуль"
|
||||
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { kitDataGridTableClassNames, kitDataGridTableLayout } from './frame-data-grid'
|
||||
import {
|
||||
applyKitActionColumn,
|
||||
KIT_ACTION_COLUMN_SIZE,
|
||||
kitColumnPinning,
|
||||
kitDataGridTableClassNames,
|
||||
kitDataGridTableLayout,
|
||||
} from './frame-data-grid'
|
||||
|
||||
describe('kitDataGridTableLayout', () => {
|
||||
it('filtering-2 defaults: dense, headerBackground false, width fixed', () => {
|
||||
@@ -15,6 +21,9 @@ describe('kitDataGridTableLayout', () => {
|
||||
|
||||
it('kit tableClassNames совпадает с filtering-2 edgeCell', () => {
|
||||
expect(kitDataGridTableClassNames.edgeCell).toBe('first:ps-3 last:pe-3')
|
||||
expect(kitDataGridTableClassNames.base).toBe(
|
||||
'[&_[data-pinned]]:bg-(--frame-panel-bg)',
|
||||
)
|
||||
})
|
||||
|
||||
it('именованные opts: auto + pin', () => {
|
||||
@@ -27,3 +36,63 @@ describe('kitDataGridTableLayout', () => {
|
||||
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,
|
||||
createFilterRule,
|
||||
flattenFilterConditions,
|
||||
flattenFilterRules,
|
||||
isFilterRule,
|
||||
updateFilterRule,
|
||||
type FilterCondition,
|
||||
} from '@/components/reui/filters/filters-query'
|
||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||
import type {
|
||||
FilterField,
|
||||
FilterGroupNode,
|
||||
FilterNode,
|
||||
FilterQuery,
|
||||
} from '@/components/reui/filters/filters-types'
|
||||
|
||||
/** Operators that take no value, so an empty `values` list is expected. */
|
||||
const VALUELESS_OPERATORS = new Set(['empty', 'not_empty'])
|
||||
@@ -131,6 +139,95 @@ export function createSearchFilterField(
|
||||
}
|
||||
}
|
||||
|
||||
export function getPrimaryTextField(fields: FilterField[]): FilterField | undefined {
|
||||
return fields.find((field) => field.type === 'text' && !field.fields)
|
||||
}
|
||||
|
||||
export function getExtraFilterFields(
|
||||
fields: FilterField[],
|
||||
primaryId?: string,
|
||||
): FilterField[] {
|
||||
if (!primaryId) return fields
|
||||
return fields.filter((field) => field.id !== primaryId)
|
||||
}
|
||||
|
||||
function ruleFieldId(path: string[]): string | undefined {
|
||||
return path[0]
|
||||
}
|
||||
|
||||
export function getFilterTextValue(query: FilterQuery, fieldId: string): string {
|
||||
const rule = flattenFilterRules(query).find((item) => ruleFieldId(item.path) === fieldId)
|
||||
if (rule?.value == null) return ''
|
||||
if (Array.isArray(rule.value)) return rule.value.map(String).join(' ')
|
||||
return String(rule.value)
|
||||
}
|
||||
|
||||
export function setFilterTextValue(
|
||||
query: FilterQuery,
|
||||
fieldId: string,
|
||||
text: string,
|
||||
): FilterQuery {
|
||||
const existing = flattenFilterRules(query).find((item) => ruleFieldId(item.path) === fieldId)
|
||||
if (existing) {
|
||||
return updateFilterRule(query, existing.id, {
|
||||
operator: 'contains',
|
||||
value: text,
|
||||
})
|
||||
}
|
||||
return {
|
||||
...query,
|
||||
rules: [
|
||||
createFilterRule({
|
||||
id: `${fieldId}-1`,
|
||||
path: [fieldId],
|
||||
operator: 'contains',
|
||||
value: text,
|
||||
}),
|
||||
...query.rules,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function mapGroupRules<V>(
|
||||
group: FilterGroupNode<V>,
|
||||
map: (node: FilterNode<V>) => FilterNode<V> | null,
|
||||
): FilterGroupNode<V> {
|
||||
const rules: FilterNode<V>[] = []
|
||||
for (const child of group.rules) {
|
||||
if (isFilterRule(child)) {
|
||||
const next = map(child)
|
||||
if (next) rules.push(next)
|
||||
continue
|
||||
}
|
||||
const nested = mapGroupRules(child, map)
|
||||
if (nested.rules.length > 0) rules.push(nested)
|
||||
}
|
||||
return { ...group, rules }
|
||||
}
|
||||
|
||||
export function stripFieldFromQuery(query: FilterQuery, fieldId: string): FilterQuery {
|
||||
return mapGroupRules(query, (node) => {
|
||||
if (isFilterRule(node) && ruleFieldId(node.path) === fieldId) return null
|
||||
return node
|
||||
})
|
||||
}
|
||||
|
||||
export function mergeFieldRules(
|
||||
extraQuery: FilterQuery,
|
||||
sourceQuery: FilterQuery,
|
||||
fieldId: string,
|
||||
): FilterQuery {
|
||||
const searchRules = flattenFilterRules(sourceQuery).filter(
|
||||
(rule) => ruleFieldId(rule.path) === fieldId,
|
||||
)
|
||||
const strippedExtra = stripFieldFromQuery(extraQuery, fieldId)
|
||||
if (searchRules.length === 0) return strippedExtra
|
||||
return {
|
||||
...strippedExtra,
|
||||
rules: [...searchRules, ...strippedExtra.rules],
|
||||
}
|
||||
}
|
||||
|
||||
export function renderSelectedCount(values: unknown[]) {
|
||||
if (values.length === 0) return 'Выберите…'
|
||||
if (values.length > 1) return `${values.length} выбрано`
|
||||
|
||||
@@ -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 = {
|
||||
base: '[&_[data-pinned]]:bg-(--frame-panel-bg)',
|
||||
edgeCell: 'first:ps-3 last:pe-3',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Compact action column size from data-grid-filtering-2.
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
*/
|
||||
export const KIT_ACTION_COLUMN_SIZE = 56
|
||||
|
||||
const ACTION_CELL_ALIGN = 'flex items-center justify-end'
|
||||
|
||||
function lastColumnId<T extends object>(columns: DataGridColumnDef<T>[]): string {
|
||||
const last = columns[columns.length - 1]
|
||||
if (!last) return ''
|
||||
if (last.id) return last.id
|
||||
if ('accessorKey' in last && typeof last.accessorKey === 'string') return last.accessorKey
|
||||
return ''
|
||||
}
|
||||
|
||||
function wrapActionCell<T extends object>(
|
||||
cell: DataGridColumnDef<T>['cell'],
|
||||
): DataGridColumnDef<T>['cell'] {
|
||||
if (typeof cell !== 'function') {
|
||||
return () => <div className={ACTION_CELL_ALIGN}>{cell as ReactNode}</div>
|
||||
}
|
||||
return (ctx) => <div className={ACTION_CELL_ALIGN}>{cell(ctx)}</div>
|
||||
}
|
||||
|
||||
/**
|
||||
* filtering-2 action column DNA: locked width, no sort/resize, inner justify-end
|
||||
* (flex on the cell wrapper, never on `td` — that breaks rowBorder alignment).
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
*/
|
||||
export function applyKitActionColumn<T extends object>(
|
||||
columns: DataGridColumnDef<T>[],
|
||||
opts: { pinLastColumn?: boolean } = {},
|
||||
): DataGridColumnDef<T>[] {
|
||||
if (columns.length === 0) return columns
|
||||
const last = columns[columns.length - 1]
|
||||
const lastId = lastColumnId(columns)
|
||||
if (lastId !== 'actions' && !opts.pinLastColumn) return columns
|
||||
|
||||
const size = last.size ?? KIT_ACTION_COLUMN_SIZE
|
||||
return [
|
||||
...columns.slice(0, -1),
|
||||
{
|
||||
...last,
|
||||
size,
|
||||
minSize: last.minSize ?? size,
|
||||
maxSize: last.maxSize ?? size,
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
cell: last.cell ? wrapActionCell(last.cell) : last.cell,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** End-pin only when the grid actually scrolls horizontally (not for action columns). */
|
||||
export function kitColumnPinning(opts: {
|
||||
pinLastColumn?: boolean
|
||||
horizontalScroll?: boolean
|
||||
lastColId: string
|
||||
pinLeftColumnIds?: string[]
|
||||
}): {
|
||||
enablePinning: boolean
|
||||
columnPinning: { start: string[]; end: string[] }
|
||||
} {
|
||||
const pinLeft = opts.pinLeftColumnIds ?? []
|
||||
const pinEnd = Boolean(opts.pinLastColumn && opts.horizontalScroll && opts.lastColId)
|
||||
return {
|
||||
enablePinning: pinEnd || pinLeft.length > 0,
|
||||
columnPinning: {
|
||||
start: pinLeft,
|
||||
end: pinEnd ? [opts.lastColId] : [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function loadStoredColumnVisibility(key: string): ColumnVisibilityState | undefined {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
@@ -280,6 +356,7 @@ export function FrameDataGrid<TData extends object>({
|
||||
expandedContent,
|
||||
getRowCanExpand,
|
||||
pinLeftColumnIds,
|
||||
horizontalScroll = false,
|
||||
tableWidth = 'fixed',
|
||||
columnPinControls = false,
|
||||
}: FrameDataGridProps<TData>) {
|
||||
@@ -344,21 +421,23 @@ export function FrameDataGrid<TData extends object>({
|
||||
}
|
||||
|
||||
const tableColumns: DataGridColumnDef<TData>[] = applyColumnPinControls(
|
||||
[
|
||||
...(expandedContent ? [expandColumn] : []),
|
||||
...(enableRowSelection ? [selectColumn] : []),
|
||||
...columns,
|
||||
],
|
||||
applyKitActionColumn(
|
||||
[
|
||||
...(expandedContent ? [expandColumn] : []),
|
||||
...(enableRowSelection ? [selectColumn] : []),
|
||||
...columns,
|
||||
],
|
||||
{ pinLastColumn },
|
||||
),
|
||||
columnPinControls,
|
||||
)
|
||||
|
||||
const lastColId = pinLastColumn ? (tableColumns[tableColumns.length - 1]?.id ?? '') : ''
|
||||
const pinLeft = pinLeftColumnIds ?? []
|
||||
const enablePinning = pinLastColumn || pinLeft.length > 0
|
||||
const columnPinning = {
|
||||
start: pinLeft,
|
||||
end: pinLastColumn && lastColId ? [lastColId] : [],
|
||||
}
|
||||
const { enablePinning, columnPinning } = kitColumnPinning({
|
||||
pinLastColumn,
|
||||
horizontalScroll,
|
||||
lastColId: lastColumnId(tableColumns),
|
||||
pinLeftColumnIds,
|
||||
})
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
/**
|
||||
* Frame shell for analytics / comparison / detail panels (not list grids).
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
* @see https://reui.io/preview/base/chart-1
|
||||
*/
|
||||
export const panelCardClassName = 'w-full gap-0 p-0'
|
||||
export const panelCardHeaderClassName = 'border-b'
|
||||
/** Flush body inside FramePanel (toolbar / data-grid). */
|
||||
export const panelCardContentFlushClassName = 'p-0'
|
||||
/** Horizontal inset for toolbar / pagination rows. */
|
||||
export const panelCardInsetClassName = 'px-5 py-3'
|
||||
export const panelCardFooterClassName = 'border-t bg-transparent'
|
||||
|
||||
export interface FrameSectionProps {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
footer?: ReactNode
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
headerClassName?: string
|
||||
contentClassName?: string
|
||||
footerClassName?: string
|
||||
size?: 'default' | 'sm'
|
||||
}
|
||||
|
||||
export function FrameSection({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
footer,
|
||||
children,
|
||||
className,
|
||||
headerClassName,
|
||||
contentClassName,
|
||||
footerClassName,
|
||||
size = 'default',
|
||||
}: FrameSectionProps) {
|
||||
const hasHeader = Boolean(title || description || actions)
|
||||
const spacing = size === 'sm' ? 'sm' : 'default'
|
||||
|
||||
return (
|
||||
<Frame
|
||||
dense
|
||||
spacing={spacing}
|
||||
className={cn(panelCardClassName, 'min-w-0', className)}
|
||||
>
|
||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||
{hasHeader ? (
|
||||
<FrameHeader
|
||||
className={cn(
|
||||
panelCardHeaderClassName,
|
||||
'flex-row items-start justify-between gap-3',
|
||||
headerClassName,
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
||||
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||
{description ? (
|
||||
<FrameDescription>{description}</FrameDescription>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
) : null}
|
||||
{children != null && children !== false ? (
|
||||
<div className={cn('min-w-0 flex-1', contentClassName)}>{children}</div>
|
||||
) : null}
|
||||
{footer ? (
|
||||
<FrameFooter className={cn(panelCardFooterClassName, footerClassName)}>
|
||||
{footer}
|
||||
</FrameFooter>
|
||||
) : null}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -36,3 +36,12 @@ export {
|
||||
export { ExpandableResourceGrid } from './expandable-resource-grid'
|
||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||
export { SettingsShell } from './settings-shell'
|
||||
export {
|
||||
FrameSection,
|
||||
panelCardClassName,
|
||||
panelCardHeaderClassName,
|
||||
panelCardContentFlushClassName,
|
||||
panelCardInsetClassName,
|
||||
panelCardFooterClassName,
|
||||
type FrameSectionProps,
|
||||
} from './frame-section'
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from '@tanstack/react-router'
|
||||
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Progress } from '@evobgp/ui/components/progress'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { kpiCols } from './kpi-cols'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
@@ -29,6 +30,8 @@ export type KpiStatItem = {
|
||||
iconClassName?: string
|
||||
variant?: KpiStatVariant
|
||||
footer?: ReactNode
|
||||
/** 0–100 completion bar under the value (stats-4). Omit to hide. */
|
||||
progress?: number
|
||||
}
|
||||
|
||||
/** CFDM-compatible card shape (id required). */
|
||||
@@ -48,6 +51,17 @@ const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
|
||||
destructive: 'text-destructive',
|
||||
}
|
||||
|
||||
const PROGRESS_TONE_CLASS: Record<KpiStatVariant, string> = {
|
||||
default: '',
|
||||
warning: '[&_[data-slot=progress-indicator]]:bg-warning',
|
||||
destructive: '[&_[data-slot=progress-indicator]]:bg-destructive',
|
||||
}
|
||||
|
||||
function clampProgress(value: number): number {
|
||||
if (Number.isNaN(value)) return 0
|
||||
return Math.min(100, Math.max(0, value))
|
||||
}
|
||||
|
||||
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
@@ -67,7 +81,7 @@ function resolveFooter(item: KpiStatItem): ReactNode {
|
||||
if (item.footer) return item.footer
|
||||
if (typeof item.hint === 'string') {
|
||||
return (
|
||||
<Badge variant="outline" size="sm">
|
||||
<Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
|
||||
{item.hint}
|
||||
</Badge>
|
||||
)
|
||||
@@ -81,30 +95,53 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||
const valueVariant = item.variant ?? 'default'
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
<div className="@container relative z-10 flex h-full min-w-0 items-start gap-3">
|
||||
{item.icon ? (
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-hidden="true"
|
||||
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
className={cn('size-10.5 shrink-0', item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
>
|
||||
{item.icon}
|
||||
</IconTile>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
|
||||
{footer ? <div className="shrink-0">{footer}</div> : null}
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
|
||||
{item.label}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-2xl leading-none font-bold tabular-nums',
|
||||
'min-w-0 break-all text-2xl leading-none font-bold tabular-nums',
|
||||
VALUE_VARIANT_CLASS[valueVariant],
|
||||
)}
|
||||
>
|
||||
{item.value}
|
||||
</div>
|
||||
{item.progress !== undefined ? (
|
||||
<Progress
|
||||
value={clampProgress(item.progress)}
|
||||
className={cn(
|
||||
'mt-1.5 w-full gap-0 **:data-[slot=progress-track]:h-1.5',
|
||||
PROGRESS_TONE_CLASS[valueVariant],
|
||||
)}
|
||||
aria-label={
|
||||
typeof item.label === 'string'
|
||||
? `${item.label}: ${clampProgress(item.progress)}%`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{footer ? (
|
||||
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -116,7 +153,7 @@ function panelClassName(item: KpiStatItem, className?: string) {
|
||||
const selected = isSelected(item)
|
||||
|
||||
return cn(
|
||||
'relative isolate flex h-full flex-col',
|
||||
'relative isolate flex h-full min-w-0 flex-col',
|
||||
clickable &&
|
||||
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
|
||||
selected && 'ring-primary/30 bg-muted/30 ring-1',
|
||||
|
||||
@@ -12,7 +12,7 @@ interface OpsDashboardProps {
|
||||
/** Slot after KPI (QuickActionGrid). Preview: stats-12 · card-12 */
|
||||
afterKpi?: ReactNode
|
||||
charts: ReactNode
|
||||
queue: ReactNode
|
||||
queue?: ReactNode
|
||||
queueTitle?: string
|
||||
queueDescription?: string
|
||||
headerActions?: ReactNode
|
||||
@@ -31,10 +31,12 @@ function OpsDashboardSkeleton() {
|
||||
</header>
|
||||
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<div className="grid min-w-0 grid-cols-1 gap-4 @5xl:grid-cols-2">
|
||||
<Skeleton className="h-56 w-full rounded-xl" />
|
||||
<Skeleton className="h-56 w-full rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-48 w-full rounded-xl" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -76,15 +78,17 @@ export function OpsDashboard({
|
||||
{charts}
|
||||
</section>
|
||||
|
||||
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
|
||||
{queueDescription ? (
|
||||
<p className="text-muted-foreground max-w-prose text-sm">{queueDescription}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{queue}
|
||||
</section>
|
||||
{queue ? (
|
||||
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
|
||||
{queueDescription ? (
|
||||
<p className="text-muted-foreground max-w-prose text-sm">{queueDescription}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{queue}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,14 @@ import {
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
} 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 {
|
||||
StatusToggleGroup,
|
||||
type StatusToggleTone,
|
||||
} from '@/components/status-toggle-group'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
DataGrid,
|
||||
@@ -18,6 +22,7 @@ import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagina
|
||||
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 { flattenFilterConditions } from '@/components/reui/filters/filters-query'
|
||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||
import {
|
||||
Frame,
|
||||
@@ -29,13 +34,31 @@ import {
|
||||
} from '@/components/reui/frame'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
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 { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
|
||||
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 {
|
||||
FrameDataGrid,
|
||||
applyKitActionColumn,
|
||||
kitColumnPinning,
|
||||
kitDataGridTableClassNames,
|
||||
kitDataGridTableLayout,
|
||||
type DataGridColumnDef,
|
||||
@@ -46,6 +69,8 @@ export interface ResourcePageTab {
|
||||
id: string
|
||||
label: string
|
||||
count?: number
|
||||
icon?: LucideIcon
|
||||
tone?: StatusToggleTone
|
||||
}
|
||||
|
||||
type SimpleGridPassthrough<T extends object> = Pick<
|
||||
@@ -278,16 +303,39 @@ function ResourcePageFiltered<T extends object>({
|
||||
onRowClick,
|
||||
virtualization = false,
|
||||
height = 480,
|
||||
horizontalScroll = false,
|
||||
}: ResourcePageProps<T>) {
|
||||
const headerActions = primaryAction ?? actions
|
||||
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
|
||||
const activeTab = controlledTab ?? internalTab
|
||||
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 isQueryControlled = controlledQuery !== undefined
|
||||
const filterQuery = isQueryControlled ? controlledQuery : internalQuery
|
||||
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 [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
@@ -328,12 +376,15 @@ function ResourcePageFiltered<T extends object>({
|
||||
|
||||
const selectedCount = selectedIds.length
|
||||
|
||||
const lastColId = pinLastColumn ? (columns[columns.length - 1]?.id ?? '') : ''
|
||||
const enablePinning = Boolean(pinLastColumn && lastColId)
|
||||
const columnPinning = {
|
||||
start: [] as string[],
|
||||
end: enablePinning ? [lastColId] : [],
|
||||
}
|
||||
const tableColumns = useMemo(
|
||||
() => applyKitActionColumn(columns, { pinLastColumn }),
|
||||
[columns, pinLastColumn],
|
||||
)
|
||||
const { enablePinning, columnPinning } = kitColumnPinning({
|
||||
pinLastColumn,
|
||||
horizontalScroll,
|
||||
lastColId: tableColumns[tableColumns.length - 1]?.id ?? '',
|
||||
})
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setRowSelection({})
|
||||
@@ -342,7 +393,7 @@ function ResourcePageFiltered<T extends object>({
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
data: filteredData,
|
||||
columns,
|
||||
columns: tableColumns,
|
||||
getRowId: (row, index) => getRowId(row, index),
|
||||
state: {
|
||||
sorting,
|
||||
@@ -366,19 +417,38 @@ function ResourcePageFiltered<T extends object>({
|
||||
[onTabChange, resetPagination],
|
||||
)
|
||||
|
||||
const handleFiltersChange = useCallback(
|
||||
(next: FilterQuery) => {
|
||||
setFilterQuery(next)
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
if (!primaryTextField) return
|
||||
setFilterQuery(setFilterTextValue(filterQuery, primaryTextField.id, value))
|
||||
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(() => {
|
||||
onClearFilters?.()
|
||||
if (!isQueryControlled) setInternalQuery(createEmptyFilterQuery())
|
||||
if (!isQueryControlled) {
|
||||
setInternalQuery(
|
||||
primaryTextField
|
||||
? createTextFilterQuery(primaryTextField.id)
|
||||
: createEmptyFilterQuery(),
|
||||
)
|
||||
}
|
||||
resetPagination()
|
||||
}, [onClearFilters, isQueryControlled, resetPagination])
|
||||
}, [onClearFilters, isQueryControlled, primaryTextField, resetPagination])
|
||||
|
||||
const countedTabs = useMemo(
|
||||
() =>
|
||||
@@ -386,6 +456,8 @@ function ResourcePageFiltered<T extends object>({
|
||||
id: tab.id,
|
||||
label: tab.label,
|
||||
count: tabCounts[tab.id] ?? tab.count ?? 0,
|
||||
icon: tab.icon,
|
||||
tone: tab.tone,
|
||||
})),
|
||||
[tabs, tabCounts],
|
||||
)
|
||||
@@ -459,46 +531,64 @@ function ResourcePageFiltered<T extends object>({
|
||||
) : null}
|
||||
|
||||
<FramePanel className="p-0 shadow-none!">
|
||||
{countedTabs.length > 0 ? (
|
||||
<>
|
||||
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
|
||||
<CountedLineTabs
|
||||
tabs={countedTabs}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-2.5">
|
||||
<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
|
||||
query={extraQuery}
|
||||
fields={extraFilterFields}
|
||||
onQueryChange={handleFiltersChange}
|
||||
variant="advanced"
|
||||
advancedMode="popover"
|
||||
size="default"
|
||||
labels={FILTERS_LABELS_RU}
|
||||
operatorLabels={FILTERS_OPERATOR_LABELS_RU}
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
Фильтры
|
||||
{extraActiveCount > 0 ? (
|
||||
<Badge size="xs" variant="secondary" radius="full">
|
||||
{extraActiveCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{!showSearch && !showFiltersPopover && countedTabs.length === 0 ? (
|
||||
<div />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{countedTabs.length > 0 ? (
|
||||
<StatusToggleGroup
|
||||
items={countedTabs}
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
aria-label="Фильтр по статусу"
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-2.5">
|
||||
{showFilters ? (
|
||||
<Filters
|
||||
query={filterQuery}
|
||||
fields={filterFields}
|
||||
onQueryChange={handleFiltersChange}
|
||||
size="default"
|
||||
labels={FILTERS_LABELS_RU}
|
||||
operatorLabels={FILTERS_OPERATOR_LABELS_RU}
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
Фильтры
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
) : null}
|
||||
{toolbarExtra}
|
||||
{selectedCount > 0 ? (
|
||||
<Badge size="sm" variant="secondary">
|
||||
{selectedCount} выбрано
|
||||
</Badge>
|
||||
) : null}
|
||||
{showFilters ? (
|
||||
{hasDirtyFilters ? (
|
||||
<Button type="button" variant="outline" onClick={handleClear}>
|
||||
<FilterXIcon className="size-4" aria-hidden="true" />
|
||||
Сбросить
|
||||
|
||||
@@ -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 }
|
||||
@@ -1,677 +0,0 @@
|
||||
import { expandRecurrence } from "@/components/reui/event-calendar/event-calendar-recurrence"
|
||||
import type {
|
||||
CalendarEvent,
|
||||
CalendarView,
|
||||
EventCalendarDateRange,
|
||||
EventCalendarOccurrence,
|
||||
EventCalendarOffDaysConfig,
|
||||
EventCalendarResource,
|
||||
EventCalendarSegment,
|
||||
} from "@/components/reui/event-calendar/event-calendar-types"
|
||||
import { TZDate } from "@date-fns/tz"
|
||||
import {
|
||||
addDays,
|
||||
addMonths,
|
||||
addWeeks,
|
||||
differenceInCalendarDays,
|
||||
differenceInMinutes,
|
||||
format,
|
||||
startOfDay,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
} from "date-fns"
|
||||
|
||||
type WeekStartsOn = 0 | 1 | 2 | 3 | 4 | 5 | 6
|
||||
|
||||
/** Packing-effective minimum in minutes so tiny events do not stack invisibly. */
|
||||
const MIN_PACK_SLOT = 30
|
||||
|
||||
/** The instant re-expressed in the display time zone (TZDate extends Date). */
|
||||
function toZoned(date: Date, timeZone: string): TZDate {
|
||||
return new TZDate(date.getTime(), timeZone)
|
||||
}
|
||||
|
||||
/** Zoned midnight of the day containing the instant. */
|
||||
function zonedStartOfDay(date: Date, timeZone: string): TZDate {
|
||||
return startOfDay(toZoned(date, timeZone))
|
||||
}
|
||||
|
||||
/** Stable per-day key in the display time zone. */
|
||||
function getDayKey(date: Date, timeZone: string): string {
|
||||
return format(toZoned(date, timeZone), "yyyy-MM-dd")
|
||||
}
|
||||
|
||||
/** Day length in minutes; 1380/1500 on DST transition days - never assume 1440. */
|
||||
function getDayTotalMinutes(dayStart: Date, timeZone: string): number {
|
||||
const next = zonedStartOfDay(
|
||||
addDays(toZoned(dayStart, timeZone), 1),
|
||||
timeZone
|
||||
)
|
||||
return differenceInMinutes(next, dayStart)
|
||||
}
|
||||
|
||||
function snapMinutes(minutes: number, snap: number): number {
|
||||
return Math.round(minutes / snap) * snap
|
||||
}
|
||||
|
||||
interface ViewRangeOptions {
|
||||
timeZone: string
|
||||
weekStartsOn: WeekStartsOn
|
||||
dayCount: number
|
||||
agendaDayCount: number
|
||||
fixedWeeks: boolean
|
||||
}
|
||||
|
||||
interface ViewDateRanges {
|
||||
visibleRange: EventCalendarDateRange
|
||||
activeRange: EventCalendarDateRange
|
||||
}
|
||||
|
||||
function getViewDateRange(
|
||||
view: CalendarView,
|
||||
date: Date,
|
||||
opts: ViewRangeOptions
|
||||
): ViewDateRanges {
|
||||
const { timeZone, weekStartsOn, dayCount, agendaDayCount, fixedWeeks } = opts
|
||||
const zoned = toZoned(date, timeZone)
|
||||
|
||||
if (view === "month") {
|
||||
const activeStart = startOfMonth(zoned)
|
||||
const activeEnd = startOfMonth(addMonths(zoned, 1))
|
||||
const visibleStart = startOfWeek(activeStart, { weekStartsOn })
|
||||
let visibleEnd: Date
|
||||
if (fixedWeeks) {
|
||||
visibleEnd = addDays(visibleStart, 42)
|
||||
} else {
|
||||
visibleEnd = startOfWeek(addDays(activeEnd, -1), { weekStartsOn })
|
||||
visibleEnd = addWeeks(visibleEnd, 1)
|
||||
}
|
||||
return {
|
||||
activeRange: { start: activeStart, end: activeEnd },
|
||||
visibleRange: { start: visibleStart, end: visibleEnd },
|
||||
}
|
||||
}
|
||||
|
||||
if (view === "week") {
|
||||
const start = startOfWeek(zoned, { weekStartsOn })
|
||||
const range = { start, end: addWeeks(start, 1) }
|
||||
return { activeRange: range, visibleRange: range }
|
||||
}
|
||||
|
||||
if (view === "day" || view === "resource") {
|
||||
const start = startOfDay(zoned)
|
||||
const range = { start, end: addDays(start, 1) }
|
||||
return { activeRange: range, visibleRange: range }
|
||||
}
|
||||
|
||||
if (view === "days") {
|
||||
const start = startOfDay(zoned)
|
||||
const range = { start, end: addDays(start, Math.max(1, dayCount)) }
|
||||
return { activeRange: range, visibleRange: range }
|
||||
}
|
||||
|
||||
// agenda
|
||||
const start = startOfDay(zoned)
|
||||
const range = { start, end: addDays(start, Math.max(1, agendaDayCount)) }
|
||||
return { activeRange: range, visibleRange: range }
|
||||
}
|
||||
|
||||
/** Day of month of the last day of the month containing the zoned date. */
|
||||
function lastDayOfZonedMonth(date: Date): number {
|
||||
return addDays(startOfMonth(addMonths(date, 1)), -1).getDate()
|
||||
}
|
||||
|
||||
/** The anchor date stepped one period forward or backward for the view. */
|
||||
function stepDate(
|
||||
view: CalendarView,
|
||||
date: Date,
|
||||
direction: 1 | -1,
|
||||
opts: Pick<ViewRangeOptions, "timeZone" | "dayCount" | "agendaDayCount">
|
||||
): Date {
|
||||
const zoned = toZoned(date, opts.timeZone)
|
||||
if (view === "month") {
|
||||
const stepped = addMonths(zoned, direction)
|
||||
// addMonths clamps the day down into a shorter month and never restores
|
||||
// it, so next-then-prev from the 31st would leave the anchor on the 28th.
|
||||
// Sticking a month end to the target month's end keeps stepping
|
||||
// invertible, which matters because the anchor is what day and week view
|
||||
// open on after a month navigation.
|
||||
if (zoned.getDate() !== lastDayOfZonedMonth(zoned)) return stepped
|
||||
return addDays(stepped, lastDayOfZonedMonth(stepped) - stepped.getDate())
|
||||
}
|
||||
if (view === "week") return addWeeks(zoned, direction)
|
||||
if (view === "day" || view === "resource") return addDays(zoned, direction)
|
||||
if (view === "days")
|
||||
return addDays(zoned, direction * Math.max(1, opts.dayCount))
|
||||
return addDays(zoned, direction * Math.max(1, opts.agendaDayCount))
|
||||
}
|
||||
|
||||
function rangesIntersect(
|
||||
a: EventCalendarDateRange,
|
||||
b: EventCalendarDateRange
|
||||
): boolean {
|
||||
return a.start < b.end && a.end > b.start
|
||||
}
|
||||
|
||||
function eventsOverlap(
|
||||
a: { start: Date; end: Date },
|
||||
b: { start: Date; end: Date }
|
||||
): boolean {
|
||||
return a.start < b.end && a.end > b.start
|
||||
}
|
||||
|
||||
/**
|
||||
* The one canonical multi-day segmentation. Splits an occurrence into per-day
|
||||
* segments clamped to the range. Rules (unit-tested in M1): exclusive end - an
|
||||
* event ending exactly at zoned midnight emits NO segment for that day;
|
||||
* zero-duration events emit one min-height segment; allDay occurrences walk
|
||||
* the same absolute instants as timed ones and only drop startMin/endMin, so
|
||||
* their bounds have to already BE display-zone midnights (see
|
||||
* CalendarEvent.allDay) or the bar paints on the wrong days.
|
||||
*/
|
||||
function segmentOccurrence<TData>(
|
||||
occurrence: EventCalendarOccurrence<TData>,
|
||||
range: EventCalendarDateRange,
|
||||
timeZone: string
|
||||
): EventCalendarSegment<TData>[] {
|
||||
const occStart = occurrence.start
|
||||
const occEnd = occurrence.end
|
||||
const isZeroLength = occEnd.getTime() === occStart.getTime()
|
||||
|
||||
const clampStart = occStart > range.start ? occStart : range.start
|
||||
const clampEnd = occEnd < range.end ? occEnd : range.end
|
||||
if (clampEnd < clampStart) return []
|
||||
if (clampEnd.getTime() === clampStart.getTime() && !isZeroLength) return []
|
||||
|
||||
const segments: EventCalendarSegment<TData>[] = []
|
||||
let cursor = zonedStartOfDay(clampStart, timeZone)
|
||||
|
||||
while (cursor < clampEnd || (isZeroLength && segments.length === 0)) {
|
||||
const next = zonedStartOfDay(
|
||||
addDays(toZoned(cursor, timeZone), 1),
|
||||
timeZone
|
||||
)
|
||||
const segStart = clampStart > cursor ? clampStart : cursor
|
||||
const segEnd = clampEnd < next ? clampEnd : next
|
||||
|
||||
const emptySeg = segEnd.getTime() <= segStart.getTime()
|
||||
if (!emptySeg || isZeroLength) {
|
||||
const isStart = segStart.getTime() === occStart.getTime()
|
||||
const isEnd = segEnd.getTime() === occEnd.getTime()
|
||||
segments.push({
|
||||
occurrence,
|
||||
day: cursor,
|
||||
isStart,
|
||||
isEnd,
|
||||
continuesBefore: !isStart,
|
||||
continuesAfter: !isEnd,
|
||||
startMin: occurrence.allDay
|
||||
? undefined
|
||||
: differenceInMinutes(segStart, cursor),
|
||||
endMin: occurrence.allDay
|
||||
? undefined
|
||||
: Math.max(
|
||||
differenceInMinutes(segEnd, cursor),
|
||||
differenceInMinutes(segStart, cursor)
|
||||
),
|
||||
})
|
||||
}
|
||||
if (isZeroLength) break
|
||||
cursor = next
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
/** True when the occurrence should render as a bar (all-day row / month lanes). */
|
||||
function isBarOccurrence(
|
||||
occurrence: EventCalendarOccurrence,
|
||||
timeZone?: string
|
||||
): boolean {
|
||||
return occurrence.allDay || spansMultipleDays(occurrence, timeZone)
|
||||
}
|
||||
|
||||
function spansMultipleDays(
|
||||
occ: { start: Date; end: Date },
|
||||
timeZone?: string
|
||||
): boolean {
|
||||
// An event ending exactly at the next midnight is still single-day
|
||||
// (exclusive end), so compare against a strictly-later instant. The
|
||||
// yardstick is the length of the day the event starts on, never a flat 24h:
|
||||
// a fall-back day is 25h long, and a 00:00-to-00:00 shift on it is still one
|
||||
// calendar day that belongs in the hour track, not in the all-day row.
|
||||
// Without a display zone the dates answer in their own frame (TZDate) or in
|
||||
// the host zone.
|
||||
const dayStart = startOfDay(
|
||||
timeZone ? toZoned(occ.start, timeZone) : occ.start
|
||||
)
|
||||
const nextDayStart = startOfDay(addDays(dayStart, 1))
|
||||
return (
|
||||
occ.end.getTime() - occ.start.getTime() >
|
||||
nextDayStart.getTime() - dayStart.getTime()
|
||||
)
|
||||
}
|
||||
|
||||
interface PackedPosition {
|
||||
column: number
|
||||
columnCount: number
|
||||
columnSpan: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Google-style overlap packing for one day's timed segments.
|
||||
* Mutates column/columnCount/columnSpan on the segments, in place.
|
||||
* z resolution happens at render: event.zIndex verbatim, else 10 + column.
|
||||
*/
|
||||
function packTimedSegments<TData>(
|
||||
segments: EventCalendarSegment<TData>[]
|
||||
): void {
|
||||
if (segments.length === 0) return
|
||||
|
||||
type Working = {
|
||||
seg: EventCalendarSegment<TData>
|
||||
startMin: number
|
||||
effEnd: number
|
||||
}
|
||||
|
||||
const items: Working[] = segments
|
||||
.map((seg) => {
|
||||
const startMin = seg.startMin ?? 0
|
||||
const endMin = seg.endMin ?? startMin
|
||||
return {
|
||||
seg,
|
||||
startMin,
|
||||
effEnd: Math.max(endMin, startMin + MIN_PACK_SLOT),
|
||||
}
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.startMin - b.startMin ||
|
||||
b.effEnd - b.startMin - (a.effEnd - a.startMin) ||
|
||||
a.seg.occurrence.key.localeCompare(b.seg.occurrence.key)
|
||||
)
|
||||
|
||||
// Sweep into connected clusters
|
||||
const clusters: Working[][] = []
|
||||
let current: Working[] = []
|
||||
let clusterEnd = -Infinity
|
||||
for (const item of items) {
|
||||
if (item.startMin >= clusterEnd) {
|
||||
current = []
|
||||
clusters.push(current)
|
||||
clusterEnd = -Infinity
|
||||
}
|
||||
current.push(item)
|
||||
clusterEnd = Math.max(clusterEnd, item.effEnd)
|
||||
}
|
||||
|
||||
for (const cluster of clusters) {
|
||||
// Greedy column assignment
|
||||
const colEnds: number[] = []
|
||||
const byColumn = new Map<number, Working[]>()
|
||||
for (const item of cluster) {
|
||||
let col = colEnds.findIndex((end) => end <= item.startMin)
|
||||
if (col === -1) {
|
||||
col = colEnds.length
|
||||
colEnds.push(0)
|
||||
}
|
||||
colEnds[col] = item.effEnd
|
||||
item.seg.column = col
|
||||
const bucket = byColumn.get(col) ?? []
|
||||
bucket.push(item)
|
||||
byColumn.set(col, bucket)
|
||||
}
|
||||
const columnCount = colEnds.length
|
||||
|
||||
// Partial-overlap expansion: widen rightward into free columns
|
||||
for (const item of cluster) {
|
||||
let span = 1
|
||||
const col = item.seg.column ?? 0
|
||||
while (col + span < columnCount) {
|
||||
const occupants = byColumn.get(col + span) ?? []
|
||||
const blocked = occupants.some(
|
||||
(o) => o.startMin < item.effEnd && o.effEnd > item.startMin
|
||||
)
|
||||
if (blocked) break
|
||||
span++
|
||||
}
|
||||
item.seg.columnCount = columnCount
|
||||
item.seg.columnSpan = span
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Greedy lane packing for bar segments within one week row (7 columns).
|
||||
* Mutates lane/rowIndex/colStart/colSpan on the segments, in place.
|
||||
*/
|
||||
/**
|
||||
* Build the laned month-row bars for one week: consecutive-day segments of
|
||||
* the same occurrence merge into ONE bar (colStart -> colSpan) stacked into
|
||||
* lanes. Returns NEW segment objects - the shared per-day segments (also
|
||||
* rendered by the all-day rows and day cells) must stay pristine: mutating
|
||||
* their isEnd/continues flags gave the first-day chip a whole-bar shape and
|
||||
* a bogus end resize handle in the week all-day row, where dragging it
|
||||
* collapsed the event to a single day.
|
||||
*/
|
||||
function packWeekRowLanes<TData>(
|
||||
segments: EventCalendarSegment<TData>[],
|
||||
rowIndex: number,
|
||||
rowStart: Date,
|
||||
timeZone: string
|
||||
): EventCalendarSegment<TData>[] {
|
||||
type Bar = {
|
||||
seg: EventCalendarSegment<TData>
|
||||
colStart: number
|
||||
colSpan: number
|
||||
isStart: boolean
|
||||
isEnd: boolean
|
||||
lane: number
|
||||
}
|
||||
|
||||
const bars: Bar[] = segments.map((seg) => {
|
||||
const dayIndex = Math.round(
|
||||
(zonedStartOfDay(seg.day, timeZone).getTime() -
|
||||
zonedStartOfDay(rowStart, timeZone).getTime()) /
|
||||
(24 * 60 * 60 * 1000)
|
||||
)
|
||||
return {
|
||||
seg,
|
||||
colStart: Math.max(0, Math.min(6, dayIndex)),
|
||||
colSpan: 1,
|
||||
isStart: seg.isStart,
|
||||
isEnd: seg.isEnd,
|
||||
lane: 0,
|
||||
}
|
||||
})
|
||||
|
||||
// Merge consecutive-day segments of the same occurrence into one bar per row
|
||||
const merged = new Map<string, Bar>()
|
||||
for (const bar of bars) {
|
||||
const key = bar.seg.occurrence.key
|
||||
const existing = merged.get(key)
|
||||
if (existing) {
|
||||
const start = Math.min(existing.colStart, bar.colStart)
|
||||
const end = Math.max(
|
||||
existing.colStart + existing.colSpan,
|
||||
bar.colStart + bar.colSpan
|
||||
)
|
||||
existing.colStart = start
|
||||
existing.colSpan = end - start
|
||||
existing.isStart = existing.isStart || bar.isStart
|
||||
existing.isEnd = existing.isEnd || bar.isEnd
|
||||
} else {
|
||||
merged.set(key, bar)
|
||||
}
|
||||
}
|
||||
|
||||
const rowBars = Array.from(merged.values()).sort(
|
||||
(a, b) =>
|
||||
a.colStart - b.colStart ||
|
||||
b.colSpan - a.colSpan ||
|
||||
a.seg.occurrence.key.localeCompare(b.seg.occurrence.key)
|
||||
)
|
||||
|
||||
const lanes: boolean[][] = []
|
||||
for (const bar of rowBars) {
|
||||
let lane = 0
|
||||
for (;;) {
|
||||
lanes[lane] ??= new Array(7).fill(false)
|
||||
const row = lanes[lane]
|
||||
let free = true
|
||||
for (let c = bar.colStart; c < bar.colStart + bar.colSpan; c++) {
|
||||
if (row[c]) {
|
||||
free = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (free) break
|
||||
lane++
|
||||
}
|
||||
for (let c = bar.colStart; c < bar.colStart + bar.colSpan; c++) {
|
||||
lanes[lane][c] = true
|
||||
}
|
||||
bar.lane = lane
|
||||
}
|
||||
|
||||
return rowBars.map((bar) => ({
|
||||
...bar.seg,
|
||||
isStart: bar.isStart,
|
||||
isEnd: bar.isEnd,
|
||||
continuesBefore: !bar.isStart,
|
||||
continuesAfter: !bar.isEnd,
|
||||
lane: bar.lane,
|
||||
rowIndex,
|
||||
colStart: bar.colStart,
|
||||
colSpan: bar.colSpan,
|
||||
}))
|
||||
}
|
||||
|
||||
interface EventCalendarDayBucket<TData = unknown> {
|
||||
allDay: EventCalendarSegment<TData>[]
|
||||
timed: EventCalendarSegment<TData>[]
|
||||
}
|
||||
|
||||
interface EventCalendarWeekRow<TData = unknown> {
|
||||
rowIndex: number
|
||||
rowStart: Date
|
||||
/** Laned bar segments (one per occurrence per row). */
|
||||
bars: EventCalendarSegment<TData>[]
|
||||
}
|
||||
|
||||
interface EventCalendarIndex<TData = unknown> {
|
||||
occurrences: EventCalendarOccurrence<TData>[]
|
||||
byDay: Map<string, EventCalendarDayBucket<TData>>
|
||||
weekRows: EventCalendarWeekRow<TData>[]
|
||||
}
|
||||
|
||||
interface BuildIndexOptions<TData> {
|
||||
timeZone: string
|
||||
weekStartsOn: WeekStartsOn
|
||||
eventOrder?: (
|
||||
a: EventCalendarOccurrence<TData>,
|
||||
b: EventCalendarOccurrence<TData>
|
||||
) => number
|
||||
getOccurrences?: (
|
||||
event: CalendarEvent<TData>,
|
||||
range: EventCalendarDateRange,
|
||||
ctx: { timeZone: string }
|
||||
) => Array<{ start: Date; end: Date }> | null
|
||||
}
|
||||
|
||||
function defaultEventOrder(
|
||||
a: EventCalendarOccurrence,
|
||||
b: EventCalendarOccurrence
|
||||
): number {
|
||||
return (
|
||||
a.start.getTime() - b.start.getTime() ||
|
||||
b.end.getTime() -
|
||||
b.start.getTime() -
|
||||
(a.end.getTime() - a.start.getTime()) ||
|
||||
a.key.localeCompare(b.key)
|
||||
)
|
||||
}
|
||||
|
||||
function buildEventIndex<TData>(
|
||||
events: CalendarEvent<TData>[],
|
||||
visibleRange: EventCalendarDateRange,
|
||||
opts: BuildIndexOptions<TData>
|
||||
): EventCalendarIndex<TData> {
|
||||
const { timeZone, weekStartsOn } = opts
|
||||
const order = opts.eventOrder ?? defaultEventOrder
|
||||
|
||||
// RECURRENCE-ID override replacement: an event carrying recurringEventId +
|
||||
// originalStart is an edited single occurrence of that series. The parent's
|
||||
// expansion drops the replaced instant; the override renders as its own
|
||||
// occurrence through the normal path below.
|
||||
const overrideTimes = new Map<string, Set<number>>()
|
||||
for (const event of events) {
|
||||
if (!event.recurringEventId || !event.originalStart) continue
|
||||
let times = overrideTimes.get(event.recurringEventId)
|
||||
if (!times) overrideTimes.set(event.recurringEventId, (times = new Set()))
|
||||
times.add(event.originalStart.getTime())
|
||||
}
|
||||
|
||||
const occurrences: EventCalendarOccurrence<TData>[] = []
|
||||
for (const event of events) {
|
||||
const replaced = overrideTimes.get(event.id)
|
||||
const custom = opts.getOccurrences?.(event, visibleRange, { timeZone })
|
||||
if (custom) {
|
||||
custom.forEach((occ, i) => {
|
||||
if (replaced?.has(occ.start.getTime())) return
|
||||
if (!rangesIntersect({ start: occ.start, end: occ.end }, visibleRange))
|
||||
return
|
||||
occurrences.push({
|
||||
key: `${event.id}::${occ.start.toISOString()}`,
|
||||
eventId: event.id,
|
||||
event,
|
||||
start: occ.start,
|
||||
end: occ.end,
|
||||
allDay: event.allDay ?? false,
|
||||
isRecurring: true,
|
||||
recurrenceIndex: i,
|
||||
})
|
||||
})
|
||||
continue
|
||||
}
|
||||
const expanded = expandRecurrence(event, visibleRange, { timeZone })
|
||||
occurrences.push(
|
||||
...(replaced
|
||||
? expanded.filter((occ) => !replaced.has(occ.start.getTime()))
|
||||
: expanded)
|
||||
)
|
||||
}
|
||||
occurrences.sort(order)
|
||||
|
||||
const byDay = new Map<string, EventCalendarDayBucket<TData>>()
|
||||
const barSegmentsByRow = new Map<number, EventCalendarSegment<TData>[]>()
|
||||
const firstRowStart = startOfWeek(toZoned(visibleRange.start, timeZone), {
|
||||
weekStartsOn,
|
||||
})
|
||||
|
||||
for (const occurrence of occurrences) {
|
||||
const segments = segmentOccurrence(occurrence, visibleRange, timeZone)
|
||||
const bar = isBarOccurrence(occurrence, timeZone)
|
||||
for (const seg of segments) {
|
||||
const key = getDayKey(seg.day, timeZone)
|
||||
let bucket = byDay.get(key)
|
||||
if (!bucket) {
|
||||
bucket = { allDay: [], timed: [] }
|
||||
byDay.set(key, bucket)
|
||||
}
|
||||
if (bar) {
|
||||
bucket.allDay.push(seg)
|
||||
// calendar-day math, not a fixed 168h divisor: DST transition weeks
|
||||
// are 167/169h long and the fixed divisor mis-buckets every later
|
||||
// Sunday one row early (which then clamps into the wrong column)
|
||||
const rowIndex = Math.floor(
|
||||
differenceInCalendarDays(toZoned(seg.day, timeZone), firstRowStart) /
|
||||
7
|
||||
)
|
||||
const rowBucket = barSegmentsByRow.get(rowIndex) ?? []
|
||||
rowBucket.push(seg)
|
||||
barSegmentsByRow.set(rowIndex, rowBucket)
|
||||
} else {
|
||||
bucket.timed.push(seg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const bucket of byDay.values()) {
|
||||
packTimedSegments(bucket.timed)
|
||||
}
|
||||
|
||||
const weekRows: EventCalendarWeekRow<TData>[] = []
|
||||
for (const [rowIndex, segs] of barSegmentsByRow) {
|
||||
const rowStart = addWeeks(firstRowStart, rowIndex)
|
||||
weekRows.push({
|
||||
rowIndex,
|
||||
rowStart,
|
||||
bars: packWeekRowLanes(segs, rowIndex, rowStart, timeZone),
|
||||
})
|
||||
}
|
||||
weekRows.sort((a, b) => a.rowIndex - b.rowIndex)
|
||||
|
||||
return { occurrences, byDay, weekRows }
|
||||
}
|
||||
|
||||
/** Cache key for index memoization; cheap string compare. */
|
||||
function getRangeKey(range: EventCalendarDateRange): string {
|
||||
return `${range.start.getTime()}-${range.end.getTime()}`
|
||||
}
|
||||
|
||||
/** Depth-first flatten of the resource tree (parents included). */
|
||||
function flattenResources(
|
||||
resources: EventCalendarResource[],
|
||||
depth = 0
|
||||
): Array<{ resource: EventCalendarResource; depth: number }> {
|
||||
const rows: Array<{ resource: EventCalendarResource; depth: number }> = []
|
||||
for (const resource of resources) {
|
||||
rows.push({ resource, depth })
|
||||
if (resource.children?.length) {
|
||||
rows.push(...flattenResources(resource.children, depth + 1))
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const DEFAULT_WEEKEND_DAYS = [0, 6]
|
||||
|
||||
/**
|
||||
* Resolves whether a day is an off day (non-working) in the display zone.
|
||||
* Callers pass the calendar's own weekendDays so the shading cannot contradict
|
||||
* the weekend the rest of the calendar renders; an explicit offDays.weekendDays
|
||||
* still wins over it.
|
||||
*/
|
||||
function resolveOffDay(
|
||||
day: Date,
|
||||
timeZone: string,
|
||||
config: boolean | EventCalendarOffDaysConfig | undefined,
|
||||
defaultWeekendDays?: number[]
|
||||
): boolean {
|
||||
if (!config) return false
|
||||
const resolved: EventCalendarOffDaysConfig = config === true ? {} : config
|
||||
const weekendDays =
|
||||
resolved.weekendDays ?? defaultWeekendDays ?? DEFAULT_WEEKEND_DAYS
|
||||
const zoned = toZoned(day, timeZone)
|
||||
if (weekendDays.includes(zoned.getDay())) return true
|
||||
if (resolved.dates?.length) {
|
||||
const key = getDayKey(day, timeZone)
|
||||
if (resolved.dates.some((date) => getDayKey(date, timeZone) === key)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return resolved.isOffDay?.(day) ?? false
|
||||
}
|
||||
|
||||
export {
|
||||
buildEventIndex,
|
||||
defaultEventOrder,
|
||||
eventsOverlap,
|
||||
flattenResources,
|
||||
getDayKey,
|
||||
getDayTotalMinutes,
|
||||
getRangeKey,
|
||||
getViewDateRange,
|
||||
isBarOccurrence,
|
||||
MIN_PACK_SLOT,
|
||||
packTimedSegments,
|
||||
packWeekRowLanes,
|
||||
rangesIntersect,
|
||||
resolveOffDay,
|
||||
segmentOccurrence,
|
||||
snapMinutes,
|
||||
spansMultipleDays,
|
||||
stepDate,
|
||||
toZoned,
|
||||
zonedStartOfDay,
|
||||
}
|
||||
export type {
|
||||
BuildIndexOptions,
|
||||
EventCalendarDayBucket,
|
||||
EventCalendarIndex,
|
||||
EventCalendarWeekRow,
|
||||
ViewDateRanges,
|
||||
ViewRangeOptions,
|
||||
WeekStartsOn,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,611 +0,0 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import {
|
||||
useEventCalendarNavigation,
|
||||
useEventCalendarSettings,
|
||||
useEventCalendarView,
|
||||
useEventCalendarViewConfig,
|
||||
} from "@/components/reui/event-calendar/event-calendar"
|
||||
import { toZoned } from "@/components/reui/event-calendar/event-calendar-lib"
|
||||
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 { addDays, format } from "date-fns"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Calendar } from "@evobgp/ui/components/calendar"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@evobgp/ui/components/tooltip"
|
||||
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon, CalendarIcon } from "lucide-react"
|
||||
|
||||
/** Configured nav button variant/size (viewConfig.navButtonVariant/Size)
|
||||
* plus the shared classNames.navButton hook, merged on every nav button. */
|
||||
function useNavButtonProps(): {
|
||||
variant: "ghost" | "outline" | "secondary" | "default"
|
||||
size: "sm" | "default"
|
||||
iconSize: "icon-sm" | "icon"
|
||||
className: string | undefined
|
||||
} {
|
||||
const viewConfig = useEventCalendarViewConfig()
|
||||
return {
|
||||
variant: viewConfig.navButtonVariant,
|
||||
size: viewConfig.navButtonSize,
|
||||
iconSize: viewConfig.navButtonSize === "sm" ? "icon-sm" : "icon",
|
||||
className: viewConfig.classNames?.navButton,
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolved nav tooltip policy (viewConfig.navTooltips + classNames.navTooltip). */
|
||||
function useNavTooltipConfig(): {
|
||||
disabled: boolean
|
||||
side: "top" | "bottom" | "left" | "right"
|
||||
delay: number
|
||||
closeDelay: number
|
||||
timeout: number
|
||||
className: string | undefined
|
||||
} {
|
||||
const viewConfig = useEventCalendarViewConfig()
|
||||
const config =
|
||||
viewConfig.navTooltips === false ? undefined : viewConfig.navTooltips
|
||||
return {
|
||||
disabled: viewConfig.navTooltips === false,
|
||||
// the nav sits at the top of the calendar, so tooltips open upward by
|
||||
// default (away from the grid); collision flipping still drops them below
|
||||
// when there is no room above
|
||||
side: config?.side ?? "top",
|
||||
delay: config?.delay ?? 600,
|
||||
closeDelay: config?.closeDelay ?? 0,
|
||||
timeout: config?.timeout ?? 300,
|
||||
className: viewConfig.classNames?.navTooltip,
|
||||
}
|
||||
}
|
||||
|
||||
type NavButtonProps = Omit<useRender.ComponentProps<"button">, "children"> & {
|
||||
children?: ReactNode
|
||||
/**
|
||||
* Tooltip policy (the part that usually goes wrong on clickable elements):
|
||||
* tooltips appear ONLY on hover or keyboard focus-visible - a pointer click
|
||||
* never re-triggers them. Buttons that open overlays (the view switcher)
|
||||
* use a hover-only tooltip that is force-closed while the overlay is up and
|
||||
* ignores focus, so nothing flashes when focus returns after selection.
|
||||
* Icon-only buttons default to their accessible label; Today defaults to
|
||||
* the actual current date (info the label doesn't carry). Pass null to
|
||||
* disable one, or any node to override; viewConfig.navTooltips=false turns
|
||||
* them all off (its object form tunes side/delay/closeDelay/timeout).
|
||||
*/
|
||||
tooltip?: ReactNode | null
|
||||
}
|
||||
|
||||
/** Hover/focus-visible tooltip wrapper; renders the bare button when disabled
|
||||
* (per-button content=null or viewConfig.navTooltips=false). */
|
||||
function NavTooltip({
|
||||
content,
|
||||
children,
|
||||
}: {
|
||||
content: ReactNode | null
|
||||
children: React.ReactElement
|
||||
}) {
|
||||
const tooltips = useNavTooltipConfig()
|
||||
if (tooltips.disabled || content === null || content === undefined)
|
||||
return children
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={children} />
|
||||
<TooltipContent side={tooltips.side} className={tooltips.className}>
|
||||
{content}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function EventCalendarNavToday({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
tooltip,
|
||||
...props
|
||||
}: NavButtonProps) {
|
||||
const { today, isToday } = useEventCalendarNavigation()
|
||||
const settings = useEventCalendarSettings()
|
||||
const nav = useNavButtonProps()
|
||||
// display-zone "today", like every other today derivation in the calendar
|
||||
// (a system-zone new Date() can name a different day than Today opens)
|
||||
const defaultTooltip = format(
|
||||
toZoned(new Date(), settings.timeZone),
|
||||
settings.i18n.formats.dayTitle,
|
||||
{ locale: settings.locale }
|
||||
)
|
||||
return (
|
||||
<NavTooltip content={tooltip === undefined ? defaultTooltip : tooltip}>
|
||||
<Button
|
||||
variant={nav.variant}
|
||||
size={nav.size}
|
||||
data-slot="event-calendar-nav-today"
|
||||
data-active={isToday || undefined}
|
||||
className={cn(nav.className, className)}
|
||||
onClick={today}
|
||||
render={render}
|
||||
{...props}
|
||||
>
|
||||
{children ?? settings.i18n.labels.today}
|
||||
</Button>
|
||||
</NavTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function EventCalendarNavPrev({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
tooltip,
|
||||
...props
|
||||
}: NavButtonProps) {
|
||||
const { prev } = useEventCalendarNavigation()
|
||||
const settings = useEventCalendarSettings()
|
||||
const nav = useNavButtonProps()
|
||||
return (
|
||||
<NavTooltip
|
||||
content={tooltip === undefined ? settings.i18n.labels.previous : tooltip}
|
||||
>
|
||||
<Button
|
||||
variant={nav.variant}
|
||||
size={nav.iconSize}
|
||||
data-slot="event-calendar-nav-prev"
|
||||
aria-label={settings.i18n.labels.previous}
|
||||
className={cn(nav.className, className)}
|
||||
onClick={prev}
|
||||
render={render}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<ChevronLeftIcon className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
</NavTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function EventCalendarNavNext({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
tooltip,
|
||||
...props
|
||||
}: NavButtonProps) {
|
||||
const { next } = useEventCalendarNavigation()
|
||||
const settings = useEventCalendarSettings()
|
||||
const nav = useNavButtonProps()
|
||||
return (
|
||||
<NavTooltip
|
||||
content={tooltip === undefined ? settings.i18n.labels.next : tooltip}
|
||||
>
|
||||
<Button
|
||||
variant={nav.variant}
|
||||
size={nav.iconSize}
|
||||
data-slot="event-calendar-nav-next"
|
||||
aria-label={settings.i18n.labels.next}
|
||||
className={cn(nav.className, className)}
|
||||
onClick={next}
|
||||
render={render}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<ChevronRightIcon className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
</NavTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
interface EventCalendarTitleProps extends useRender.ComponentProps<"div"> {
|
||||
format?: (ctx: { title: string }) => ReactNode
|
||||
}
|
||||
|
||||
function EventCalendarTitle({
|
||||
className,
|
||||
render,
|
||||
format: formatTitle,
|
||||
...props
|
||||
}: EventCalendarTitleProps) {
|
||||
const { title } = useEventCalendarNavigation()
|
||||
const viewConfig = useEventCalendarViewConfig()
|
||||
const defaultProps = {
|
||||
"data-slot": "event-calendar-title",
|
||||
"aria-live": "polite" as const,
|
||||
className: cn(
|
||||
"min-w-0 truncate text-sm font-semibold",
|
||||
viewConfig.classNames?.title,
|
||||
className
|
||||
),
|
||||
children: formatTitle?.({ title }) ?? title,
|
||||
}
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
interface EventCalendarViewSwitcherProps extends Omit<
|
||||
useRender.ComponentProps<"button">,
|
||||
"children"
|
||||
> {
|
||||
children?: ReactNode
|
||||
/** Hover/focus-visible hint; defaults to the "Select view" label. Pass
|
||||
* null to disable (overlay-opener policy). */
|
||||
tooltip?: ReactNode | null
|
||||
}
|
||||
|
||||
function EventCalendarViewSwitcher({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
tooltip,
|
||||
...props
|
||||
}: EventCalendarViewSwitcherProps) {
|
||||
const { view, dayCount, availableViews, setView } = useEventCalendarView()
|
||||
const settings = useEventCalendarSettings()
|
||||
const viewConfig = useEventCalendarViewConfig()
|
||||
const nav = useNavButtonProps()
|
||||
const tooltips = useNavTooltipConfig()
|
||||
const labels = settings.i18n.labels
|
||||
// Controlled open: selecting a view swaps the whole content subtree in the
|
||||
// same click, so closing must not depend on the menu's internal handler.
|
||||
const [open, setOpen] = useState(false)
|
||||
// Hover-only tooltip: when the menu closes, Base UI focuses the trigger
|
||||
// again and a focus-opened tooltip would flash - ignore focus opens.
|
||||
const [tipOpen, setTipOpen] = useState(false)
|
||||
|
||||
const selectView = (v: CalendarView, opts?: { dayCount?: number }) => {
|
||||
setOpen(false)
|
||||
setView(v, opts)
|
||||
}
|
||||
|
||||
const viewLabel = (v: CalendarView) =>
|
||||
v === "days"
|
||||
? settings.i18n.viewNames.days(dayCount)
|
||||
: settings.i18n.viewNames[v]
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={(next: boolean) => {
|
||||
setOpen(next)
|
||||
if (next) setTipOpen(false)
|
||||
}}
|
||||
>
|
||||
{/* Tooltip on an overlay-opener: hover-only (focus opens ignored) and
|
||||
force-closed while the menu is up, so it never lingers or flashes
|
||||
when focus returns on close. Inherits the nav TooltipProvider's
|
||||
delay/closeDelay/timeout. */}
|
||||
<Tooltip
|
||||
open={tipOpen && !open}
|
||||
onOpenChange={(next: boolean, details: { reason?: string }) => {
|
||||
// opens are hover-only; the trigger-focus open that follows a
|
||||
// menu close is ignored, closes always land
|
||||
if (next && details?.reason !== "trigger-hover") return
|
||||
setTipOpen(next)
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant={nav.variant}
|
||||
size={nav.size}
|
||||
data-slot="event-calendar-view-switcher"
|
||||
aria-label={labels.selectView}
|
||||
className={cn("gap-1", nav.className, className)}
|
||||
render={render}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{viewLabel(view)}
|
||||
<ChevronDownIcon className="size-4 opacity-60" aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
{tipOpen && !open && tooltip !== null && !tooltips.disabled && (
|
||||
<TooltipContent side={tooltips.side} className={tooltips.className}>
|
||||
{tooltip ?? labels.selectView}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={cn("min-w-44", viewConfig.classNames?.viewSwitcherContent)}
|
||||
>
|
||||
{/* Base UI contract: GroupLabel must live inside Menu.Group */}
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel
|
||||
className={cn(
|
||||
"text-muted-foreground font-normal",
|
||||
viewConfig.classNames?.viewSwitcherLabel
|
||||
)}
|
||||
>
|
||||
{settings.i18n.labels.selectView}
|
||||
</DropdownMenuLabel>
|
||||
{availableViews.map((v) =>
|
||||
v === "days" ? (
|
||||
viewConfig.dayCountPresets.map((count) => (
|
||||
<DropdownMenuItem
|
||||
key={`days-${count}`}
|
||||
data-active={
|
||||
(view === "days" && dayCount === count) || undefined
|
||||
}
|
||||
onClick={() => selectView("days", { dayCount: count })}
|
||||
>
|
||||
{settings.i18n.viewNames.days(count)}
|
||||
{/* hint derived from the preset itself, not i18n's default */}
|
||||
{viewConfig.enableShortcuts && (
|
||||
<EventCalendarViewShortcut>
|
||||
{count}
|
||||
</EventCalendarViewShortcut>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
key={v}
|
||||
data-active={view === v || undefined}
|
||||
onClick={() => selectView(v)}
|
||||
>
|
||||
{viewLabel(v)}
|
||||
{viewConfig.enableShortcuts && (
|
||||
<EventCalendarViewShortcut>
|
||||
{labels.viewShortcuts[v]}
|
||||
</EventCalendarViewShortcut>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
/** Outline key badge; theme-token only, so it adapts to every style. */
|
||||
function EventCalendarViewShortcut({ children }: { children: ReactNode }) {
|
||||
const viewConfig = useEventCalendarViewConfig()
|
||||
return (
|
||||
<kbd
|
||||
data-slot="event-calendar-view-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ms-auto inline-flex size-5 shrink-0 items-center justify-center rounded-sm border font-sans text-xs",
|
||||
viewConfig.classNames?.viewShortcut
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
|
||||
interface EventCalendarDatePickerProps extends Omit<
|
||||
useRender.ComponentProps<"button">,
|
||||
"children"
|
||||
> {
|
||||
children?: ReactNode
|
||||
/** "auto" (default) resolves per view: range for week/N-days/agenda. */
|
||||
mode?: "auto" | "single" | "range"
|
||||
/** Hover/focus-visible hint; defaults to null - no tooltip, because the
|
||||
* button opens an overlay (see the NavButtonProps tooltip policy). */
|
||||
tooltip?: ReactNode | null
|
||||
}
|
||||
|
||||
/** Views whose period reads better as a highlighted range. */
|
||||
const RANGE_VIEWS: CalendarView[] = ["week", "days", "agenda"]
|
||||
|
||||
/**
|
||||
* Optional go-to-date picker (shadcn Calendar in a popover), view-aware:
|
||||
* week/N-days/agenda highlight the whole active range (any click re-anchors
|
||||
* the period), other views select a single date. Not part of the default
|
||||
* nav - compose it yourself (or any external picker driving
|
||||
* useEventCalendarNavigation().goTo). No tooltip by default: it opens an
|
||||
* overlay (see the NavButtonProps tooltip policy); pass `tooltip` to opt in.
|
||||
*/
|
||||
function EventCalendarDatePicker({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
tooltip = null,
|
||||
mode,
|
||||
...props
|
||||
}: EventCalendarDatePickerProps) {
|
||||
const { date, goTo, activeRange } = useEventCalendarNavigation()
|
||||
const { view } = useEventCalendarView()
|
||||
const settings = useEventCalendarSettings()
|
||||
const viewConfig = useEventCalendarViewConfig()
|
||||
const nav = useNavButtonProps()
|
||||
const [open, setOpen] = useState(false)
|
||||
const zoned = toZoned(date, settings.timeZone)
|
||||
|
||||
const configured = mode ?? "auto"
|
||||
const resolved =
|
||||
configured === "auto"
|
||||
? RANGE_VIEWS.includes(view)
|
||||
? "range"
|
||||
: "single"
|
||||
: configured
|
||||
|
||||
const pick = (next: Date | undefined) => {
|
||||
if (!next) return
|
||||
goTo(next)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<NavTooltip content={tooltip}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant={nav.variant}
|
||||
size={nav.iconSize}
|
||||
data-slot="event-calendar-date-picker"
|
||||
data-mode={resolved}
|
||||
aria-label={settings.i18n.labels.goToDate}
|
||||
className={cn(nav.className, className)}
|
||||
render={render}
|
||||
/>
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<CalendarIcon className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
</NavTooltip>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className={cn("w-auto p-0!", viewConfig.classNames?.datePickerContent)}
|
||||
>
|
||||
{resolved === "range" ? (
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={{
|
||||
from: toZoned(activeRange.start, settings.timeZone),
|
||||
to: toZoned(addDays(activeRange.end, -1), settings.timeZone),
|
||||
}}
|
||||
defaultMonth={zoned}
|
||||
onDayClick={pick}
|
||||
locale={settings.locale}
|
||||
weekStartsOn={settings.weekStartsOn}
|
||||
/>
|
||||
) : (
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={zoned}
|
||||
defaultMonth={zoned}
|
||||
onSelect={pick}
|
||||
locale={settings.locale}
|
||||
weekStartsOn={settings.weekStartsOn}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
type EventCalendarToolbarProps = useRender.ComponentProps<"div">
|
||||
|
||||
/** Free slot for consumer toolbar buttons; pure layout shell. */
|
||||
function EventCalendarToolbar({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: EventCalendarToolbarProps) {
|
||||
const viewConfig = useEventCalendarViewConfig()
|
||||
const defaultProps = {
|
||||
"data-slot": "event-calendar-toolbar",
|
||||
className: cn(
|
||||
"flex items-center gap-2",
|
||||
viewConfig.classNames?.toolbar,
|
||||
className
|
||||
),
|
||||
children: props.children,
|
||||
}
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
interface EventCalendarNavProps extends useRender.ComponentProps<"div"> {
|
||||
/**
|
||||
* Render the view switcher in the composed layout. Turn off when the
|
||||
* calendar ships with a fixed view (e.g. a month-only embed) and users
|
||||
* should not be able to change it.
|
||||
* @default true
|
||||
*/
|
||||
showViewSwitcher?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Default composed nav: Today, prev/next, title, spacer, view switcher.
|
||||
* Pass children to use it as a pure layout shell instead.
|
||||
*/
|
||||
function EventCalendarNav({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
showViewSwitcher = true,
|
||||
...props
|
||||
}: EventCalendarNavProps) {
|
||||
const viewConfig = useEventCalendarViewConfig()
|
||||
const tooltips = useNavTooltipConfig()
|
||||
const defaultProps = {
|
||||
"data-slot": "event-calendar-nav",
|
||||
className: cn(
|
||||
"flex min-w-0 flex-wrap items-center gap-1 px-2 py-2",
|
||||
viewConfig.stickyNav && "bg-background sticky top-0 z-30",
|
||||
viewConfig.classNames?.nav,
|
||||
className
|
||||
),
|
||||
children: children ?? (
|
||||
// Shared provider: first tooltip waits, moving between buttons is instant
|
||||
<TooltipProvider
|
||||
delay={tooltips.delay}
|
||||
closeDelay={tooltips.closeDelay}
|
||||
timeout={tooltips.timeout}
|
||||
>
|
||||
<EventCalendarNavToday />
|
||||
{showViewSwitcher && <EventCalendarViewSwitcher />}
|
||||
<div className="flex items-center">
|
||||
<EventCalendarNavPrev />
|
||||
<EventCalendarNavNext />
|
||||
</div>
|
||||
{/* ms-3 sets the title apart from the tight control cluster so the
|
||||
period reads as its own group, not another button */}
|
||||
<EventCalendarTitle className="ms-3" />
|
||||
<div className="grow" />
|
||||
</TooltipProvider>
|
||||
),
|
||||
}
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
EventCalendarDatePicker,
|
||||
EventCalendarNav,
|
||||
EventCalendarNavNext,
|
||||
EventCalendarNavPrev,
|
||||
EventCalendarNavToday,
|
||||
EventCalendarTitle,
|
||||
EventCalendarToolbar,
|
||||
EventCalendarViewSwitcher,
|
||||
}
|
||||
export type {
|
||||
EventCalendarNavProps,
|
||||
EventCalendarTitleProps,
|
||||
EventCalendarToolbarProps,
|
||||
EventCalendarViewSwitcherProps,
|
||||
}
|
||||
@@ -1,592 +0,0 @@
|
||||
import type {
|
||||
CalendarEvent,
|
||||
EventCalendarDateRange,
|
||||
EventCalendarOccurrence,
|
||||
EventCalendarRecurrenceRule,
|
||||
EventCalendarWeekday,
|
||||
} from "@/components/reui/event-calendar/event-calendar-types"
|
||||
import { TZDate } from "@date-fns/tz"
|
||||
import { addDays, addMonths, addWeeks, addYears } from "date-fns"
|
||||
|
||||
/** Guard: max window-intersecting occurrences per event per expansion. */
|
||||
const MAX_OCCURRENCES = 1000
|
||||
|
||||
/** Runaway guard: absolute cap on period iterations regardless of visibility. */
|
||||
const MAX_ITERATIONS = 10000
|
||||
|
||||
/** Gregorian mean period lengths for the O(1) fast-forward approximation. */
|
||||
const PERIOD_MS: Record<EventCalendarRecurrenceRule["freq"], number> = {
|
||||
daily: 86400000,
|
||||
weekly: 604800000,
|
||||
monthly: 2629746000, // 365.2425 / 12 days
|
||||
yearly: 31556952000, // 365.2425 days
|
||||
}
|
||||
|
||||
const WEEKDAYS: EventCalendarWeekday[] = [
|
||||
"SU",
|
||||
"MO",
|
||||
"TU",
|
||||
"WE",
|
||||
"TH",
|
||||
"FR",
|
||||
"SA",
|
||||
]
|
||||
|
||||
class EventCalendarRecurrenceError extends Error {
|
||||
constructor(part: string) {
|
||||
super(
|
||||
`Unsupported recurrence part: ${part}. Use the getOccurrences prop to plug a full RRULE engine for exotic rules.`
|
||||
)
|
||||
this.name = "EventCalendarRecurrenceError"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a raw RRULE line (with or without the "RRULE:" prefix) into the
|
||||
* structured subset. Pass the display time zone so Z-less UNTIL values are
|
||||
* interpreted as wall time in that zone rather than the machine zone.
|
||||
*/
|
||||
function parseRRuleString(
|
||||
input: string,
|
||||
timeZone?: string
|
||||
): EventCalendarRecurrenceRule {
|
||||
const body = input.trim().replace(/^RRULE:/i, "")
|
||||
const rule: Partial<EventCalendarRecurrenceRule> = {}
|
||||
|
||||
for (const pair of body.split(";")) {
|
||||
if (!pair) continue
|
||||
const [rawKey, rawValue] = pair.split("=")
|
||||
const key = rawKey?.toUpperCase()
|
||||
const value = rawValue ?? ""
|
||||
|
||||
switch (key) {
|
||||
case "FREQ": {
|
||||
const freq = value.toLowerCase()
|
||||
if (
|
||||
freq !== "daily" &&
|
||||
freq !== "weekly" &&
|
||||
freq !== "monthly" &&
|
||||
freq !== "yearly"
|
||||
) {
|
||||
throw new EventCalendarRecurrenceError(`FREQ=${value}`)
|
||||
}
|
||||
rule.freq = freq
|
||||
break
|
||||
}
|
||||
case "INTERVAL":
|
||||
rule.interval = Math.max(1, parseInt(value, 10) || 1)
|
||||
break
|
||||
case "COUNT":
|
||||
rule.count = Math.max(1, parseInt(value, 10) || 1)
|
||||
break
|
||||
case "UNTIL":
|
||||
rule.until = parseRRuleDate(value, timeZone)
|
||||
break
|
||||
case "BYDAY":
|
||||
rule.byWeekday = value.split(",").map((token) => {
|
||||
// RFC 5545 3.1: enumerated values are case-insensitive, and FREQ is
|
||||
// already folded above - rejecting "mo" here would be inconsistent
|
||||
const match = /^(-?\d+)?(SU|MO|TU|WE|TH|FR|SA)$/.exec(
|
||||
token.trim().toUpperCase()
|
||||
)
|
||||
if (!match) throw new EventCalendarRecurrenceError(`BYDAY=${token}`)
|
||||
const day = match[2] as EventCalendarWeekday
|
||||
return match[1] ? { day, ordinal: parseInt(match[1], 10) } : day
|
||||
})
|
||||
break
|
||||
case "BYMONTHDAY":
|
||||
rule.byMonthDay = value.split(",").map((v) => {
|
||||
const day = parseInt(v, 10)
|
||||
// NaN would survive parsing, match no day in any month and leave the
|
||||
// event permanently invisible with no error anywhere
|
||||
if (Number.isNaN(day)) {
|
||||
throw new EventCalendarRecurrenceError(`BYMONTHDAY=${v}`)
|
||||
}
|
||||
return day
|
||||
})
|
||||
break
|
||||
case "BYMONTH":
|
||||
rule.byMonth = value.split(",").map((v) => parseInt(v, 10))
|
||||
break
|
||||
case "WKST": {
|
||||
const day = value.trim().toUpperCase() as EventCalendarWeekday
|
||||
if (!WEEKDAYS.includes(day)) {
|
||||
throw new EventCalendarRecurrenceError(`WKST=${value}`)
|
||||
}
|
||||
rule.weekStart = day
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw new EventCalendarRecurrenceError(key ?? pair)
|
||||
}
|
||||
}
|
||||
|
||||
if (!rule.freq) throw new EventCalendarRecurrenceError("missing FREQ")
|
||||
return rule as EventCalendarRecurrenceRule
|
||||
}
|
||||
|
||||
function parseRRuleDate(value: string, timeZone?: string): Date {
|
||||
// RFC 5545 basic formats: YYYYMMDD or YYYYMMDDTHHMMSS(Z). The T and Z
|
||||
// designators are case-insensitive too (RFC 5545 3.1), so fold before matching.
|
||||
const match = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/.exec(
|
||||
value.trim().toUpperCase()
|
||||
)
|
||||
if (!match) throw new EventCalendarRecurrenceError(`UNTIL=${value}`)
|
||||
const [, y, m, d, hh = "23", mm = "59", ss = "59", z] = match
|
||||
// Z-less values (including date-only ones, which mean end of that day
|
||||
// inclusive) are wall time in the display zone, not the machine zone.
|
||||
const date = z
|
||||
? new Date(`${y}-${m}-${d}T${hh}:${mm}:${ss}Z`)
|
||||
: timeZone
|
||||
? new Date(new TZDate(+y, +m - 1, +d, +hh, +mm, +ss, timeZone).getTime())
|
||||
: new Date(`${y}-${m}-${d}T${hh}:${mm}:${ss}`)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new EventCalendarRecurrenceError(`UNTIL=${value}`)
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
/** Serializes the structured subset back to an RRULE line (without prefix). */
|
||||
function formatRRuleString(rule: EventCalendarRecurrenceRule): string {
|
||||
const parts: string[] = [`FREQ=${rule.freq.toUpperCase()}`]
|
||||
if (rule.interval && rule.interval > 1)
|
||||
parts.push(`INTERVAL=${rule.interval}`)
|
||||
if (rule.count) parts.push(`COUNT=${rule.count}`)
|
||||
if (rule.until) {
|
||||
const u = rule.until
|
||||
const pad = (n: number) => String(n).padStart(2, "0")
|
||||
parts.push(
|
||||
`UNTIL=${u.getUTCFullYear()}${pad(u.getUTCMonth() + 1)}${pad(u.getUTCDate())}T${pad(u.getUTCHours())}${pad(u.getUTCMinutes())}${pad(u.getUTCSeconds())}Z`
|
||||
)
|
||||
}
|
||||
if (rule.byWeekday?.length) {
|
||||
parts.push(
|
||||
`BYDAY=${rule.byWeekday
|
||||
.map((d) => (typeof d === "string" ? d : `${d.ordinal}${d.day}`))
|
||||
.join(",")}`
|
||||
)
|
||||
}
|
||||
if (rule.byMonthDay?.length)
|
||||
parts.push(`BYMONTHDAY=${rule.byMonthDay.join(",")}`)
|
||||
if (rule.byMonth?.length) parts.push(`BYMONTH=${rule.byMonth.join(",")}`)
|
||||
if (rule.weekStart) parts.push(`WKST=${rule.weekStart}`)
|
||||
return parts.join(";")
|
||||
}
|
||||
|
||||
function resolveRule(
|
||||
recurrence: EventCalendarRecurrenceRule | string,
|
||||
timeZone?: string
|
||||
): EventCalendarRecurrenceRule {
|
||||
return typeof recurrence === "string"
|
||||
? parseRRuleString(recurrence, timeZone)
|
||||
: recurrence
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands one event into its occurrences intersecting the range.
|
||||
* Non-recurring events yield at most one occurrence. Recurrence iteration is
|
||||
* wall-time based in the display zone (DST-safe day/week/month steps), and a
|
||||
* span that is a whole number of local days keeps that day count across a DST
|
||||
* transition (timed spans keep their absolute length instead).
|
||||
*
|
||||
* exDates remove exactly-matching instants (after COUNT numbering,
|
||||
* Google-style: an exception still consumes its COUNT slot); rDates add extra
|
||||
* instants with the same duration. RECURRENCE-ID override replacement lives
|
||||
* in buildEventIndex, where the override event and its parent series meet.
|
||||
*/
|
||||
function expandRecurrence<TData>(
|
||||
event: CalendarEvent<TData>,
|
||||
range: EventCalendarDateRange,
|
||||
ctx: { timeZone: string }
|
||||
): EventCalendarOccurrence<TData>[] {
|
||||
const allDay = event.allDay ?? false
|
||||
|
||||
if (!event.recurrence) {
|
||||
// The exclusive `end > start` test is right for anything with duration,
|
||||
// but it also drops a zero-length milestone pinned to the first visible
|
||||
// instant - which reads as an event that randomly disappears until you
|
||||
// page one period back. A point occurrence only has to be inside.
|
||||
const isPoint = event.end.getTime() === event.start.getTime()
|
||||
if (
|
||||
event.start < range.end &&
|
||||
(event.end > range.start || (isPoint && event.start >= range.start))
|
||||
) {
|
||||
return [
|
||||
{
|
||||
key: `${event.id}::${event.start.toISOString()}`,
|
||||
eventId: event.id,
|
||||
event,
|
||||
start: event.start,
|
||||
end: event.end,
|
||||
allDay,
|
||||
isRecurring: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const rule = resolveRule(event.recurrence, ctx.timeZone)
|
||||
const interval = Math.max(1, rule.interval ?? 1)
|
||||
const durationMs = event.end.getTime() - event.start.getTime()
|
||||
const zonedStart = new TZDate(event.start.getTime(), ctx.timeZone)
|
||||
// A span of whole local days is wall time, not an absolute delta: a 3 day
|
||||
// all-day bar crossing spring forward would otherwise end at 01:00 and
|
||||
// occupy a fourth day in the month grid. Timed spans stay absolute so a two
|
||||
// hour meeting is still two hours.
|
||||
const daySpan = Math.round(durationMs / 86400000)
|
||||
const wallDaySpan =
|
||||
daySpan > 0 &&
|
||||
addDays(zonedStart, daySpan).getTime() === event.end.getTime()
|
||||
? daySpan
|
||||
: null
|
||||
const endFor = (start: Date): Date =>
|
||||
wallDaySpan === null
|
||||
? new Date(start.getTime() + durationMs)
|
||||
: new Date(
|
||||
addDays(
|
||||
new TZDate(start.getTime(), ctx.timeZone),
|
||||
wallDaySpan
|
||||
).getTime()
|
||||
)
|
||||
// Excluded instants matched exactly; filtering happens at push time so an
|
||||
// exception still consumes its COUNT slot (Google-style numbering).
|
||||
const exTimes = new Set((rule.exDates ?? []).map((d) => d.getTime()))
|
||||
|
||||
const weeklyDays: number[] | null =
|
||||
rule.freq === "weekly" && rule.byWeekday?.length
|
||||
? [
|
||||
...new Set(
|
||||
rule.byWeekday.map((d) => {
|
||||
if (typeof d !== "string") {
|
||||
throw new EventCalendarRecurrenceError(
|
||||
"BYDAY ordinal outside monthly/yearly"
|
||||
)
|
||||
}
|
||||
return WEEKDAYS.indexOf(d)
|
||||
})
|
||||
),
|
||||
]
|
||||
: null
|
||||
|
||||
// BYDAY resolved inside a month: monthly, and yearly within each BYMONTH
|
||||
// (FREQ=YEARLY;BYMONTH=11;BYDAY=4TH is Thanksgiving, not "the anchor's day")
|
||||
const monthlyByDay =
|
||||
(rule.freq === "monthly" || rule.freq === "yearly") &&
|
||||
rule.byWeekday?.length
|
||||
? rule.byWeekday
|
||||
: null
|
||||
|
||||
// RFC 5545 3.3.10 week numbering: with INTERVAL > 1 the week start decides
|
||||
// which selected days share a period, so an ignored WKST puts half of every
|
||||
// biweekly series a week off. Default MO, per the RFC.
|
||||
const weekStartIndex = WEEKDAYS.indexOf(rule.weekStart ?? "MO")
|
||||
/** Days from the WKST-aligned week start to `day` (0-6). */
|
||||
const fromWeekStart = (day: number) => (day - weekStartIndex + 7) % 7
|
||||
|
||||
// yearly BYMONTH filter (1-12), ascending; defaults to the anchor's month
|
||||
const validByMonth = rule.byMonth?.filter((m) => m >= 1 && m <= 12) ?? []
|
||||
const yearlyMonths: number[] =
|
||||
validByMonth.length > 0
|
||||
? [...new Set(validByMonth)].sort((a, b) => a - b)
|
||||
: [zonedStart.getMonth() + 1]
|
||||
|
||||
// month-shaped BY* parts make the per-period occurrence count variable
|
||||
const hasMonthDayParts =
|
||||
(rule.freq === "monthly" &&
|
||||
Boolean(rule.byMonthDay?.length || monthlyByDay)) ||
|
||||
(rule.freq === "yearly" &&
|
||||
Boolean(rule.byMonth?.length || rule.byMonthDay?.length || monthlyByDay))
|
||||
|
||||
const daysInMonth = (year: number, month: number) =>
|
||||
new Date(year, month + 1, 0).getDate()
|
||||
|
||||
// RFC 5545 3.3.10 LIMIT filters: at these frequencies a BY* part narrows the
|
||||
// set instead of reshaping it. Dropping them silently expanded the series as
|
||||
// if the part were absent (FREQ=DAILY;BYDAY=MO filled every day). Filtering
|
||||
// here rather than at push time keeps COUNT numbering RFC-correct: a
|
||||
// candidate the filter removes is not an occurrence and consumes no slot.
|
||||
const limitByMonth =
|
||||
rule.freq !== "yearly" && validByMonth.length > 0 ? validByMonth : null
|
||||
const limitByMonthDay =
|
||||
rule.freq === "daily" && rule.byMonthDay?.length ? rule.byMonthDay : null
|
||||
const limitByWeekday =
|
||||
rule.freq === "daily" && rule.byWeekday?.length
|
||||
? rule.byWeekday.map((d) =>
|
||||
WEEKDAYS.indexOf(typeof d === "string" ? d : d.day)
|
||||
)
|
||||
: null
|
||||
const hasLimits = Boolean(limitByMonth || limitByMonthDay || limitByWeekday)
|
||||
|
||||
const passesLimits = (candidate: TZDate): boolean => {
|
||||
if (limitByMonth && !limitByMonth.includes(candidate.getMonth() + 1)) {
|
||||
return false
|
||||
}
|
||||
if (limitByWeekday && !limitByWeekday.includes(candidate.getDay())) {
|
||||
return false
|
||||
}
|
||||
if (limitByMonthDay) {
|
||||
const total = daysInMonth(candidate.getFullYear(), candidate.getMonth())
|
||||
const day = candidate.getDate()
|
||||
// negative BYMONTHDAY counts back from month end, as in monthDays
|
||||
if (!limitByMonthDay.some((n) => (n < 0 ? total + 1 + n : n) === day)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Wall-clock instant in the display zone carrying DTSTART's time-of-day. */
|
||||
const zonedDate = (year: number, month: number, day: number) =>
|
||||
new TZDate(
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
zonedStart.getHours(),
|
||||
zonedStart.getMinutes(),
|
||||
zonedStart.getSeconds(),
|
||||
zonedStart.getMilliseconds(),
|
||||
ctx.timeZone
|
||||
)
|
||||
|
||||
/** Selected days-of-month, ascending: BYMONTHDAY (and) BYDAY, else the clamped anchor day. */
|
||||
const monthDays = (year: number, month: number): number[] => {
|
||||
const total = daysInMonth(year, month)
|
||||
let days: number[] | null = null
|
||||
if (rule.byMonthDay?.length) {
|
||||
// negative BYMONTHDAY counts back from month end; nonexistent days skip
|
||||
days = rule.byMonthDay
|
||||
.map((n) => (n < 0 ? total + 1 + n : n))
|
||||
.filter((n) => n >= 1 && n <= total)
|
||||
}
|
||||
if (monthlyByDay) {
|
||||
const byDayMatches: number[] = []
|
||||
for (const entry of monthlyByDay) {
|
||||
const day = typeof entry === "string" ? entry : entry.day
|
||||
const ordinal = typeof entry === "string" ? 0 : entry.ordinal
|
||||
const weekday = WEEKDAYS.indexOf(day)
|
||||
const matches: number[] = []
|
||||
for (let d = 1; d <= total; d++) {
|
||||
if (new Date(year, month, d).getDay() === weekday) matches.push(d)
|
||||
}
|
||||
if (ordinal === 0) {
|
||||
byDayMatches.push(...matches) // plain BYDAY: every matching weekday
|
||||
} else {
|
||||
// 2TU = 2nd Tuesday, -1FR = last Friday; absent ordinals skip
|
||||
const pick =
|
||||
ordinal > 0
|
||||
? matches[ordinal - 1]
|
||||
: matches[matches.length + ordinal]
|
||||
if (pick !== undefined) byDayMatches.push(pick)
|
||||
}
|
||||
}
|
||||
days = days ? days.filter((n) => byDayMatches.includes(n)) : byDayMatches
|
||||
}
|
||||
if (!days) days = [Math.min(zonedStart.getDate(), total)] // clamp to month end
|
||||
return [...new Set(days)].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
/** Chronological candidates of one period, derived from the DTSTART anchor by index (no drift). */
|
||||
const periodCandidates = (period: number): TZDate[] => {
|
||||
if (rule.freq === "daily") return [addDays(zonedStart, period * interval)]
|
||||
if (rule.freq === "weekly") {
|
||||
const base = addWeeks(zonedStart, period * interval)
|
||||
if (!weeklyDays) return [base]
|
||||
const week: TZDate[] = []
|
||||
// walk the WKST-aligned week so candidates stay chronological
|
||||
const baseOffset = fromWeekStart(base.getDay())
|
||||
for (let offset = 0; offset < 7; offset++) {
|
||||
const candidate = addDays(base, offset - baseOffset)
|
||||
if (!weeklyDays.includes(candidate.getDay())) continue
|
||||
// days of the DTSTART week before DTSTART are not part of the series
|
||||
if (candidate.getTime() < zonedStart.getTime()) continue
|
||||
week.push(candidate)
|
||||
}
|
||||
return week
|
||||
}
|
||||
if (rule.freq === "monthly") {
|
||||
const anchor = addMonths(zonedStart, period * interval)
|
||||
const year = anchor.getFullYear()
|
||||
const month = anchor.getMonth()
|
||||
return monthDays(year, month)
|
||||
.map((day) => zonedDate(year, month, day))
|
||||
.filter((c) => c.getTime() >= zonedStart.getTime())
|
||||
}
|
||||
// yearly
|
||||
const year = addYears(zonedStart, period * interval).getFullYear()
|
||||
const dates: TZDate[] = []
|
||||
for (const month of yearlyMonths) {
|
||||
for (const day of monthDays(year, month - 1)) {
|
||||
dates.push(zonedDate(year, month - 1, day))
|
||||
}
|
||||
}
|
||||
return dates.filter((c) => c.getTime() >= zonedStart.getTime())
|
||||
}
|
||||
|
||||
/** Candidates of one period with the LIMIT filters applied. */
|
||||
const candidatesFor = (period: number): TZDate[] =>
|
||||
hasLimits
|
||||
? periodCandidates(period).filter(passesLimits)
|
||||
: periodCandidates(period)
|
||||
|
||||
/** Earliest/latest instant a period can produce - loop bounds without expanding it. */
|
||||
const periodEdge = (period: number, edge: "first" | "last"): TZDate => {
|
||||
if (rule.freq === "daily") return addDays(zonedStart, period * interval)
|
||||
if (rule.freq === "weekly") {
|
||||
const base = addWeeks(zonedStart, period * interval)
|
||||
if (!weeklyDays) return base
|
||||
return addDays(
|
||||
base,
|
||||
(edge === "first" ? 0 : 6) - fromWeekStart(base.getDay())
|
||||
)
|
||||
}
|
||||
if (rule.freq === "monthly") {
|
||||
const anchor = addMonths(zonedStart, period * interval)
|
||||
const year = anchor.getFullYear()
|
||||
const month = anchor.getMonth()
|
||||
return zonedDate(
|
||||
year,
|
||||
month,
|
||||
edge === "first" ? 1 : daysInMonth(year, month)
|
||||
)
|
||||
}
|
||||
const year = addYears(zonedStart, period * interval).getFullYear()
|
||||
const month =
|
||||
(edge === "first"
|
||||
? yearlyMonths[0]
|
||||
: yearlyMonths[yearlyMonths.length - 1]) - 1
|
||||
return zonedDate(
|
||||
year,
|
||||
month,
|
||||
edge === "first" ? 1 : daysInMonth(year, month)
|
||||
)
|
||||
}
|
||||
|
||||
// O(1) fast-forward: land a couple of periods before the window instead of
|
||||
// iterating from DTSTART, so years-old series still reach the visible range.
|
||||
const aheadMs = range.start.getTime() - durationMs - zonedStart.getTime()
|
||||
const stepMs = PERIOD_MS[rule.freq] * interval
|
||||
let startPeriod =
|
||||
aheadMs > 0 ? Math.max(0, Math.floor(aheadMs / stepMs) - 2) : 0
|
||||
// mean-length drift is bounded well under one period - refine forward
|
||||
while (
|
||||
periodEdge(startPeriod, "last").getTime() + durationMs <=
|
||||
range.start.getTime()
|
||||
) {
|
||||
startPeriod++
|
||||
}
|
||||
|
||||
// series ordinal at startPeriod, so COUNT and recurrenceIndex stay exact
|
||||
let index = 0
|
||||
if (startPeriod > 0) {
|
||||
if (hasMonthDayParts || hasLimits) {
|
||||
// per-period counts vary (skipped days, 4-vs-5 weekday months, a LIMIT
|
||||
// filter that empties a whole period) - sum them
|
||||
for (let period = 0; period < startPeriod; period++) {
|
||||
index += candidatesFor(period).length
|
||||
}
|
||||
} else if (weeklyDays) {
|
||||
// week 0 only counts selected weekdays at/after DTSTART's, inside the
|
||||
// WKST-aligned week that holds it
|
||||
const anchorOffset = fromWeekStart(zonedStart.getDay())
|
||||
const firstWeek = weeklyDays.filter(
|
||||
(d) => fromWeekStart(d) >= anchorOffset
|
||||
).length
|
||||
index = firstWeek + (startPeriod - 1) * weeklyDays.length
|
||||
} else {
|
||||
index = startPeriod // one occurrence per period
|
||||
}
|
||||
}
|
||||
|
||||
const occurrences: EventCalendarOccurrence<TData>[] = []
|
||||
|
||||
const pushIfVisible = (rawStart: Date) => {
|
||||
// normalize to a plain instant so consumers never receive zone-carrying
|
||||
// TZDate instances (mixed-zone formatting bugs)
|
||||
const start = new Date(rawStart.getTime())
|
||||
if (exTimes.has(start.getTime())) return
|
||||
const end = endFor(start)
|
||||
if (start < range.end && end > range.start) {
|
||||
occurrences.push({
|
||||
key: `${event.id}::${start.toISOString()}`,
|
||||
eventId: event.id,
|
||||
event,
|
||||
start,
|
||||
end,
|
||||
allDay,
|
||||
isRecurring: true,
|
||||
recurrenceIndex: index,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let iterations = 0
|
||||
let period = startPeriod
|
||||
while (iterations < MAX_ITERATIONS && occurrences.length < MAX_OCCURRENCES) {
|
||||
iterations++
|
||||
if (rule.count !== undefined && index >= rule.count) break
|
||||
// whole-period bounds: never break on a mid-period weekday/day-of-month,
|
||||
// so earlier candidates of the final period are still emitted
|
||||
const earliest = periodEdge(period, "first")
|
||||
if (earliest.getTime() >= range.end.getTime()) break
|
||||
if (rule.until && earliest.getTime() > rule.until.getTime()) break
|
||||
|
||||
let ended = false
|
||||
for (const candidate of candidatesFor(period)) {
|
||||
if (rule.count !== undefined && index >= rule.count) {
|
||||
ended = true
|
||||
break
|
||||
}
|
||||
if (rule.until && candidate.getTime() > rule.until.getTime()) {
|
||||
ended = true
|
||||
break
|
||||
}
|
||||
pushIfVisible(candidate)
|
||||
index++
|
||||
if (occurrences.length >= MAX_OCCURRENCES) {
|
||||
ended = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (ended) break
|
||||
period++
|
||||
}
|
||||
|
||||
// RDATE: extra instants join the set (deduped against generated starts and
|
||||
// exclusions) with the same wall-time duration. Sorted so direct consumers
|
||||
// still receive chronological order (buildEventIndex re-sorts regardless).
|
||||
if (rule.rDates?.length) {
|
||||
const seen = new Set(occurrences.map((o) => o.start.getTime()))
|
||||
for (const rDate of rule.rDates) {
|
||||
const start = new Date(rDate.getTime())
|
||||
if (seen.has(start.getTime()) || exTimes.has(start.getTime())) continue
|
||||
const end = endFor(start)
|
||||
if (start >= range.end || end <= range.start) continue
|
||||
seen.add(start.getTime())
|
||||
occurrences.push({
|
||||
key: `${event.id}::${start.toISOString()}`,
|
||||
eventId: event.id,
|
||||
event,
|
||||
start,
|
||||
end,
|
||||
allDay,
|
||||
isRecurring: true,
|
||||
// keep counting past the generated instants: an RDATE with no index
|
||||
// would fall back to undefined and break any consumer that identifies
|
||||
// an instance by its position in the series
|
||||
recurrenceIndex: index++,
|
||||
})
|
||||
}
|
||||
occurrences.sort((a, b) => a.start.getTime() - b.start.getTime())
|
||||
}
|
||||
|
||||
return occurrences
|
||||
}
|
||||
|
||||
export {
|
||||
EventCalendarRecurrenceError,
|
||||
expandRecurrence,
|
||||
formatRRuleString,
|
||||
MAX_OCCURRENCES,
|
||||
parseRRuleString,
|
||||
}
|
||||
@@ -1,812 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"
|
||||
import {
|
||||
EventCalendarViewContext,
|
||||
useEventCalendar,
|
||||
useEventCalendarDay,
|
||||
useEventCalendarSelector,
|
||||
useEventCalendarSettings,
|
||||
useEventCalendarViewConfig,
|
||||
useEventCalendarViewSettings,
|
||||
} from "@/components/reui/event-calendar/event-calendar"
|
||||
import {
|
||||
useEventCalendarGestures,
|
||||
wasRecentChipPress,
|
||||
wasRecentDrag,
|
||||
} from "@/components/reui/event-calendar/event-calendar-dnd"
|
||||
import {
|
||||
EVENT_CALENDAR_GHOST,
|
||||
EVENT_CALENDAR_SLOT_DRAFT,
|
||||
EventCalendarEvent,
|
||||
} from "@/components/reui/event-calendar/event-calendar-event"
|
||||
import {
|
||||
flattenResources,
|
||||
getDayKey,
|
||||
getDayTotalMinutes,
|
||||
packTimedSegments,
|
||||
resolveOffDay,
|
||||
snapMinutes,
|
||||
toZoned,
|
||||
zonedStartOfDay,
|
||||
} from "@/components/reui/event-calendar/event-calendar-lib"
|
||||
import {
|
||||
EventCalendarNowIndicator,
|
||||
EventCalendarTimeGutter,
|
||||
minuteBlockStyle,
|
||||
} from "@/components/reui/event-calendar/event-calendar-time-grid"
|
||||
import type {
|
||||
EventCalendarResource,
|
||||
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, addMinutes } from "date-fns"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { ScrollArea } from "@evobgp/ui/components/scroll-area"
|
||||
|
||||
const EMPTY_ALL_DAY_SEGMENTS: EventCalendarSegment[] = []
|
||||
|
||||
interface EventCalendarResourceViewProps extends useRender.ComponentProps<"div"> {
|
||||
dayStartHour?: number
|
||||
dayEndHour?: number
|
||||
showAllDay?: boolean
|
||||
/** Gutter/gridline interval in minutes; defaults to the interval view config. */
|
||||
interval?: number
|
||||
}
|
||||
|
||||
/** Leaf resources become booking columns for the anchor day. */
|
||||
function EventCalendarResourceView({
|
||||
className,
|
||||
render,
|
||||
dayStartHour,
|
||||
dayEndHour,
|
||||
showAllDay = true,
|
||||
interval: intervalProp,
|
||||
...props
|
||||
}: EventCalendarResourceViewProps) {
|
||||
const instance = useEventCalendar()
|
||||
const settings = useEventCalendarSettings()
|
||||
const viewConfig = useEventCalendarViewConfig()
|
||||
const { effective } = useEventCalendarViewSettings()
|
||||
const anchorDate = useEventCalendarSelector((state) => state.date, {
|
||||
isEqual: (a, b) => a.getTime() === b.getTime(),
|
||||
})
|
||||
|
||||
const startHour = dayStartHour ?? settings.dayStartHour
|
||||
const endHour = dayEndHour ?? settings.dayEndHour
|
||||
const interval = Math.min(
|
||||
Math.max(intervalProp ?? viewConfig.interval, 5),
|
||||
240
|
||||
)
|
||||
const contained = viewConfig.scrollMode !== "page"
|
||||
const day = zonedStartOfDay(anchorDate, settings.timeZone)
|
||||
|
||||
const resources = useMemo(
|
||||
() =>
|
||||
flattenResources(settings.resources)
|
||||
.filter(({ resource }) => !resource.children?.length)
|
||||
.map(({ resource }) => resource),
|
||||
[settings.resources]
|
||||
)
|
||||
|
||||
// Initial scroll + api.scrollToTime (same contract as the time grid)
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
useEffect(() => {
|
||||
if (!contained) return
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const viewport = el.querySelector<HTMLElement>(
|
||||
"[data-slot=scroll-area-viewport]"
|
||||
)
|
||||
const slotRow = el.querySelector<HTMLElement>(
|
||||
"[data-slot=event-calendar-time-gutter] > div"
|
||||
)
|
||||
const slotPx = slotRow?.getBoundingClientRect().height || 64
|
||||
const pxPerMinute = slotPx / interval
|
||||
const scrollTo = (minutes: number) => {
|
||||
// keep the hour label above the target line visible (it hangs -top-2)
|
||||
viewport?.scrollTo({
|
||||
top: Math.max(0, (minutes - startHour * 60) * pxPerMinute - 12),
|
||||
})
|
||||
}
|
||||
scrollTo(viewConfig.scrollToHour * 60)
|
||||
instance.internals.registerScrollHandler((time) => {
|
||||
const minutes =
|
||||
typeof time === "number"
|
||||
? time
|
||||
: toZoned(time, settings.timeZone).getHours() * 60 +
|
||||
toZoned(time, settings.timeZone).getMinutes()
|
||||
scrollTo(minutes)
|
||||
})
|
||||
// Classic (width-consuming) scrollbars squeeze the scrolling track while
|
||||
// the header/all-day rows outside keep full width, drifting the column
|
||||
// borders. Mirror the measured gutter onto those rows via a CSS var -
|
||||
// 0px for overlay scrollbars and the custom ScrollArea, so both modes
|
||||
// lay out identically.
|
||||
const root = el.closest<HTMLElement>(
|
||||
"[data-slot=event-calendar-time-grid], [data-slot=event-calendar-resource-view]"
|
||||
)
|
||||
const syncScrollbarGutter = () => {
|
||||
root?.style.setProperty(
|
||||
"--ec-scrollbar-w",
|
||||
`${viewport ? viewport.offsetWidth - viewport.clientWidth : 0}px`
|
||||
)
|
||||
}
|
||||
syncScrollbarGutter()
|
||||
const gutterObserver = viewport
|
||||
? new ResizeObserver(syncScrollbarGutter)
|
||||
: null
|
||||
if (viewport) gutterObserver?.observe(viewport)
|
||||
return () => {
|
||||
instance.internals.registerScrollHandler(null)
|
||||
gutterObserver?.disconnect()
|
||||
}
|
||||
}, [
|
||||
contained,
|
||||
instance,
|
||||
settings.timeZone,
|
||||
startHour,
|
||||
interval,
|
||||
viewConfig.scrollToHour,
|
||||
// scrollbars custom<->native swaps the scroller DOM: re-bind the
|
||||
// viewport, the scroll wiring, and the measured --ec-scrollbar-w
|
||||
viewConfig.scrollbars,
|
||||
])
|
||||
|
||||
const slots = useMemo(() => {
|
||||
const result: number[] = []
|
||||
for (let m = startHour * 60; m < endHour * 60; m += interval) {
|
||||
result.push(m)
|
||||
}
|
||||
return result
|
||||
}, [startHour, endHour, interval])
|
||||
|
||||
// All-day segments for renderAllDaySection - the same index bucket the
|
||||
// cells read; inert (stable empty array, so the subscription never
|
||||
// re-renders) while the override is unset.
|
||||
const allDaySegments = useEventCalendarSelector<
|
||||
unknown,
|
||||
EventCalendarSegment[]
|
||||
>(
|
||||
() =>
|
||||
viewConfig.renderAllDaySection
|
||||
? (instance.internals
|
||||
.getIndex()
|
||||
.byDay.get(getDayKey(day, settings.timeZone))?.allDay ??
|
||||
EMPTY_ALL_DAY_SEGMENTS)
|
||||
: EMPTY_ALL_DAY_SEGMENTS,
|
||||
{
|
||||
isEqual: (a, b) =>
|
||||
a === b ||
|
||||
(a.length === b.length && a.every((segment, i) => segment === b[i])),
|
||||
}
|
||||
)
|
||||
|
||||
const gridTemplateColumns = `repeat(${resources.length || 1}, minmax(var(--ec-resource-col-min,8rem), 1fr))`
|
||||
|
||||
const track = (
|
||||
<div className="relative flex">
|
||||
{/* shared gutter component, so renderTimeGutterSlot and
|
||||
classNames.timeGutter customizations apply here too */}
|
||||
<EventCalendarTimeGutter
|
||||
days={[day]}
|
||||
slots={slots}
|
||||
startHour={startHour}
|
||||
interval={interval}
|
||||
/>
|
||||
<div className="grid min-w-0 flex-1" style={{ gridTemplateColumns }}>
|
||||
{resources.map((resource) => (
|
||||
<EventCalendarResourceColumn
|
||||
key={resource.id}
|
||||
resource={resource}
|
||||
day={day}
|
||||
startHour={startHour}
|
||||
endHour={endHour}
|
||||
interval={interval}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{effective.nowIndicator && (
|
||||
<EventCalendarNowIndicator
|
||||
days={[day]}
|
||||
startHour={startHour}
|
||||
endHour={endHour}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const defaultProps = {
|
||||
"data-slot": "event-calendar-resource-view",
|
||||
"data-view": "resource",
|
||||
className: cn(
|
||||
"flex flex-col border-t",
|
||||
contained && "min-h-0 flex-1 overflow-hidden",
|
||||
viewConfig.classNames?.timeGrid,
|
||||
className
|
||||
),
|
||||
style: { "--ec-hour-height": "4rem" } as CSSProperties,
|
||||
children: (
|
||||
<>
|
||||
{/* Resource header row */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex border-b pe-(--ec-scrollbar-w,0px)",
|
||||
!contained &&
|
||||
"bg-background sticky top-(--ec-sticky-offset,0px) z-20",
|
||||
viewConfig.classNames?.timeGridHeader
|
||||
)}
|
||||
>
|
||||
<div className="w-(--ec-gutter-width,4.5rem) shrink-0 border-e" />
|
||||
<div className="grid min-w-0 flex-1" style={{ gridTemplateColumns }}>
|
||||
{resources.map((resource) => (
|
||||
<div
|
||||
key={resource.id}
|
||||
data-slot="event-calendar-resource-header"
|
||||
className={cn(
|
||||
"min-w-0 truncate border-e px-2 py-1.5 text-center font-medium last:border-e-0",
|
||||
viewConfig.classNames?.resourceHeader
|
||||
)}
|
||||
>
|
||||
{viewConfig.renderResourceHeader?.({ resource }) ??
|
||||
resource.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* All-day row per resource */}
|
||||
{showAllDay && (
|
||||
<div
|
||||
data-slot="event-calendar-all-day-section"
|
||||
className={cn(
|
||||
"flex border-b pe-(--ec-scrollbar-w,0px)",
|
||||
viewConfig.classNames?.allDaySection
|
||||
)}
|
||||
>
|
||||
{viewConfig.renderAllDaySection?.({
|
||||
days: [day],
|
||||
segments: allDaySegments,
|
||||
}) ?? (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
// pt-1.5 matches the all-day cell's top inset; the inner box is
|
||||
// one bar-row tall and centers the label so it sits on the SAME
|
||||
// baseline as the first all-day chip and stays top-aligned when
|
||||
// the chips wrap onto more lanes (mirrors the time-grid label)
|
||||
"text-muted-foreground w-(--ec-gutter-width,4.5rem) shrink-0 border-e ps-2 pe-2.5 pt-1.5",
|
||||
viewConfig.classNames?.allDayLabel
|
||||
)}
|
||||
>
|
||||
<span className="flex h-[calc(var(--ec-month-bar-h,1.625rem)-0.125rem)] items-center justify-end">
|
||||
{settings.i18n.labels.allDay}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="grid min-w-0 flex-1"
|
||||
style={{ gridTemplateColumns }}
|
||||
>
|
||||
{resources.map((resource) => (
|
||||
<EventCalendarResourceAllDayCell
|
||||
key={resource.id}
|
||||
resource={resource}
|
||||
day={day}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{contained ? (
|
||||
<div ref={scrollRef} className="min-h-0 flex-1">
|
||||
{viewConfig.scrollbars === "native" ? (
|
||||
<div
|
||||
data-slot="scroll-area-viewport"
|
||||
data-ec-native-scroll=""
|
||||
className="h-full overflow-y-auto"
|
||||
>
|
||||
{track}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-full">{track}</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
track
|
||||
)}
|
||||
</>
|
||||
),
|
||||
}
|
||||
|
||||
return (
|
||||
<EventCalendarViewContext.Provider value={{ view: "resource" }}>
|
||||
{useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})}
|
||||
</EventCalendarViewContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function EventCalendarResourceAllDayCell({
|
||||
resource,
|
||||
day,
|
||||
}: {
|
||||
resource: EventCalendarResource
|
||||
day: Date
|
||||
}) {
|
||||
const settings = useEventCalendarSettings()
|
||||
const viewConfig = useEventCalendarViewConfig()
|
||||
const { effective } = useEventCalendarViewSettings()
|
||||
const gestures = useEventCalendarGestures()
|
||||
const { segments } = useEventCalendarDay(day)
|
||||
const mine = segments.allDay.filter(
|
||||
(segment) => segment.occurrence.event.resourceId === resource.id
|
||||
)
|
||||
const dayStart = zonedStartOfDay(day, settings.timeZone)
|
||||
const dayEnd = addDays(toZoned(dayStart, settings.timeZone), 1)
|
||||
const isOff = resolveOffDay(
|
||||
day,
|
||||
settings.timeZone,
|
||||
effective.offDays
|
||||
? typeof viewConfig.offDays === "object"
|
||||
? viewConfig.offDays
|
||||
: true
|
||||
: false,
|
||||
settings.weekendDays
|
||||
)
|
||||
const offClassName =
|
||||
(typeof viewConfig.offDays === "object" && viewConfig.offDays.className) ||
|
||||
"bg-muted/25"
|
||||
const isDropTarget = useEventCalendarSelector<
|
||||
unknown,
|
||||
"valid" | "invalid" | null
|
||||
>((state) => {
|
||||
const drag = state.drag
|
||||
if (!drag || !drag.proposedDayGranular) return null
|
||||
const covered = drag.proposedStart < dayEnd && drag.proposedEnd > dayStart
|
||||
if (!covered) return null
|
||||
return drag.valid ? "valid" : "invalid"
|
||||
})
|
||||
// Slot-draft highlight, mirroring the time-grid all-day cell. The dnd
|
||||
// layer's all-day create branch does not plumb resourceId into the draft,
|
||||
// so every resource cell covering the day highlights together.
|
||||
const inDraft = useEventCalendarSelector<unknown, boolean>((state) => {
|
||||
const draft = state.slotDraft
|
||||
if (!draft || !draft.allDay) return false
|
||||
return draft.start < dayEnd && draft.end > dayStart
|
||||
})
|
||||
return (
|
||||
<div
|
||||
data-slot="event-calendar-all-day-cell"
|
||||
// data-ec-day makes this a DnD day target: without it collectSurface()
|
||||
// finds no cells and dragging an all-day chip silently converts it to
|
||||
// a timed event via the column branch
|
||||
data-ec-day={dayStart.getTime()}
|
||||
data-drop-target={isDropTarget ?? undefined}
|
||||
data-off={isOff || undefined}
|
||||
className={cn(
|
||||
// reserve one bar row so the all-day row keeps the same height with or
|
||||
// without events, matching the time-grid all-day row (which reserves
|
||||
// the same via its bars-grid minHeight)
|
||||
"relative flex min-h-[calc(var(--ec-month-bar-h,1.625rem)+0.625rem)] min-w-0 flex-col gap-0.5 border-e px-1 py-1.5 last:border-e-0",
|
||||
isOff && offClassName,
|
||||
viewConfig.dayClassName?.(day),
|
||||
inDraft &&
|
||||
cn(
|
||||
EVENT_CALENDAR_SLOT_DRAFT.surface,
|
||||
EVENT_CALENDAR_SLOT_DRAFT.segment,
|
||||
EVENT_CALENDAR_SLOT_DRAFT.segmentStart,
|
||||
EVENT_CALENDAR_SLOT_DRAFT.segmentEnd,
|
||||
viewConfig.classNames?.slotDraft
|
||||
),
|
||||
viewConfig.classNames?.allDayCell
|
||||
)}
|
||||
onPointerDown={(e) => {
|
||||
if (e.target === e.currentTarget) gestures.beginCreate(e, day, true)
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (
|
||||
e.target === e.currentTarget &&
|
||||
!wasRecentDrag() &&
|
||||
!wasRecentChipPress()
|
||||
) {
|
||||
settings.onSlotClick?.(
|
||||
{
|
||||
date: dayStart,
|
||||
allDay: true,
|
||||
view: "resource",
|
||||
resourceId: resource.id,
|
||||
},
|
||||
e
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{mine.map((segment) => (
|
||||
<EventCalendarEvent
|
||||
key={segment.occurrence.key}
|
||||
segment={segment}
|
||||
// one bar-row tall, matching the time-grid all-day bars so the row
|
||||
// height stays identical across views (and equals the reserved min)
|
||||
className="h-[calc(var(--ec-month-bar-h,1.625rem)-0.125rem)]"
|
||||
/>
|
||||
))}
|
||||
{isDropTarget && (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="event-calendar-drop-indicator"
|
||||
data-drop-target={isDropTarget}
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0.5 z-10 rounded-sm border border-dashed",
|
||||
isDropTarget === "valid"
|
||||
? "border-primary/50"
|
||||
: "border-destructive/60",
|
||||
viewConfig.classNames?.dropIndicator
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EventCalendarResourceColumn({
|
||||
resource,
|
||||
day,
|
||||
startHour,
|
||||
endHour,
|
||||
interval,
|
||||
}: {
|
||||
resource: EventCalendarResource
|
||||
day: Date
|
||||
startHour: number
|
||||
endHour: number
|
||||
interval: number
|
||||
}) {
|
||||
const settings = useEventCalendarSettings()
|
||||
const viewConfig = useEventCalendarViewConfig()
|
||||
const { effective } = useEventCalendarViewSettings()
|
||||
const gestures = useEventCalendarGestures()
|
||||
const { segments, isToday } = useEventCalendarDay(day)
|
||||
const isOff = resolveOffDay(
|
||||
day,
|
||||
settings.timeZone,
|
||||
effective.offDays
|
||||
? typeof viewConfig.offDays === "object"
|
||||
? viewConfig.offDays
|
||||
: true
|
||||
: false,
|
||||
settings.weekendDays
|
||||
)
|
||||
const offClassName =
|
||||
(typeof viewConfig.offDays === "object" && viewConfig.offDays.className) ||
|
||||
"bg-muted/25"
|
||||
|
||||
const timeZone = settings.timeZone
|
||||
const dayStart = zonedStartOfDay(day, timeZone)
|
||||
const totalMinutes = getDayTotalMinutes(day, timeZone)
|
||||
const boundsStartMin = startHour * 60
|
||||
const boundsEndMin = Math.min(endHour * 60, totalMinutes)
|
||||
const boundsMinutes = Math.max(60, boundsEndMin - boundsStartMin)
|
||||
|
||||
// Filter this resource's timed segments and repack per column.
|
||||
// Clones keep the shared index cache untouched. Segments the day bounds clip
|
||||
// away are dropped here too, otherwise they hold a column nobody can see and
|
||||
// leave a phantom empty half beside the first in-bounds chip.
|
||||
const packed = useMemo(() => {
|
||||
const mine = segments.timed
|
||||
.filter((segment) => {
|
||||
if (segment.occurrence.event.resourceId !== resource.id) return false
|
||||
const startMin = Math.max(segment.startMin ?? 0, boundsStartMin)
|
||||
const endMin = Math.min(segment.endMin ?? startMin, boundsEndMin)
|
||||
return endMin > boundsStartMin && startMin < boundsEndMin
|
||||
})
|
||||
.map((segment) => ({ ...segment }) as EventCalendarSegment)
|
||||
packTimedSegments(mine)
|
||||
return mine
|
||||
}, [segments.timed, resource.id, boundsStartMin, boundsEndMin])
|
||||
|
||||
const dragGhost = useEventCalendarSelector<
|
||||
unknown,
|
||||
{
|
||||
window: [number, number]
|
||||
valid: boolean
|
||||
kind: string
|
||||
color?: string
|
||||
title: string
|
||||
occurrence: EventCalendarSegment["occurrence"]
|
||||
proposedStart: Date
|
||||
proposedEnd: Date
|
||||
} | null
|
||||
>(
|
||||
(state) => {
|
||||
const drag = state.drag
|
||||
if (!drag || drag.proposedDayGranular) return null
|
||||
// Moves carry a proposedResourceId (they can cross columns); resizes stay
|
||||
// in place and leave it undefined, so fall back to the event's own
|
||||
// resource - otherwise the resize ghost is filtered out of every column.
|
||||
const targetResourceId =
|
||||
drag.proposedResourceId ?? drag.occurrence.event.resourceId
|
||||
if (targetResourceId !== resource.id) return null
|
||||
const from = Math.max(
|
||||
(drag.proposedStart.getTime() - dayStart.getTime()) / 60000,
|
||||
boundsStartMin
|
||||
)
|
||||
const to = Math.min(
|
||||
(drag.proposedEnd.getTime() - dayStart.getTime()) / 60000,
|
||||
boundsEndMin
|
||||
)
|
||||
if (to <= from) return null
|
||||
return {
|
||||
window: [from, to] as [number, number],
|
||||
valid: drag.valid,
|
||||
kind: drag.kind,
|
||||
color: drag.occurrence.event.color,
|
||||
title: drag.occurrence.event.title,
|
||||
occurrence: drag.occurrence,
|
||||
proposedStart: drag.proposedStart,
|
||||
proposedEnd: drag.proposedEnd,
|
||||
}
|
||||
},
|
||||
{
|
||||
isEqual: (a, b) =>
|
||||
a === b ||
|
||||
(a !== null &&
|
||||
b !== null &&
|
||||
a.window[0] === b.window[0] &&
|
||||
a.window[1] === b.window[1] &&
|
||||
a.valid === b.valid &&
|
||||
a.proposedStart.getTime() === b.proposedStart.getTime() &&
|
||||
a.proposedEnd.getTime() === b.proposedEnd.getTime()),
|
||||
}
|
||||
)
|
||||
|
||||
// Instants behind `draftWindow` below, for the range readout. Same
|
||||
// resource filter, so a draft on a neighbouring resource never labels this
|
||||
// column.
|
||||
const draftRange = useEventCalendarSelector<
|
||||
unknown,
|
||||
{ start: Date; end: Date } | null
|
||||
>(
|
||||
(state) => {
|
||||
const draft = state.slotDraft
|
||||
if (!draft || draft.allDay || draft.resourceId !== resource.id) {
|
||||
return null
|
||||
}
|
||||
return { start: draft.start, end: draft.end }
|
||||
},
|
||||
{
|
||||
isEqual: (a, b) =>
|
||||
a === b ||
|
||||
(a !== null &&
|
||||
b !== null &&
|
||||
a.start.getTime() === b.start.getTime() &&
|
||||
a.end.getTime() === b.end.getTime()),
|
||||
}
|
||||
)
|
||||
|
||||
const draftWindow = useEventCalendarSelector<
|
||||
unknown,
|
||||
[number, number] | null
|
||||
>(
|
||||
(state) => {
|
||||
const draft = state.slotDraft
|
||||
if (!draft || draft.allDay || draft.resourceId !== resource.id) {
|
||||
return null
|
||||
}
|
||||
const from = Math.max(
|
||||
(draft.start.getTime() - dayStart.getTime()) / 60000,
|
||||
boundsStartMin
|
||||
)
|
||||
const to = Math.min(
|
||||
(draft.end.getTime() - dayStart.getTime()) / 60000,
|
||||
boundsEndMin
|
||||
)
|
||||
return to > from ? [from, to] : null
|
||||
},
|
||||
{
|
||||
isEqual: (a, b) =>
|
||||
a === b || (a !== null && b !== null && a[0] === b[0] && a[1] === b[1]),
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="event-calendar-day-column"
|
||||
data-today={isToday || undefined}
|
||||
data-off={isOff || undefined}
|
||||
data-ec-day={dayStart.getTime()}
|
||||
data-ec-bounds-start={boundsStartMin}
|
||||
data-ec-bounds-end={boundsEndMin}
|
||||
data-ec-resource={resource.id}
|
||||
data-drop-target={
|
||||
dragGhost ? (dragGhost.valid ? "valid" : "invalid") : undefined
|
||||
}
|
||||
role="group"
|
||||
aria-label={resource.title}
|
||||
className={cn(
|
||||
"relative min-w-0 border-e last:border-e-0",
|
||||
isOff && offClassName,
|
||||
// the resource view is a single day, so today gets no column tint (the
|
||||
// header marks it); only a consumer todayClassName can tint it
|
||||
isToday && viewConfig.todayClassName,
|
||||
viewConfig.dayClassName?.(day),
|
||||
viewConfig.classNames?.dayColumn
|
||||
)}
|
||||
style={{
|
||||
height: `calc(var(--ec-hour-height) * ${boundsMinutes / 60})`,
|
||||
backgroundImage: `repeating-linear-gradient(to bottom, transparent, transparent calc(var(--ec-hour-height) * ${interval / 60} - var(--ec-slot-line-width, 1px)), var(--ec-slot-line-color, var(--color-border)) calc(var(--ec-hour-height) * ${interval / 60} - var(--ec-slot-line-width, 1px)), var(--ec-slot-line-color, var(--color-border)) calc(var(--ec-hour-height) * ${interval / 60}))`,
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.target === e.currentTarget) gestures.beginCreate(e, day, false)
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (
|
||||
e.target !== e.currentTarget ||
|
||||
wasRecentDrag() ||
|
||||
wasRecentChipPress()
|
||||
)
|
||||
return
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const pxPerMinute = rect.height / boundsMinutes
|
||||
const minutes = snapMinutes(
|
||||
boundsStartMin + (e.clientY - rect.top) / pxPerMinute,
|
||||
settings.snapDuration
|
||||
)
|
||||
const clamped = Math.min(
|
||||
Math.max(minutes, boundsStartMin),
|
||||
boundsEndMin - settings.slotDuration
|
||||
)
|
||||
settings.onSlotClick?.(
|
||||
{
|
||||
date: addMinutes(dayStart, clamped),
|
||||
end: addMinutes(dayStart, clamped + settings.slotDuration),
|
||||
allDay: false,
|
||||
view: "resource",
|
||||
resourceId: resource.id,
|
||||
},
|
||||
e
|
||||
)
|
||||
}}
|
||||
>
|
||||
{packed.map((segment) => {
|
||||
const startMin = Math.max(segment.startMin ?? 0, boundsStartMin)
|
||||
const endMin = Math.min(segment.endMin ?? startMin, boundsEndMin)
|
||||
if (endMin <= boundsStartMin || startMin >= boundsEndMin) return null
|
||||
const columnCount = segment.columnCount ?? 1
|
||||
const column = segment.column ?? 0
|
||||
const span = segment.columnSpan ?? 1
|
||||
const zIndex = segment.occurrence.event.zIndex ?? 10 + column
|
||||
// Strict side-by-side columns - no cascade overlap (fade-truncate +
|
||||
// hover reveal carry the legibility); the ring separates neighbors.
|
||||
const colPct = 100 / columnCount
|
||||
return (
|
||||
<div
|
||||
key={segment.occurrence.key}
|
||||
// min-h keeps 15-min chips readable (Google-style: the block may
|
||||
// slightly outgrow its true window); hover raises a squeezed
|
||||
// cascade chip above its overlapping neighbors
|
||||
className="absolute z-(--ec-z) min-h-(--ec-event-min-h,1.5rem) px-0.5 hover:z-40"
|
||||
style={
|
||||
{
|
||||
...minuteBlockStyle(startMin, endMin, boundsStartMin),
|
||||
left: `${column * colPct}%`,
|
||||
width: `${span * colPct}%`,
|
||||
"--ec-z": zIndex,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<EventCalendarEvent
|
||||
segment={segment}
|
||||
className={cn(
|
||||
columnCount > 1 && "ring-background ring-1",
|
||||
// short chips: single centered row, exact-fit line height so
|
||||
// the title never slices mid-glyph
|
||||
endMin - startMin < viewConfig.compactEventMinutes
|
||||
? "h-full gap-1 py-0 leading-4"
|
||||
: "h-full flex-col items-start justify-start gap-0 py-1",
|
||||
viewConfig.classNames?.timedChip
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* Standardized ghost (EVENT_CALENDAR_GHOST): faint drop placeholder
|
||||
for moves (the cursor-attached carry clone owns the visual), dashed
|
||||
clone for resizes, destructive marking when invalid. */}
|
||||
{dragGhost && (
|
||||
<div
|
||||
data-slot="event-calendar-drag-ghost"
|
||||
data-kind={dragGhost.kind}
|
||||
data-drop-invalid={!dragGhost.valid || undefined}
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0.5 z-50 min-h-(--ec-event-min-h,1.5rem)",
|
||||
dragGhost.kind === "move"
|
||||
? cn(
|
||||
EVENT_CALENDAR_GHOST.move,
|
||||
!dragGhost.valid && EVENT_CALENDAR_GHOST.invalid
|
||||
)
|
||||
: cn(
|
||||
EVENT_CALENDAR_GHOST.resize,
|
||||
!dragGhost.valid && EVENT_CALENDAR_GHOST.invalidResize
|
||||
),
|
||||
viewConfig.classNames?.dragGhost
|
||||
)}
|
||||
style={
|
||||
{
|
||||
...minuteBlockStyle(
|
||||
dragGhost.window[0],
|
||||
dragGhost.window[1],
|
||||
boundsStartMin
|
||||
),
|
||||
"--ec-event-color": dragGhost.color ?? "var(--color-primary)",
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{dragGhost.kind !== "move" && (
|
||||
<EventCalendarEvent
|
||||
preview
|
||||
segment={{
|
||||
occurrence: {
|
||||
...dragGhost.occurrence,
|
||||
start: dragGhost.proposedStart,
|
||||
end: dragGhost.proposedEnd,
|
||||
allDay: false,
|
||||
},
|
||||
day,
|
||||
isStart: true,
|
||||
isEnd: true,
|
||||
continuesBefore: false,
|
||||
continuesAfter: false,
|
||||
startMin: dragGhost.window[0],
|
||||
endMin: dragGhost.window[1],
|
||||
}}
|
||||
className={cn(
|
||||
dragGhost.window[1] - dragGhost.window[0] <
|
||||
viewConfig.compactEventMinutes
|
||||
? "h-full gap-1 py-0 leading-4"
|
||||
: "h-full flex-col items-start justify-start gap-0 py-1",
|
||||
viewConfig.classNames?.timedChip,
|
||||
"inset-ring-0",
|
||||
!dragGhost.valid && EVENT_CALENDAR_GHOST.invalidContent
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{draftWindow && (
|
||||
<div
|
||||
data-slot="event-calendar-slot-draft"
|
||||
className={cn(
|
||||
EVENT_CALENDAR_SLOT_DRAFT.box,
|
||||
"pointer-events-none absolute inset-x-0.5 z-40 overflow-hidden",
|
||||
viewConfig.classNames?.slotDraft
|
||||
)}
|
||||
style={minuteBlockStyle(
|
||||
draftWindow[0],
|
||||
draftWindow[1],
|
||||
boundsStartMin
|
||||
)}
|
||||
>
|
||||
{draftRange && (
|
||||
<span className={cn("block", EVENT_CALENDAR_SLOT_DRAFT.label)}>
|
||||
{settings.i18n.functions.formatEventTime(
|
||||
toZoned(draftRange.start, settings.timeZone),
|
||||
toZoned(draftRange.end, settings.timeZone),
|
||||
false,
|
||||
{ locale: settings.locale }
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { EventCalendarResourceView }
|
||||
export type { EventCalendarResourceViewProps }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,240 +0,0 @@
|
||||
type EventCalendarEventId = string
|
||||
|
||||
type CalendarView = "month" | "week" | "day" | "days" | "agenda" | "resource"
|
||||
|
||||
/** Bookable resource. The resource view flattens children and renders leaves
|
||||
* only, as booking columns; a parent's own title never renders. */
|
||||
interface EventCalendarResource {
|
||||
id: string
|
||||
title: string
|
||||
color?: string
|
||||
children?: EventCalendarResource[]
|
||||
}
|
||||
|
||||
/** Half-open: start is inclusive, end is exclusive. */
|
||||
interface EventCalendarDateRange {
|
||||
start: Date
|
||||
end: Date
|
||||
}
|
||||
|
||||
type EventCalendarWeekday = "MO" | "TU" | "WE" | "TH" | "FR" | "SA" | "SU"
|
||||
|
||||
interface EventCalendarRecurrenceRule {
|
||||
freq: "daily" | "weekly" | "monthly" | "yearly"
|
||||
interval?: number
|
||||
count?: number
|
||||
/** Inclusive instant. */
|
||||
until?: Date
|
||||
byWeekday?: Array<
|
||||
EventCalendarWeekday | { day: EventCalendarWeekday; ordinal: number }
|
||||
>
|
||||
byMonthDay?: number[]
|
||||
byMonth?: number[]
|
||||
weekStart?: EventCalendarWeekday
|
||||
exDates?: Date[]
|
||||
rDates?: Date[]
|
||||
}
|
||||
|
||||
interface CalendarEvent<TData = unknown> {
|
||||
id: EventCalendarEventId
|
||||
title: string
|
||||
start: Date
|
||||
/** Exclusive; must be >= start. */
|
||||
end: Date
|
||||
/** Start and end must be midnights in the display time zone: segmentation
|
||||
* walks raw instants, so another zone's midnight paints the wrong days. */
|
||||
allDay?: boolean
|
||||
/** Structured rule or a raw "RRULE:..." line. */
|
||||
recurrence?: EventCalendarRecurrenceRule | string
|
||||
/** This event is an edited single occurrence of that series. */
|
||||
recurringEventId?: EventCalendarEventId
|
||||
/** Which occurrence it replaces (RECURRENCE-ID semantics). */
|
||||
originalStart?: Date
|
||||
/** Token or css color; flows to the --ec-event-color css var. */
|
||||
color?: string
|
||||
/** Excluded from drag and resize regardless of interactions state. */
|
||||
readOnly?: boolean
|
||||
/** Per-event overrides; defaults come from interactions.drag / .resize. */
|
||||
draggable?: boolean
|
||||
resizable?: boolean
|
||||
/** Packing prominence; feeds getEventPriority ordering. */
|
||||
priority?: number
|
||||
/** Verbatim stacking override; replaces the computed 10 + column. */
|
||||
zIndex?: number
|
||||
resourceId?: string
|
||||
data?: TData
|
||||
}
|
||||
|
||||
interface EventCalendarOccurrence<TData = unknown> {
|
||||
/** Stable per instance: `${event.id}::${startISO}`. */
|
||||
key: string
|
||||
eventId: EventCalendarEventId
|
||||
event: CalendarEvent<TData>
|
||||
start: Date
|
||||
end: Date
|
||||
allDay: boolean
|
||||
isRecurring: boolean
|
||||
recurrenceIndex?: number
|
||||
}
|
||||
|
||||
interface EventCalendarSegment<TData = unknown> {
|
||||
occurrence: EventCalendarOccurrence<TData>
|
||||
/** Zoned midnight of the segment's day; startMin/endMin count from it. */
|
||||
day: Date
|
||||
isStart: boolean
|
||||
isEnd: boolean
|
||||
continuesBefore: boolean
|
||||
continuesAfter: boolean
|
||||
/** Timed only: minutes from the zoned day start. */
|
||||
startMin?: number
|
||||
endMin?: number
|
||||
/** Layout output: lane stacks month bars and all-day lanes;
|
||||
* column/columnCount/columnSpan pack time-grid overlaps;
|
||||
* rowIndex/colStart/colSpan place a bar in the week row. */
|
||||
lane?: number
|
||||
column?: number
|
||||
columnCount?: number
|
||||
columnSpan?: number
|
||||
rowIndex?: number
|
||||
colStart?: number
|
||||
colSpan?: number
|
||||
}
|
||||
|
||||
interface EventCalendarSelection {
|
||||
eventKeys: string[]
|
||||
/** Committed slot selection; EventCalendarSlotDraft holds the in-gesture one. */
|
||||
slot: { start: Date; end: Date; allDay: boolean } | null
|
||||
}
|
||||
|
||||
interface EventCalendarInteractions {
|
||||
drag: boolean
|
||||
resize: boolean
|
||||
selectSlot: boolean
|
||||
}
|
||||
|
||||
interface EventCalendarDragState<TData = unknown> {
|
||||
kind: "move" | "resize-start" | "resize-end"
|
||||
occurrence: EventCalendarOccurrence<TData>
|
||||
proposedStart: Date
|
||||
proposedEnd: Date
|
||||
proposedAllDay: boolean
|
||||
/** Day-granular proposal (month cells, all-day lane), not minute columns; the
|
||||
* all-day ghost keys on it: proposedAllDay misses timed MULTI-DAY bars. */
|
||||
proposedDayGranular: boolean
|
||||
proposedResourceId?: string
|
||||
/** Last canDropEvent verdict; drives data-drop-invalid styling. */
|
||||
valid: boolean
|
||||
}
|
||||
|
||||
/** The in-progress drag-create rectangle only; cleared on commit or cancel. */
|
||||
interface EventCalendarSlotDraft {
|
||||
start: Date
|
||||
end: Date
|
||||
allDay: boolean
|
||||
view: CalendarView
|
||||
resourceId?: string
|
||||
}
|
||||
|
||||
/** "View settings" toggles; undefined defers to the root view-config prop. */
|
||||
interface EventCalendarViewSettings {
|
||||
weekends?: boolean
|
||||
weekNumbers?: boolean
|
||||
nowIndicator?: boolean
|
||||
offDays?: boolean
|
||||
}
|
||||
|
||||
interface EventCalendarState<TData = unknown> {
|
||||
view: CalendarView
|
||||
/** Anchor date; navigation steps this, and both ranges derive from it. */
|
||||
date: Date
|
||||
/** Read by the "days" view only; other views ignore it. */
|
||||
dayCount: number
|
||||
/** Full rendered grid incl. outside days - fetch remote data for THIS. */
|
||||
visibleRange: EventCalendarDateRange
|
||||
/** The logical period (the month/week itself). */
|
||||
activeRange: EventCalendarDateRange
|
||||
events: CalendarEvent<TData>[]
|
||||
selection: EventCalendarSelection
|
||||
interactions: EventCalendarInteractions
|
||||
loading: boolean
|
||||
drag: EventCalendarDragState<TData> | null
|
||||
slotDraft: EventCalendarSlotDraft | null
|
||||
viewSettings: EventCalendarViewSettings
|
||||
}
|
||||
|
||||
interface EventCalendarRangeInfo {
|
||||
range: EventCalendarDateRange
|
||||
activeRange: EventCalendarDateRange
|
||||
view: CalendarView
|
||||
date: Date
|
||||
timeZone: string
|
||||
}
|
||||
|
||||
interface EventCalendarProposedUpdate<TData = unknown> {
|
||||
event: CalendarEvent<TData>
|
||||
/** null when source === "api". */
|
||||
occurrence: EventCalendarOccurrence<TData> | null
|
||||
start: Date
|
||||
end: Date
|
||||
allDay: boolean
|
||||
resourceId?: string
|
||||
source: "drag" | "resize-start" | "resize-end" | "keyboard" | "api"
|
||||
}
|
||||
|
||||
/** false = reject/revert; void or true = accept; object = accept with adjustment. */
|
||||
type EventCalendarUpdateResult =
|
||||
| boolean
|
||||
| void
|
||||
| { start?: Date; end?: Date; allDay?: boolean }
|
||||
|
||||
/** A click is a point, not a range; `end` is present for timed slots. */
|
||||
interface EventCalendarSlotInfo {
|
||||
date: Date
|
||||
end?: Date
|
||||
allDay: boolean
|
||||
view: CalendarView
|
||||
resourceId?: string
|
||||
}
|
||||
|
||||
/** Off-days; `true` = weekend defaults. Marked cells carry `data-off`. */
|
||||
interface EventCalendarOffDaysConfig {
|
||||
/** Weekday numbers treated as off (0 = Sunday). Default [0, 6]. */
|
||||
weekendDays?: number[]
|
||||
/** Additional explicit off dates (compared by day in the display zone). */
|
||||
dates?: Date[]
|
||||
/** Full custom predicate; runs in addition to weekendDays/dates. */
|
||||
isOffDay?: (day: Date) => boolean
|
||||
/** Marker classes; default "bg-muted/25". */
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface EventCalendarDataAdapter<TData = unknown> {
|
||||
getEvents(
|
||||
range: EventCalendarDateRange,
|
||||
signal?: AbortSignal
|
||||
): Promise<CalendarEvent<TData>[]>
|
||||
}
|
||||
|
||||
export type {
|
||||
CalendarEvent,
|
||||
CalendarView,
|
||||
EventCalendarDataAdapter,
|
||||
EventCalendarDateRange,
|
||||
EventCalendarDragState,
|
||||
EventCalendarEventId,
|
||||
EventCalendarInteractions,
|
||||
EventCalendarOccurrence,
|
||||
EventCalendarOffDaysConfig,
|
||||
EventCalendarProposedUpdate,
|
||||
EventCalendarRangeInfo,
|
||||
EventCalendarRecurrenceRule,
|
||||
EventCalendarResource,
|
||||
EventCalendarSegment,
|
||||
EventCalendarSelection,
|
||||
EventCalendarSlotDraft,
|
||||
EventCalendarSlotInfo,
|
||||
EventCalendarState,
|
||||
EventCalendarViewSettings,
|
||||
EventCalendarUpdateResult,
|
||||
EventCalendarWeekday,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,143 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { addHours, parseISO } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { EventCalendar } from '@/components/reui/event-calendar/event-calendar'
|
||||
import { EventCalendarContent } from '@/components/reui/event-calendar/event-calendar-content'
|
||||
import { EventCalendarNav } from '@/components/reui/event-calendar/event-calendar-nav'
|
||||
import type { CalendarEvent } from '@/components/reui/event-calendar/event-calendar-types'
|
||||
import { jobKindRu } from '@/lib/ui-labels'
|
||||
import type { JobRow } from '@/types/api'
|
||||
|
||||
/**
|
||||
* Schedule calendar — ReUI EventCalendar (month + agenda).
|
||||
* @see https://reui.io/preview/base/components/c-event-calendar-1
|
||||
* @see https://reui.io/docs/components/base/event-calendar
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
|
||||
const CALENDAR_I18N = {
|
||||
labels: {
|
||||
today: 'Сегодня',
|
||||
previous: 'Назад',
|
||||
next: 'Вперёд',
|
||||
addEvent: 'Добавить',
|
||||
allDay: 'Весь день',
|
||||
more: (count: number) => `ещё ${count}`,
|
||||
noEvents: 'Нет задач',
|
||||
loading: 'Загрузка…',
|
||||
event: 'задача',
|
||||
events: (count: number) =>
|
||||
count === 1 ? '1 задача' : count < 5 ? `${count} задачи` : `${count} задач`,
|
||||
selectView: 'Вид',
|
||||
week: (weekNumber: number) => `Н${weekNumber}`,
|
||||
resources: 'Ресурсы',
|
||||
goToDate: 'Перейти к дате',
|
||||
dropNotAllowed: 'Сюда нельзя',
|
||||
continues: 'продолжается',
|
||||
timeFrom: (time: string) => `с ${time}`,
|
||||
timeUntil: (time: string) => `до ${time}`,
|
||||
moreCompact: (count: number) => `+${count}`,
|
||||
timeRange: (from: string, to: string) => `${from}–${to}`,
|
||||
},
|
||||
viewNames: {
|
||||
month: 'Месяц',
|
||||
week: 'Неделя',
|
||||
day: 'День',
|
||||
agenda: 'Повестка',
|
||||
resource: 'Сетка',
|
||||
},
|
||||
formats: {
|
||||
monthTitle: 'LLLL yyyy',
|
||||
dayTitle: 'EEEE, d MMMM yyyy',
|
||||
monthDayHeader: 'EEEEE',
|
||||
agendaDayHeader: 'EEEE, d MMMM',
|
||||
eventTime: 'HH:mm',
|
||||
timeGutter: 'HH:mm',
|
||||
},
|
||||
}
|
||||
|
||||
function jobTimestamp(job: JobRow): string | undefined {
|
||||
return job.created_at ?? job.started_at ?? job.finished_at ?? undefined
|
||||
}
|
||||
|
||||
function jobEventColor(status: string): string {
|
||||
const s = status.toLowerCase()
|
||||
if (s === 'succeeded') return 'var(--success)'
|
||||
if (s === 'running' || s === 'queued') return 'var(--warning)'
|
||||
if (s === 'failed' || s === 'error' || s === 'cancelled') return 'var(--destructive)'
|
||||
return 'var(--primary)'
|
||||
}
|
||||
|
||||
function jobsToEvents(jobs: JobRow[]): CalendarEvent<JobRow>[] {
|
||||
const events: CalendarEvent<JobRow>[] = []
|
||||
for (const job of jobs) {
|
||||
const raw = jobTimestamp(job)
|
||||
if (!raw) continue
|
||||
let start: Date
|
||||
try {
|
||||
start = parseISO(raw)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (Number.isNaN(start.getTime())) continue
|
||||
let end = job.finished_at ? parseISO(job.finished_at) : addHours(start, 1)
|
||||
if (Number.isNaN(end.getTime()) || end <= start) {
|
||||
end = addHours(start, 1)
|
||||
}
|
||||
events.push({
|
||||
id: job.job_id,
|
||||
title: jobKindRu(job.kind),
|
||||
start,
|
||||
end,
|
||||
readOnly: true,
|
||||
draggable: false,
|
||||
resizable: false,
|
||||
color: jobEventColor(job.status),
|
||||
data: job,
|
||||
})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
export function ScheduleAgendaPanel({
|
||||
jobs,
|
||||
isLoading,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const events = useMemo(() => jobsToEvents(jobs), [jobs])
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm" className="w-full min-w-0">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Календарь задач</FrameTitle>
|
||||
<FrameDescription>Задачи обновления и применения по дням</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<EventCalendar
|
||||
className="h-[560px]"
|
||||
events={events}
|
||||
loading={isLoading}
|
||||
defaultView="month"
|
||||
views={['month', 'agenda']}
|
||||
locale={ru}
|
||||
weekStartsOn={1}
|
||||
interactions={{ drag: false, resize: false, selectSlot: false }}
|
||||
i18n={CALENDAR_I18N}
|
||||
>
|
||||
<EventCalendarNav />
|
||||
<EventCalendarContent />
|
||||
</EventCalendar>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -96,6 +96,7 @@ export function ScheduleModulesGrid({
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
size: 104,
|
||||
header: () => null,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">
|
||||
|
||||
+12
-14
@@ -5,7 +5,7 @@ import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
|
||||
export type BadgeTabItem = {
|
||||
export type SegmentedTabItem = {
|
||||
value: string
|
||||
label: string
|
||||
count?: number
|
||||
@@ -13,8 +13,8 @@ export type BadgeTabItem = {
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
interface BadgeTabsProps {
|
||||
items: BadgeTabItem[]
|
||||
interface SegmentedTabsProps {
|
||||
items: SegmentedTabItem[]
|
||||
value?: string
|
||||
defaultValue?: string
|
||||
onValueChange?: (value: string) => void
|
||||
@@ -24,8 +24,12 @@ interface BadgeTabsProps {
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
/** Underline tabs with optional badge counts (ReUI c-tabs-7 pattern). */
|
||||
export function BadgeTabs({
|
||||
/**
|
||||
* Page-level section switcher — native shadcn Tabs, default (segmented) list.
|
||||
* @see https://reui.io/preview/base/components/c-tabs-9
|
||||
* @see https://reui.io/components/tabs
|
||||
*/
|
||||
export function SegmentedTabs({
|
||||
items,
|
||||
value,
|
||||
defaultValue,
|
||||
@@ -34,22 +38,16 @@ export function BadgeTabs({
|
||||
className,
|
||||
listClassName,
|
||||
contentClassName,
|
||||
}: BadgeTabsProps) {
|
||||
}: SegmentedTabsProps) {
|
||||
return (
|
||||
<Tabs
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={onValueChange}
|
||||
orientation="horizontal"
|
||||
className={cn('w-full min-w-0', className)}
|
||||
className={cn('w-full min-w-0 gap-4', className)}
|
||||
>
|
||||
<TabsList
|
||||
variant="line"
|
||||
className={cn(
|
||||
'mb-3.5 w-full min-w-0 justify-start gap-4',
|
||||
listClassName,
|
||||
)}
|
||||
>
|
||||
<TabsList className={cn('h-auto w-full min-w-0 justify-start', listClassName)}>
|
||||
{items.map((item) => (
|
||||
<TabsTrigger key={item.value} value={item.value} className="shrink-0 gap-2">
|
||||
{item.icon}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
|
||||
import { KpiStatGrid } from '@/components/kpi-stat-grid'
|
||||
import { panelCardInsetClassName } from '@/components/panel-card'
|
||||
import { KpiStatGrid, panelCardInsetClassName } from '@/components/reui-kit'
|
||||
import {
|
||||
chartPanelGridClassName,
|
||||
dashboardMainSidebarClassName,
|
||||
@@ -10,23 +9,14 @@ import {
|
||||
export function KpiStatGridSkeleton({ count = 4 }: { count?: number }) {
|
||||
return (
|
||||
<KpiStatGrid
|
||||
items={Array.from({ length: count }, (_, index) => ({
|
||||
id: `kpi-skeleton-${index}`,
|
||||
icon: <Skeleton className="size-4 rounded-sm" />,
|
||||
value: <Skeleton className="h-8 w-16" />,
|
||||
label: <Skeleton className="h-4 w-28" />,
|
||||
footer: <Skeleton className="h-5 w-32 rounded-full" />,
|
||||
}))}
|
||||
items={[]}
|
||||
isLoading
|
||||
skeletonCount={count}
|
||||
aria-label="Загрузка показателей"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** @deprecated Use KpiStatGridSkeleton */
|
||||
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
|
||||
return <KpiStatGridSkeleton count={count} />
|
||||
}
|
||||
|
||||
/** Matches dashboard layout: KPI row, charts, main+sidebar, bottom grids. */
|
||||
export function AnalyticsDashboardSkeleton() {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
@@ -42,6 +42,13 @@ const DOT_COLOR: Record<string, string> = {
|
||||
outline: 'bg-muted-foreground',
|
||||
}
|
||||
|
||||
const TONE_VARIANT: Record<string, BadgeVariant> = {
|
||||
neutral: 'outline',
|
||||
info: 'info-light',
|
||||
warning: 'warning-light',
|
||||
success: 'success-light',
|
||||
}
|
||||
|
||||
export function jobStatusVariant(status: string): BadgeVariant {
|
||||
return STATUS_VARIANT[status.toLowerCase()] ?? 'outline'
|
||||
}
|
||||
@@ -70,3 +77,41 @@ export function StatusBadge({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { CircleCheck, CircleOff, Clock, ListFilter } from 'lucide-react'
|
||||
|
||||
import { ToggleGroup, ToggleGroupItem } from '@evobgp/ui/components/toggle-group'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
|
||||
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
||||
|
||||
export type StatusToggleTone = 'neutral' | 'success' | 'warning' | 'info' | 'destructive'
|
||||
|
||||
export type StatusToggleItem = {
|
||||
id: string
|
||||
label: string
|
||||
count?: number
|
||||
icon?: LucideIcon
|
||||
tone?: StatusToggleTone
|
||||
}
|
||||
|
||||
interface StatusToggleGroupProps {
|
||||
items: StatusToggleItem[]
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
className?: string
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
const TONE_BY_ID: Record<string, StatusToggleTone> = {
|
||||
all: 'neutral',
|
||||
enabled: 'success',
|
||||
online: 'success',
|
||||
established: 'success',
|
||||
succeeded: 'success',
|
||||
pending: 'warning',
|
||||
queued: 'warning',
|
||||
running: 'info',
|
||||
active: 'info',
|
||||
disabled: 'destructive',
|
||||
offline: 'destructive',
|
||||
failed: 'destructive',
|
||||
}
|
||||
|
||||
const ICON_BY_ID: Record<string, LucideIcon> = {
|
||||
all: ListFilter,
|
||||
enabled: CircleCheck,
|
||||
online: CircleCheck,
|
||||
established: CircleCheck,
|
||||
succeeded: CircleCheck,
|
||||
pending: Clock,
|
||||
queued: Clock,
|
||||
running: Clock,
|
||||
active: Clock,
|
||||
disabled: CircleOff,
|
||||
offline: CircleOff,
|
||||
failed: CircleOff,
|
||||
}
|
||||
|
||||
const BADGE_VARIANT: Record<StatusToggleTone, BadgeVariant> = {
|
||||
neutral: 'outline',
|
||||
success: 'success-light',
|
||||
warning: 'warning-light',
|
||||
info: 'info-light',
|
||||
destructive: 'destructive-light',
|
||||
}
|
||||
|
||||
const ICON_CLASS: Record<StatusToggleTone, string> = {
|
||||
neutral: 'text-muted-foreground',
|
||||
success: 'text-success',
|
||||
warning: 'text-warning',
|
||||
info: 'text-info',
|
||||
destructive: 'text-destructive',
|
||||
}
|
||||
|
||||
function resolveTone(item: StatusToggleItem): StatusToggleTone {
|
||||
return item.tone ?? TONE_BY_ID[item.id] ?? 'neutral'
|
||||
}
|
||||
|
||||
function resolveIcon(item: StatusToggleItem): LucideIcon {
|
||||
return item.icon ?? ICON_BY_ID[item.id] ?? ListFilter
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid status filter — native ToggleGroup in the ResourcePage toolbar.
|
||||
* @see https://reui.io/preview/base/components/c-toggle-group-5
|
||||
* @see https://reui.io/preview/base/components/c-toggle-group-13
|
||||
* @see https://reui.io/components/toggle-group
|
||||
* @see https://reui.io/docs/components/base/badge
|
||||
*/
|
||||
export function StatusToggleGroup({
|
||||
items,
|
||||
value,
|
||||
onValueChange,
|
||||
className,
|
||||
'aria-label': ariaLabel = 'Статус',
|
||||
}: StatusToggleGroupProps) {
|
||||
return (
|
||||
<ToggleGroup
|
||||
multiple={false}
|
||||
value={[value]}
|
||||
onValueChange={(values) => {
|
||||
const next = values[0]
|
||||
if (next) onValueChange(next)
|
||||
}}
|
||||
variant="outline"
|
||||
size="default"
|
||||
spacing={0}
|
||||
aria-label={ariaLabel}
|
||||
className={cn('w-fit', className)}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const tone = resolveTone(item)
|
||||
const Icon = resolveIcon(item)
|
||||
return (
|
||||
<ToggleGroupItem key={item.id} value={item.id} className="gap-1.5 px-2.5">
|
||||
<Icon
|
||||
className={cn('size-3.5', ICON_CLASS[tone])}
|
||||
data-icon="inline-start"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
{item.count !== undefined ? (
|
||||
<Badge variant={BADGE_VARIANT[tone]} size="xs" radius="full" className="tabular-nums">
|
||||
{item.count}
|
||||
</Badge>
|
||||
) : null}
|
||||
</ToggleGroupItem>
|
||||
)
|
||||
})}
|
||||
</ToggleGroup>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
const AnthropicBlack = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
{...props}
|
||||
fillRule="evenodd"
|
||||
style={{ flex: "none", lineHeight: "1" }}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<title>Anthropic</title>
|
||||
<path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export { AnthropicBlack };
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
const AnthropicWhite = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
{...props}
|
||||
fill="#ffff"
|
||||
fillRule="evenodd"
|
||||
style={{ flex: "none", lineHeight: "1" }}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<title>Anthropic</title>
|
||||
<path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export { AnthropicWhite };
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
const Convex = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} viewBox="28 28 128 132" fill="none">
|
||||
<path
|
||||
fill="#F3B01C"
|
||||
d="M108.092 130.021c18.166-2.018 35.293-11.698 44.723-27.854-4.466 39.961-48.162 65.218-83.83 49.711-3.286-1.425-6.115-3.796-8.056-6.844-8.016-12.586-10.65-28.601-6.865-43.135 10.817 18.668 32.81 30.111 54.028 28.122Z"
|
||||
/>
|
||||
<path
|
||||
fill="#8D2676"
|
||||
d="M53.401 90.174c-7.364 17.017-7.682 36.94 1.345 53.336-31.77-23.902-31.423-75.052-.388-98.715 2.87-2.187 6.282-3.485 9.86-3.683 14.713-.776 29.662 4.91 40.146 15.507-21.3.212-42.046 13.857-50.963 33.555Z"
|
||||
/>
|
||||
<path
|
||||
fill="#EE342F"
|
||||
d="M114.637 61.855C103.89 46.87 87.069 36.668 68.639 36.358c35.625-16.17 79.446 10.047 84.217 48.807.444 3.598-.139 7.267-1.734 10.512-6.656 13.518-18.998 24.002-33.42 27.882 10.567-19.599 9.263-43.544-3.065-61.704Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export { Convex };
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
const Discord = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} viewBox="0 0 256 199" preserveAspectRatio="xMidYMid">
|
||||
<path
|
||||
d="M216.856 16.597A208.502 208.502 0 0 0 164.042 0c-2.275 4.113-4.933 9.645-6.766 14.046-19.692-2.961-39.203-2.961-58.533 0-1.832-4.4-4.55-9.933-6.846-14.046a207.809 207.809 0 0 0-52.855 16.638C5.618 67.147-3.443 116.4 1.087 164.956c22.169 16.555 43.653 26.612 64.775 33.193A161.094 161.094 0 0 0 79.735 175.3a136.413 136.413 0 0 1-21.846-10.632 108.636 108.636 0 0 0 5.356-4.237c42.122 19.702 87.89 19.702 129.51 0a131.66 131.66 0 0 0 5.355 4.237 136.07 136.07 0 0 1-21.886 10.653c4.006 8.02 8.638 15.67 13.873 22.848 21.142-6.58 42.646-16.637 64.815-33.213 5.316-56.288-9.08-105.09-38.056-148.36ZM85.474 135.095c-12.645 0-23.015-11.805-23.015-26.18s10.149-26.2 23.015-26.2c12.867 0 23.236 11.804 23.015 26.2.02 14.375-10.148 26.18-23.015 26.18Zm85.051 0c-12.645 0-23.014-11.805-23.014-26.18s10.148-26.2 23.014-26.2c12.867 0 23.236 11.804 23.015 26.2 0 14.375-10.148 26.18-23.015 26.18Z"
|
||||
fill="#5865F2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export { Discord };
|
||||
@@ -1,171 +0,0 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
const Gemini = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} viewBox="0 0 296 298" fill="none">
|
||||
<mask
|
||||
id="a"
|
||||
width="296"
|
||||
height="298"
|
||||
x="0"
|
||||
y="0"
|
||||
maskUnits="userSpaceOnUse"
|
||||
style={{ maskType: "alpha" }}
|
||||
>
|
||||
<path
|
||||
fill="#3186FF"
|
||||
d="M141.201 4.886c2.282-6.17 11.042-6.071 13.184.148l5.985 17.37a184.004 184.004 0 0 0 111.257 113.049l19.304 6.997c6.143 2.227 6.156 10.91.02 13.155l-19.35 7.082a184.001 184.001 0 0 0-109.495 109.385l-7.573 20.629c-2.241 6.105-10.869 6.121-13.133.025l-7.908-21.296a184 184 0 0 0-109.02-108.658l-19.698-7.239c-6.102-2.243-6.118-10.867-.025-13.132l20.083-7.467A183.998 183.998 0 0 0 133.291 26.28l7.91-21.394Z"
|
||||
/>
|
||||
</mask>
|
||||
<g mask="url(#a)">
|
||||
<g filter="url(#b)">
|
||||
<ellipse cx="163" cy="149" fill="#3689FF" rx="196" ry="159" />
|
||||
</g>
|
||||
<g filter="url(#c)">
|
||||
<ellipse cx="33.5" cy="142.5" fill="#F6C013" rx="68.5" ry="72.5" />
|
||||
</g>
|
||||
<g filter="url(#d)">
|
||||
<ellipse cx="19.5" cy="148.5" fill="#F6C013" rx="68.5" ry="72.5" />
|
||||
</g>
|
||||
<g filter="url(#e)">
|
||||
<path
|
||||
fill="#FA4340"
|
||||
d="M194 10.5C172 82.5 65.5 134.333 22.5 135L144-66l50 76.5Z"
|
||||
/>
|
||||
</g>
|
||||
<g filter="url(#f)">
|
||||
<path
|
||||
fill="#FA4340"
|
||||
d="M190.5-12.5C168.5 59.5 62 111.333 19 112L140.5-89l50 76.5Z"
|
||||
/>
|
||||
</g>
|
||||
<g filter="url(#g)">
|
||||
<path
|
||||
fill="#14BB69"
|
||||
d="M194.5 279.5C172.5 207.5 66 155.667 23 155l121.5 201 50-76.5Z"
|
||||
/>
|
||||
</g>
|
||||
<g filter="url(#h)">
|
||||
<path
|
||||
fill="#14BB69"
|
||||
d="M196.5 320.5C174.5 248.5 68 196.667 25 196l121.5 201 50-76.5Z"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="b"
|
||||
width="464"
|
||||
height="390"
|
||||
x="-69"
|
||||
y="-46"
|
||||
colorInterpolationFilters="sRGB"
|
||||
filterUnits="userSpaceOnUse"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur
|
||||
result="effect1_foregroundBlur_69_17998"
|
||||
stdDeviation="18"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="c"
|
||||
width="265"
|
||||
height="273"
|
||||
x="-99"
|
||||
y="6"
|
||||
colorInterpolationFilters="sRGB"
|
||||
filterUnits="userSpaceOnUse"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur
|
||||
result="effect1_foregroundBlur_69_17998"
|
||||
stdDeviation="32"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="d"
|
||||
width="265"
|
||||
height="273"
|
||||
x="-113"
|
||||
y="12"
|
||||
colorInterpolationFilters="sRGB"
|
||||
filterUnits="userSpaceOnUse"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur
|
||||
result="effect1_foregroundBlur_69_17998"
|
||||
stdDeviation="32"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="e"
|
||||
width="299.5"
|
||||
height="329"
|
||||
x="-41.5"
|
||||
y="-130"
|
||||
colorInterpolationFilters="sRGB"
|
||||
filterUnits="userSpaceOnUse"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur
|
||||
result="effect1_foregroundBlur_69_17998"
|
||||
stdDeviation="32"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="f"
|
||||
width="299.5"
|
||||
height="329"
|
||||
x="-45"
|
||||
y="-153"
|
||||
colorInterpolationFilters="sRGB"
|
||||
filterUnits="userSpaceOnUse"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur
|
||||
result="effect1_foregroundBlur_69_17998"
|
||||
stdDeviation="32"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="g"
|
||||
width="299.5"
|
||||
height="329"
|
||||
x="-41"
|
||||
y="91"
|
||||
colorInterpolationFilters="sRGB"
|
||||
filterUnits="userSpaceOnUse"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur
|
||||
result="effect1_foregroundBlur_69_17998"
|
||||
stdDeviation="32"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="h"
|
||||
width="299.5"
|
||||
height="329"
|
||||
x="-39"
|
||||
y="132"
|
||||
colorInterpolationFilters="sRGB"
|
||||
filterUnits="userSpaceOnUse"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur
|
||||
result="effect1_foregroundBlur_69_17998"
|
||||
stdDeviation="32"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export { Gemini };
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
const GoogleCloud = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} preserveAspectRatio="xMidYMid" viewBox="0 -25 256 256">
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="m170.252 56.819 22.253-22.253 1.483-9.37C153.437-11.677 88.976-7.496 52.42 33.92 42.267 45.423 34.734 59.764 30.717 74.573l7.97-1.123 44.505-7.34 3.436-3.513c19.797-21.742 53.27-24.667 76.128-6.168l7.496.39Z"
|
||||
/>
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M224.205 73.918a100.249 100.249 0 0 0-30.217-48.722l-31.232 31.232a55.515 55.515 0 0 1 20.379 44.037v5.544c15.35 0 27.797 12.445 27.797 27.796 0 15.352-12.446 27.485-27.797 27.485h-55.671l-5.466 5.934v33.34l5.466 5.231h55.67c39.93.311 72.553-31.494 72.864-71.424a72.303 72.303 0 0 0-31.793-60.453"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M71.87 205.796h55.593V161.29H71.87a27.275 27.275 0 0 1-11.399-2.498l-7.887 2.42-22.409 22.253-1.952 7.574c12.567 9.489 27.9 14.825 43.647 14.757"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M71.87 61.425C31.94 61.664-.237 94.228.001 134.159a72.301 72.301 0 0 0 28.222 56.88l32.248-32.246c-13.99-6.322-20.208-22.786-13.887-36.776 6.32-13.99 22.786-20.208 36.775-13.888a27.796 27.796 0 0 1 13.887 13.888l32.248-32.248A72.224 72.224 0 0 0 71.87 61.425"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export { GoogleCloud };
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
const Hono = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} preserveAspectRatio="xMidYMid" viewBox="0 0 256 330">
|
||||
<path
|
||||
d="M134.129.029c.876-.113 1.65.108 2.319.662a1256.253 1256.253 0 0 1 69.573 93.427c16.094 24.231 29.788 49.851 41.082 76.862 18.037 48.108 8.65 89.963-28.16 125.564-32.209 27.22-69.314 37.822-111.318 31.805-50.208-10.237-84.332-39.28-102.373-87.133C.553 225.638-.993 209.736.614 193.51c2.676-27.93 9.302-54.877 19.878-80.838 4.407-10.592 10.15-20.31 17.228-29.154a381.88 381.88 0 0 1 16.565 21.203c2.44 2.55 4.98 4.98 7.62 7.289C82.06 72.01 106.135 34.685 134.13.029Z"
|
||||
fill="#FF5B11"
|
||||
opacity=".993"
|
||||
/>
|
||||
<path
|
||||
d="M129.49 53.7c24.314 28.2 46.29 58.238 65.93 90.114a187.318 187.318 0 0 1 15.24 33.13c8.338 32.804-.607 59.86-26.836 81.169-25.367 17.85-53.196 23.15-83.488 15.902-32.666-10.136-51.55-32.113-56.653-65.929-1.238-10.662-.133-21.043 3.314-31.142a225.41 225.41 0 0 1 17.89-35.78l19.878-29.155a5509.508 5509.508 0 0 0 44.726-58.31Z"
|
||||
fill="#FF9758"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export { Hono };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user