Compare commits

...
5 Commits
Author SHA1 Message Date
Denozordec dc803bcb34 refactor(web): remove deprecated dashboard components and enhance KPI grid
quality / commitlint (push) Skipped
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 46s
quality / web (push) Successful in 1m16s
quality / go (push) Successful in 2m42s
quality / bird2 (push) Successful in 16s
CD / quality (push) Successful in 5m19s
CD / publish (push) Successful in 7m19s
- Deleted unused components: `DashboardActivityTimeline`, `DashboardFramePanel`, `DashboardModulesGrid`, `DashboardRecentJobsGrid`, and `DashboardRecentRevisionsGrid` to streamline the dashboard.
- Updated `DashboardKpiGrid` to improve KPI display logic, including progress indicators and enhanced badge functionality.
- Refactored `DashboardNetworkHealth` to provide better status representation based on loading states and network conditions.
- Introduced new properties for KPI cards to support progress tracking and improved visual feedback.

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

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

Co-authored-by: Cursor <[email protected]>
2026-08-23 12:46:20 +07:00
Denozordec 764a4e5b4e feat(web): enhance action column functionality in data grid components
quality / commitlint (push) Skipped
quality / changes (push) Successful in 6s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 23s
quality / web (push) Successful in 1m4s
CD / quality (push) Successful in 1m36s
CD / publish (push) Successful in 2m55s
- Introduced `KIT_ACTION_COLUMN_SIZE` for consistent action column sizing across data grids.
- Updated `applyKitActionColumn` to enforce action column properties such as fixed size, no sorting, and no resizing.
- Enhanced `kitColumnPinning` logic to conditionally enable pinning based on horizontal scrolling.
- Added tests for `applyKitActionColumn` and `kitColumnPinning` to ensure expected behavior.
- Adjusted `AccessApiKeysGrid`, `ScheduleModulesGrid`, and `ResourcePageFiltered` components to utilize new action column features.
2026-08-23 02:58:07 +07:00
Denozordec 2d29a892f9 feat(web): integrate shadcn and enhance data grid styles
quality / changes (push) Successful in 8s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 31s
quality / web (push) Successful in 1m7s
quality / go (push) Successful in 1m10s
quality / bird2 (push) Successful in 17s
CD / quality (push) Successful in 3m20s
CD / publish (push) Successful in 4m5s
- Added `shadcn` dependency to the project for improved styling.
- Updated `kitDataGridTableClassNames` to include a new base class for pinned cells.
- Enhanced global styles by importing `shadcn/tailwind.css`.
- Added tests to verify new class names in the data grid component.
2026-08-23 02:33:22 +07:00
172 changed files with 4266 additions and 17462 deletions
+11 -8
View File
@@ -46,28 +46,31 @@ pnpm dlx shadcn@latest add @reui/data-grid # ReUI enterprise → apps/web/src/co
|--------|----------|--------|
| Button, Card, Sheet, Field, Sidebar | `@shadcn` | `@evobgp/ui/components/*` |
| Blocks (sidebar-07, dashboard-01) | `@shadcn` | blocks → `apps/web/src/components/` |
| Data Grid (sort, pagination, virtual) | `@reui` | `@/components/reui/data-grid/*`обёртка `DataGridCard` |
| Data Grid (sort, pagination, virtual) | `@reui` | `@/components/reui/data-grid/*``reui-kit/ResourcePage` |
| Мультифильтры | `@reui` | `@/components/reui/filters` |
| Number field со stepper | `@reui` | `@/components/reui/number-field` |
| Autocomplete | `@reui` | `@/components/reui/autocomplete``AutoCompleteInput` |
| Date selector / range | `@reui` | `@/components/reui/date-selector` |
| Semantic badge (success/info/warning) | `@reui` | `@/components/reui/badge` или `StatusBadge` |
**Простые списки** — shadcn `Table`. **Сложные data-списки** — ReUI data-grid (через `DataGridCard`), не shadcn Data Table.
**Простые списки** — shadcn `Table`. **Сложные data-списки** — ReUI data-grid через `ResourcePage`, не shadcn Data Table.
## Уже установленные shared-обёртки
В `apps/web/src/components/`:
- `PageHeader`, `PageShell` — заголовки и обёртки страниц
- `QueryState` — обёртка loading/error/empty для TanStack Query
- `EmptyState` — пустые списки
- `EmptyState` — пустые списки (empty-state-14 DNA)
- `ConfirmDialog` — подтверждения (не `window.confirm`)
- `LoadingButton` — кнопка с loading-состоянием
- `StatusBadge` — статусные бейджи
- `SectionCards` — сетка KPI-карточек
- `Skeletons` (`TableSkeleton`, `SectionCardsSkeleton`) — скелетоны
- `LoadingButton` SoT кнопка с loading (`Button` + `Spinner`)
- `StatusBadge` / `CategoryBadge` / `ModeBadge` — semantic ReUI Badge
- `SegmentedTabs` — секции страницы (c-tabs-9)
- `StatusToggleGroup` — статус грида (c-toggle-group-5)
- `FormDrawer` — Sheet overlay (sheet-8 / form-7)
- `Skeletons` (`TableSkeleton`, `KpiStatGridSkeleton`) — скелетоны
- `TruncatedText` — текст с тултипом
- `ModeToggle` — переключатель темы
Kit: `reui-kit/ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `SettingsShell`, `DetailPanel`, `FrameSection`.
Перед созданием новой обёртки — проверить существующие через Codegraph.
+2 -2
View File
@@ -70,14 +70,14 @@ alwaysApply: true
|------|------|--------|
| shadcn | `packages/ui/src/components/` | `@evobgp/ui/components/*` |
| ReUI CLI | `apps/web/src/components/reui/` | `@/components/reui/*` |
| PRO blocks (reference) | `apps/web/src/components/blocks/` | adapt into kit, не копипаст в routes |
| PRO blocks (reference) | — | CLI → adapt into kit; не держать demo-деревья в `src/components/blocks/` |
| Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` |
## Установленные ReUI (apps/web)
**Components:** `frame`, `data-grid/*`, `filters`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`, `icon-tile`, `stepper`
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell`
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell`, `FrameSection`
**Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
+10 -3
View File
@@ -23,10 +23,10 @@ alwaysApply: false
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, LoadingButton |
| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, QuickActionGrid, OpsDashboard, SettingsShell |
| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, QuickActionGrid, OpsDashboard, SettingsShell, DetailPanel, FrameSection |
| Роуты | `apps/web/src/routes/` | TanStack Router (file-based) |
**Design contract:** [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Surface: **frame**. KPI hybrid SoT: [stats-12](https://reui.io/preview/base/stats-12). Lists: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2). Quick Actions: `QuickActionGrid`.
**Design contract:** [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Surface: **frame**. KPI hybrid SoT: [stats-12](https://reui.io/preview/base/stats-12). Lists: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2). Page tabs: [c-tabs-9](https://reui.io/preview/base/components/c-tabs-9). Grid status: [c-toggle-group-5](https://reui.io/preview/base/components/c-toggle-group-5). Quick Actions: `QuickActionGrid`.
Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`.
@@ -48,9 +48,16 @@ alwaysApply: false
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
*Проверка:* https://ui.shadcn.com/docs/components/form
**WEB-06** | MUST | Сложные data-списки — Frame + ReUI DataGrid через `reui-kit/ResourcePage` или `DataGridSection` (не Card shell, не shadcn Data Table). Surface lock: `UI_SURFACE = 'frame'` (`lib/ui-surface.ts`). Простые списки — shadcn `Table`.
**WEB-06** | MUST | Сложные data-списки — Frame + ReUI DataGrid через `reui-kit/ResourcePage` (не Card shell, не shadcn Data Table). Surface lock: `UI_SURFACE = 'frame'` (`lib/ui-surface.ts`). Простые списки — shadcn `Table`.
*Проверка:* `@/components/reui-kit`, `@/components/reui/data-grid`, `docs/ui-design-contract.md`.
**WEB-06a** | MUST | Переключатели по роли UX:
- секции страницы (Сеть / Операции / Справочники) — native `Tabs` default list (`SegmentedTabs`, [c-tabs-9](https://reui.io/preview/base/components/c-tabs-9));
- статус грида (Все/Вкл) — `ToggleGroup` в toolbar (`StatusToggleGroup`, [c-toggle-group-5](https://reui.io/preview/base/components/c-toggle-group-5));
- настройки — вертикальный Tabs rail ([settings-3](https://reui.io/preview/base/settings-3)).
Не `variant="line"` для page/in-grid tabs.
*Проверка:* `@/components/segmented-tabs`, `@/components/status-toggle-group`.
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
*Проверка:* https://ui.shadcn.com/docs/components/sonner
+1 -1
View File
@@ -23,7 +23,7 @@
| Конфиг BIRD, `birdc` | `internal/birdfmt/`, `internal/birddeploy/` |
| Бандлы и подписи | `internal/bundle/`, `internal/signing/` |
| Точки входа процессов | `cmd/*/` |
| Веб (SvelteKit) | `web/` |
| Веб (React + ReUI) | `apps/web/` |
| Compose, деплой | `deploy/compose/` |
Точки входа бинарников и их роли — в таблице в начале [docs/architecture.md](docs/architecture.md).
+1 -1
View File
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import globals from 'globals'
export default tseslint.config(
{ ignores: ['dist', 'src/routeTree.gen.ts', 'src/components/blocks/**'] },
{ ignores: ['dist', 'src/routeTree.gen.ts'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
+1
View File
@@ -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
View File
@@ -1,50 +1 @@
import type { ComponentProps, ReactNode } from 'react'
import { Badge } from '@/components/reui/badge'
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
const TONE_VARIANT: Record<string, BadgeVariant> = {
neutral: 'outline',
info: 'info-light',
warning: 'warning-light',
success: 'success-light',
}
export function ModeBadge({
enabled,
onLabel = 'включён',
offLabel = 'выключен',
className,
}: {
enabled: boolean
onLabel?: string
offLabel?: string
className?: string
}) {
return enabled ? (
<Badge variant="success-light" size="sm" radius="full" className={className}>
{onLabel}
</Badge>
) : (
<Badge variant="secondary" size="sm" radius="full" className={className}>
{offLabel}
</Badge>
)
}
export function CategoryBadge({
children,
tone = 'neutral',
className,
}: {
children: ReactNode
tone?: keyof typeof TONE_VARIANT
className?: string
}) {
return (
<Badge variant={TONE_VARIANT[tone]} size="sm" radius="full" className={className}>
{children}
</Badge>
)
}
export { CategoryBadge, ModeBadge } from '@/components/status-badge'
@@ -1,50 +0,0 @@
import type { ReactNode } from 'react'
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { cn } from '@evobgp/ui/lib/utils'
export interface CountedLineTab {
id: string
label: string
count?: number
}
interface CountedLineTabsProps {
tabs: CountedLineTab[]
value: string
onValueChange: (value: string) => void
className?: string
listClassName?: string
children?: ReactNode
}
/** Line tabs with count pills (c-tabs-2 / data-grid-filtering-2). */
export function CountedLineTabs({
tabs,
value,
onValueChange,
className,
listClassName,
children,
}: CountedLineTabsProps) {
return (
<Tabs value={value} onValueChange={onValueChange} className={className}>
<TabsList variant="line" className={cn('gap-5', listClassName)}>
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>{tab.label}</span>
{tab.count !== undefined ? (
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{tab.count}
</span>
) : null}
</TabsTrigger>
))}
</TabsList>
{children}
</Tabs>
)
}
@@ -1,102 +0,0 @@
import { AlertTriangle, CheckCircle, Info } from 'lucide-react'
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
import { Badge } from '@/components/reui/badge'
import {
Timeline,
TimelineContent,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from '@/components/reui/timeline'
import { cn } from '@evobgp/ui/lib/utils'
import { recentPlatformActivity } from '@/lib/metrics'
import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
const KIND_META = {
job: { icon: Info, className: 'text-info' },
revision: { icon: CheckCircle, className: 'text-success' },
network: { icon: AlertTriangle, className: 'text-warning' },
} as const
function statusBadgeVariant(status: string) {
const s = status.toLowerCase()
if (['ok', 'success', 'completed', 'done'].includes(s)) return 'success-light' as const
if (['running', 'queued', 'pending'].includes(s)) return 'info-light' as const
if (['warning', 'mismatch'].includes(s)) return 'warning-light' as const
if (['failed', 'error', 'cancelled'].includes(s)) return 'destructive-light' as const
return 'outline' as const
}
export function DashboardActivityTimeline({
jobs,
revisions,
peers,
speakers,
loading,
}: {
jobs: JobRow[]
revisions: RevisionRow[]
peers: PeerRow[]
speakers: SpeakerRow[]
loading?: boolean
}) {
const items = recentPlatformActivity(jobs, revisions, peers, speakers, 6)
return (
<DashboardFramePanel
title="Недавняя активность"
description="Задачи, ревизии и сетевые события"
className="h-full min-w-0"
>
{loading ? (
<p className="text-muted-foreground px-4 py-6 text-sm">Загрузка</p>
) : items.length === 0 ? (
<p className="text-muted-foreground px-4 py-6 text-sm">Нет недавних событий</p>
) : (
<div className="px-4 py-4">
<Timeline defaultValue={items.length}>
{items.map((item, index) => {
const meta = KIND_META[item.kind]
const Icon = meta.icon
return (
<TimelineItem
key={item.id}
step={index + 1}
className="group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=vertical]/timeline:not-last:pb-4"
>
<TimelineHeader>
<TimelineSeparator className="bg-border! group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:top-2 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem)] group-data-[orientation=vertical]/timeline:translate-y-5" />
<TimelineIndicator className="border-none bg-transparent group-data-[orientation=vertical]/timeline:-left-6">
<span
className={cn(
'bg-muted/70 flex size-7 items-center justify-center rounded-full',
meta.className,
)}
>
<Icon className="size-3.5" aria-hidden />
</span>
</TimelineIndicator>
</TimelineHeader>
<TimelineContent className="min-w-0 pb-1 text-foreground">
<TimelineTitle className="text-sm leading-snug font-normal break-words">
{item.message}
</TimelineTitle>
<div className="mt-2">
<Badge variant={statusBadgeVariant(item.status)} size="sm">
{item.statusLabel ?? item.status}
</Badge>
</div>
</TimelineContent>
</TimelineItem>
)
})}
</Timeline>
</div>
)}
</DashboardFramePanel>
)
}
@@ -1,36 +0,0 @@
import type { ReactNode } from 'react'
import {
PanelCard,
panelCardContentFlushClassName,
} from '@/components/panel-card'
import { cn } from '@evobgp/ui/lib/utils'
/** Card-surface panel for dashboard sections (replaces legacy Frame shell). */
export function DashboardFramePanel({
title,
description,
actions,
children,
className,
contentClassName,
}: {
title?: string
description?: string
actions?: ReactNode
children: ReactNode
className?: string
contentClassName?: string
}) {
return (
<PanelCard
title={title}
description={description}
actions={actions}
className={cn('h-full', className)}
contentClassName={cn(panelCardContentFlushClassName, contentClassName)}
>
{children}
</PanelCard>
)
}
@@ -3,12 +3,10 @@ import {
Boxes,
ListChecks,
Network,
ServerCog,
Share2,
} from 'lucide-react'
import type { ReactNode } from 'react'
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import { Badge } from '@/components/reui/badge'
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
@@ -16,6 +14,11 @@ import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
type KpiCard = KpiStatItem & { icon: ReactNode }
function ratioPercent(part: number, total: number): number | undefined {
if (total <= 0) return undefined
return Math.round((part / total) * 100)
}
function buildKpis({
modules,
peers,
@@ -40,17 +43,34 @@ function buildKpis({
).length
const offlineSpeakers = Math.max(0, speakers.length - network.speakersOnline)
const riskCount = network.peersMismatch + failedJobs + offlineSpeakers
const disabledModules = Math.max(0, modules.length - enabledModules)
return [
{
id: 'modules',
icon: <Boxes aria-hidden />,
iconClassName: 'text-primary',
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`,
value: loading ? '—' : enabledModules,
label: 'Модули активны',
progress: loading ? undefined : ratioPercent(enabledModules, modules.length),
footer: (
<Badge variant="primary-light" size="sm">
{loading ? '…' : `${modules.length} всего`}
<Badge
variant={
loading || modules.length === 0
? 'outline'
: disabledModules === 0
? 'success-light'
: 'warning-light'
}
size="sm"
>
{loading
? '…'
: modules.length === 0
? 'нет модулей'
: disabledModules === 0
? 'все активны'
: `${disabledModules} выкл`}
</Badge>
),
},
@@ -60,6 +80,7 @@ function buildKpis({
iconClassName: 'text-info',
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
label: 'BGP готовность',
progress: loading || bgpPct === null ? undefined : bgpPct,
footer: (
<Badge
variant={
@@ -73,34 +94,9 @@ function buildKpis({
>
{loading || bgpPct === null
? 'нет включённых пиров'
: `${network.peersEstablished} установлено`}
</Badge>
),
},
{
id: 'peers',
icon: <Share2 aria-hidden />,
iconClassName: 'text-success',
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
label: 'Пиры установлены',
footer: (
<Badge variant="success-light" size="sm">
{loading ? '…' : `${network.peersTotal} в каталоге`}
</Badge>
),
},
{
id: 'speakers',
icon: <ServerCog aria-hidden />,
iconClassName: 'text-warning',
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
label: 'Спикеры в сети',
footer: (
<Badge
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
size="sm"
>
{loading ? '…' : 'в сети'}
: bgpPct >= 90
? 'сессии в норме'
: `${network.peersEstablished} установлено`}
</Badge>
),
},
@@ -112,7 +108,7 @@ function buildKpis({
label: 'Активные задачи',
footer: (
<Badge variant={running > 0 ? 'info-light' : 'outline'} size="sm">
{loading ? '…' : `${jobs.length} в выборке`}
{loading ? '…' : running > 0 ? 'выполняются' : 'очередь пуста'}
</Badge>
),
},
@@ -1,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}
/>
)
}
+19 -2
View File
@@ -7,6 +7,10 @@ import { InboxIcon, type LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import { IconStack } from '@/components/reui/icon-stack'
import {
Frame,
FramePanel,
} from '@/components/reui/frame'
import {
Empty,
EmptyContent,
@@ -25,6 +29,8 @@ interface EmptyStateProps {
className?: string
stackedIcon?: boolean
centered?: boolean
/** Wrap in Frame (empty-state-14). Nested grids should leave this false. */
framed?: boolean
}
function isLucideIcon(icon: LucideIcon | ReactNode): icon is LucideIcon {
@@ -44,15 +50,16 @@ export function EmptyState({
className,
stackedIcon = true,
centered = true,
framed = false,
}: EmptyStateProps) {
const Icon = isLucideIcon(icon) ? icon : InboxIcon
const customIcon = icon && !isLucideIcon(icon) ? icon : null
const body = (
const empty = (
<Empty
className={cn(
'max-w-md flex-none border-0 bg-transparent p-0',
!centered && className,
!centered && !framed && className,
)}
>
<EmptyHeader className="gap-5 text-center">
@@ -88,6 +95,16 @@ export function EmptyState({
</Empty>
)
const body = framed ? (
<Frame dense spacing="sm" className={cn('w-full', !centered && className)}>
<FramePanel className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
{empty}
</FramePanel>
</Frame>
) : (
empty
)
if (!centered) return body
return (
@@ -1,107 +0,0 @@
"use client"
import { useState } from "react"
import { Checkbox } from "@evobgp/ui/components/checkbox"
import { Field } from "@evobgp/ui/components/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@evobgp/ui/components/input-group"
import { Label } from "@evobgp/ui/components/label"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@evobgp/ui/components/popover"
import { SearchIcon, XIcon, ListFilterIcon } from "lucide-react"
const statuses = ["Pending", "Shipped", "Cancelled"] as const
type Status = (typeof statuses)[number]
function toggleStatus(values: Status[], value: Status) {
return values.includes(value)
? values.filter((item) => item !== value)
: [...values, value]
}
export function Pattern() {
const [searchQuery, setSearchQuery] = useState("")
const [selectedStatuses, setSelectedStatuses] = useState<Status[]>([])
return (
<Field className="max-w-sm">
<InputGroup>
<InputGroupAddon align="inline-start">
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search orders..."
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
<InputGroupAddon align="inline-end" className="gap-1">
{searchQuery.length > 0 ? (
<InputGroupButton
aria-label="Clear search"
size="icon-xs"
variant="ghost"
onClick={() => setSearchQuery("")}
>
<XIcon aria-hidden="true" />
</InputGroupButton>
) : null}
<Popover>
<PopoverTrigger
render={
<InputGroupButton
variant="ghost"
size="xs"
className="gap-1.5"
aria-label="Filter order status"
/>
}
>
<ListFilterIcon className="size-3.5" aria-hidden="true" />
Status
{selectedStatuses.length > 0 ? (
<span className="text-muted-foreground tabular-nums">
{selectedStatuses.length}
</span>
) : null}
</PopoverTrigger>
<PopoverContent align="end" className="w-40 p-3">
<div className="flex flex-col gap-2">
{statuses.map((status) => (
<div key={status} className="flex items-center gap-2.5">
<Checkbox
id={`order-status-${status}`}
checked={selectedStatuses.includes(status)}
onCheckedChange={() =>
setSelectedStatuses((previous) =>
toggleStatus(previous, status)
)
}
/>
<Label
htmlFor={`order-status-${status}`}
className="text-sm font-normal"
>
{status}
</Label>
</div>
))}
</div>
</PopoverContent>
</Popover>
</InputGroupAddon>
</InputGroup>
</Field>
)
}
@@ -1,19 +0,0 @@
import { Field } from '@evobgp/ui/components/field'
import { SelectMenu } from '@/components/select-field'
const items = [
{ label: 'Select an item', value: 'placeholder' as const },
...Array.from({ length: 100 }).map((_, i) => ({
label: `Item ${i}`,
value: `item-${i}` as const,
})),
]
export function Pattern() {
return (
<Field className="max-w-xs">
<SelectMenu items={items} placeholder="Select an item" />
</Field>
)
}
@@ -1,129 +0,0 @@
import { Button } from "@evobgp/ui/components/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@evobgp/ui/components/card"
import { Input } from "@evobgp/ui/components/input"
import { Label } from "@evobgp/ui/components/label"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
export function Pattern() {
return (
<div className="flex w-full max-w-xs flex-col gap-6">
<Tabs defaultValue="account">
<TabsList variant="line" className="mb-3.5 w-full">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="account">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Account</CardTitle>
<CardDescription className="text-sm">
Update your account information.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="underline-name" className="text-sm">
Name
</Label>
<Input
id="underline-name"
defaultValue="Alex Chen"
className="h-9"
/>
</div>
<div className="space-y-2">
<Label htmlFor="underline-email" className="text-sm">
Email
</Label>
<Input
id="underline-email"
type="email"
defaultValue="[email protected]"
className="h-9"
/>
</div>
</CardContent>
<CardFooter className="pt-3">
<Button size="sm">Save changes</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="password">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Password</CardTitle>
<CardDescription className="text-sm">
Change your password here.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="underline-current" className="text-sm">
Current password
</Label>
<Input id="underline-current" type="password" className="h-9" />
</div>
<div className="space-y-2">
<Label htmlFor="underline-new" className="text-sm">
New password
</Label>
<Input id="underline-new" type="password" className="h-9" />
</div>
</CardContent>
<CardFooter className="pt-3">
<Button size="sm">Update password</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="settings">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Settings</CardTitle>
<CardDescription className="text-sm">
Manage your preferences.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="underline-theme" className="text-sm">
Theme
</Label>
<Input
id="underline-theme"
defaultValue="Light"
className="h-9"
/>
</div>
<div className="space-y-2">
<Label htmlFor="underline-language" className="text-sm">
Language
</Label>
<Input
id="underline-language"
defaultValue="English"
className="h-9"
/>
</div>
</CardContent>
<CardFooter className="pt-3">
<Button size="sm">Save settings</Button>
</CardFooter>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
@@ -1,46 +0,0 @@
import { Card, CardContent } from "@evobgp/ui/components/card"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
import { LayoutDashboardIcon, BarChart3Icon, SettingsIcon } from "lucide-react"
export function Pattern() {
return (
<div className="flex w-full max-w-md flex-col gap-6">
<Tabs defaultValue="overview">
<TabsList className="w-full">
<TabsTrigger value="overview">
<LayoutDashboardIcon className="size-4" />
Overview
</TabsTrigger>
<TabsTrigger value="analytics">
<BarChart3Icon className="size-4" />
Analytics
</TabsTrigger>
<TabsTrigger value="settings">
<SettingsIcon className="size-4" />
Settings
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<Card>
<CardContent>Overview dashboard content goes here.</CardContent>
</Card>
</TabsContent>
<TabsContent value="analytics">
<Card>
<CardContent>Analytics charts and metrics.</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings">
<Card>
<CardContent>Application settings and preferences.</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
@@ -1,61 +0,0 @@
import { Badge } from "@/components/reui/badge"
import { Card, CardContent } from "@evobgp/ui/components/card"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
export function Pattern() {
return (
<div className="flex w-full max-w-md flex-col gap-6">
<Tabs defaultValue="inbox">
<TabsList variant="line" className="mb-3.5 w-full">
<TabsTrigger value="inbox" className="gap-2">
Inbox
<Badge variant="primary-light" size="sm">
12
</Badge>
</TabsTrigger>
<TabsTrigger value="drafts" className="gap-2">
Drafts
<Badge variant="info-light" size="sm">
3
</Badge>
</TabsTrigger>
<TabsTrigger value="sent" className="gap-2">
Sent
</TabsTrigger>
<TabsTrigger value="spam" className="gap-2">
Spam
<Badge variant="destructive-light" size="sm">
24
</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value="inbox">
<Card>
<CardContent>12 unread messages in your inbox.</CardContent>
</Card>
</TabsContent>
<TabsContent value="drafts">
<Card>
<CardContent>3 drafts waiting to be sent.</CardContent>
</Card>
</TabsContent>
<TabsContent value="sent">
<Card>
<CardContent>All sent messages appear here.</CardContent>
</Card>
</TabsContent>
<TabsContent value="spam">
<Card>
<CardContent>24 spam messages detected.</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
+13 -3
View File
@@ -1,6 +1,8 @@
import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import { FieldGroup } from '@evobgp/ui/components/field'
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
import {
Sheet,
SheetContent,
@@ -20,7 +22,11 @@ interface FormDrawerProps {
className?: string
}
/** Create/edit overlay — Sheet DNA (sheet-8 / sheet-1). Preview: https://reui.io/preview/base/sheet-8 */
/**
* Create/edit overlay — Sheet DNA (sheet-8) + FieldGroup (form-7).
* @see https://reui.io/preview/base/sheet-8
* @see https://reui.io/preview/base/form-7
*/
export function FormDrawer({
open,
onOpenChange,
@@ -34,13 +40,17 @@ export function FormDrawer({
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className={cn('w-full gap-0 overflow-hidden p-0 sm:max-w-lg', className)}
className={cn('flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-lg', className)}
>
<SheetHeader className="shrink-0 border-b border-border/50">
<SheetTitle>{title}</SheetTitle>
{description ? <SheetDescription>{description}</SheetDescription> : null}
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">{children}</div>
<div className="min-h-0 flex-1 overflow-hidden">
<ScrollArea className="h-full">
<FieldGroup className="p-4">{children}</FieldGroup>
</ScrollArea>
</div>
<SheetFooter className="mt-0 shrink-0 border-t border-border/50 bg-muted/50">
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
{footer}
-14
View File
@@ -1,14 +0,0 @@
/**
* Re-export from reui-kit — keep `@/components/kpi-stat-grid` imports stable.
* @see https://reui.io/preview/base/stats-12
*/
export {
KpiStatGrid,
KpiStatCard,
KpiStatCardTile,
kpiStatItemKey,
type KpiStatItem,
type KpiStatCardData,
type KpiStatVariant,
type OpsKpiCard,
} from '@/components/reui-kit/kpi-stat-grid'
+1 -1
View File
@@ -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' },
],
},
{
+5 -6
View File
@@ -1,18 +1,17 @@
import { Button } from '@evobgp/ui/components/button'
import { Loader2Icon } from 'lucide-react'
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import { Spinner } from '@evobgp/ui/components/spinner'
import type { ComponentProps, ReactNode } from 'react'
type LoadingButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
type LoadingButtonProps = ComponentProps<typeof Button> & {
loading?: boolean
variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'
children: ReactNode
}
/** SoT for async actions — Button + Spinner. */
export function LoadingButton({ loading, disabled, children, ...props }: LoadingButtonProps) {
return (
<Button disabled={disabled || loading} {...props}>
{loading ? <Loader2Icon className="animate-spin" data-icon="inline-start" /> : null}
{loading ? <Spinner data-icon="inline-start" /> : null}
{children}
</Button>
)
@@ -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>
+11 -93
View File
@@ -1,93 +1,11 @@
import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { cn } from '@evobgp/ui/lib/utils'
/**
* Frame shell for analytics / comparison panels (not list grids).
* @see https://reui.io/docs/components/base/frame
* @see https://reui.io/preview/base/chart-1
*/
export const panelCardClassName = 'w-full gap-0 p-0'
export const panelCardHeaderClassName = 'border-b'
/** Flush body inside FramePanel (toolbar / data-grid). */
export const panelCardContentFlushClassName = 'p-0'
/** Horizontal inset for toolbar / pagination rows. */
export const panelCardInsetClassName = 'px-5 py-3'
export const panelCardFooterClassName = 'border-t bg-transparent'
interface PanelCardProps {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
footer?: ReactNode
children?: ReactNode
className?: string
headerClassName?: string
contentClassName?: string
footerClassName?: string
size?: 'default' | 'sm'
}
export function PanelCard({
title,
description,
actions,
footer,
children,
className,
headerClassName,
contentClassName,
footerClassName,
size = 'default',
}: PanelCardProps) {
const hasHeader = Boolean(title || description || actions)
const spacing = size === 'sm' ? 'sm' : 'default'
return (
<Frame
dense
spacing={spacing}
className={cn(panelCardClassName, 'min-w-0', className)}
>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
{hasHeader ? (
<FrameHeader
className={cn(
panelCardHeaderClassName,
'flex-row items-start justify-between gap-3',
headerClassName,
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-px">
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? (
<FrameDescription>{description}</FrameDescription>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{actions}
</div>
) : null}
</FrameHeader>
) : null}
{children != null && children !== false ? (
<div className={cn('min-w-0 flex-1', contentClassName)}>{children}</div>
) : null}
{footer ? (
<FrameFooter className={cn(panelCardFooterClassName, footerClassName)}>
{footer}
</FrameFooter>
) : null}
</FramePanel>
</Frame>
)
}
export {
FrameSection,
FrameSection as PanelCard,
panelCardClassName,
panelCardHeaderClassName,
panelCardContentFlushClassName,
panelCardInsetClassName,
panelCardFooterClassName,
type FrameSectionProps,
type FrameSectionProps as PanelCardProps,
} from '@/components/reui-kit/frame-section'
@@ -1,24 +1,13 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import { IconStack } from '@/components/reui/icon-stack'
import {
Frame,
FramePanel,
} from '@/components/reui/frame'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@evobgp/ui/components/empty'
import { EmptyState } from '@/components/empty-state'
/** empty-state-14: Frame + IconStack. Preview: https://reui.io/preview/base/empty-state-14 */
export function IllustratedEmptyState({
title,
description,
icon: Icon,
icon,
action,
}: {
title: string
@@ -27,23 +16,13 @@ export function IllustratedEmptyState({
action?: ReactNode
}) {
return (
<Frame dense spacing="sm" className="w-full">
<FramePanel className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
{action ? <div className="w-full self-end">{action}</div> : null}
<Empty className="max-w-md gap-5 bg-transparent p-0">
<EmptyHeader className="items-center gap-5 text-center">
<EmptyMedia className="mb-0">
<IconStack aria-hidden>
<Icon strokeWidth={1.9} aria-hidden />
</IconStack>
</EmptyMedia>
<div className="flex flex-col items-center gap-2">
<EmptyTitle className="text-base font-semibold tracking-tight">{title}</EmptyTitle>
<EmptyDescription className="max-w-sm text-sm/relaxed">{description}</EmptyDescription>
</div>
</EmptyHeader>
</Empty>
</FramePanel>
</Frame>
<EmptyState
framed
centered={false}
icon={icon}
title={title}
description={description}
action={action}
/>
)
}
+1 -1
View File
@@ -1,7 +1,7 @@
export { DonutBreakdownCard } from './donut-breakdown-card'
export { IllustratedEmptyState } from './illustrated-empty-state'
export { KpiSparklineCard, type KpiSparklineMetric } from './kpi-sparkline-card'
export { KpiStatGrid, KpiStatCard, type KpiStatItem } from '@/components/kpi-stat-grid'
export { KpiStatGrid, KpiStatCard, type KpiStatItem } from '@/components/reui-kit'
export { PanelCorners } from './panel-corners'
export { ProjectsEmptyState } from './projects-empty-state'
export { SegmentedProgressCard, type SegmentStat } from './segmented-progress-card'
@@ -1,9 +1,9 @@
import { Boxes, Plus } from 'lucide-react'
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
import { EmptyState } from '@/components/empty-state'
import { Button } from '@evobgp/ui/components/button'
/** empty-state-3 pattern for first module. */
/** empty-state-14 for first module. Preview: https://reui.io/preview/base/empty-state-14 */
export function ProjectsEmptyState({
canCreate = true,
onCreate,
@@ -12,7 +12,9 @@ export function ProjectsEmptyState({
onCreate?: () => void
}) {
return (
<IllustratedEmptyState
<EmptyState
framed
centered={false}
icon={Boxes}
title="Создайте первый модуль"
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
@@ -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
/** 0100 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">
@@ -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}
+4 -14
View File
@@ -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 (
+46 -1
View File
@@ -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,12 +0,0 @@
import type { SVGProps } from "react";
const ClaudeAiIcon = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} preserveAspectRatio="xMidYMid" viewBox="0 0 256 257">
<path
fill="#D97757"
d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"
/>
</svg>
);
export { ClaudeAiIcon };
@@ -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 CursorDark = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} id="Ebene_1" version="1.1" viewBox="0 0 466.73 532.09">
<path
fill="#fff"
d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"
/>
</svg>
)
export { CursorDark }
@@ -1,9 +0,0 @@
import type { SVGProps } from "react";
const CursorLight = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} id="Ebene_1" version="1.1" viewBox="0 0 466.73 532.09">
<path d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z" />
</svg>
);
export { CursorLight };
@@ -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 };
-171
View File
@@ -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,41 +0,0 @@
import type { SVGProps } from "react";
const GoogleCalendar = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 512 512" fill="none">
<g clipPath="url(#clip0_5072_3766)">
<path
d="M390.736 121.264H121.264V390.736H390.736V121.264Z"
fill="white"
/>
<path
d="M390.736 512L512 390.736L451.368 380.392L390.736 390.736L379.67 446.196L390.736 512Z"
fill="#EA4335"
/>
<path
d="M0 390.736V471.578C0 493.912 18.088 512 40.42 512H121.264L133.714 451.368L121.264 390.736L55.198 380.392L0 390.736Z"
fill="#188038"
/>
<path
d="M512 121.264V40.42C512 18.088 493.912 0 471.58 0H390.736C383.36 30.072 379.671 52.2027 379.67 66.392C379.67 80.58 383.359 98.8707 390.736 121.264C417.556 128.944 437.767 132.784 451.368 132.784C464.969 132.784 485.18 128.945 512 121.264Z"
fill="#1967D2"
/>
<path d="M512 121.264H390.736V390.736H512V121.264Z" fill="#FBBC04" />
<path d="M390.736 390.736H121.264V512H390.736V390.736Z" fill="#34A853" />
<path
d="M390.736 0H40.422C18.088 0 0 18.088 0 40.42V390.736H121.264V121.264H390.736V0Z"
fill="#4285F4"
/>
<path
d="M176.54 330.308C166.468 323.504 159.494 313.568 155.688 300.428L179.066 290.796C181.186 298.88 184.891 305.145 190.182 309.592C195.436 314.038 201.836 316.228 209.314 316.228C216.959 316.228 223.527 313.903 229.018 309.254C234.51 304.606 237.272 298.678 237.272 291.504C237.272 284.16 234.375 278.164 228.582 273.516C222.788 268.868 215.512 266.544 206.822 266.544H193.314V243.404H205.44C212.917 243.404 219.216 241.382 224.336 237.338C229.456 233.298 232.016 227.772 232.016 220.732C232.016 214.468 229.726 209.482 225.146 205.744C220.566 202.004 214.77 200.118 207.73 200.118C200.858 200.118 195.402 201.938 191.36 205.608C187.319 209.289 184.282 213.937 182.534 219.116L159.394 209.482C162.458 200.792 168.084 193.112 176.336 186.476C184.588 179.84 195.132 176.506 207.932 176.506C217.398 176.506 225.92 178.326 233.466 181.996C241.01 185.668 246.938 190.754 251.216 197.222C255.496 203.722 257.616 210.998 257.616 219.082C257.616 227.334 255.63 234.308 251.656 240.034C247.682 245.76 242.796 250.138 237.002 253.204V254.584C244.483 257.669 250.982 262.735 255.798 269.238C260.682 275.806 263.142 283.654 263.142 292.818C263.142 301.978 260.816 310.164 256.168 317.338C251.52 324.514 245.088 330.172 236.934 334.282C228.75 338.392 219.554 340.482 209.348 340.482C197.524 340.514 186.612 337.112 176.54 330.308ZM320.132 214.298L294.466 232.858L281.632 213.39L327.678 180.176H345.328V336.842H320.132V214.298Z"
fill="#4285F4"
/>
</g>
<defs>
<clipPath id="clip0_5072_3766">
<rect width="512" height="512" fill="white" />
</clipPath>
</defs>
</svg>
);
export { GoogleCalendar };
@@ -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 };
-17
View File
@@ -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