Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
094f75913b | ||
|
|
764a4e5b4e |
@@ -46,28 +46,31 @@ pnpm dlx shadcn@latest add @reui/data-grid # ReUI enterprise → apps/web/src/co
|
||||
|--------|----------|--------|
|
||||
| Button, Card, Sheet, Field, Sidebar | `@shadcn` | `@evobgp/ui/components/*` |
|
||||
| Blocks (sidebar-07, dashboard-01) | `@shadcn` | blocks → `apps/web/src/components/` |
|
||||
| Data Grid (sort, pagination, virtual) | `@reui` | `@/components/reui/data-grid/*` → обёртка `DataGridCard` |
|
||||
| Data Grid (sort, pagination, virtual) | `@reui` | `@/components/reui/data-grid/*` → `reui-kit/ResourcePage` |
|
||||
| Мультифильтры | `@reui` | `@/components/reui/filters` |
|
||||
| Number field со stepper | `@reui` | `@/components/reui/number-field` |
|
||||
| Autocomplete | `@reui` | `@/components/reui/autocomplete` → `AutoCompleteInput` |
|
||||
| Date selector / range | `@reui` | `@/components/reui/date-selector` |
|
||||
| Semantic badge (success/info/warning) | `@reui` | `@/components/reui/badge` или `StatusBadge` |
|
||||
|
||||
**Простые списки** — shadcn `Table`. **Сложные data-списки** — ReUI data-grid (через `DataGridCard`), не shadcn Data Table.
|
||||
**Простые списки** — shadcn `Table`. **Сложные data-списки** — ReUI data-grid через `ResourcePage`, не shadcn Data Table.
|
||||
|
||||
## Уже установленные shared-обёртки
|
||||
|
||||
В `apps/web/src/components/`:
|
||||
- `PageHeader`, `PageShell` — заголовки и обёртки страниц
|
||||
- `QueryState` — обёртка loading/error/empty для TanStack Query
|
||||
- `EmptyState` — пустые списки
|
||||
- `EmptyState` — пустые списки (empty-state-14 DNA)
|
||||
- `ConfirmDialog` — подтверждения (не `window.confirm`)
|
||||
- `LoadingButton` — кнопка с loading-состоянием
|
||||
- `StatusBadge` — статусные бейджи
|
||||
- `SectionCards` — сетка KPI-карточек
|
||||
- `Skeletons` (`TableSkeleton`, `SectionCardsSkeleton`) — скелетоны
|
||||
- `LoadingButton` — SoT кнопка с loading (`Button` + `Spinner`)
|
||||
- `StatusBadge` / `CategoryBadge` / `ModeBadge` — semantic ReUI Badge
|
||||
- `SegmentedTabs` — секции страницы (c-tabs-9)
|
||||
- `StatusToggleGroup` — статус грида (c-toggle-group-5)
|
||||
- `FormDrawer` — Sheet overlay (sheet-8 / form-7)
|
||||
- `Skeletons` (`TableSkeleton`, `KpiStatGridSkeleton`) — скелетоны
|
||||
- `TruncatedText` — текст с тултипом
|
||||
- `ModeToggle` — переключатель темы
|
||||
|
||||
Kit: `reui-kit/ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `SettingsShell`, `DetailPanel`, `FrameSection`.
|
||||
|
||||
Перед созданием новой обёртки — проверить существующие через Codegraph.
|
||||
|
||||
|
||||
@@ -70,14 +70,14 @@ alwaysApply: true
|
||||
|------|------|--------|
|
||||
| shadcn | `packages/ui/src/components/` | `@evobgp/ui/components/*` |
|
||||
| ReUI CLI | `apps/web/src/components/reui/` | `@/components/reui/*` |
|
||||
| PRO blocks (reference) | `apps/web/src/components/blocks/` | adapt into kit, не копипаст в routes |
|
||||
| PRO blocks (reference) | — | CLI → adapt into kit; не держать demo-деревья в `src/components/blocks/` |
|
||||
| Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` |
|
||||
|
||||
## Установленные ReUI (apps/web)
|
||||
|
||||
**Components:** `frame`, `data-grid/*`, `filters`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`, `icon-tile`, `stepper`
|
||||
|
||||
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell`
|
||||
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell`, `FrameSection`
|
||||
|
||||
**Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
|
||||
|
||||
|
||||
@@ -23,10 +23,10 @@ alwaysApply: false
|
||||
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
|
||||
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
|
||||
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, LoadingButton |
|
||||
| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, QuickActionGrid, OpsDashboard, SettingsShell |
|
||||
| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, QuickActionGrid, OpsDashboard, SettingsShell, DetailPanel, FrameSection |
|
||||
| Роуты | `apps/web/src/routes/` | TanStack Router (file-based) |
|
||||
|
||||
**Design contract:** [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Surface: **frame**. KPI hybrid SoT: [stats-12](https://reui.io/preview/base/stats-12). Lists: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2). Quick Actions: `QuickActionGrid`.
|
||||
**Design contract:** [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Surface: **frame**. KPI hybrid SoT: [stats-12](https://reui.io/preview/base/stats-12). Lists: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2). Page tabs: [c-tabs-9](https://reui.io/preview/base/components/c-tabs-9). Grid status: [c-toggle-group-5](https://reui.io/preview/base/components/c-toggle-group-5). Quick Actions: `QuickActionGrid`.
|
||||
|
||||
Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`.
|
||||
|
||||
@@ -48,9 +48,16 @@ alwaysApply: false
|
||||
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
|
||||
*Проверка:* https://ui.shadcn.com/docs/components/form
|
||||
|
||||
**WEB-06** | MUST | Сложные data-списки — Frame + ReUI DataGrid через `reui-kit/ResourcePage` или `DataGridSection` (не Card shell, не shadcn Data Table). Surface lock: `UI_SURFACE = 'frame'` (`lib/ui-surface.ts`). Простые списки — shadcn `Table`.
|
||||
**WEB-06** | MUST | Сложные data-списки — Frame + ReUI DataGrid через `reui-kit/ResourcePage` (не Card shell, не shadcn Data Table). Surface lock: `UI_SURFACE = 'frame'` (`lib/ui-surface.ts`). Простые списки — shadcn `Table`.
|
||||
*Проверка:* `@/components/reui-kit`, `@/components/reui/data-grid`, `docs/ui-design-contract.md`.
|
||||
|
||||
**WEB-06a** | MUST | Переключатели по роли UX:
|
||||
- секции страницы (Сеть / Операции / Справочники) — native `Tabs` default list (`SegmentedTabs`, [c-tabs-9](https://reui.io/preview/base/components/c-tabs-9));
|
||||
- статус грида (Все/Вкл) — `ToggleGroup` в toolbar (`StatusToggleGroup`, [c-toggle-group-5](https://reui.io/preview/base/components/c-toggle-group-5));
|
||||
- настройки — вертикальный Tabs rail ([settings-3](https://reui.io/preview/base/settings-3)).
|
||||
Не `variant="line"` для page/in-grid tabs.
|
||||
*Проверка:* `@/components/segmented-tabs`, `@/components/status-toggle-group`.
|
||||
|
||||
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
|
||||
*Проверка:* https://ui.shadcn.com/docs/components/sonner
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
| Конфиг BIRD, `birdc` | `internal/birdfmt/`, `internal/birddeploy/` |
|
||||
| Бандлы и подписи | `internal/bundle/`, `internal/signing/` |
|
||||
| Точки входа процессов | `cmd/*/` |
|
||||
| Веб (SvelteKit) | `web/` |
|
||||
| Веб (React + ReUI) | `apps/web/` |
|
||||
| Compose, деплой | `deploy/compose/` |
|
||||
|
||||
Точки входа бинарников и их роли — в таблице в начале [docs/architecture.md](docs/architecture.md).
|
||||
|
||||
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import globals from 'globals'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'src/routeTree.gen.ts', 'src/components/blocks/**'] },
|
||||
{ ignores: ['dist', 'src/routeTree.gen.ts'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
|
||||
@@ -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: "maya@reui.io",
|
||||
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: "jonas@reui.io",
|
||||
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: "priya@reui.io",
|
||||
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: "emil@reui.io",
|
||||
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: "nora@reui.io",
|
||||
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: "procurement@reui.dev",
|
||||
website: "https://reui.dev",
|
||||
owners: [
|
||||
{
|
||||
id: "owner-1",
|
||||
name: "Maya Chen",
|
||||
address: "214 Mission Street, San Francisco, CA",
|
||||
share: "52%",
|
||||
},
|
||||
{
|
||||
id: "owner-2",
|
||||
name: "Kai Morgan",
|
||||
address: "18 King Street, Seattle, WA",
|
||||
share: "28%",
|
||||
},
|
||||
],
|
||||
taxId: "US-82-4917721",
|
||||
incorporationDate: "08/08/2025",
|
||||
ownershipAttested: true,
|
||||
documentIds: ["certificate", "bank-letter"],
|
||||
submissionNotes:
|
||||
"Route final review to finance once tax residency is uploaded.",
|
||||
}
|
||||
@@ -1,646 +0,0 @@
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Checkbox } from "@evobgp/ui/components/checkbox"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldTitle,
|
||||
} from "@evobgp/ui/components/field"
|
||||
import { Input } from "@evobgp/ui/components/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
import { Textarea } from "@evobgp/ui/components/textarea"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@evobgp/ui/components/tooltip"
|
||||
import {
|
||||
CATEGORY_OPTIONS,
|
||||
COUNTRY_OPTIONS,
|
||||
DOCUMENT_REQUIREMENTS,
|
||||
REGISTRATION_OPTIONS,
|
||||
type DocumentId,
|
||||
type OwnerRecord,
|
||||
type VendorCategory,
|
||||
type VendorCountry,
|
||||
type WizardOption,
|
||||
type WizardValues,
|
||||
} from "./data"
|
||||
import { InfoIcon, Trash2Icon, PlusIcon } from "lucide-react"
|
||||
|
||||
export type WizardErrors = Partial<Record<keyof WizardValues, string>>
|
||||
|
||||
type WizardValueChange = (
|
||||
field: keyof WizardValues,
|
||||
value: WizardValues[keyof WizardValues]
|
||||
) => void
|
||||
|
||||
interface WizardSummary {
|
||||
categoryLabel: string
|
||||
countryLabel: string
|
||||
registrationLabel: string
|
||||
}
|
||||
|
||||
function RequiredMark() {
|
||||
return <span className="text-destructive">*</span>
|
||||
}
|
||||
|
||||
function FieldLabelHint({
|
||||
htmlFor,
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
htmlFor?: string
|
||||
label: string
|
||||
hint: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-fit items-center gap-1.5 leading-snug">
|
||||
<FieldLabel htmlFor={htmlFor}>{children}</FieldLabel>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex size-4 shrink-0 items-center justify-center rounded-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
aria-label={`${label} hint`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoIcon className="size-3.5" aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-56 text-xs text-balance">
|
||||
{hint}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WizardSelectField<TValue extends string>({
|
||||
id,
|
||||
value,
|
||||
options,
|
||||
placeholder,
|
||||
onValueChange,
|
||||
}: {
|
||||
id: string
|
||||
value: TValue
|
||||
options: WizardOption<TValue>[]
|
||||
placeholder: string
|
||||
onValueChange: (value: TValue) => void
|
||||
}) {
|
||||
const selectedOption =
|
||||
options.find((option) => option.value === value) ?? null
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={selectedOption}
|
||||
items={options}
|
||||
onValueChange={(nextValue) => {
|
||||
if (nextValue) {
|
||||
onValueChange(nextValue.value)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={id} className="w-full [&_small]:hidden">
|
||||
<SelectValue>
|
||||
{(item: WizardOption<TValue> | null) => item?.label ?? placeholder}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option}>
|
||||
{option.description ? (
|
||||
<span className="flex min-w-0 flex-col items-start gap-px">
|
||||
<span className="truncate font-medium">{option.label}</span>
|
||||
<small className="text-muted-foreground truncate text-xs">
|
||||
{option.description}
|
||||
</small>
|
||||
</span>
|
||||
) : (
|
||||
option.label
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryLine({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-4 text-sm">
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="text-foreground min-w-0 text-right font-medium break-words">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SummarySection({
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={[
|
||||
"border-border/70 flex min-w-0 flex-col gap-3 rounded-md border p-4",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeading({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 id={id} className="text-base font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RemoveRowButton({
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive justify-self-start sm:justify-self-end"
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Trash2Icon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function OwnerRows({
|
||||
owners,
|
||||
error,
|
||||
onOwnerChange,
|
||||
onAddOwner,
|
||||
onRemoveOwner,
|
||||
}: {
|
||||
owners: OwnerRecord[]
|
||||
error?: string
|
||||
onOwnerChange: (id: string, field: keyof OwnerRecord, value: string) => void
|
||||
onAddOwner: () => void
|
||||
onRemoveOwner: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Field data-invalid={Boolean(error)} className="gap-3 sm:col-span-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{owners.map((owner, index) => (
|
||||
<div
|
||||
key={owner.id}
|
||||
className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)_5rem_auto]"
|
||||
>
|
||||
<Field className="gap-1.5">
|
||||
<FieldLabel htmlFor={`${owner.id}-name`} className="sr-only">
|
||||
Owner {index + 1} name
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={`${owner.id}-name`}
|
||||
value={owner.name}
|
||||
onChange={(event) =>
|
||||
onOwnerChange(owner.id, "name", event.target.value)
|
||||
}
|
||||
placeholder="Owner full name"
|
||||
autoComplete="name"
|
||||
/>
|
||||
</Field>
|
||||
<Field className="gap-1.5">
|
||||
<FieldLabel htmlFor={`${owner.id}-address`} className="sr-only">
|
||||
Owner {index + 1} address
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={`${owner.id}-address`}
|
||||
value={owner.address}
|
||||
onChange={(event) =>
|
||||
onOwnerChange(owner.id, "address", event.target.value)
|
||||
}
|
||||
placeholder="Registered address"
|
||||
autoComplete="street-address"
|
||||
/>
|
||||
</Field>
|
||||
<Field className="gap-1.5">
|
||||
<FieldLabel htmlFor={`${owner.id}-share`} className="sr-only">
|
||||
Owner {index + 1} share
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={`${owner.id}-share`}
|
||||
value={owner.share}
|
||||
onChange={(event) =>
|
||||
onOwnerChange(owner.id, "share", event.target.value)
|
||||
}
|
||||
placeholder="Share"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</Field>
|
||||
<RemoveRowButton
|
||||
label={`Remove owner ${index + 1}`}
|
||||
onClick={() => onRemoveOwner(owner.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? <FieldError>{error}</FieldError> : null}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-auto"
|
||||
onClick={onAddOwner}
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Add owner
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
function AttestationCheckbox({
|
||||
id,
|
||||
checked,
|
||||
label,
|
||||
description,
|
||||
error,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
id: string
|
||||
checked: boolean
|
||||
label: string
|
||||
description: string
|
||||
error?: string
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<Field
|
||||
data-invalid={Boolean(error)}
|
||||
orientation="horizontal"
|
||||
className="gap-3"
|
||||
>
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={(nextChecked) => onCheckedChange(nextChecked === true)}
|
||||
aria-invalid={Boolean(error)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
<FieldDescription>{description}</FieldDescription>
|
||||
</FieldContent>
|
||||
{error ? <FieldError>{error}</FieldError> : null}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
export function CompanyStepFields({
|
||||
values,
|
||||
errors,
|
||||
summary,
|
||||
onValueChange,
|
||||
}: {
|
||||
values: WizardValues
|
||||
errors: WizardErrors
|
||||
summary: WizardSummary
|
||||
onValueChange: WizardValueChange
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-6"
|
||||
aria-labelledby="wizard-2-company-title"
|
||||
>
|
||||
<SectionHeading
|
||||
id="wizard-2-company-title"
|
||||
title="Company Details"
|
||||
description="Identify the vendor record before compliance review."
|
||||
/>
|
||||
|
||||
<FieldGroup className="grid gap-5 sm:grid-cols-2">
|
||||
<Field data-invalid={Boolean(errors.companyName)} className="gap-2">
|
||||
<FieldLabelHint
|
||||
htmlFor="wizard-2-company"
|
||||
label="Legal Company Name"
|
||||
hint="Match the registered legal entity name."
|
||||
>
|
||||
Legal Company Name <RequiredMark />
|
||||
</FieldLabelHint>
|
||||
<Input
|
||||
id="wizard-2-company"
|
||||
value={values.companyName}
|
||||
onChange={(event) =>
|
||||
onValueChange("companyName", event.target.value)
|
||||
}
|
||||
aria-invalid={Boolean(errors.companyName)}
|
||||
autoComplete="organization"
|
||||
/>
|
||||
{errors.companyName ? (
|
||||
<FieldError>{errors.companyName}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field className="gap-2">
|
||||
<FieldLabelHint
|
||||
htmlFor="wizard-2-registration"
|
||||
label="Portal Registration"
|
||||
hint="Use the status from the vendor portal."
|
||||
>
|
||||
Portal Registration
|
||||
</FieldLabelHint>
|
||||
<WizardSelectField
|
||||
id="wizard-2-registration"
|
||||
value={values.registrationStatus}
|
||||
options={REGISTRATION_OPTIONS}
|
||||
placeholder="Select status"
|
||||
onValueChange={(value) =>
|
||||
onValueChange("registrationStatus", value)
|
||||
}
|
||||
/>
|
||||
<FieldDescription>{summary.registrationLabel}</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<Field className="gap-2">
|
||||
<FieldLabel htmlFor="wizard-2-category">Category</FieldLabel>
|
||||
<WizardSelectField<VendorCategory>
|
||||
id="wizard-2-category"
|
||||
value={values.category}
|
||||
options={CATEGORY_OPTIONS}
|
||||
placeholder="Select category"
|
||||
onValueChange={(value) => onValueChange("category", value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field className="gap-2">
|
||||
<FieldLabel htmlFor="wizard-2-country">Country</FieldLabel>
|
||||
<WizardSelectField<VendorCountry>
|
||||
id="wizard-2-country"
|
||||
value={values.country}
|
||||
options={COUNTRY_OPTIONS}
|
||||
placeholder="Select country"
|
||||
onValueChange={(value) => onValueChange("country", value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.contactEmail)} className="gap-2">
|
||||
<FieldLabel htmlFor="wizard-2-email">
|
||||
Vendor Email <RequiredMark />
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="wizard-2-email"
|
||||
value={values.contactEmail}
|
||||
onChange={(event) =>
|
||||
onValueChange("contactEmail", event.target.value)
|
||||
}
|
||||
aria-invalid={Boolean(errors.contactEmail)}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
/>
|
||||
{errors.contactEmail ? (
|
||||
<FieldError>{errors.contactEmail}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field className="gap-2">
|
||||
<FieldLabel htmlFor="wizard-2-website">Website</FieldLabel>
|
||||
<Input
|
||||
id="wizard-2-website"
|
||||
value={values.website}
|
||||
onChange={(event) => onValueChange("website", event.target.value)}
|
||||
autoComplete="url"
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function OwnershipStepFields({
|
||||
values,
|
||||
errors,
|
||||
onValueChange,
|
||||
onOwnerChange,
|
||||
onAddOwner,
|
||||
onRemoveOwner,
|
||||
}: {
|
||||
values: WizardValues
|
||||
errors: WizardErrors
|
||||
onValueChange: WizardValueChange
|
||||
onOwnerChange: (id: string, field: keyof OwnerRecord, value: string) => void
|
||||
onRemoveOwner: (id: string) => void
|
||||
onAddOwner: () => void
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-6"
|
||||
aria-labelledby="wizard-2-ownership-title"
|
||||
>
|
||||
<SectionHeading
|
||||
id="wizard-2-ownership-title"
|
||||
title="Compliance"
|
||||
description="Confirm ownership and tax records."
|
||||
/>
|
||||
|
||||
<FieldGroup className="grid gap-5 sm:grid-cols-2">
|
||||
<OwnerRows
|
||||
owners={values.owners}
|
||||
error={errors.owners}
|
||||
onOwnerChange={onOwnerChange}
|
||||
onAddOwner={onAddOwner}
|
||||
onRemoveOwner={onRemoveOwner}
|
||||
/>
|
||||
|
||||
<Field data-invalid={Boolean(errors.taxId)} className="gap-2">
|
||||
<FieldLabelHint
|
||||
htmlFor="wizard-2-tax"
|
||||
label="Tax/VAT ID"
|
||||
hint="Enter the identifier finance will validate."
|
||||
>
|
||||
Tax/VAT ID <RequiredMark />
|
||||
</FieldLabelHint>
|
||||
<Input
|
||||
id="wizard-2-tax"
|
||||
value={values.taxId}
|
||||
onChange={(event) => onValueChange("taxId", event.target.value)}
|
||||
aria-invalid={Boolean(errors.taxId)}
|
||||
placeholder="Enter tax number"
|
||||
/>
|
||||
{errors.taxId ? <FieldError>{errors.taxId}</FieldError> : null}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
data-invalid={Boolean(errors.incorporationDate)}
|
||||
className="gap-2"
|
||||
>
|
||||
<FieldLabelHint
|
||||
htmlFor="wizard-2-incorporated"
|
||||
label="Date Incorporated"
|
||||
hint="Use the date from the registration record."
|
||||
>
|
||||
Date Incorporated <RequiredMark />
|
||||
</FieldLabelHint>
|
||||
<Input
|
||||
id="wizard-2-incorporated"
|
||||
value={values.incorporationDate}
|
||||
onChange={(event) =>
|
||||
onValueChange("incorporationDate", event.target.value)
|
||||
}
|
||||
aria-invalid={Boolean(errors.incorporationDate)}
|
||||
placeholder="MM/DD/YYYY"
|
||||
/>
|
||||
{errors.incorporationDate ? (
|
||||
<FieldError>{errors.incorporationDate}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<AttestationCheckbox
|
||||
id="wizard-2-ownership-attested"
|
||||
checked={values.ownershipAttested}
|
||||
label="Ownership reviewed"
|
||||
description="Records match company documents."
|
||||
error={errors.ownershipAttested}
|
||||
onCheckedChange={(checked) =>
|
||||
onValueChange("ownershipAttested", checked)
|
||||
}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function DocumentsStepFields({
|
||||
values,
|
||||
errors,
|
||||
summary,
|
||||
onValueChange,
|
||||
onDocumentToggle,
|
||||
}: {
|
||||
values: WizardValues
|
||||
errors: WizardErrors
|
||||
summary: WizardSummary
|
||||
onValueChange: WizardValueChange
|
||||
onDocumentToggle: (documentId: DocumentId, checked: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-6"
|
||||
aria-labelledby="wizard-2-documents-title"
|
||||
>
|
||||
<SectionHeading
|
||||
id="wizard-2-documents-title"
|
||||
title="Submit"
|
||||
description="Review files and confirm."
|
||||
/>
|
||||
|
||||
<FieldGroup className="grid gap-6">
|
||||
<Field data-invalid={Boolean(errors.documentIds)} className="gap-3">
|
||||
<FieldLabelHint
|
||||
label="Required Documents"
|
||||
hint="Select files already received from the vendor."
|
||||
>
|
||||
Required Documents
|
||||
</FieldLabelHint>
|
||||
<FieldGroup className="grid gap-2 sm:grid-cols-2">
|
||||
{DOCUMENT_REQUIREMENTS.map((document) => (
|
||||
<FieldLabel key={document.id}>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
checked={values.documentIds.includes(document.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
onDocumentToggle(document.id, checked === true)
|
||||
}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldTitle>{document.title}</FieldTitle>
|
||||
<FieldDescription>{document.description}</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
))}
|
||||
</FieldGroup>
|
||||
{errors.documentIds ? (
|
||||
<FieldError>{errors.documentIds}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field className="gap-2">
|
||||
<FieldLabel htmlFor="wizard-2-notes">Reviewer Notes</FieldLabel>
|
||||
<Textarea
|
||||
id="wizard-2-notes"
|
||||
value={values.submissionNotes}
|
||||
onChange={(event) =>
|
||||
onValueChange("submissionNotes", event.target.value)
|
||||
}
|
||||
className="min-h-24 resize-none"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<SummarySection title="Vendor">
|
||||
<dl className="flex flex-col gap-2.5">
|
||||
<SummaryLine label="Company" value={values.companyName} />
|
||||
<SummaryLine label="Category" value={summary.categoryLabel} />
|
||||
<SummaryLine label="Country" value={summary.countryLabel} />
|
||||
</dl>
|
||||
</SummarySection>
|
||||
|
||||
<SummarySection title="Compliance">
|
||||
<dl className="flex flex-col gap-2.5">
|
||||
<SummaryLine label="Tax/VAT" value={values.taxId} />
|
||||
<SummaryLine label="Owners" value={values.owners.length} />
|
||||
<SummaryLine label="Status" value="Reviewed" />
|
||||
</dl>
|
||||
</SummarySection>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useMemo, useState, type FormEvent } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import {
|
||||
Stepper,
|
||||
StepperContent,
|
||||
StepperIndicator,
|
||||
StepperItem,
|
||||
StepperNav,
|
||||
StepperPanel,
|
||||
StepperSeparator,
|
||||
StepperTitle,
|
||||
StepperTrigger,
|
||||
} from "@/components/reui/stepper"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Spinner } from "@evobgp/ui/components/spinner"
|
||||
import {
|
||||
CATEGORY_OPTIONS,
|
||||
COUNTRY_OPTIONS,
|
||||
DEFAULT_WIZARD_VALUES,
|
||||
REGISTRATION_OPTIONS,
|
||||
WIZARD_STEPS,
|
||||
type DocumentId,
|
||||
type OwnerRecord,
|
||||
type WizardOption,
|
||||
type WizardStepId,
|
||||
type WizardValues,
|
||||
} from "./data"
|
||||
import {
|
||||
CompanyStepFields,
|
||||
DocumentsStepFields,
|
||||
OwnershipStepFields,
|
||||
type WizardErrors,
|
||||
} from "./procurement-vendor-steps"
|
||||
import { CheckIcon, FlagIcon, ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
|
||||
|
||||
function createDefaultValues(): WizardValues {
|
||||
return {
|
||||
...DEFAULT_WIZARD_VALUES,
|
||||
owners: DEFAULT_WIZARD_VALUES.owners.map((owner) => ({ ...owner })),
|
||||
documentIds: [...DEFAULT_WIZARD_VALUES.documentIds],
|
||||
}
|
||||
}
|
||||
|
||||
function isValidEmail(value: string) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
|
||||
}
|
||||
|
||||
function getOptionLabel<TValue extends string>(
|
||||
options: WizardOption<TValue>[],
|
||||
value: TValue
|
||||
) {
|
||||
return options.find((option) => option.value === value)?.label ?? "Not set"
|
||||
}
|
||||
|
||||
function getStepErrors(stepId: WizardStepId, values: WizardValues) {
|
||||
const nextErrors: WizardErrors = {}
|
||||
|
||||
if (stepId === "company") {
|
||||
if (!values.companyName.trim()) {
|
||||
nextErrors.companyName = "Enter the legal company name."
|
||||
}
|
||||
|
||||
if (!isValidEmail(values.contactEmail)) {
|
||||
nextErrors.contactEmail = "Enter a valid procurement email."
|
||||
}
|
||||
}
|
||||
|
||||
if (stepId === "ownership") {
|
||||
if (!values.taxId.trim()) {
|
||||
nextErrors.taxId = "Enter the Tax/VAT ID."
|
||||
}
|
||||
|
||||
if (!values.incorporationDate.trim()) {
|
||||
nextErrors.incorporationDate = "Enter the incorporation date."
|
||||
}
|
||||
|
||||
if (!values.owners.some((owner) => owner.name.trim())) {
|
||||
nextErrors.owners = "Add at least one beneficial owner."
|
||||
}
|
||||
|
||||
if (!values.ownershipAttested) {
|
||||
nextErrors.ownershipAttested = "Confirm beneficial ownership review."
|
||||
}
|
||||
}
|
||||
|
||||
if (stepId === "documents" && values.documentIds.length < 2) {
|
||||
nextErrors.documentIds = "Select at least two received documents."
|
||||
}
|
||||
|
||||
return nextErrors
|
||||
}
|
||||
|
||||
function getAllRequiredErrors(values: WizardValues) {
|
||||
return {
|
||||
...getStepErrors("company", values),
|
||||
...getStepErrors("ownership", values),
|
||||
...getStepErrors("documents", values),
|
||||
}
|
||||
}
|
||||
|
||||
function getFirstInvalidStep(values: WizardValues) {
|
||||
if (Object.keys(getStepErrors("company", values)).length > 0) return 1
|
||||
if (Object.keys(getStepErrors("ownership", values)).length > 0) return 2
|
||||
if (Object.keys(getStepErrors("documents", values)).length > 0) return 3
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function DotSeparator() {
|
||||
return (
|
||||
<span
|
||||
className="size-1 shrink-0 rounded-full bg-gray-400 dark:bg-gray-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProcurementVendorWizard() {
|
||||
const [currentStep, setCurrentStep] = useState(2)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [values, setValues] = useState<WizardValues>(createDefaultValues)
|
||||
const [errors, setErrors] = useState<WizardErrors>({})
|
||||
|
||||
const currentStepConfig = useMemo(
|
||||
() =>
|
||||
WIZARD_STEPS.find((step) => step.step === currentStep) ?? WIZARD_STEPS[0],
|
||||
[currentStep]
|
||||
)
|
||||
|
||||
const summary = useMemo(
|
||||
() => ({
|
||||
categoryLabel: getOptionLabel(CATEGORY_OPTIONS, values.category),
|
||||
countryLabel: getOptionLabel(COUNTRY_OPTIONS, values.country),
|
||||
registrationLabel: getOptionLabel(
|
||||
REGISTRATION_OPTIONS,
|
||||
values.registrationStatus
|
||||
),
|
||||
}),
|
||||
[values.category, values.country, values.registrationStatus]
|
||||
)
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(field: keyof WizardValues, value: WizardValues[keyof WizardValues]) => {
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
[field]: value,
|
||||
}))
|
||||
|
||||
setErrors((current) => {
|
||||
if (!current[field]) {
|
||||
return current
|
||||
}
|
||||
|
||||
const nextErrors = { ...current }
|
||||
delete nextErrors[field]
|
||||
return nextErrors
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleOwnerChange = useCallback(
|
||||
(id: string, field: keyof OwnerRecord, value: string) => {
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
owners: current.owners.map((owner) =>
|
||||
owner.id === id ? { ...owner, [field]: value } : owner
|
||||
),
|
||||
}))
|
||||
|
||||
setErrors((current) => {
|
||||
if (!current.owners) return current
|
||||
const nextErrors = { ...current }
|
||||
delete nextErrors.owners
|
||||
return nextErrors
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleAddOwner = useCallback(() => {
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
owners: [
|
||||
...current.owners,
|
||||
{
|
||||
id: `owner-${Date.now()}`,
|
||||
name: "",
|
||||
address: "",
|
||||
share: "",
|
||||
},
|
||||
],
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const handleRemoveOwner = useCallback((id: string) => {
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
owners: current.owners.filter((owner) => owner.id !== id),
|
||||
}))
|
||||
|
||||
setErrors((current) => {
|
||||
if (!current.owners) return current
|
||||
const nextErrors = { ...current }
|
||||
delete nextErrors.owners
|
||||
return nextErrors
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleDocumentToggle = useCallback(
|
||||
(documentId: DocumentId, checked: boolean) => {
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
documentIds: checked
|
||||
? Array.from(new Set([...current.documentIds, documentId]))
|
||||
: current.documentIds.filter((id) => id !== documentId),
|
||||
}))
|
||||
|
||||
setErrors((current) => {
|
||||
if (!current.documentIds) return current
|
||||
const nextErrors = { ...current }
|
||||
delete nextErrors.documentIds
|
||||
return nextErrors
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setCurrentStep((step) => Math.max(1, step - 1))
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (isSubmitting) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentStepConfig.id !== "documents") {
|
||||
const stepErrors = getStepErrors(currentStepConfig.id, values)
|
||||
|
||||
if (Object.keys(stepErrors).length > 0) {
|
||||
setErrors((current) => ({ ...current, ...stepErrors }))
|
||||
return
|
||||
}
|
||||
|
||||
const nextStep = Math.min(currentStep + 1, WIZARD_STEPS.length)
|
||||
setCurrentStep(nextStep)
|
||||
return
|
||||
}
|
||||
|
||||
const requiredErrors = getAllRequiredErrors(values)
|
||||
const invalidStep = getFirstInvalidStep(values)
|
||||
|
||||
if (Object.keys(requiredErrors).length > 0 && invalidStep !== null) {
|
||||
setErrors(requiredErrors)
|
||||
setCurrentStep(invalidStep)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
window.setTimeout(() => {
|
||||
setIsSubmitting(false)
|
||||
toast.success("Vendor file submitted", {
|
||||
description: `${values.companyName} is ready for procurement review.`,
|
||||
})
|
||||
}, 900)
|
||||
},
|
||||
[currentStep, currentStepConfig.id, isSubmitting, values]
|
||||
)
|
||||
|
||||
const isLastStep = currentStep === WIZARD_STEPS.length
|
||||
const canGoBack = currentStep > 1 && !isSubmitting
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex w-full max-w-2xl flex-col"
|
||||
aria-label="Procurement vendor onboarding wizard"
|
||||
>
|
||||
<Stepper
|
||||
value={currentStep}
|
||||
onValueChange={setCurrentStep}
|
||||
indicators={{
|
||||
completed: (
|
||||
<CheckIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
loading: <Spinner className="size-3.5" />,
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<Frame stacked={true} className="w-full">
|
||||
<FrameHeader>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<FrameTitle>Procurement Vendor Setup</FrameTitle>
|
||||
<FrameDescription className="inline-flex min-w-0 items-center gap-1.5 text-sm">
|
||||
<span className="shrink-0">{values.portalId}</span>
|
||||
<DotSeparator />
|
||||
<span className="truncate">{summary.categoryLabel}</span>
|
||||
</FrameDescription>
|
||||
</div>
|
||||
<Badge variant="warning-light">Review file</Badge>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="flex items-center overflow-x-auto">
|
||||
<StepperNav
|
||||
aria-label="Wizard progress"
|
||||
className="min-w-full items-center"
|
||||
>
|
||||
{WIZARD_STEPS.map((step, index) => (
|
||||
<StepperItem
|
||||
key={step.id}
|
||||
step={step.step}
|
||||
loading={isSubmitting && step.step === currentStep}
|
||||
className="relative min-w-0"
|
||||
>
|
||||
<StepperTrigger
|
||||
type="button"
|
||||
className="min-w-0 justify-start gap-2 disabled:opacity-100"
|
||||
>
|
||||
<StepperIndicator className="data-[state=active]:bg-primary/10 data-[state=active]:text-primary data-[state=completed]:bg-primary data-[state=inactive]:border-border data-[state=inactive]:text-foreground data-[state=active]:before:border-primary relative isolate size-6 overflow-visible rounded-full border before:pointer-events-none before:absolute before:inset-0 before:z-10 before:rounded-full before:border before:border-dashed before:border-transparent before:content-[''] data-[state=active]:border-0 data-[state=active]:before:animate-[spin_8s_linear_infinite] motion-reduce:data-[state=active]:before:animate-none">
|
||||
{index + 1}
|
||||
</StepperIndicator>
|
||||
<StepperTitle className="text-foreground hidden truncate text-sm capitalize sm:block">
|
||||
{step.title}
|
||||
</StepperTitle>
|
||||
</StepperTrigger>
|
||||
|
||||
{WIZARD_STEPS.length > index + 1 ? (
|
||||
<StepperSeparator className="bg-border group-data-[state=completed]/step:bg-primary mx-2" />
|
||||
) : null}
|
||||
</StepperItem>
|
||||
))}
|
||||
</StepperNav>
|
||||
</FramePanel>
|
||||
|
||||
<FramePanel>
|
||||
<StepperPanel className="text-sm">
|
||||
{WIZARD_STEPS.map((step) => (
|
||||
<StepperContent key={step.id} value={step.step}>
|
||||
{step.id === "company" ? (
|
||||
<CompanyStepFields
|
||||
values={values}
|
||||
errors={errors}
|
||||
summary={summary}
|
||||
onValueChange={handleValueChange}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step.id === "ownership" ? (
|
||||
<OwnershipStepFields
|
||||
values={values}
|
||||
errors={errors}
|
||||
onValueChange={handleValueChange}
|
||||
onOwnerChange={handleOwnerChange}
|
||||
onAddOwner={handleAddOwner}
|
||||
onRemoveOwner={handleRemoveOwner}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step.id === "documents" ? (
|
||||
<DocumentsStepFields
|
||||
values={values}
|
||||
errors={errors}
|
||||
summary={summary}
|
||||
onValueChange={handleValueChange}
|
||||
onDocumentToggle={handleDocumentToggle}
|
||||
/>
|
||||
) : null}
|
||||
</StepperContent>
|
||||
))}
|
||||
</StepperPanel>
|
||||
</FramePanel>
|
||||
|
||||
<FrameFooter className="gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<FrameDescription className="hidden min-w-0 items-center gap-1.5 text-sm sm:flex">
|
||||
<FlagIcon className="size-4 shrink-0" aria-hidden="true" />
|
||||
{currentStepConfig.description}
|
||||
</FrameDescription>
|
||||
|
||||
<div className="flex w-full items-center justify-end gap-2 sm:w-auto">
|
||||
{canGoBack ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleBack}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Previous
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="shrink-0"
|
||||
>
|
||||
{isSubmitting ? <Spinner data-icon="inline-start" /> : null}
|
||||
{isLastStep ? "Submit" : "Next Step"}
|
||||
{!isLastStep && !isSubmitting ? (
|
||||
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
|
||||
) : null}
|
||||
</Button>
|
||||
</div>
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
</Stepper>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { ProcurementVendorWizard } from "./components/procurement-vendor-wizard"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-start justify-center p-4 sm:p-6 lg:p-10">
|
||||
<ProcurementVendorWizard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,50 +1 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
|
||||
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
||||
|
||||
const TONE_VARIANT: Record<string, BadgeVariant> = {
|
||||
neutral: 'outline',
|
||||
info: 'info-light',
|
||||
warning: 'warning-light',
|
||||
success: 'success-light',
|
||||
}
|
||||
|
||||
export function ModeBadge({
|
||||
enabled,
|
||||
onLabel = 'включён',
|
||||
offLabel = 'выключен',
|
||||
className,
|
||||
}: {
|
||||
enabled: boolean
|
||||
onLabel?: string
|
||||
offLabel?: string
|
||||
className?: string
|
||||
}) {
|
||||
return enabled ? (
|
||||
<Badge variant="success-light" size="sm" radius="full" className={className}>
|
||||
{onLabel}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" size="sm" radius="full" className={className}>
|
||||
{offLabel}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export function CategoryBadge({
|
||||
children,
|
||||
tone = 'neutral',
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
tone?: keyof typeof TONE_VARIANT
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Badge variant={TONE_VARIANT[tone]} size="sm" radius="full" className={className}>
|
||||
{children}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
export { CategoryBadge, ModeBadge } from '@/components/status-badge'
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export interface CountedLineTab {
|
||||
id: string
|
||||
label: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
interface CountedLineTabsProps {
|
||||
tabs: CountedLineTab[]
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
className?: string
|
||||
listClassName?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/** Line tabs with count pills (c-tabs-2 / data-grid-filtering-2). */
|
||||
export function CountedLineTabs({
|
||||
tabs,
|
||||
value,
|
||||
onValueChange,
|
||||
className,
|
||||
listClassName,
|
||||
children,
|
||||
}: CountedLineTabsProps) {
|
||||
return (
|
||||
<Tabs value={value} onValueChange={onValueChange} className={className}>
|
||||
<TabsList variant="line" className={cn('gap-5', listClassName)}>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
{tab.count !== undefined ? (
|
||||
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
|
||||
{tab.count}
|
||||
</span>
|
||||
) : null}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{children}
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
PanelCard,
|
||||
FrameSection,
|
||||
panelCardContentFlushClassName,
|
||||
} from '@/components/panel-card'
|
||||
} from '@/components/reui-kit'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
/** Card-surface panel for dashboard sections (replaces legacy Frame shell). */
|
||||
/** Frame panel for dashboard sections. Preview: https://reui.io/docs/components/base/frame */
|
||||
export function DashboardFramePanel({
|
||||
title,
|
||||
description,
|
||||
@@ -23,7 +23,7 @@ export function DashboardFramePanel({
|
||||
contentClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<PanelCard
|
||||
<FrameSection
|
||||
title={title}
|
||||
description={description}
|
||||
actions={actions}
|
||||
@@ -31,6 +31,6 @@ export function DashboardFramePanel({
|
||||
contentClassName={cn(panelCardContentFlushClassName, contentClassName)}
|
||||
>
|
||||
{children}
|
||||
</PanelCard>
|
||||
</FrameSection>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} 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'
|
||||
|
||||
@@ -61,7 +61,6 @@ const ACTIONS: QuickActionItem[] = [
|
||||
title: 'Мониторинг',
|
||||
description: 'Состояние системы, BIRD и PostgreSQL.',
|
||||
to: '/monitoring',
|
||||
search: { tab: 'system' },
|
||||
icon: <Gauge aria-hidden />,
|
||||
iconClassName: 'text-destructive',
|
||||
},
|
||||
|
||||
@@ -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="alex.chen@example.com"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="pt-3">
|
||||
<Button size="sm">Save changes</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="password">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Password</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
Change your password here.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="underline-current" className="text-sm">
|
||||
Current password
|
||||
</Label>
|
||||
<Input id="underline-current" type="password" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="underline-new" className="text-sm">
|
||||
New password
|
||||
</Label>
|
||||
<Input id="underline-new" type="password" className="h-9" />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="pt-3">
|
||||
<Button size="sm">Update password</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="settings">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Settings</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
Manage your preferences.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="underline-theme" className="text-sm">
|
||||
Theme
|
||||
</Label>
|
||||
<Input
|
||||
id="underline-theme"
|
||||
defaultValue="Light"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="underline-language" className="text-sm">
|
||||
Language
|
||||
</Label>
|
||||
<Input
|
||||
id="underline-language"
|
||||
defaultValue="English"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="pt-3">
|
||||
<Button size="sm">Save settings</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@evobgp/ui/components/tabs"
|
||||
import { LayoutDashboardIcon, BarChart3Icon, SettingsIcon } from "lucide-react"
|
||||
|
||||
export function Pattern() {
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col gap-6">
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="overview">
|
||||
<LayoutDashboardIcon className="size-4" />
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="analytics">
|
||||
<BarChart3Icon className="size-4" />
|
||||
Analytics
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings">
|
||||
<SettingsIcon className="size-4" />
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview">
|
||||
<Card>
|
||||
<CardContent>Overview dashboard content goes here.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="analytics">
|
||||
<Card>
|
||||
<CardContent>Analytics charts and metrics.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="settings">
|
||||
<Card>
|
||||
<CardContent>Application settings and preferences.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@evobgp/ui/components/tabs"
|
||||
|
||||
export function Pattern() {
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col gap-6">
|
||||
<Tabs defaultValue="inbox">
|
||||
<TabsList variant="line" className="mb-3.5 w-full">
|
||||
<TabsTrigger value="inbox" className="gap-2">
|
||||
Inbox
|
||||
<Badge variant="primary-light" size="sm">
|
||||
12
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="drafts" className="gap-2">
|
||||
Drafts
|
||||
<Badge variant="info-light" size="sm">
|
||||
3
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sent" className="gap-2">
|
||||
Sent
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="spam" className="gap-2">
|
||||
Spam
|
||||
<Badge variant="destructive-light" size="sm">
|
||||
24
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="inbox">
|
||||
<Card>
|
||||
<CardContent>12 unread messages in your inbox.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="drafts">
|
||||
<Card>
|
||||
<CardContent>3 drafts waiting to be sent.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="sent">
|
||||
<Card>
|
||||
<CardContent>All sent messages appear here.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="spam">
|
||||
<Card>
|
||||
<CardContent>24 spam messages detected.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { FieldGroup } from '@evobgp/ui/components/field'
|
||||
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -20,7 +22,11 @@ interface FormDrawerProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Create/edit overlay — Sheet DNA (sheet-8 / sheet-1). Preview: https://reui.io/preview/base/sheet-8 */
|
||||
/**
|
||||
* Create/edit overlay — Sheet DNA (sheet-8) + FieldGroup (form-7).
|
||||
* @see https://reui.io/preview/base/sheet-8
|
||||
* @see https://reui.io/preview/base/form-7
|
||||
*/
|
||||
export function FormDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -34,13 +40,17 @@ export function FormDrawer({
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className={cn('w-full gap-0 overflow-hidden p-0 sm:max-w-lg', className)}
|
||||
className={cn('flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-lg', className)}
|
||||
>
|
||||
<SheetHeader className="shrink-0 border-b border-border/50">
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
{description ? <SheetDescription>{description}</SheetDescription> : null}
|
||||
</SheetHeader>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">{children}</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full">
|
||||
<FieldGroup className="p-4">{children}</FieldGroup>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<SheetFooter className="mt-0 shrink-0 border-t border-border/50 bg-muted/50">
|
||||
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||
{footer}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
/**
|
||||
* Re-export from reui-kit — keep `@/components/kpi-stat-grid` imports stable.
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export {
|
||||
KpiStatGrid,
|
||||
KpiStatCard,
|
||||
KpiStatCardTile,
|
||||
kpiStatItemKey,
|
||||
type KpiStatItem,
|
||||
type KpiStatCardData,
|
||||
type KpiStatVariant,
|
||||
type OpsKpiCard,
|
||||
} from '@/components/reui-kit/kpi-stat-grid'
|
||||
@@ -89,7 +89,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
items: [
|
||||
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и применение', search: { tab: 'revisions' } },
|
||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание обновления' },
|
||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD', search: { tab: 'system' } },
|
||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Loader2Icon } from 'lucide-react'
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import { Spinner } from '@evobgp/ui/components/spinner'
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
|
||||
type LoadingButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
type LoadingButtonProps = ComponentProps<typeof Button> & {
|
||||
loading?: boolean
|
||||
variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
|
||||
size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/** SoT for async actions — Button + Spinner. */
|
||||
export function LoadingButton({ loading, disabled, children, ...props }: LoadingButtonProps) {
|
||||
return (
|
||||
<Button disabled={disabled || loading} {...props}>
|
||||
{loading ? <Loader2Icon className="animate-spin" data-icon="inline-start" /> : null}
|
||||
{loading ? <Spinner data-icon="inline-start" /> : null}
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -3,8 +3,8 @@ import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Checkbox } from '@evobgp/ui/components/checkbox'
|
||||
import { Field, FieldDescription, FieldLabel } from '@evobgp/ui/components/field'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
|
||||
import { FormDrawer } from '@/components/form-drawer'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
@@ -171,42 +171,42 @@ export function ModuleCreateDialog({
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-create-name">Название</Label>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="mod-create-name">Название</FieldLabel>
|
||||
<Input
|
||||
id="mod-create-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Имя модуля"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<Field orientation="horizontal">
|
||||
<div className="grid min-w-0 flex-1 gap-1 pr-2">
|
||||
<Label htmlFor="mod-create-enabled">Включён</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<FieldLabel htmlFor="mod-create-enabled">Включён</FieldLabel>
|
||||
<FieldDescription>
|
||||
Выключенный модуль не участвует в обновлении и применении.
|
||||
</p>
|
||||
</FieldDescription>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="mod-create-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={(v) => setEnabled(v === true)}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-create-priority">Приоритет</Label>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="mod-create-priority">Приоритет</FieldLabel>
|
||||
<Input
|
||||
id="mod-create-priority"
|
||||
type="number"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-create-interval">Интервал обновления (сек)</Label>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="mod-create-interval">Интервал обновления (сек)</FieldLabel>
|
||||
<Input
|
||||
id="mod-create-interval"
|
||||
type="number"
|
||||
@@ -215,17 +215,17 @@ export function ModuleCreateDialog({
|
||||
value={refreshIntervalSec}
|
||||
onChange={(e) => setRefreshIntervalSec(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-create-cron">Cron (опционально)</Label>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="mod-create-cron">Cron (опционально)</FieldLabel>
|
||||
<Input
|
||||
id="mod-create-cron"
|
||||
placeholder="0 * * * *"
|
||||
value={cronExpr}
|
||||
onChange={(e) => setCronExpr(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<CommunitySelect
|
||||
id="mod-create-community"
|
||||
@@ -248,8 +248,8 @@ export function ModuleCreateDialog({
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>DoH профили</Label>
|
||||
<Field>
|
||||
<FieldLabel>DoH профили</FieldLabel>
|
||||
{dohProfiles.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
|
||||
) : (
|
||||
@@ -281,7 +281,7 @@ export function ModuleCreateDialog({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
</>
|
||||
) : null}
|
||||
</FormDrawer>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Clock, Gauge, Globe, Tags } from 'lucide-react'
|
||||
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { KpiStatGridSkeleton } from '@/components/skeletons'
|
||||
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
|
||||
|
||||
@@ -8,13 +8,22 @@ import {
|
||||
KanbanColumn,
|
||||
KanbanColumnContent,
|
||||
KanbanItem,
|
||||
KanbanOverlay,
|
||||
} from '@/components/reui/kanban'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemTitle,
|
||||
} from '@evobgp/ui/components/item'
|
||||
import { jobKindRu } from '@/lib/ui-labels'
|
||||
import type { JobRow } from '@/types/api'
|
||||
|
||||
/**
|
||||
* Operations jobs board view — ReUI Kanban (read-only columns).
|
||||
* Operations jobs board — ReUI Kanban (read-only columns).
|
||||
* @see https://reui.io/preview/base/kanban-board-8
|
||||
* @see https://reui.io/docs/components/base/kanban
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
@@ -78,9 +87,9 @@ export function OperationsJobsKanban({
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">{COLUMN_LABELS[columnId]}</p>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
<Badge variant="outline" size="sm">
|
||||
{columns[columnId]?.length ?? 0}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<KanbanColumnContent value={columnId}>
|
||||
{(columns[columnId] ?? []).map((job) => (
|
||||
@@ -88,24 +97,35 @@ export function OperationsJobsKanban({
|
||||
key={job.job_id}
|
||||
value={job.job_id}
|
||||
disabled
|
||||
className="bg-background flex flex-col gap-1 rounded-md border p-3"
|
||||
className="p-0"
|
||||
>
|
||||
<p className="text-sm leading-tight font-medium">
|
||||
{jobKindRu(job.kind)}
|
||||
</p>
|
||||
<StatusBadge status={job.status} />
|
||||
{job.meta?.module_id ? (
|
||||
<DataGridMutedCell>
|
||||
{nameById.get(String(job.meta.module_id)) ??
|
||||
String(job.meta.module_id)}
|
||||
</DataGridMutedCell>
|
||||
) : null}
|
||||
<Item variant="outline" size="sm">
|
||||
<ItemContent>
|
||||
<ItemTitle>{jobKindRu(job.kind)}</ItemTitle>
|
||||
<StatusBadge status={job.status} />
|
||||
{job.meta?.module_id ? (
|
||||
<ItemDescription>
|
||||
<DataGridMutedCell>
|
||||
{nameById.get(String(job.meta.module_id)) ??
|
||||
String(job.meta.module_id)}
|
||||
</DataGridMutedCell>
|
||||
</ItemDescription>
|
||||
) : null}
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</KanbanItem>
|
||||
))}
|
||||
</KanbanColumnContent>
|
||||
</KanbanColumn>
|
||||
))}
|
||||
</KanbanBoard>
|
||||
<KanbanOverlay>
|
||||
<Item variant="outline" size="sm">
|
||||
<ItemContent>
|
||||
<ItemTitle>Задача</ItemTitle>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</KanbanOverlay>
|
||||
</Kanban>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
@@ -1,93 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
/**
|
||||
* Frame shell for analytics / comparison panels (not list grids).
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
* @see https://reui.io/preview/base/chart-1
|
||||
*/
|
||||
export const panelCardClassName = 'w-full gap-0 p-0'
|
||||
export const panelCardHeaderClassName = 'border-b'
|
||||
/** Flush body inside FramePanel (toolbar / data-grid). */
|
||||
export const panelCardContentFlushClassName = 'p-0'
|
||||
/** Horizontal inset for toolbar / pagination rows. */
|
||||
export const panelCardInsetClassName = 'px-5 py-3'
|
||||
export const panelCardFooterClassName = 'border-t bg-transparent'
|
||||
|
||||
interface PanelCardProps {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
footer?: ReactNode
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
headerClassName?: string
|
||||
contentClassName?: string
|
||||
footerClassName?: string
|
||||
size?: 'default' | 'sm'
|
||||
}
|
||||
|
||||
export function PanelCard({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
footer,
|
||||
children,
|
||||
className,
|
||||
headerClassName,
|
||||
contentClassName,
|
||||
footerClassName,
|
||||
size = 'default',
|
||||
}: PanelCardProps) {
|
||||
const hasHeader = Boolean(title || description || actions)
|
||||
const spacing = size === 'sm' ? 'sm' : 'default'
|
||||
|
||||
return (
|
||||
<Frame
|
||||
dense
|
||||
spacing={spacing}
|
||||
className={cn(panelCardClassName, 'min-w-0', className)}
|
||||
>
|
||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||
{hasHeader ? (
|
||||
<FrameHeader
|
||||
className={cn(
|
||||
panelCardHeaderClassName,
|
||||
'flex-row items-start justify-between gap-3',
|
||||
headerClassName,
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
||||
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||
{description ? (
|
||||
<FrameDescription>{description}</FrameDescription>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
) : null}
|
||||
{children != null && children !== false ? (
|
||||
<div className={cn('min-w-0 flex-1', contentClassName)}>{children}</div>
|
||||
) : null}
|
||||
{footer ? (
|
||||
<FrameFooter className={cn(panelCardFooterClassName, footerClassName)}>
|
||||
{footer}
|
||||
</FrameFooter>
|
||||
) : null}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
export {
|
||||
FrameSection,
|
||||
FrameSection as PanelCard,
|
||||
panelCardClassName,
|
||||
panelCardHeaderClassName,
|
||||
panelCardContentFlushClassName,
|
||||
panelCardInsetClassName,
|
||||
panelCardFooterClassName,
|
||||
type FrameSectionProps,
|
||||
type FrameSectionProps as PanelCardProps,
|
||||
} from '@/components/reui-kit/frame-section'
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { IconStack } from '@/components/reui/icon-stack'
|
||||
import {
|
||||
Frame,
|
||||
FramePanel,
|
||||
} from '@/components/reui/frame'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@evobgp/ui/components/empty'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
|
||||
/** empty-state-14: Frame + IconStack. Preview: https://reui.io/preview/base/empty-state-14 */
|
||||
export function IllustratedEmptyState({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
icon,
|
||||
action,
|
||||
}: {
|
||||
title: string
|
||||
@@ -27,23 +16,13 @@ export function IllustratedEmptyState({
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FramePanel className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
|
||||
{action ? <div className="w-full self-end">{action}</div> : null}
|
||||
<Empty className="max-w-md gap-5 bg-transparent p-0">
|
||||
<EmptyHeader className="items-center gap-5 text-center">
|
||||
<EmptyMedia className="mb-0">
|
||||
<IconStack aria-hidden>
|
||||
<Icon strokeWidth={1.9} aria-hidden />
|
||||
</IconStack>
|
||||
</EmptyMedia>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<EmptyTitle className="text-base font-semibold tracking-tight">{title}</EmptyTitle>
|
||||
<EmptyDescription className="max-w-sm text-sm/relaxed">{description}</EmptyDescription>
|
||||
</div>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
<EmptyState
|
||||
framed
|
||||
centered={false}
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={description}
|
||||
action={action}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { DonutBreakdownCard } from './donut-breakdown-card'
|
||||
export { IllustratedEmptyState } from './illustrated-empty-state'
|
||||
export { KpiSparklineCard, type KpiSparklineMetric } from './kpi-sparkline-card'
|
||||
export { KpiStatGrid, KpiStatCard, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||
export { KpiStatGrid, KpiStatCard, type KpiStatItem } from '@/components/reui-kit'
|
||||
export { PanelCorners } from './panel-corners'
|
||||
export { ProjectsEmptyState } from './projects-empty-state'
|
||||
export { SegmentedProgressCard, type SegmentStat } from './segmented-progress-card'
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Boxes, Plus } from 'lucide-react'
|
||||
|
||||
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
/** empty-state-3 pattern for first module. */
|
||||
/** empty-state-14 for first module. Preview: https://reui.io/preview/base/empty-state-14 */
|
||||
export function ProjectsEmptyState({
|
||||
canCreate = true,
|
||||
onCreate,
|
||||
@@ -12,7 +12,9 @@ export function ProjectsEmptyState({
|
||||
onCreate?: () => void
|
||||
}) {
|
||||
return (
|
||||
<IllustratedEmptyState
|
||||
<EmptyState
|
||||
framed
|
||||
centered={false}
|
||||
icon={Boxes}
|
||||
title="Создайте первый модуль"
|
||||
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { kitDataGridTableClassNames, kitDataGridTableLayout } from './frame-data-grid'
|
||||
import {
|
||||
applyKitActionColumn,
|
||||
KIT_ACTION_COLUMN_SIZE,
|
||||
kitColumnPinning,
|
||||
kitDataGridTableClassNames,
|
||||
kitDataGridTableLayout,
|
||||
} from './frame-data-grid'
|
||||
|
||||
describe('kitDataGridTableLayout', () => {
|
||||
it('filtering-2 defaults: dense, headerBackground false, width fixed', () => {
|
||||
@@ -30,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'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -87,6 +87,81 @@ export const kitDataGridTableClassNames = {
|
||||
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)
|
||||
@@ -281,6 +356,7 @@ export function FrameDataGrid<TData extends object>({
|
||||
expandedContent,
|
||||
getRowCanExpand,
|
||||
pinLeftColumnIds,
|
||||
horizontalScroll = false,
|
||||
tableWidth = 'fixed',
|
||||
columnPinControls = false,
|
||||
}: FrameDataGridProps<TData>) {
|
||||
@@ -345,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'
|
||||
|
||||
@@ -67,7 +67,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 +81,39 @@ 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>
|
||||
{footer ? (
|
||||
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -116,7 +125,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',
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
} from '@tanstack/react-table'
|
||||
import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react'
|
||||
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { StatusToggleGroup } from '@/components/status-toggle-group'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
DataGrid,
|
||||
@@ -36,6 +36,8 @@ import { FILTERS_LABELS_RU, FILTERS_OPERATOR_LABELS_RU } from '@/lib/filters-i18
|
||||
import { applyFiltersToData, createEmptyFilterQuery } from './filter-utils'
|
||||
import {
|
||||
FrameDataGrid,
|
||||
applyKitActionColumn,
|
||||
kitColumnPinning,
|
||||
kitDataGridTableClassNames,
|
||||
kitDataGridTableLayout,
|
||||
type DataGridColumnDef,
|
||||
@@ -278,6 +280,7 @@ 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')
|
||||
@@ -328,12 +331,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 +348,7 @@ function ResourcePageFiltered<T extends object>({
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
data: filteredData,
|
||||
columns,
|
||||
columns: tableColumns,
|
||||
getRowId: (row, index) => getRowId(row, index),
|
||||
state: {
|
||||
sorting,
|
||||
@@ -459,38 +465,34 @@ 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-wrap items-center gap-2">
|
||||
{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>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{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 />
|
||||
)}
|
||||
) : null}
|
||||
{!showFilters && countedTabs.length === 0 ? <div /> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{toolbarExtra}
|
||||
{selectedCount > 0 ? (
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
EventCalendarViewContext,
|
||||
useEventCalendar,
|
||||
useEventCalendarSelector,
|
||||
useEventCalendarSettings,
|
||||
useEventCalendarViewConfig,
|
||||
} from "@/components/reui/event-calendar/event-calendar"
|
||||
import { EventCalendarEvent } from "@/components/reui/event-calendar/event-calendar-event"
|
||||
import {
|
||||
getDayKey,
|
||||
getRangeKey,
|
||||
toZoned,
|
||||
zonedStartOfDay,
|
||||
} from "@/components/reui/event-calendar/event-calendar-lib"
|
||||
import type {
|
||||
EventCalendarDateRange,
|
||||
EventCalendarSegment,
|
||||
} from "@/components/reui/event-calendar/event-calendar-types"
|
||||
import { IconStack } from "@/components/reui/icon-stack"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { addDays, format } from "date-fns"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { ScrollArea } from "@evobgp/ui/components/scroll-area"
|
||||
import { CalendarIcon } from "lucide-react"
|
||||
|
||||
// The agenda window length is the agendaDayCount SETTING (the store derives
|
||||
// visibleRange from it); a per-view prop here would silently disagree.
|
||||
type EventCalendarAgendaViewProps = useRender.ComponentProps<"div">
|
||||
|
||||
function EventCalendarAgendaView({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: EventCalendarAgendaViewProps) {
|
||||
const instance = useEventCalendar()
|
||||
const settings = useEventCalendarSettings()
|
||||
const viewConfig = useEventCalendarViewConfig()
|
||||
const visibleRange = useEventCalendarSelector<
|
||||
unknown,
|
||||
EventCalendarDateRange
|
||||
>((state) => state.visibleRange, {
|
||||
isEqual: (a, b) => getRangeKey(a) === getRangeKey(b),
|
||||
})
|
||||
// Subscribe to event changes via the day-bucket content of the whole range
|
||||
useEventCalendarSelector((state) => state.events)
|
||||
|
||||
const days = useMemo(() => {
|
||||
const result: Date[] = []
|
||||
let cursor = zonedStartOfDay(visibleRange.start, settings.timeZone)
|
||||
while (cursor < visibleRange.end) {
|
||||
result.push(cursor)
|
||||
cursor = zonedStartOfDay(
|
||||
addDays(toZoned(cursor, settings.timeZone), 1),
|
||||
settings.timeZone
|
||||
)
|
||||
}
|
||||
return result
|
||||
}, [visibleRange, settings.timeZone])
|
||||
|
||||
const index = instance.internals.getIndex()
|
||||
const groups = days
|
||||
.map((day) => ({
|
||||
day,
|
||||
bucket: index.byDay.get(getDayKey(day, settings.timeZone)),
|
||||
}))
|
||||
.filter((group) => {
|
||||
const total =
|
||||
(group.bucket?.allDay.length ?? 0) + (group.bucket?.timed.length ?? 0)
|
||||
return total > 0
|
||||
})
|
||||
|
||||
const isToday = (day: Date) =>
|
||||
getDayKey(day, settings.timeZone) ===
|
||||
getDayKey(new Date(), settings.timeZone)
|
||||
|
||||
const native = viewConfig.scrollbars === "native"
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{groups.length === 0 ? (
|
||||
<div
|
||||
data-slot="event-calendar-no-events"
|
||||
className={cn(
|
||||
"flex min-h-72 flex-col items-center justify-center gap-4 py-16",
|
||||
viewConfig.classNames?.noEvents
|
||||
)}
|
||||
>
|
||||
{viewConfig.renderNoEvents?.() ?? (
|
||||
<>
|
||||
<IconStack>
|
||||
<CalendarIcon className="size-5" aria-hidden="true" />
|
||||
</IconStack>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{settings.i18n.labels.noEvents}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Drop the very last row's bottom border so it does not double up with
|
||||
// the calendar container's own bottom border. Targets the last day
|
||||
// group's last child (its last agenda item); per-item `border-b` is
|
||||
// kept everywhere else, including each day's internal rows.
|
||||
<div className="flex flex-col [&>*:last-child>*:last-child]:border-b-0">
|
||||
{groups.map(({ day, bucket }) => {
|
||||
const items = [...(bucket?.allDay ?? []), ...(bucket?.timed ?? [])]
|
||||
const zoned = toZoned(day, settings.timeZone)
|
||||
const weekday = format(zoned, "EEEE", { locale: settings.locale })
|
||||
const dayDate = format(zoned, "MMMM d, yyyy", {
|
||||
locale: settings.locale,
|
||||
})
|
||||
return (
|
||||
<div
|
||||
key={day.getTime()}
|
||||
data-slot="event-calendar-agenda-day"
|
||||
data-today={isToday(day) || undefined}
|
||||
// A named group per day so a screen reader can step day by day
|
||||
// (and hear how full one is) instead of arrowing every row.
|
||||
role="group"
|
||||
aria-label={`${weekday}, ${dayDate}, ${settings.i18n.labels.events(items.length)}`}
|
||||
>
|
||||
{/* Group header: weekday (leading) + full date (trailing) */}
|
||||
<div
|
||||
data-slot="event-calendar-agenda-day-header"
|
||||
// The day bar is the agenda's only structure, so give it a
|
||||
// heading level: the H key and the rotor can jump between
|
||||
// days, which is the whole point of a long agenda.
|
||||
role="heading"
|
||||
aria-level={3}
|
||||
className={cn(
|
||||
"bg-muted/60 sticky top-0 z-10 flex items-baseline justify-between gap-4 border-b px-4 py-2",
|
||||
// The custom ScrollArea's overlay scrollbar (w-2.5 = 10px)
|
||||
// is painted UNDER this sticky, z-10, opaque header, so the
|
||||
// thumb vanishes behind the day bar at the top of the view.
|
||||
// Inset the header by the scrollbar lane so its background
|
||||
// stops before the scrollbar instead of covering it. Native
|
||||
// scrollbars already sit outside the content box, so this
|
||||
// only applies to the custom-scrollbar path.
|
||||
!native && "me-2.5",
|
||||
viewConfig.classNames?.agendaDayHeader
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-foreground font-semibold",
|
||||
isToday(day) && "text-primary"
|
||||
)}
|
||||
>
|
||||
{weekday}
|
||||
</span>
|
||||
<span className="text-muted-foreground font-medium tabular-nums">
|
||||
{dayDate}
|
||||
</span>
|
||||
</div>
|
||||
{items.map((segment) => (
|
||||
<EventCalendarAgendaItem
|
||||
key={segment.occurrence.key}
|
||||
segment={segment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const defaultProps = {
|
||||
"data-slot": "event-calendar-agenda-view",
|
||||
"data-view": "agenda",
|
||||
// Unlike the grid views the agenda has no row/column semantics to carry a
|
||||
// name, so label the region with the day range it covers - through
|
||||
// formatDayRange, so a consumer override reaches it.
|
||||
role: "group",
|
||||
"aria-label": settings.i18n.functions.formatDayRange(visibleRange, {
|
||||
locale: settings.locale,
|
||||
}),
|
||||
className: cn(
|
||||
"flex min-h-0 flex-1 flex-col overflow-hidden border-t",
|
||||
viewConfig.classNames?.agendaView,
|
||||
className
|
||||
),
|
||||
children: native ? (
|
||||
<div
|
||||
data-slot="scroll-area-viewport"
|
||||
data-ec-native-scroll=""
|
||||
className="h-full overflow-y-auto"
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-full">{body}</ScrollArea>
|
||||
),
|
||||
}
|
||||
|
||||
return (
|
||||
<EventCalendarViewContext.Provider value={{ view: "agenda" }}>
|
||||
{useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})}
|
||||
</EventCalendarViewContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One agenda row: a full-width, selectable table row - time column, color dot,
|
||||
* and title (all replaceable via renderAgendaEvent). Clicking selects the
|
||||
* event (drag/resize stay off in the agenda).
|
||||
*/
|
||||
function EventCalendarAgendaItem({
|
||||
segment,
|
||||
}: {
|
||||
segment: EventCalendarSegment
|
||||
}) {
|
||||
const viewConfig = useEventCalendarViewConfig()
|
||||
return (
|
||||
<EventCalendarEvent
|
||||
segment={segment}
|
||||
className={cn(
|
||||
// read-only list: hover only, no selected/focused styling on click
|
||||
"hover:bg-accent/40 gap-3 rounded-none border-b px-4 py-2.5 transition-colors",
|
||||
viewConfig.classNames?.agendaItem
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { EventCalendarAgendaView }
|
||||
export type { EventCalendarAgendaViewProps }
|
||||
@@ -1,80 +0,0 @@
|
||||
import { type ComponentType, type ReactNode } from "react"
|
||||
import {
|
||||
useEventCalendarSelector,
|
||||
useEventCalendarViewConfig,
|
||||
} from "@/components/reui/event-calendar/event-calendar"
|
||||
import { EventCalendarAgendaView } from "@/components/reui/event-calendar/event-calendar-agenda-view"
|
||||
import { EventCalendarMonthView } from "@/components/reui/event-calendar/event-calendar-month-view"
|
||||
import { EventCalendarResourceView } from "@/components/reui/event-calendar/event-calendar-resource-view"
|
||||
import {
|
||||
EventCalendarDaysView,
|
||||
EventCalendarDayView,
|
||||
EventCalendarWeekView,
|
||||
} from "@/components/reui/event-calendar/event-calendar-time-grid"
|
||||
import type { CalendarView } from "@/components/reui/event-calendar/event-calendar-types"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
const DEFAULT_VIEW_COMPONENTS: Record<CalendarView, ComponentType> = {
|
||||
month: EventCalendarMonthView,
|
||||
week: EventCalendarWeekView,
|
||||
day: EventCalendarDayView,
|
||||
days: EventCalendarDaysView,
|
||||
agenda: EventCalendarAgendaView,
|
||||
resource: EventCalendarResourceView,
|
||||
}
|
||||
|
||||
interface EventCalendarContentProps extends Omit<
|
||||
useRender.ComponentProps<"div">,
|
||||
"children"
|
||||
> {
|
||||
/** Swap individual view implementations. */
|
||||
components?: Partial<Record<CalendarView, ComponentType>>
|
||||
/** Replaces the switchboard entirely; read useEventCalendarView() inside. */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
function EventCalendarContent({
|
||||
className,
|
||||
render,
|
||||
components,
|
||||
children,
|
||||
...props
|
||||
}: EventCalendarContentProps) {
|
||||
const viewConfig = useEventCalendarViewConfig()
|
||||
const view = useEventCalendarSelector((state) => state.view)
|
||||
const loading = useEventCalendarSelector((state) => state.loading)
|
||||
|
||||
const resolved = {
|
||||
...DEFAULT_VIEW_COMPONENTS,
|
||||
...viewConfig.components,
|
||||
...components,
|
||||
}
|
||||
// A spread copies keys that hold `undefined`, so `components={{ month: isPro
|
||||
// ? ProMonth : undefined }}` would erase the default and render <undefined />.
|
||||
const ActiveView = resolved[view] ?? DEFAULT_VIEW_COMPONENTS[view]
|
||||
|
||||
const defaultProps = {
|
||||
"data-slot": "event-calendar-content",
|
||||
"data-view": view,
|
||||
"data-loading": loading || undefined,
|
||||
className: cn(
|
||||
"relative flex min-h-0 min-w-0 flex-1 flex-col",
|
||||
"data-loading:pointer-events-none data-loading:opacity-60",
|
||||
viewConfig.classNames?.content,
|
||||
className
|
||||
),
|
||||
children: children ?? <ActiveView />,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export { DEFAULT_VIEW_COMPONENTS, EventCalendarContent }
|
||||
export type { EventCalendarContentProps }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,555 +0,0 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import {
|
||||
useEventCalendar,
|
||||
useEventCalendarSelector,
|
||||
useEventCalendarViewConfig,
|
||||
useEventCalendarViewContext,
|
||||
} from "@/components/reui/event-calendar/event-calendar"
|
||||
import {
|
||||
markChipPress,
|
||||
useEventCalendarGestures,
|
||||
wasRecentDrag,
|
||||
} from "@/components/reui/event-calendar/event-calendar-dnd"
|
||||
import {
|
||||
spansMultipleDays,
|
||||
toZoned,
|
||||
zonedStartOfDay,
|
||||
} from "@/components/reui/event-calendar/event-calendar-lib"
|
||||
import type {
|
||||
EventCalendarOccurrence,
|
||||
EventCalendarSegment,
|
||||
} from "@/components/reui/event-calendar/event-calendar-types"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { addDays, format } from "date-fns"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@evobgp/ui/components/tooltip"
|
||||
import { RepeatIcon } from "lucide-react"
|
||||
|
||||
/** Event color presets; each stays legible on light and dark surfaces. */
|
||||
const EVENT_CALENDAR_COLORS: Array<{ name: string; value: string }> = [
|
||||
{ name: "Blue", value: "var(--color-blue-500)" },
|
||||
{ name: "Emerald", value: "var(--color-emerald-500)" },
|
||||
{ name: "Violet", value: "var(--color-violet-500)" },
|
||||
{ name: "Rose", value: "var(--color-rose-500)" },
|
||||
{ name: "Amber", value: "var(--color-amber-500)" },
|
||||
{ name: "Cyan", value: "var(--color-cyan-500)" },
|
||||
{ name: "Orange", value: "var(--color-orange-500)" },
|
||||
{ name: "Pink", value: "var(--color-pink-500)" },
|
||||
{ name: "Teal", value: "var(--color-teal-500)" },
|
||||
{ name: "Indigo", value: "var(--color-indigo-500)" },
|
||||
]
|
||||
|
||||
/**
|
||||
* Drag-ghost surfaces, shared verbatim by every view. A move CARRIES the
|
||||
* event: the dnd engine attaches a full clone to the cursor
|
||||
* (data-slot=event-calendar-drag-carry), so this in-grid ghost is only the
|
||||
* dashed placeholder for the snapped drop slot. A resize STRETCHES instead:
|
||||
* the chip itself at the proposed extent, dashed rather than solid.
|
||||
*/
|
||||
const EVENT_CALENDAR_GHOST = {
|
||||
move: "rounded-sm border border-dashed border-(--ec-event-color)/50 bg-(--ec-event-color)/8",
|
||||
resize:
|
||||
"rounded-sm border border-dashed border-(--ec-event-color)/70 overflow-hidden",
|
||||
invalid: "border-destructive/70 bg-destructive/10",
|
||||
invalidResize: "border-destructive/70",
|
||||
/** Applied to the clone inside an invalid resize ghost. */
|
||||
invalidContent: "opacity-60",
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Fade-out truncation for stacked timed blocks, where squeezed cascade
|
||||
* columns clip titles into a mash of glyphs; a right-edge mask fade reads
|
||||
* cleaner than an ellipsis at those widths. Masked ONLY below a 10rem
|
||||
* container: mask-image forces text off subpixel antialiasing, so masking
|
||||
* wide chips makes the whole grid read bolder and shimmer while resizing.
|
||||
* Wide chips keep the plain ellipsis. Exported for consumer renderEvent.
|
||||
*/
|
||||
const EVENT_CALENDAR_FADE_TRUNCATE =
|
||||
"w-full truncate @max-[10rem]:text-clip @max-[10rem]:[mask-image:linear-gradient(to_right,#000_calc(100%-0.75rem),transparent)] @max-[10rem]:rtl:[mask-image:linear-gradient(to_left,#000_calc(100%-0.75rem),transparent)]"
|
||||
|
||||
/**
|
||||
* The drag-to-create selection, shared by every view: a dashed primary
|
||||
* outline over a faint wash with the range printed inside. `box` is the timed
|
||||
* grid's single minute-positioned rectangle; `segment` is one day-cell slice
|
||||
* of a multi-cell draft, with side borders and rounding only on the run's two
|
||||
* ends so it reads as one dashed box rather than a row of them.
|
||||
*/
|
||||
const EVENT_CALENDAR_SLOT_DRAFT = {
|
||||
box: "rounded-sm border border-dashed border-primary/40 bg-primary/5",
|
||||
segment: "border-y border-dashed border-primary/40",
|
||||
segmentStart: "rounded-s-sm border-s",
|
||||
segmentEnd: "rounded-e-sm border-e",
|
||||
/**
|
||||
* The wash for segmented views, on the CELL not the dashed overlay: the
|
||||
* overlay stacks above the chips, so tinting it would wash them instead.
|
||||
*/
|
||||
surface: "bg-primary/5",
|
||||
/**
|
||||
* The range readout. `leading-none` is load-bearing: the shortest timed
|
||||
* draft is one snap interval tall (16px at the default 15-minute snap and
|
||||
* 4rem hour height), and looser leading renders ~18px, clipped by the
|
||||
* draft's own overflow-hidden.
|
||||
*/
|
||||
label:
|
||||
"text-primary truncate px-1 py-0.5 text-[0.6875rem] leading-none font-medium",
|
||||
} as const
|
||||
|
||||
interface EventCalendarChipContextValue<TData = unknown> {
|
||||
occurrence: EventCalendarOccurrence<TData>
|
||||
segment: EventCalendarSegment<TData>
|
||||
isDragging: boolean
|
||||
isSelected: boolean
|
||||
}
|
||||
|
||||
const EventCalendarChipContext =
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
createContext<EventCalendarChipContextValue<any> | null>(null)
|
||||
|
||||
/** The chip's subject; usable inside renderEvent content and chip children. */
|
||||
function useEventCalendarEventChip<
|
||||
TData = unknown,
|
||||
>(): EventCalendarChipContextValue<TData> {
|
||||
const ctx = useContext(EventCalendarChipContext)
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useEventCalendarEventChip must be used within <EventCalendarEvent>"
|
||||
)
|
||||
}
|
||||
return ctx as EventCalendarChipContextValue<TData>
|
||||
}
|
||||
|
||||
interface EventCalendarEventProps<TData = unknown> extends Omit<
|
||||
useRender.ComponentProps<"button">,
|
||||
"children"
|
||||
> {
|
||||
segment: EventCalendarSegment<TData>
|
||||
/** Replaces the default chip CONTENT; the wrapper stays calendar-owned. */
|
||||
children?: ReactNode
|
||||
/**
|
||||
* Static drag clone: the chip as-is but inert - no gestures, resize
|
||||
* handles, selection/drag state, focus or pointer events.
|
||||
*/
|
||||
preview?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The one interactive event element in every view. The wrapper owns a11y,
|
||||
* data attributes, selection and drag/resize wiring; content comes from
|
||||
* children, else renderEvent, else the built-in default.
|
||||
*/
|
||||
function EventCalendarEvent<TData = unknown>({
|
||||
segment,
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
preview = false,
|
||||
...props
|
||||
}: EventCalendarEventProps<TData>) {
|
||||
const instance = useEventCalendar<TData>()
|
||||
const viewConfig = useEventCalendarViewConfig<TData>()
|
||||
const { view } = useEventCalendarViewContext()
|
||||
const gestures = useEventCalendarGestures<TData>()
|
||||
const { settings } = instance
|
||||
const occurrence = segment.occurrence
|
||||
const event = occurrence.event
|
||||
|
||||
const isSelectedRaw = useEventCalendarSelector<TData, boolean>(
|
||||
(state) => state.selection.eventKeys.includes(occurrence.key),
|
||||
{ calendar: instance }
|
||||
)
|
||||
const isDraggingRaw = useEventCalendarSelector<TData, boolean>(
|
||||
(state) => state.drag?.occurrence.key === occurrence.key,
|
||||
{ calendar: instance }
|
||||
)
|
||||
// reactive, unlike gestures.canResize: api.setInteractions({ resize })
|
||||
// must add/remove the handles without waiting for an unrelated re-render
|
||||
const resizeOn = useEventCalendarSelector<TData, boolean>(
|
||||
(state) => state.interactions.resize,
|
||||
{ calendar: instance }
|
||||
)
|
||||
// A preview clone must never inherit the source's selected/dragging state
|
||||
// (the drag key matches, which would dim the clone itself).
|
||||
const isSelected = preview ? false : isSelectedRaw
|
||||
const isDragging = preview ? false : isDraggingRaw
|
||||
|
||||
const isBar =
|
||||
occurrence.allDay || spansMultipleDays(occurrence, settings.timeZone)
|
||||
const inTimeGrid =
|
||||
view === "week" || view === "day" || view === "days" || view === "resource"
|
||||
const interactive = view !== "agenda" && !preview
|
||||
const timedBlock = inTimeGrid && !isBar
|
||||
const horizontalBar = isBar && !inTimeGrid
|
||||
// >= compactEventMinutes renders the stacked (title over time) layout, where
|
||||
// squeezed cascade columns fade-truncate instead of clipping into neighbors
|
||||
const stackedBlock =
|
||||
timedBlock &&
|
||||
(segment.endMin ?? 0) - (segment.startMin ?? 0) >=
|
||||
viewConfig.compactEventMinutes
|
||||
|
||||
const defaultContent = (
|
||||
<>
|
||||
{/* leading dot for single-row chips (month cells, all-day bars); a
|
||||
time-grid block takes its color from the tinted surface instead, and
|
||||
in its stacked layout a dot would sit alone on the first line */}
|
||||
{!timedBlock && (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="event-calendar-event-dot"
|
||||
// -me-0.5 tightens only the dot-to-title gap; the chip keeps gap-1.5
|
||||
className="-me-0.5 size-1.5 shrink-0 rounded-full bg-(--ec-event-color)"
|
||||
/>
|
||||
)}
|
||||
{occurrence.isRecurring && (
|
||||
<RepeatIcon className="size-2.5 shrink-0 opacity-70" aria-hidden="true" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium",
|
||||
stackedBlock ? EVENT_CALENDAR_FADE_TRUNCATE : "truncate"
|
||||
)}
|
||||
>
|
||||
{event.title}
|
||||
</span>
|
||||
{/* month cells are narrow: a compact never-shrinking start time keeps
|
||||
the title readable; grids show the full range */}
|
||||
{!occurrence.allDay &&
|
||||
segment.isStart &&
|
||||
(view === "month" ? (
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{format(
|
||||
toZoned(occurrence.start, settings.timeZone),
|
||||
settings.i18n.formats.eventTime,
|
||||
{ locale: settings.locale }
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground hidden @[8rem]:inline",
|
||||
stackedBlock ? EVENT_CALENDAR_FADE_TRUNCATE : "truncate"
|
||||
)}
|
||||
>
|
||||
{settings.i18n.functions.formatEventTime(
|
||||
toZoned(occurrence.start, settings.timeZone),
|
||||
toZoned(occurrence.end, settings.timeZone),
|
||||
occurrence.allDay,
|
||||
{ locale: settings.locale }
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
// Per-day time text for a multi-day event: "From 9:00 AM", "All day",
|
||||
// "Until 5:00 PM". Boundaries come from the occurrence vs segment.day, never
|
||||
// the packing flags - lane merging rewrites those on shared segments.
|
||||
const agendaTimeText = (() => {
|
||||
if (view !== "agenda") return ""
|
||||
if (occurrence.allDay) return settings.i18n.labels.allDay
|
||||
const dayStart = zonedStartOfDay(segment.day, settings.timeZone)
|
||||
const dayEnd = addDays(toZoned(dayStart, settings.timeZone), 1)
|
||||
const startsBefore = occurrence.start < dayStart
|
||||
const endsAfter = occurrence.end > dayEnd
|
||||
if (startsBefore && endsAfter) return settings.i18n.labels.allDay
|
||||
if (endsAfter) {
|
||||
return settings.i18n.labels.timeFrom(
|
||||
format(
|
||||
toZoned(occurrence.start, settings.timeZone),
|
||||
settings.i18n.formats.eventTime,
|
||||
{ locale: settings.locale }
|
||||
)
|
||||
)
|
||||
}
|
||||
if (startsBefore) {
|
||||
return settings.i18n.labels.timeUntil(
|
||||
format(
|
||||
toZoned(occurrence.end, settings.timeZone),
|
||||
settings.i18n.formats.eventTime,
|
||||
{ locale: settings.locale }
|
||||
)
|
||||
)
|
||||
}
|
||||
return settings.i18n.functions.formatEventTime(
|
||||
toZoned(occurrence.start, settings.timeZone),
|
||||
toZoned(occurrence.end, settings.timeZone),
|
||||
false,
|
||||
{ locale: settings.locale }
|
||||
)
|
||||
})()
|
||||
|
||||
// Agenda default row: time column, color-dot badge, plain title
|
||||
const agendaDefaultContent = (
|
||||
<>
|
||||
<span className="text-muted-foreground w-40 shrink-0 truncate tabular-nums">
|
||||
{agendaTimeText}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="event-calendar-agenda-dot"
|
||||
className="size-2 shrink-0 rounded-full bg-(--ec-event-color)"
|
||||
/>
|
||||
<span className="truncate text-sm">{event.title}</span>
|
||||
{occurrence.isRecurring && (
|
||||
<RepeatIcon className="text-muted-foreground size-2.5 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
// Memoized so a drag - which re-renders the lane on every pointer move -
|
||||
// never re-invokes the consumer's renderEvent per frame: a referentially
|
||||
// stable element lets React skip the custom subtree instead of flickering it.
|
||||
// The render fns are deps, so an inline arrow from the consumer defeats it.
|
||||
const customContent = useMemo(() => {
|
||||
const renderProps = { occurrence, segment, view, isDragging, isSelected }
|
||||
return view === "agenda"
|
||||
? viewConfig.renderAgendaEvent?.(renderProps)
|
||||
: viewConfig.renderEvent?.(renderProps)
|
||||
}, [
|
||||
occurrence,
|
||||
segment,
|
||||
view,
|
||||
isDragging,
|
||||
isSelected,
|
||||
viewConfig.renderAgendaEvent,
|
||||
viewConfig.renderEvent,
|
||||
])
|
||||
const content =
|
||||
children ??
|
||||
customContent ??
|
||||
(view === "agenda" ? agendaDefaultContent : defaultContent)
|
||||
|
||||
const timeLabel = settings.i18n.functions.formatEventTime(
|
||||
toZoned(occurrence.start, settings.timeZone),
|
||||
toZoned(occurrence.end, settings.timeZone),
|
||||
occurrence.allDay,
|
||||
{ locale: settings.locale }
|
||||
)
|
||||
// native hover tooltip text; a formatter returning undefined drops the title
|
||||
const label = settings.i18n.functions.formatEventLabel
|
||||
? settings.i18n.functions.formatEventLabel(event.title, timeLabel)
|
||||
: `${event.title}, ${timeLabel}`
|
||||
|
||||
// Optional styled tooltip (viewConfig.eventTooltip, default off). It replaces
|
||||
// the native title so the two never stack, and a preview never gets one. A
|
||||
// falsy renderEventTooltip result (including the false/"" of `cond && <node>`)
|
||||
// falls back to the label; an empty label leaves no content and skips it.
|
||||
const tooltipOpts =
|
||||
typeof viewConfig.eventTooltip === "object" ? viewConfig.eventTooltip : null
|
||||
const tooltipContent =
|
||||
!preview && viewConfig.eventTooltip
|
||||
? viewConfig.renderEventTooltip?.({
|
||||
occurrence,
|
||||
segment,
|
||||
view,
|
||||
label,
|
||||
}) || label
|
||||
: null
|
||||
const tooltipOn = Boolean(tooltipContent)
|
||||
|
||||
const showResize =
|
||||
interactive && resizeOn && !event.readOnly && event.resizable !== false
|
||||
// Hover grip pill (mirrors the gantt bars) marking the resize direction.
|
||||
// Shown on compact sub-compactEventMinutes blocks too: the 1.5rem chip
|
||||
// min-height leaves edge room without colliding with the centered title.
|
||||
const grip = (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="event-calendar-resize-grip"
|
||||
className={cn(
|
||||
"bg-foreground/40 rounded-full",
|
||||
timedBlock ? "h-0.5 w-2.5" : "h-2.5 w-0.5",
|
||||
viewConfig.classNames?.resizeGrip
|
||||
)}
|
||||
/>
|
||||
)
|
||||
const resizeHandles = showResize && (
|
||||
<>
|
||||
{timedBlock && segment.isStart && (
|
||||
<span
|
||||
data-slot="event-calendar-resize-handle"
|
||||
data-edge="start"
|
||||
className={cn(
|
||||
"absolute inset-x-1 top-0 flex h-1.5 cursor-ns-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100",
|
||||
viewConfig.classNames?.resizeHandle
|
||||
)}
|
||||
onPointerDown={(e) => gestures.beginResize(e, segment, "start")}
|
||||
>
|
||||
{grip}
|
||||
</span>
|
||||
)}
|
||||
{timedBlock && segment.isEnd && (
|
||||
<span
|
||||
data-slot="event-calendar-resize-handle"
|
||||
data-edge="end"
|
||||
className={cn(
|
||||
"absolute inset-x-1 bottom-0 flex h-1.5 cursor-ns-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100",
|
||||
viewConfig.classNames?.resizeHandle
|
||||
)}
|
||||
onPointerDown={(e) => gestures.beginResize(e, segment, "end")}
|
||||
>
|
||||
{grip}
|
||||
</span>
|
||||
)}
|
||||
{(horizontalBar || (isBar && inTimeGrid)) && segment.isStart && (
|
||||
<span
|
||||
data-slot="event-calendar-resize-handle"
|
||||
data-edge="start"
|
||||
className={cn(
|
||||
"absolute inset-y-0 start-0 flex w-2 cursor-ew-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100",
|
||||
viewConfig.classNames?.resizeHandle
|
||||
)}
|
||||
onPointerDown={(e) => gestures.beginResize(e, segment, "start")}
|
||||
>
|
||||
{grip}
|
||||
</span>
|
||||
)}
|
||||
{(horizontalBar || (isBar && inTimeGrid)) && segment.isEnd && (
|
||||
<span
|
||||
data-slot="event-calendar-resize-handle"
|
||||
data-edge="end"
|
||||
className={cn(
|
||||
"absolute inset-y-0 end-0 flex w-2 cursor-ew-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100",
|
||||
viewConfig.classNames?.resizeHandle
|
||||
)}
|
||||
onPointerDown={(e) => gestures.beginResize(e, segment, "end")}
|
||||
>
|
||||
{grip}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const defaultProps = {
|
||||
type: "button" as const,
|
||||
"data-slot": "event-calendar-event",
|
||||
"data-view": view,
|
||||
"data-all-day": occurrence.allDay || undefined,
|
||||
"data-recurring": occurrence.isRecurring || undefined,
|
||||
"data-selected": isSelected || undefined,
|
||||
"data-dragging": isDragging || undefined,
|
||||
"data-preview": preview || undefined,
|
||||
"data-past": occurrence.end.getTime() < Date.now() || undefined,
|
||||
title: preview || tooltipOn ? undefined : label,
|
||||
"aria-label":
|
||||
settings.i18n.functions.formatEventAriaLabel?.(
|
||||
event.title,
|
||||
timeLabel,
|
||||
segment.continuesBefore || segment.continuesAfter
|
||||
) ??
|
||||
`${event.title}, ${timeLabel}${
|
||||
segment.continuesBefore || segment.continuesAfter
|
||||
? `, ${settings.i18n.labels.continues}`
|
||||
: ""
|
||||
}`,
|
||||
// A background tint alone conveys selection, so the chip is a real toggle
|
||||
// wherever it is interactive (agenda rows never select, previews are inert).
|
||||
"aria-pressed": interactive ? isSelected : undefined,
|
||||
"aria-hidden": preview || undefined,
|
||||
tabIndex: preview ? -1 : undefined,
|
||||
style: {
|
||||
"--ec-event-color": event.color ?? "var(--color-primary)",
|
||||
} as CSSProperties,
|
||||
onPointerDown: (e: React.PointerEvent) => {
|
||||
e.stopPropagation()
|
||||
// suppress the trailing slot-create click when this press yields no drag
|
||||
markChipPress()
|
||||
if (interactive) gestures.beginMove(e, segment)
|
||||
},
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (wasRecentDrag()) return
|
||||
// consumer first: e.preventDefault() opts out of built-in selection
|
||||
settings.onEventClick?.(occurrence, e)
|
||||
// the agenda is a read-only list: a click never selects/focuses a row
|
||||
if (e.defaultPrevented || view === "agenda") return
|
||||
instance.api.selectEvent(occurrence.key)
|
||||
},
|
||||
onDoubleClick: (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
settings.onEventDoubleClick?.(occurrence, e)
|
||||
},
|
||||
className: cn(
|
||||
"group/ec-event text-foreground relative flex w-full min-w-0 cursor-pointer touch-none items-center overflow-hidden text-start select-none",
|
||||
"focus-visible:ring-ring/50 outline-none focus-visible:ring-2",
|
||||
preview && "pointer-events-none",
|
||||
view === "agenda"
|
||||
? // plain list row: color lives in the dot badge, not a tinted pill;
|
||||
// hover AND selection surfaces are owned by the agenda row wrapper
|
||||
"gap-3 rounded-md text-sm"
|
||||
: cn(
|
||||
// @container removes intrinsic sizing; only grid chips are containers
|
||||
// py-1: room above/below inline badges (attendee pill etc.)
|
||||
"@container gap-1.5 rounded-sm px-1.5 py-1 leading-normal",
|
||||
// soft tint + inset ring, not an accent border: legible on both themes
|
||||
"bg-(--ec-event-color)/15 hover:bg-(--ec-event-color)/25",
|
||||
// a flat tint reads darker on a dark surface, so lift it there
|
||||
"dark:bg-(--ec-event-color)/20 dark:hover:bg-(--ec-event-color)/30",
|
||||
"inset-ring inset-ring-(--ec-event-color)/15",
|
||||
"transition-[background-color,box-shadow] duration-150",
|
||||
"data-dragging:opacity-40",
|
||||
"data-selected:bg-(--ec-event-color)/30 data-selected:inset-ring-(--ec-event-color)/40",
|
||||
segment.continuesBefore && "rounded-s-none",
|
||||
segment.continuesAfter && "rounded-e-none"
|
||||
),
|
||||
viewConfig.classNames?.event,
|
||||
className
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
{content}
|
||||
{resizeHandles}
|
||||
</>
|
||||
),
|
||||
}
|
||||
|
||||
const chip = useRender({
|
||||
defaultTagName: "button",
|
||||
render,
|
||||
props: mergeProps<"button">(defaultProps, props),
|
||||
})
|
||||
|
||||
return (
|
||||
<EventCalendarChipContext.Provider
|
||||
value={{ occurrence, segment, isDragging, isSelected }}
|
||||
>
|
||||
{tooltipOn ? (
|
||||
<TooltipProvider delay={tooltipOpts?.delay ?? 600}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={chip} />
|
||||
<TooltipContent
|
||||
side={tooltipOpts?.side ?? "top"}
|
||||
className={viewConfig.classNames?.eventTooltip}
|
||||
>
|
||||
{tooltipContent}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
chip
|
||||
)}
|
||||
</EventCalendarChipContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
EVENT_CALENDAR_COLORS,
|
||||
EVENT_CALENDAR_FADE_TRUNCATE,
|
||||
EVENT_CALENDAR_GHOST,
|
||||
EVENT_CALENDAR_SLOT_DRAFT,
|
||||
EventCalendarEvent,
|
||||
useEventCalendarEventChip,
|
||||
}
|
||||
export type { EventCalendarChipContextValue, EventCalendarEventProps }
|
||||
@@ -1,291 +0,0 @@
|
||||
import type {
|
||||
CalendarView,
|
||||
EventCalendarDateRange,
|
||||
} from "@/components/reui/event-calendar/event-calendar-types"
|
||||
import {
|
||||
format,
|
||||
isSameMonth,
|
||||
isSameYear,
|
||||
subMilliseconds,
|
||||
type Locale,
|
||||
} from "date-fns"
|
||||
|
||||
interface EventCalendarI18nConfig {
|
||||
labels: {
|
||||
today: string
|
||||
previous: string
|
||||
next: string
|
||||
addEvent: string
|
||||
allDay: string
|
||||
more: (count: number) => string
|
||||
noEvents: string
|
||||
loading: string
|
||||
event: string
|
||||
events: (count: number) => string
|
||||
selectView: string
|
||||
week: (weekNumber: number) => string
|
||||
resources: string
|
||||
goToDate: string
|
||||
/** Cursor hint while a drag/resize hovers a rejected position. */
|
||||
dropNotAllowed: string
|
||||
/** Aria-label suffix on chip segments that continue past the cell. */
|
||||
continues: string
|
||||
/** Agenda label for the first day of a multi-day event. */
|
||||
timeFrom: (time: string) => string
|
||||
/** Agenda label for the last day of a multi-day event. */
|
||||
timeUntil: (time: string) => string
|
||||
/** View-switcher shortcut hint characters, per view. */
|
||||
viewShortcuts: Record<CalendarView, string>
|
||||
/** Aria-label of the agenda day collapse/expand toggle. */
|
||||
toggleDayEvents: (count: number, expanded: boolean) => string
|
||||
/** Aria-label of the agenda event details toggle. */
|
||||
eventDetails: (title: string) => string
|
||||
/** Compact "+N" overflow (agenda summary dot stack). */
|
||||
moreCompact: (count: number) => string
|
||||
/** Joins a bounded from-to time span. */
|
||||
timeRange: (from: string, to: string) => string
|
||||
}
|
||||
viewNames: {
|
||||
month: string
|
||||
week: string
|
||||
day: string
|
||||
days: (count: number) => string
|
||||
agenda: string
|
||||
resource: string
|
||||
}
|
||||
/** date-fns format strings, applied with the calendar `locale`. */
|
||||
formats: {
|
||||
monthTitle: string
|
||||
/** Undefined = smart cross-month range via functions.formatTitle. */
|
||||
weekTitle?: string
|
||||
dayTitle: string
|
||||
/** Undefined = smart range label via functions.formatTitle. */
|
||||
agendaTitle?: string
|
||||
monthDayHeader: string
|
||||
/** Narrow variant used by the month view below the compact breakpoint. */
|
||||
monthDayHeaderNarrow: string
|
||||
timeGridDayHeader: string
|
||||
agendaDayHeader: string
|
||||
/** Agenda date-gutter day number. */
|
||||
agendaDayNumber: string
|
||||
/** Agenda date-gutter weekday label. */
|
||||
agendaWeekday: string
|
||||
/** "+N more" popover day header. */
|
||||
moreDayHeader: string
|
||||
/** Month cell aria-label date. */
|
||||
monthCellAriaLabel: string
|
||||
/** Time-grid day column aria-label date. */
|
||||
dayAria: string
|
||||
/** Undefined = the resource view title falls back to dayTitle. */
|
||||
resourceTitle?: string
|
||||
timeGutter: string
|
||||
/** Sub-hour gutter labels (interval below 60 minutes). */
|
||||
timeGutterMinute: string
|
||||
eventTime: string
|
||||
monthCellDay: string
|
||||
}
|
||||
functions: {
|
||||
formatTitle: (
|
||||
view: CalendarView,
|
||||
ctx: {
|
||||
date: Date
|
||||
activeRange: EventCalendarDateRange
|
||||
visibleRange: EventCalendarDateRange
|
||||
locale?: Locale
|
||||
}
|
||||
) => string
|
||||
formatEventTime: (
|
||||
start: Date,
|
||||
end: Date,
|
||||
allDay: boolean,
|
||||
/** date-fns options (the calendar `locale`); trailing so a 3-arg override still fits. */
|
||||
opts?: { locale?: Locale }
|
||||
) => string
|
||||
formatDayRange: (
|
||||
range: EventCalendarDateRange,
|
||||
opts?: { locale?: Locale }
|
||||
) => string
|
||||
/** Chip native tooltip text; return undefined to drop the attribute. */
|
||||
formatEventLabel?: (title: string, timeLabel: string) => string | undefined
|
||||
/** Chip aria-label composition. */
|
||||
formatEventAriaLabel?: (
|
||||
title: string,
|
||||
timeLabel: string,
|
||||
continues: boolean
|
||||
) => string
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_LABELS: EventCalendarI18nConfig["labels"] = {
|
||||
today: "Today",
|
||||
previous: "Previous",
|
||||
next: "Next",
|
||||
addEvent: "Add event",
|
||||
allDay: "All day",
|
||||
more: (count) => `+${count} more`,
|
||||
noEvents: "No events",
|
||||
loading: "Loading events",
|
||||
event: "event",
|
||||
events: (count) => (count === 1 ? "1 event" : `${count} events`),
|
||||
selectView: "Select view",
|
||||
week: (weekNumber) => `W${weekNumber}`,
|
||||
resources: "Resources",
|
||||
goToDate: "Go to date",
|
||||
dropNotAllowed: "Can't place here",
|
||||
continues: "continues",
|
||||
timeFrom: (time) => `From ${time}`,
|
||||
timeUntil: (time) => `Until ${time}`,
|
||||
viewShortcuts: {
|
||||
month: "M",
|
||||
week: "W",
|
||||
day: "D",
|
||||
days: "5",
|
||||
agenda: "A",
|
||||
resource: "G",
|
||||
},
|
||||
toggleDayEvents: (count) => (count === 1 ? "1 event" : `${count} events`),
|
||||
eventDetails: (title) => title,
|
||||
moreCompact: (count) => `+${count}`,
|
||||
timeRange: (from, to) => `${from} - ${to}`,
|
||||
}
|
||||
|
||||
const DEFAULT_VIEW_NAMES: EventCalendarI18nConfig["viewNames"] = {
|
||||
month: "Month",
|
||||
week: "Week",
|
||||
day: "Day",
|
||||
days: (count) => (count === 1 ? "1 day" : `${count} days`),
|
||||
agenda: "Agenda",
|
||||
resource: "Time Grid",
|
||||
}
|
||||
|
||||
const DEFAULT_FORMATS: EventCalendarI18nConfig["formats"] = {
|
||||
monthTitle: "MMMM yyyy",
|
||||
weekTitle: undefined,
|
||||
dayTitle: "EEEE, MMMM d, yyyy",
|
||||
agendaTitle: undefined,
|
||||
monthDayHeader: "EEE",
|
||||
monthDayHeaderNarrow: "EEEEE",
|
||||
timeGridDayHeader: "EEE d",
|
||||
agendaDayHeader: "EEEE, MMMM d",
|
||||
agendaDayNumber: "d",
|
||||
agendaWeekday: "EEE",
|
||||
moreDayHeader: "EEEE, MMMM d",
|
||||
monthCellAriaLabel: "PPPP",
|
||||
dayAria: "PPPP",
|
||||
resourceTitle: undefined,
|
||||
timeGutter: "h a",
|
||||
timeGutterMinute: "h:mm a",
|
||||
eventTime: "h:mm a",
|
||||
monthCellDay: "d",
|
||||
}
|
||||
|
||||
/**
|
||||
* Default formatting functions BOUND to a config's labels/formats, so that
|
||||
* `formats` overrides flow into the default renderers (a consumer overriding
|
||||
* formats.monthTitle without replacing formatTitle still sees it applied).
|
||||
*/
|
||||
function makeDefaultFunctions(
|
||||
cfg: Pick<EventCalendarI18nConfig, "labels" | "formats">
|
||||
): EventCalendarI18nConfig["functions"] {
|
||||
return {
|
||||
formatTitle: (view, { date, activeRange, locale }) => {
|
||||
const opts = { locale }
|
||||
if (view === "month") {
|
||||
return format(date, cfg.formats.monthTitle, opts)
|
||||
}
|
||||
if (view === "resource") {
|
||||
return format(
|
||||
date,
|
||||
cfg.formats.resourceTitle ?? cfg.formats.dayTitle,
|
||||
opts
|
||||
)
|
||||
}
|
||||
if (view === "day") {
|
||||
return format(date, cfg.formats.dayTitle, opts)
|
||||
}
|
||||
if (view === "week" && cfg.formats.weekTitle) {
|
||||
return format(date, cfg.formats.weekTitle, opts)
|
||||
}
|
||||
if (view === "agenda" && cfg.formats.agendaTitle) {
|
||||
return format(date, cfg.formats.agendaTitle, opts)
|
||||
}
|
||||
// week / days / agenda: smart range label, last day is activeRange.end - 1ms.
|
||||
// subMilliseconds keeps the zoned date type (a plain new Date(ms)
|
||||
// would flip the label to the machine zone near midnight)
|
||||
const rangeEnd = subMilliseconds(activeRange.end, 1)
|
||||
const start = activeRange.start
|
||||
if (isSameMonth(start, rangeEnd)) {
|
||||
return `${format(start, "MMMM d", opts)} - ${format(rangeEnd, "d, yyyy", opts)}`
|
||||
}
|
||||
if (isSameYear(start, rangeEnd)) {
|
||||
return `${format(start, "MMM d", opts)} - ${format(rangeEnd, "MMM d, yyyy", opts)}`
|
||||
}
|
||||
return `${format(start, "MMM d, yyyy", opts)} - ${format(rangeEnd, "MMM d, yyyy", opts)}`
|
||||
},
|
||||
formatEventTime: (start, end, allDay, opts) => {
|
||||
if (allDay) return cfg.labels.allDay
|
||||
const fmt = cfg.formats.eventTime
|
||||
// Multi-day timed events carry the date on both sides. Compare calendar
|
||||
// days off the last rendered instant (end is exclusive, so a 14:00 to
|
||||
// midnight event still ends on the start day). Elapsed ms would miss an
|
||||
// exactly-24h event and a DST day that only runs 23 hours.
|
||||
const lastInstant =
|
||||
end.getTime() - 1 >= start.getTime() ? subMilliseconds(end, 1) : start
|
||||
if (format(start, "yyyy-MM-dd") !== format(lastInstant, "yyyy-MM-dd")) {
|
||||
return `${format(start, `MMM d, ${fmt}`, opts)} - ${format(end, `MMM d, ${fmt}`, opts)}`
|
||||
}
|
||||
return `${format(start, fmt, opts)} - ${format(end, fmt, opts)}`
|
||||
},
|
||||
formatDayRange: (range, opts) => {
|
||||
// subMilliseconds keeps the zoned date type, same reason as formatTitle
|
||||
const rangeEnd = subMilliseconds(range.end, 1)
|
||||
return `${format(range.start, "MMM d", opts)} - ${format(rangeEnd, "MMM d", opts)}`
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_EVENT_CALENDAR_I18N: EventCalendarI18nConfig = {
|
||||
labels: DEFAULT_LABELS,
|
||||
viewNames: DEFAULT_VIEW_NAMES,
|
||||
formats: DEFAULT_FORMATS,
|
||||
functions: makeDefaultFunctions({
|
||||
labels: DEFAULT_LABELS,
|
||||
formats: DEFAULT_FORMATS,
|
||||
}),
|
||||
}
|
||||
|
||||
/**
|
||||
* One level deeper than `Partial`, because the merge below is per nested
|
||||
* section: overriding a single label must not force a consumer to restate the
|
||||
* other 22. Unknown keys are still rejected by the excess property check.
|
||||
*/
|
||||
type EventCalendarI18nOverrides = {
|
||||
[K in keyof EventCalendarI18nConfig]?: Partial<EventCalendarI18nConfig[K]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow merge per nested object, matching the filters.tsx i18n contract:
|
||||
* a partial override replaces individual keys, never whole sections. Default
|
||||
* functions are re-bound to the MERGED labels/formats; explicit `functions`
|
||||
* overrides still win.
|
||||
*/
|
||||
function mergeEventCalendarI18n(
|
||||
overrides?: EventCalendarI18nOverrides
|
||||
): EventCalendarI18nConfig {
|
||||
if (!overrides) return DEFAULT_EVENT_CALENDAR_I18N
|
||||
const labels = { ...DEFAULT_LABELS, ...overrides.labels }
|
||||
const viewNames = { ...DEFAULT_VIEW_NAMES, ...overrides.viewNames }
|
||||
const formats = { ...DEFAULT_FORMATS, ...overrides.formats }
|
||||
return {
|
||||
labels,
|
||||
viewNames,
|
||||
formats,
|
||||
functions: {
|
||||
...makeDefaultFunctions({ labels, formats }),
|
||||
...overrides.functions,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export { DEFAULT_EVENT_CALENDAR_I18N, mergeEventCalendarI18n }
|
||||
export type { EventCalendarI18nConfig, EventCalendarI18nOverrides }
|
||||
@@ -1,677 +0,0 @@
|
||||
import { expandRecurrence } from "@/components/reui/event-calendar/event-calendar-recurrence"
|
||||
import type {
|
||||
CalendarEvent,
|
||||
CalendarView,
|
||||
EventCalendarDateRange,
|
||||
EventCalendarOccurrence,
|
||||
EventCalendarOffDaysConfig,
|
||||
EventCalendarResource,
|
||||
EventCalendarSegment,
|
||||
} from "@/components/reui/event-calendar/event-calendar-types"
|
||||
import { TZDate } from "@date-fns/tz"
|
||||
import {
|
||||
addDays,
|
||||
addMonths,
|
||||
addWeeks,
|
||||
differenceInCalendarDays,
|
||||
differenceInMinutes,
|
||||
format,
|
||||
startOfDay,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
} from "date-fns"
|
||||
|
||||
type WeekStartsOn = 0 | 1 | 2 | 3 | 4 | 5 | 6
|
||||
|
||||
/** Packing-effective minimum in minutes so tiny events do not stack invisibly. */
|
||||
const MIN_PACK_SLOT = 30
|
||||
|
||||
/** The instant re-expressed in the display time zone (TZDate extends Date). */
|
||||
function toZoned(date: Date, timeZone: string): TZDate {
|
||||
return new TZDate(date.getTime(), timeZone)
|
||||
}
|
||||
|
||||
/** Zoned midnight of the day containing the instant. */
|
||||
function zonedStartOfDay(date: Date, timeZone: string): TZDate {
|
||||
return startOfDay(toZoned(date, timeZone))
|
||||
}
|
||||
|
||||
/** Stable per-day key in the display time zone. */
|
||||
function getDayKey(date: Date, timeZone: string): string {
|
||||
return format(toZoned(date, timeZone), "yyyy-MM-dd")
|
||||
}
|
||||
|
||||
/** Day length in minutes; 1380/1500 on DST transition days - never assume 1440. */
|
||||
function getDayTotalMinutes(dayStart: Date, timeZone: string): number {
|
||||
const next = zonedStartOfDay(
|
||||
addDays(toZoned(dayStart, timeZone), 1),
|
||||
timeZone
|
||||
)
|
||||
return differenceInMinutes(next, dayStart)
|
||||
}
|
||||
|
||||
function snapMinutes(minutes: number, snap: number): number {
|
||||
return Math.round(minutes / snap) * snap
|
||||
}
|
||||
|
||||
interface ViewRangeOptions {
|
||||
timeZone: string
|
||||
weekStartsOn: WeekStartsOn
|
||||
dayCount: number
|
||||
agendaDayCount: number
|
||||
fixedWeeks: boolean
|
||||
}
|
||||
|
||||
interface ViewDateRanges {
|
||||
visibleRange: EventCalendarDateRange
|
||||
activeRange: EventCalendarDateRange
|
||||
}
|
||||
|
||||
function getViewDateRange(
|
||||
view: CalendarView,
|
||||
date: Date,
|
||||
opts: ViewRangeOptions
|
||||
): ViewDateRanges {
|
||||
const { timeZone, weekStartsOn, dayCount, agendaDayCount, fixedWeeks } = opts
|
||||
const zoned = toZoned(date, timeZone)
|
||||
|
||||
if (view === "month") {
|
||||
const activeStart = startOfMonth(zoned)
|
||||
const activeEnd = startOfMonth(addMonths(zoned, 1))
|
||||
const visibleStart = startOfWeek(activeStart, { weekStartsOn })
|
||||
let visibleEnd: Date
|
||||
if (fixedWeeks) {
|
||||
visibleEnd = addDays(visibleStart, 42)
|
||||
} else {
|
||||
visibleEnd = startOfWeek(addDays(activeEnd, -1), { weekStartsOn })
|
||||
visibleEnd = addWeeks(visibleEnd, 1)
|
||||
}
|
||||
return {
|
||||
activeRange: { start: activeStart, end: activeEnd },
|
||||
visibleRange: { start: visibleStart, end: visibleEnd },
|
||||
}
|
||||
}
|
||||
|
||||
if (view === "week") {
|
||||
const start = startOfWeek(zoned, { weekStartsOn })
|
||||
const range = { start, end: addWeeks(start, 1) }
|
||||
return { activeRange: range, visibleRange: range }
|
||||
}
|
||||
|
||||
if (view === "day" || view === "resource") {
|
||||
const start = startOfDay(zoned)
|
||||
const range = { start, end: addDays(start, 1) }
|
||||
return { activeRange: range, visibleRange: range }
|
||||
}
|
||||
|
||||
if (view === "days") {
|
||||
const start = startOfDay(zoned)
|
||||
const range = { start, end: addDays(start, Math.max(1, dayCount)) }
|
||||
return { activeRange: range, visibleRange: range }
|
||||
}
|
||||
|
||||
// agenda
|
||||
const start = startOfDay(zoned)
|
||||
const range = { start, end: addDays(start, Math.max(1, agendaDayCount)) }
|
||||
return { activeRange: range, visibleRange: range }
|
||||
}
|
||||
|
||||
/** Day of month of the last day of the month containing the zoned date. */
|
||||
function lastDayOfZonedMonth(date: Date): number {
|
||||
return addDays(startOfMonth(addMonths(date, 1)), -1).getDate()
|
||||
}
|
||||
|
||||
/** The anchor date stepped one period forward or backward for the view. */
|
||||
function stepDate(
|
||||
view: CalendarView,
|
||||
date: Date,
|
||||
direction: 1 | -1,
|
||||
opts: Pick<ViewRangeOptions, "timeZone" | "dayCount" | "agendaDayCount">
|
||||
): Date {
|
||||
const zoned = toZoned(date, opts.timeZone)
|
||||
if (view === "month") {
|
||||
const stepped = addMonths(zoned, direction)
|
||||
// addMonths clamps the day down into a shorter month and never restores
|
||||
// it, so next-then-prev from the 31st would leave the anchor on the 28th.
|
||||
// Sticking a month end to the target month's end keeps stepping
|
||||
// invertible, which matters because the anchor is what day and week view
|
||||
// open on after a month navigation.
|
||||
if (zoned.getDate() !== lastDayOfZonedMonth(zoned)) return stepped
|
||||
return addDays(stepped, lastDayOfZonedMonth(stepped) - stepped.getDate())
|
||||
}
|
||||
if (view === "week") return addWeeks(zoned, direction)
|
||||
if (view === "day" || view === "resource") return addDays(zoned, direction)
|
||||
if (view === "days")
|
||||
return addDays(zoned, direction * Math.max(1, opts.dayCount))
|
||||
return addDays(zoned, direction * Math.max(1, opts.agendaDayCount))
|
||||
}
|
||||
|
||||
function rangesIntersect(
|
||||
a: EventCalendarDateRange,
|
||||
b: EventCalendarDateRange
|
||||
): boolean {
|
||||
return a.start < b.end && a.end > b.start
|
||||
}
|
||||
|
||||
function eventsOverlap(
|
||||
a: { start: Date; end: Date },
|
||||
b: { start: Date; end: Date }
|
||||
): boolean {
|
||||
return a.start < b.end && a.end > b.start
|
||||
}
|
||||
|
||||
/**
|
||||
* The one canonical multi-day segmentation. Splits an occurrence into per-day
|
||||
* segments clamped to the range. Rules (unit-tested in M1): exclusive end - an
|
||||
* event ending exactly at zoned midnight emits NO segment for that day;
|
||||
* zero-duration events emit one min-height segment; allDay occurrences walk
|
||||
* the same absolute instants as timed ones and only drop startMin/endMin, so
|
||||
* their bounds have to already BE display-zone midnights (see
|
||||
* CalendarEvent.allDay) or the bar paints on the wrong days.
|
||||
*/
|
||||
function segmentOccurrence<TData>(
|
||||
occurrence: EventCalendarOccurrence<TData>,
|
||||
range: EventCalendarDateRange,
|
||||
timeZone: string
|
||||
): EventCalendarSegment<TData>[] {
|
||||
const occStart = occurrence.start
|
||||
const occEnd = occurrence.end
|
||||
const isZeroLength = occEnd.getTime() === occStart.getTime()
|
||||
|
||||
const clampStart = occStart > range.start ? occStart : range.start
|
||||
const clampEnd = occEnd < range.end ? occEnd : range.end
|
||||
if (clampEnd < clampStart) return []
|
||||
if (clampEnd.getTime() === clampStart.getTime() && !isZeroLength) return []
|
||||
|
||||
const segments: EventCalendarSegment<TData>[] = []
|
||||
let cursor = zonedStartOfDay(clampStart, timeZone)
|
||||
|
||||
while (cursor < clampEnd || (isZeroLength && segments.length === 0)) {
|
||||
const next = zonedStartOfDay(
|
||||
addDays(toZoned(cursor, timeZone), 1),
|
||||
timeZone
|
||||
)
|
||||
const segStart = clampStart > cursor ? clampStart : cursor
|
||||
const segEnd = clampEnd < next ? clampEnd : next
|
||||
|
||||
const emptySeg = segEnd.getTime() <= segStart.getTime()
|
||||
if (!emptySeg || isZeroLength) {
|
||||
const isStart = segStart.getTime() === occStart.getTime()
|
||||
const isEnd = segEnd.getTime() === occEnd.getTime()
|
||||
segments.push({
|
||||
occurrence,
|
||||
day: cursor,
|
||||
isStart,
|
||||
isEnd,
|
||||
continuesBefore: !isStart,
|
||||
continuesAfter: !isEnd,
|
||||
startMin: occurrence.allDay
|
||||
? undefined
|
||||
: differenceInMinutes(segStart, cursor),
|
||||
endMin: occurrence.allDay
|
||||
? undefined
|
||||
: Math.max(
|
||||
differenceInMinutes(segEnd, cursor),
|
||||
differenceInMinutes(segStart, cursor)
|
||||
),
|
||||
})
|
||||
}
|
||||
if (isZeroLength) break
|
||||
cursor = next
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
/** True when the occurrence should render as a bar (all-day row / month lanes). */
|
||||
function isBarOccurrence(
|
||||
occurrence: EventCalendarOccurrence,
|
||||
timeZone?: string
|
||||
): boolean {
|
||||
return occurrence.allDay || spansMultipleDays(occurrence, timeZone)
|
||||
}
|
||||
|
||||
function spansMultipleDays(
|
||||
occ: { start: Date; end: Date },
|
||||
timeZone?: string
|
||||
): boolean {
|
||||
// An event ending exactly at the next midnight is still single-day
|
||||
// (exclusive end), so compare against a strictly-later instant. The
|
||||
// yardstick is the length of the day the event starts on, never a flat 24h:
|
||||
// a fall-back day is 25h long, and a 00:00-to-00:00 shift on it is still one
|
||||
// calendar day that belongs in the hour track, not in the all-day row.
|
||||
// Without a display zone the dates answer in their own frame (TZDate) or in
|
||||
// the host zone.
|
||||
const dayStart = startOfDay(
|
||||
timeZone ? toZoned(occ.start, timeZone) : occ.start
|
||||
)
|
||||
const nextDayStart = startOfDay(addDays(dayStart, 1))
|
||||
return (
|
||||
occ.end.getTime() - occ.start.getTime() >
|
||||
nextDayStart.getTime() - dayStart.getTime()
|
||||
)
|
||||
}
|
||||
|
||||
interface PackedPosition {
|
||||
column: number
|
||||
columnCount: number
|
||||
columnSpan: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Google-style overlap packing for one day's timed segments.
|
||||
* Mutates column/columnCount/columnSpan on the segments, in place.
|
||||
* z resolution happens at render: event.zIndex verbatim, else 10 + column.
|
||||
*/
|
||||
function packTimedSegments<TData>(
|
||||
segments: EventCalendarSegment<TData>[]
|
||||
): void {
|
||||
if (segments.length === 0) return
|
||||
|
||||
type Working = {
|
||||
seg: EventCalendarSegment<TData>
|
||||
startMin: number
|
||||
effEnd: number
|
||||
}
|
||||
|
||||
const items: Working[] = segments
|
||||
.map((seg) => {
|
||||
const startMin = seg.startMin ?? 0
|
||||
const endMin = seg.endMin ?? startMin
|
||||
return {
|
||||
seg,
|
||||
startMin,
|
||||
effEnd: Math.max(endMin, startMin + MIN_PACK_SLOT),
|
||||
}
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.startMin - b.startMin ||
|
||||
b.effEnd - b.startMin - (a.effEnd - a.startMin) ||
|
||||
a.seg.occurrence.key.localeCompare(b.seg.occurrence.key)
|
||||
)
|
||||
|
||||
// Sweep into connected clusters
|
||||
const clusters: Working[][] = []
|
||||
let current: Working[] = []
|
||||
let clusterEnd = -Infinity
|
||||
for (const item of items) {
|
||||
if (item.startMin >= clusterEnd) {
|
||||
current = []
|
||||
clusters.push(current)
|
||||
clusterEnd = -Infinity
|
||||
}
|
||||
current.push(item)
|
||||
clusterEnd = Math.max(clusterEnd, item.effEnd)
|
||||
}
|
||||
|
||||
for (const cluster of clusters) {
|
||||
// Greedy column assignment
|
||||
const colEnds: number[] = []
|
||||
const byColumn = new Map<number, Working[]>()
|
||||
for (const item of cluster) {
|
||||
let col = colEnds.findIndex((end) => end <= item.startMin)
|
||||
if (col === -1) {
|
||||
col = colEnds.length
|
||||
colEnds.push(0)
|
||||
}
|
||||
colEnds[col] = item.effEnd
|
||||
item.seg.column = col
|
||||
const bucket = byColumn.get(col) ?? []
|
||||
bucket.push(item)
|
||||
byColumn.set(col, bucket)
|
||||
}
|
||||
const columnCount = colEnds.length
|
||||
|
||||
// Partial-overlap expansion: widen rightward into free columns
|
||||
for (const item of cluster) {
|
||||
let span = 1
|
||||
const col = item.seg.column ?? 0
|
||||
while (col + span < columnCount) {
|
||||
const occupants = byColumn.get(col + span) ?? []
|
||||
const blocked = occupants.some(
|
||||
(o) => o.startMin < item.effEnd && o.effEnd > item.startMin
|
||||
)
|
||||
if (blocked) break
|
||||
span++
|
||||
}
|
||||
item.seg.columnCount = columnCount
|
||||
item.seg.columnSpan = span
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Greedy lane packing for bar segments within one week row (7 columns).
|
||||
* Mutates lane/rowIndex/colStart/colSpan on the segments, in place.
|
||||
*/
|
||||
/**
|
||||
* Build the laned month-row bars for one week: consecutive-day segments of
|
||||
* the same occurrence merge into ONE bar (colStart -> colSpan) stacked into
|
||||
* lanes. Returns NEW segment objects - the shared per-day segments (also
|
||||
* rendered by the all-day rows and day cells) must stay pristine: mutating
|
||||
* their isEnd/continues flags gave the first-day chip a whole-bar shape and
|
||||
* a bogus end resize handle in the week all-day row, where dragging it
|
||||
* collapsed the event to a single day.
|
||||
*/
|
||||
function packWeekRowLanes<TData>(
|
||||
segments: EventCalendarSegment<TData>[],
|
||||
rowIndex: number,
|
||||
rowStart: Date,
|
||||
timeZone: string
|
||||
): EventCalendarSegment<TData>[] {
|
||||
type Bar = {
|
||||
seg: EventCalendarSegment<TData>
|
||||
colStart: number
|
||||
colSpan: number
|
||||
isStart: boolean
|
||||
isEnd: boolean
|
||||
lane: number
|
||||
}
|
||||
|
||||
const bars: Bar[] = segments.map((seg) => {
|
||||
const dayIndex = Math.round(
|
||||
(zonedStartOfDay(seg.day, timeZone).getTime() -
|
||||
zonedStartOfDay(rowStart, timeZone).getTime()) /
|
||||
(24 * 60 * 60 * 1000)
|
||||
)
|
||||
return {
|
||||
seg,
|
||||
colStart: Math.max(0, Math.min(6, dayIndex)),
|
||||
colSpan: 1,
|
||||
isStart: seg.isStart,
|
||||
isEnd: seg.isEnd,
|
||||
lane: 0,
|
||||
}
|
||||
})
|
||||
|
||||
// Merge consecutive-day segments of the same occurrence into one bar per row
|
||||
const merged = new Map<string, Bar>()
|
||||
for (const bar of bars) {
|
||||
const key = bar.seg.occurrence.key
|
||||
const existing = merged.get(key)
|
||||
if (existing) {
|
||||
const start = Math.min(existing.colStart, bar.colStart)
|
||||
const end = Math.max(
|
||||
existing.colStart + existing.colSpan,
|
||||
bar.colStart + bar.colSpan
|
||||
)
|
||||
existing.colStart = start
|
||||
existing.colSpan = end - start
|
||||
existing.isStart = existing.isStart || bar.isStart
|
||||
existing.isEnd = existing.isEnd || bar.isEnd
|
||||
} else {
|
||||
merged.set(key, bar)
|
||||
}
|
||||
}
|
||||
|
||||
const rowBars = Array.from(merged.values()).sort(
|
||||
(a, b) =>
|
||||
a.colStart - b.colStart ||
|
||||
b.colSpan - a.colSpan ||
|
||||
a.seg.occurrence.key.localeCompare(b.seg.occurrence.key)
|
||||
)
|
||||
|
||||
const lanes: boolean[][] = []
|
||||
for (const bar of rowBars) {
|
||||
let lane = 0
|
||||
for (;;) {
|
||||
lanes[lane] ??= new Array(7).fill(false)
|
||||
const row = lanes[lane]
|
||||
let free = true
|
||||
for (let c = bar.colStart; c < bar.colStart + bar.colSpan; c++) {
|
||||
if (row[c]) {
|
||||
free = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (free) break
|
||||
lane++
|
||||
}
|
||||
for (let c = bar.colStart; c < bar.colStart + bar.colSpan; c++) {
|
||||
lanes[lane][c] = true
|
||||
}
|
||||
bar.lane = lane
|
||||
}
|
||||
|
||||
return rowBars.map((bar) => ({
|
||||
...bar.seg,
|
||||
isStart: bar.isStart,
|
||||
isEnd: bar.isEnd,
|
||||
continuesBefore: !bar.isStart,
|
||||
continuesAfter: !bar.isEnd,
|
||||
lane: bar.lane,
|
||||
rowIndex,
|
||||
colStart: bar.colStart,
|
||||
colSpan: bar.colSpan,
|
||||
}))
|
||||
}
|
||||
|
||||
interface EventCalendarDayBucket<TData = unknown> {
|
||||
allDay: EventCalendarSegment<TData>[]
|
||||
timed: EventCalendarSegment<TData>[]
|
||||
}
|
||||
|
||||
interface EventCalendarWeekRow<TData = unknown> {
|
||||
rowIndex: number
|
||||
rowStart: Date
|
||||
/** Laned bar segments (one per occurrence per row). */
|
||||
bars: EventCalendarSegment<TData>[]
|
||||
}
|
||||
|
||||
interface EventCalendarIndex<TData = unknown> {
|
||||
occurrences: EventCalendarOccurrence<TData>[]
|
||||
byDay: Map<string, EventCalendarDayBucket<TData>>
|
||||
weekRows: EventCalendarWeekRow<TData>[]
|
||||
}
|
||||
|
||||
interface BuildIndexOptions<TData> {
|
||||
timeZone: string
|
||||
weekStartsOn: WeekStartsOn
|
||||
eventOrder?: (
|
||||
a: EventCalendarOccurrence<TData>,
|
||||
b: EventCalendarOccurrence<TData>
|
||||
) => number
|
||||
getOccurrences?: (
|
||||
event: CalendarEvent<TData>,
|
||||
range: EventCalendarDateRange,
|
||||
ctx: { timeZone: string }
|
||||
) => Array<{ start: Date; end: Date }> | null
|
||||
}
|
||||
|
||||
function defaultEventOrder(
|
||||
a: EventCalendarOccurrence,
|
||||
b: EventCalendarOccurrence
|
||||
): number {
|
||||
return (
|
||||
a.start.getTime() - b.start.getTime() ||
|
||||
b.end.getTime() -
|
||||
b.start.getTime() -
|
||||
(a.end.getTime() - a.start.getTime()) ||
|
||||
a.key.localeCompare(b.key)
|
||||
)
|
||||
}
|
||||
|
||||
function buildEventIndex<TData>(
|
||||
events: CalendarEvent<TData>[],
|
||||
visibleRange: EventCalendarDateRange,
|
||||
opts: BuildIndexOptions<TData>
|
||||
): EventCalendarIndex<TData> {
|
||||
const { timeZone, weekStartsOn } = opts
|
||||
const order = opts.eventOrder ?? defaultEventOrder
|
||||
|
||||
// RECURRENCE-ID override replacement: an event carrying recurringEventId +
|
||||
// originalStart is an edited single occurrence of that series. The parent's
|
||||
// expansion drops the replaced instant; the override renders as its own
|
||||
// occurrence through the normal path below.
|
||||
const overrideTimes = new Map<string, Set<number>>()
|
||||
for (const event of events) {
|
||||
if (!event.recurringEventId || !event.originalStart) continue
|
||||
let times = overrideTimes.get(event.recurringEventId)
|
||||
if (!times) overrideTimes.set(event.recurringEventId, (times = new Set()))
|
||||
times.add(event.originalStart.getTime())
|
||||
}
|
||||
|
||||
const occurrences: EventCalendarOccurrence<TData>[] = []
|
||||
for (const event of events) {
|
||||
const replaced = overrideTimes.get(event.id)
|
||||
const custom = opts.getOccurrences?.(event, visibleRange, { timeZone })
|
||||
if (custom) {
|
||||
custom.forEach((occ, i) => {
|
||||
if (replaced?.has(occ.start.getTime())) return
|
||||
if (!rangesIntersect({ start: occ.start, end: occ.end }, visibleRange))
|
||||
return
|
||||
occurrences.push({
|
||||
key: `${event.id}::${occ.start.toISOString()}`,
|
||||
eventId: event.id,
|
||||
event,
|
||||
start: occ.start,
|
||||
end: occ.end,
|
||||
allDay: event.allDay ?? false,
|
||||
isRecurring: true,
|
||||
recurrenceIndex: i,
|
||||
})
|
||||
})
|
||||
continue
|
||||
}
|
||||
const expanded = expandRecurrence(event, visibleRange, { timeZone })
|
||||
occurrences.push(
|
||||
...(replaced
|
||||
? expanded.filter((occ) => !replaced.has(occ.start.getTime()))
|
||||
: expanded)
|
||||
)
|
||||
}
|
||||
occurrences.sort(order)
|
||||
|
||||
const byDay = new Map<string, EventCalendarDayBucket<TData>>()
|
||||
const barSegmentsByRow = new Map<number, EventCalendarSegment<TData>[]>()
|
||||
const firstRowStart = startOfWeek(toZoned(visibleRange.start, timeZone), {
|
||||
weekStartsOn,
|
||||
})
|
||||
|
||||
for (const occurrence of occurrences) {
|
||||
const segments = segmentOccurrence(occurrence, visibleRange, timeZone)
|
||||
const bar = isBarOccurrence(occurrence, timeZone)
|
||||
for (const seg of segments) {
|
||||
const key = getDayKey(seg.day, timeZone)
|
||||
let bucket = byDay.get(key)
|
||||
if (!bucket) {
|
||||
bucket = { allDay: [], timed: [] }
|
||||
byDay.set(key, bucket)
|
||||
}
|
||||
if (bar) {
|
||||
bucket.allDay.push(seg)
|
||||
// calendar-day math, not a fixed 168h divisor: DST transition weeks
|
||||
// are 167/169h long and the fixed divisor mis-buckets every later
|
||||
// Sunday one row early (which then clamps into the wrong column)
|
||||
const rowIndex = Math.floor(
|
||||
differenceInCalendarDays(toZoned(seg.day, timeZone), firstRowStart) /
|
||||
7
|
||||
)
|
||||
const rowBucket = barSegmentsByRow.get(rowIndex) ?? []
|
||||
rowBucket.push(seg)
|
||||
barSegmentsByRow.set(rowIndex, rowBucket)
|
||||
} else {
|
||||
bucket.timed.push(seg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const bucket of byDay.values()) {
|
||||
packTimedSegments(bucket.timed)
|
||||
}
|
||||
|
||||
const weekRows: EventCalendarWeekRow<TData>[] = []
|
||||
for (const [rowIndex, segs] of barSegmentsByRow) {
|
||||
const rowStart = addWeeks(firstRowStart, rowIndex)
|
||||
weekRows.push({
|
||||
rowIndex,
|
||||
rowStart,
|
||||
bars: packWeekRowLanes(segs, rowIndex, rowStart, timeZone),
|
||||
})
|
||||
}
|
||||
weekRows.sort((a, b) => a.rowIndex - b.rowIndex)
|
||||
|
||||
return { occurrences, byDay, weekRows }
|
||||
}
|
||||
|
||||
/** Cache key for index memoization; cheap string compare. */
|
||||
function getRangeKey(range: EventCalendarDateRange): string {
|
||||
return `${range.start.getTime()}-${range.end.getTime()}`
|
||||
}
|
||||
|
||||
/** Depth-first flatten of the resource tree (parents included). */
|
||||
function flattenResources(
|
||||
resources: EventCalendarResource[],
|
||||
depth = 0
|
||||
): Array<{ resource: EventCalendarResource; depth: number }> {
|
||||
const rows: Array<{ resource: EventCalendarResource; depth: number }> = []
|
||||
for (const resource of resources) {
|
||||
rows.push({ resource, depth })
|
||||
if (resource.children?.length) {
|
||||
rows.push(...flattenResources(resource.children, depth + 1))
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const DEFAULT_WEEKEND_DAYS = [0, 6]
|
||||
|
||||
/**
|
||||
* Resolves whether a day is an off day (non-working) in the display zone.
|
||||
* Callers pass the calendar's own weekendDays so the shading cannot contradict
|
||||
* the weekend the rest of the calendar renders; an explicit offDays.weekendDays
|
||||
* still wins over it.
|
||||
*/
|
||||
function resolveOffDay(
|
||||
day: Date,
|
||||
timeZone: string,
|
||||
config: boolean | EventCalendarOffDaysConfig | undefined,
|
||||
defaultWeekendDays?: number[]
|
||||
): boolean {
|
||||
if (!config) return false
|
||||
const resolved: EventCalendarOffDaysConfig = config === true ? {} : config
|
||||
const weekendDays =
|
||||
resolved.weekendDays ?? defaultWeekendDays ?? DEFAULT_WEEKEND_DAYS
|
||||
const zoned = toZoned(day, timeZone)
|
||||
if (weekendDays.includes(zoned.getDay())) return true
|
||||
if (resolved.dates?.length) {
|
||||
const key = getDayKey(day, timeZone)
|
||||
if (resolved.dates.some((date) => getDayKey(date, timeZone) === key)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return resolved.isOffDay?.(day) ?? false
|
||||
}
|
||||
|
||||
export {
|
||||
buildEventIndex,
|
||||
defaultEventOrder,
|
||||
eventsOverlap,
|
||||
flattenResources,
|
||||
getDayKey,
|
||||
getDayTotalMinutes,
|
||||
getRangeKey,
|
||||
getViewDateRange,
|
||||
isBarOccurrence,
|
||||
MIN_PACK_SLOT,
|
||||
packTimedSegments,
|
||||
packWeekRowLanes,
|
||||
rangesIntersect,
|
||||
resolveOffDay,
|
||||
segmentOccurrence,
|
||||
snapMinutes,
|
||||
spansMultipleDays,
|
||||
stepDate,
|
||||
toZoned,
|
||||
zonedStartOfDay,
|
||||
}
|
||||
export type {
|
||||
BuildIndexOptions,
|
||||
EventCalendarDayBucket,
|
||||
EventCalendarIndex,
|
||||
EventCalendarWeekRow,
|
||||
ViewDateRanges,
|
||||
ViewRangeOptions,
|
||||
WeekStartsOn,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,611 +0,0 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import {
|
||||
useEventCalendarNavigation,
|
||||
useEventCalendarSettings,
|
||||
useEventCalendarView,
|
||||
useEventCalendarViewConfig,
|
||||
} from "@/components/reui/event-calendar/event-calendar"
|
||||
import { toZoned } from "@/components/reui/event-calendar/event-calendar-lib"
|
||||
import type { CalendarView } from "@/components/reui/event-calendar/event-calendar-types"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { addDays, format } from "date-fns"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Calendar } from "@evobgp/ui/components/calendar"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@evobgp/ui/components/tooltip"
|
||||
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon, CalendarIcon } from "lucide-react"
|
||||
|
||||
/** Configured nav button variant/size (viewConfig.navButtonVariant/Size)
|
||||
* plus the shared classNames.navButton hook, merged on every nav button. */
|
||||
function useNavButtonProps(): {
|
||||
variant: "ghost" | "outline" | "secondary" | "default"
|
||||
size: "sm" | "default"
|
||||
iconSize: "icon-sm" | "icon"
|
||||
className: string | undefined
|
||||
} {
|
||||
const viewConfig = useEventCalendarViewConfig()
|
||||
return {
|
||||
variant: viewConfig.navButtonVariant,
|
||||
size: viewConfig.navButtonSize,
|
||||
iconSize: viewConfig.navButtonSize === "sm" ? "icon-sm" : "icon",
|
||||
className: viewConfig.classNames?.navButton,
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolved nav tooltip policy (viewConfig.navTooltips + classNames.navTooltip). */
|
||||
function useNavTooltipConfig(): {
|
||||
disabled: boolean
|
||||
side: "top" | "bottom" | "left" | "right"
|
||||
delay: number
|
||||
closeDelay: number
|
||||
timeout: number
|
||||
className: string | undefined
|
||||
} {
|
||||
const viewConfig = useEventCalendarViewConfig()
|
||||
const config =
|
||||
viewConfig.navTooltips === false ? undefined : viewConfig.navTooltips
|
||||
return {
|
||||
disabled: viewConfig.navTooltips === false,
|
||||
// the nav sits at the top of the calendar, so tooltips open upward by
|
||||
// default (away from the grid); collision flipping still drops them below
|
||||
// when there is no room above
|
||||
side: config?.side ?? "top",
|
||||
delay: config?.delay ?? 600,
|
||||
closeDelay: config?.closeDelay ?? 0,
|
||||
timeout: config?.timeout ?? 300,
|
||||
className: viewConfig.classNames?.navTooltip,
|
||||
}
|
||||
}
|
||||
|
||||
type NavButtonProps = Omit<useRender.ComponentProps<"button">, "children"> & {
|
||||
children?: ReactNode
|
||||
/**
|
||||
* Tooltip policy (the part that usually goes wrong on clickable elements):
|
||||
* tooltips appear ONLY on hover or keyboard focus-visible - a pointer click
|
||||
* never re-triggers them. Buttons that open overlays (the view switcher)
|
||||
* use a hover-only tooltip that is force-closed while the overlay is up and
|
||||
* ignores focus, so nothing flashes when focus returns after selection.
|
||||
* Icon-only buttons default to their accessible label; Today defaults to
|
||||
* the actual current date (info the label doesn't carry). Pass null to
|
||||
* disable one, or any node to override; viewConfig.navTooltips=false turns
|
||||
* them all off (its object form tunes side/delay/closeDelay/timeout).
|
||||
*/
|
||||
tooltip?: ReactNode | null
|
||||
}
|
||||
|
||||
/** Hover/focus-visible tooltip wrapper; renders the bare button when disabled
|
||||
* (per-button content=null or viewConfig.navTooltips=false). */
|
||||
function NavTooltip({
|
||||
content,
|
||||
children,
|
||||
}: {
|
||||
content: ReactNode | null
|
||||
children: React.ReactElement
|
||||
}) {
|
||||
const tooltips = useNavTooltipConfig()
|
||||
if (tooltips.disabled || content === null || content === undefined)
|
||||
return children
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={children} />
|
||||
<TooltipContent side={tooltips.side} className={tooltips.className}>
|
||||
{content}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function EventCalendarNavToday({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
tooltip,
|
||||
...props
|
||||
}: NavButtonProps) {
|
||||
const { today, isToday } = useEventCalendarNavigation()
|
||||
const settings = useEventCalendarSettings()
|
||||
const nav = useNavButtonProps()
|
||||
// display-zone "today", like every other today derivation in the calendar
|
||||
// (a system-zone new Date() can name a different day than Today opens)
|
||||
const defaultTooltip = format(
|
||||
toZoned(new Date(), settings.timeZone),
|
||||
settings.i18n.formats.dayTitle,
|
||||
{ locale: settings.locale }
|
||||
)
|
||||
return (
|
||||
<NavTooltip content={tooltip === undefined ? defaultTooltip : tooltip}>
|
||||
<Button
|
||||
variant={nav.variant}
|
||||
size={nav.size}
|
||||
data-slot="event-calendar-nav-today"
|
||||
data-active={isToday || undefined}
|
||||
className={cn(nav.className, className)}
|
||||
onClick={today}
|
||||
render={render}
|
||||
{...props}
|
||||
>
|
||||
{children ?? settings.i18n.labels.today}
|
||||
</Button>
|
||||
</NavTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function EventCalendarNavPrev({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
tooltip,
|
||||
...props
|
||||
}: NavButtonProps) {
|
||||
const { prev } = useEventCalendarNavigation()
|
||||
const settings = useEventCalendarSettings()
|
||||
const nav = useNavButtonProps()
|
||||
return (
|
||||
<NavTooltip
|
||||
content={tooltip === undefined ? settings.i18n.labels.previous : tooltip}
|
||||
>
|
||||
<Button
|
||||
variant={nav.variant}
|
||||
size={nav.iconSize}
|
||||
data-slot="event-calendar-nav-prev"
|
||||
aria-label={settings.i18n.labels.previous}
|
||||
className={cn(nav.className, className)}
|
||||
onClick={prev}
|
||||
render={render}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<ChevronLeftIcon className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
</NavTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function EventCalendarNavNext({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
tooltip,
|
||||
...props
|
||||
}: NavButtonProps) {
|
||||
const { next } = useEventCalendarNavigation()
|
||||
const settings = useEventCalendarSettings()
|
||||
const nav = useNavButtonProps()
|
||||
return (
|
||||
<NavTooltip
|
||||
content={tooltip === undefined ? settings.i18n.labels.next : tooltip}
|
||||
>
|
||||
<Button
|
||||
variant={nav.variant}
|
||||
size={nav.iconSize}
|
||||
data-slot="event-calendar-nav-next"
|
||||
aria-label={settings.i18n.labels.next}
|
||||
className={cn(nav.className, className)}
|
||||
onClick={next}
|
||||
render={render}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<ChevronRightIcon className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
</NavTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
interface EventCalendarTitleProps extends useRender.ComponentProps<"div"> {
|
||||
format?: (ctx: { title: string }) => ReactNode
|
||||
}
|
||||
|
||||
function EventCalendarTitle({
|
||||
className,
|
||||
render,
|
||||
format: formatTitle,
|
||||
...props
|
||||
}: EventCalendarTitleProps) {
|
||||
const { title } = useEventCalendarNavigation()
|
||||
const viewConfig = useEventCalendarViewConfig()
|
||||
const defaultProps = {
|
||||
"data-slot": "event-calendar-title",
|
||||
"aria-live": "polite" as const,
|
||||
className: cn(
|
||||
"min-w-0 truncate text-sm font-semibold",
|
||||
viewConfig.classNames?.title,
|
||||
className
|
||||
),
|
||||
children: formatTitle?.({ title }) ?? title,
|
||||
}
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
interface EventCalendarViewSwitcherProps extends Omit<
|
||||
useRender.ComponentProps<"button">,
|
||||
"children"
|
||||
> {
|
||||
children?: ReactNode
|
||||
/** Hover/focus-visible hint; defaults to the "Select view" label. Pass
|
||||
* null to disable (overlay-opener policy). */
|
||||
tooltip?: ReactNode | null
|
||||
}
|
||||
|
||||
function EventCalendarViewSwitcher({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
tooltip,
|
||||
...props
|
||||
}: EventCalendarViewSwitcherProps) {
|
||||
const { view, dayCount, availableViews, setView } = useEventCalendarView()
|
||||
const settings = useEventCalendarSettings()
|
||||
const viewConfig = useEventCalendarViewConfig()
|
||||
const nav = useNavButtonProps()
|
||||
const tooltips = useNavTooltipConfig()
|
||||
const labels = settings.i18n.labels
|
||||
// Controlled open: selecting a view swaps the whole content subtree in the
|
||||
// same click, so closing must not depend on the menu's internal handler.
|
||||
const [open, setOpen] = useState(false)
|
||||
// Hover-only tooltip: when the menu closes, Base UI focuses the trigger
|
||||
// again and a focus-opened tooltip would flash - ignore focus opens.
|
||||
const [tipOpen, setTipOpen] = useState(false)
|
||||
|
||||
const selectView = (v: CalendarView, opts?: { dayCount?: number }) => {
|
||||
setOpen(false)
|
||||
setView(v, opts)
|
||||
}
|
||||
|
||||
const viewLabel = (v: CalendarView) =>
|
||||
v === "days"
|
||||
? settings.i18n.viewNames.days(dayCount)
|
||||
: settings.i18n.viewNames[v]
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={(next: boolean) => {
|
||||
setOpen(next)
|
||||
if (next) setTipOpen(false)
|
||||
}}
|
||||
>
|
||||
{/* Tooltip on an overlay-opener: hover-only (focus opens ignored) and
|
||||
force-closed while the menu is up, so it never lingers or flashes
|
||||
when focus returns on close. Inherits the nav TooltipProvider's
|
||||
delay/closeDelay/timeout. */}
|
||||
<Tooltip
|
||||
open={tipOpen && !open}
|
||||
onOpenChange={(next: boolean, details: { reason?: string }) => {
|
||||
// opens are hover-only; the trigger-focus open that follows a
|
||||
// menu close is ignored, closes always land
|
||||
if (next && details?.reason !== "trigger-hover") return
|
||||
setTipOpen(next)
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant={nav.variant}
|
||||
size={nav.size}
|
||||
data-slot="event-calendar-view-switcher"
|
||||
aria-label={labels.selectView}
|
||||
className={cn("gap-1", nav.className, className)}
|
||||
render={render}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{viewLabel(view)}
|
||||
<ChevronDownIcon className="size-4 opacity-60" aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
{tipOpen && !open && tooltip !== null && !tooltips.disabled && (
|
||||
<TooltipContent side={tooltips.side} className={tooltips.className}>
|
||||
{tooltip ?? labels.selectView}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={cn("min-w-44", viewConfig.classNames?.viewSwitcherContent)}
|
||||
>
|
||||
{/* Base UI contract: GroupLabel must live inside Menu.Group */}
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel
|
||||
className={cn(
|
||||
"text-muted-foreground font-normal",
|
||||
viewConfig.classNames?.viewSwitcherLabel
|
||||
)}
|
||||
>
|
||||
{settings.i18n.labels.selectView}
|
||||
</DropdownMenuLabel>
|
||||
{availableViews.map((v) =>
|
||||
v === "days" ? (
|
||||
viewConfig.dayCountPresets.map((count) => (
|
||||
<DropdownMenuItem
|
||||
key={`days-${count}`}
|
||||
data-active={
|
||||
(view === "days" && dayCount === count) || undefined
|
||||
}
|
||||
onClick={() => selectView("days", { dayCount: count })}
|
||||
>
|
||||
{settings.i18n.viewNames.days(count)}
|
||||
{/* hint derived from the preset itself, not i18n's default */}
|
||||
{viewConfig.enableShortcuts && (
|
||||
<EventCalendarViewShortcut>
|
||||
{count}
|
||||
</EventCalendarViewShortcut>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
key={v}
|
||||
data-active={view === v || undefined}
|
||||
onClick={() => selectView(v)}
|
||||
>
|
||||
{viewLabel(v)}
|
||||
{viewConfig.enableShortcuts && (
|
||||
<EventCalendarViewShortcut>
|
||||
{labels.viewShortcuts[v]}
|
||||
</EventCalendarViewShortcut>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
/** Outline key badge; theme-token only, so it adapts to every style. */
|
||||
function EventCalendarViewShortcut({ children }: { children: ReactNode }) {
|
||||
const viewConfig = useEventCalendarViewConfig()
|
||||
return (
|
||||
<kbd
|
||||
data-slot="event-calendar-view-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ms-auto inline-flex size-5 shrink-0 items-center justify-center rounded-sm border font-sans text-xs",
|
||||
viewConfig.classNames?.viewShortcut
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
|
||||
interface EventCalendarDatePickerProps extends Omit<
|
||||
useRender.ComponentProps<"button">,
|
||||
"children"
|
||||
> {
|
||||
children?: ReactNode
|
||||
/** "auto" (default) resolves per view: range for week/N-days/agenda. */
|
||||
mode?: "auto" | "single" | "range"
|
||||
/** Hover/focus-visible hint; defaults to null - no tooltip, because the
|
||||
* button opens an overlay (see the NavButtonProps tooltip policy). */
|
||||
tooltip?: ReactNode | null
|
||||
}
|
||||
|
||||
/** Views whose period reads better as a highlighted range. */
|
||||
const RANGE_VIEWS: CalendarView[] = ["week", "days", "agenda"]
|
||||
|
||||
/**
|
||||
* Optional go-to-date picker (shadcn Calendar in a popover), view-aware:
|
||||
* week/N-days/agenda highlight the whole active range (any click re-anchors
|
||||
* the period), other views select a single date. Not part of the default
|
||||
* nav - compose it yourself (or any external picker driving
|
||||
* useEventCalendarNavigation().goTo). No tooltip by default: it opens an
|
||||
* overlay (see the NavButtonProps tooltip policy); pass `tooltip` to opt in.
|
||||
*/
|
||||
function EventCalendarDatePicker({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
tooltip = null,
|
||||
mode,
|
||||
...props
|
||||
}: EventCalendarDatePickerProps) {
|
||||
const { date, goTo, activeRange } = useEventCalendarNavigation()
|
||||
const { view } = useEventCalendarView()
|
||||
const settings = useEventCalendarSettings()
|
||||
const viewConfig = useEventCalendarViewConfig()
|
||||
const nav = useNavButtonProps()
|
||||
const [open, setOpen] = useState(false)
|
||||
const zoned = toZoned(date, settings.timeZone)
|
||||
|
||||
const configured = mode ?? "auto"
|
||||
const resolved =
|
||||
configured === "auto"
|
||||
? RANGE_VIEWS.includes(view)
|
||||
? "range"
|
||||
: "single"
|
||||
: configured
|
||||
|
||||
const pick = (next: Date | undefined) => {
|
||||
if (!next) return
|
||||
goTo(next)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<NavTooltip content={tooltip}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant={nav.variant}
|
||||
size={nav.iconSize}
|
||||
data-slot="event-calendar-date-picker"
|
||||
data-mode={resolved}
|
||||
aria-label={settings.i18n.labels.goToDate}
|
||||
className={cn(nav.className, className)}
|
||||
render={render}
|
||||
/>
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<CalendarIcon className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
</NavTooltip>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className={cn("w-auto p-0!", viewConfig.classNames?.datePickerContent)}
|
||||
>
|
||||
{resolved === "range" ? (
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={{
|
||||
from: toZoned(activeRange.start, settings.timeZone),
|
||||
to: toZoned(addDays(activeRange.end, -1), settings.timeZone),
|
||||
}}
|
||||
defaultMonth={zoned}
|
||||
onDayClick={pick}
|
||||
locale={settings.locale}
|
||||
weekStartsOn={settings.weekStartsOn}
|
||||
/>
|
||||
) : (
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={zoned}
|
||||
defaultMonth={zoned}
|
||||
onSelect={pick}
|
||||
locale={settings.locale}
|
||||
weekStartsOn={settings.weekStartsOn}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
type EventCalendarToolbarProps = useRender.ComponentProps<"div">
|
||||
|
||||
/** Free slot for consumer toolbar buttons; pure layout shell. */
|
||||
function EventCalendarToolbar({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: EventCalendarToolbarProps) {
|
||||
const viewConfig = useEventCalendarViewConfig()
|
||||
const defaultProps = {
|
||||
"data-slot": "event-calendar-toolbar",
|
||||
className: cn(
|
||||
"flex items-center gap-2",
|
||||
viewConfig.classNames?.toolbar,
|
||||
className
|
||||
),
|
||||
children: props.children,
|
||||
}
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
interface EventCalendarNavProps extends useRender.ComponentProps<"div"> {
|
||||
/**
|
||||
* Render the view switcher in the composed layout. Turn off when the
|
||||
* calendar ships with a fixed view (e.g. a month-only embed) and users
|
||||
* should not be able to change it.
|
||||
* @default true
|
||||
*/
|
||||
showViewSwitcher?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Default composed nav: Today, prev/next, title, spacer, view switcher.
|
||||
* Pass children to use it as a pure layout shell instead.
|
||||
*/
|
||||
function EventCalendarNav({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
showViewSwitcher = true,
|
||||
...props
|
||||
}: EventCalendarNavProps) {
|
||||
const viewConfig = useEventCalendarViewConfig()
|
||||
const tooltips = useNavTooltipConfig()
|
||||
const defaultProps = {
|
||||
"data-slot": "event-calendar-nav",
|
||||
className: cn(
|
||||
"flex min-w-0 flex-wrap items-center gap-1 px-2 py-2",
|
||||
viewConfig.stickyNav && "bg-background sticky top-0 z-30",
|
||||
viewConfig.classNames?.nav,
|
||||
className
|
||||
),
|
||||
children: children ?? (
|
||||
// Shared provider: first tooltip waits, moving between buttons is instant
|
||||
<TooltipProvider
|
||||
delay={tooltips.delay}
|
||||
closeDelay={tooltips.closeDelay}
|
||||
timeout={tooltips.timeout}
|
||||
>
|
||||
<EventCalendarNavToday />
|
||||
{showViewSwitcher && <EventCalendarViewSwitcher />}
|
||||
<div className="flex items-center">
|
||||
<EventCalendarNavPrev />
|
||||
<EventCalendarNavNext />
|
||||
</div>
|
||||
{/* ms-3 sets the title apart from the tight control cluster so the
|
||||
period reads as its own group, not another button */}
|
||||
<EventCalendarTitle className="ms-3" />
|
||||
<div className="grow" />
|
||||
</TooltipProvider>
|
||||
),
|
||||
}
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
EventCalendarDatePicker,
|
||||
EventCalendarNav,
|
||||
EventCalendarNavNext,
|
||||
EventCalendarNavPrev,
|
||||
EventCalendarNavToday,
|
||||
EventCalendarTitle,
|
||||
EventCalendarToolbar,
|
||||
EventCalendarViewSwitcher,
|
||||
}
|
||||
export type {
|
||||
EventCalendarNavProps,
|
||||
EventCalendarTitleProps,
|
||||
EventCalendarToolbarProps,
|
||||
EventCalendarViewSwitcherProps,
|
||||
}
|
||||
@@ -1,592 +0,0 @@
|
||||
import type {
|
||||
CalendarEvent,
|
||||
EventCalendarDateRange,
|
||||
EventCalendarOccurrence,
|
||||
EventCalendarRecurrenceRule,
|
||||
EventCalendarWeekday,
|
||||
} from "@/components/reui/event-calendar/event-calendar-types"
|
||||
import { TZDate } from "@date-fns/tz"
|
||||
import { addDays, addMonths, addWeeks, addYears } from "date-fns"
|
||||
|
||||
/** Guard: max window-intersecting occurrences per event per expansion. */
|
||||
const MAX_OCCURRENCES = 1000
|
||||
|
||||
/** Runaway guard: absolute cap on period iterations regardless of visibility. */
|
||||
const MAX_ITERATIONS = 10000
|
||||
|
||||
/** Gregorian mean period lengths for the O(1) fast-forward approximation. */
|
||||
const PERIOD_MS: Record<EventCalendarRecurrenceRule["freq"], number> = {
|
||||
daily: 86400000,
|
||||
weekly: 604800000,
|
||||
monthly: 2629746000, // 365.2425 / 12 days
|
||||
yearly: 31556952000, // 365.2425 days
|
||||
}
|
||||
|
||||
const WEEKDAYS: EventCalendarWeekday[] = [
|
||||
"SU",
|
||||
"MO",
|
||||
"TU",
|
||||
"WE",
|
||||
"TH",
|
||||
"FR",
|
||||
"SA",
|
||||
]
|
||||
|
||||
class EventCalendarRecurrenceError extends Error {
|
||||
constructor(part: string) {
|
||||
super(
|
||||
`Unsupported recurrence part: ${part}. Use the getOccurrences prop to plug a full RRULE engine for exotic rules.`
|
||||
)
|
||||
this.name = "EventCalendarRecurrenceError"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a raw RRULE line (with or without the "RRULE:" prefix) into the
|
||||
* structured subset. Pass the display time zone so Z-less UNTIL values are
|
||||
* interpreted as wall time in that zone rather than the machine zone.
|
||||
*/
|
||||
function parseRRuleString(
|
||||
input: string,
|
||||
timeZone?: string
|
||||
): EventCalendarRecurrenceRule {
|
||||
const body = input.trim().replace(/^RRULE:/i, "")
|
||||
const rule: Partial<EventCalendarRecurrenceRule> = {}
|
||||
|
||||
for (const pair of body.split(";")) {
|
||||
if (!pair) continue
|
||||
const [rawKey, rawValue] = pair.split("=")
|
||||
const key = rawKey?.toUpperCase()
|
||||
const value = rawValue ?? ""
|
||||
|
||||
switch (key) {
|
||||
case "FREQ": {
|
||||
const freq = value.toLowerCase()
|
||||
if (
|
||||
freq !== "daily" &&
|
||||
freq !== "weekly" &&
|
||||
freq !== "monthly" &&
|
||||
freq !== "yearly"
|
||||
) {
|
||||
throw new EventCalendarRecurrenceError(`FREQ=${value}`)
|
||||
}
|
||||
rule.freq = freq
|
||||
break
|
||||
}
|
||||
case "INTERVAL":
|
||||
rule.interval = Math.max(1, parseInt(value, 10) || 1)
|
||||
break
|
||||
case "COUNT":
|
||||
rule.count = Math.max(1, parseInt(value, 10) || 1)
|
||||
break
|
||||
case "UNTIL":
|
||||
rule.until = parseRRuleDate(value, timeZone)
|
||||
break
|
||||
case "BYDAY":
|
||||
rule.byWeekday = value.split(",").map((token) => {
|
||||
// RFC 5545 3.1: enumerated values are case-insensitive, and FREQ is
|
||||
// already folded above - rejecting "mo" here would be inconsistent
|
||||
const match = /^(-?\d+)?(SU|MO|TU|WE|TH|FR|SA)$/.exec(
|
||||
token.trim().toUpperCase()
|
||||
)
|
||||
if (!match) throw new EventCalendarRecurrenceError(`BYDAY=${token}`)
|
||||
const day = match[2] as EventCalendarWeekday
|
||||
return match[1] ? { day, ordinal: parseInt(match[1], 10) } : day
|
||||
})
|
||||
break
|
||||
case "BYMONTHDAY":
|
||||
rule.byMonthDay = value.split(",").map((v) => {
|
||||
const day = parseInt(v, 10)
|
||||
// NaN would survive parsing, match no day in any month and leave the
|
||||
// event permanently invisible with no error anywhere
|
||||
if (Number.isNaN(day)) {
|
||||
throw new EventCalendarRecurrenceError(`BYMONTHDAY=${v}`)
|
||||
}
|
||||
return day
|
||||
})
|
||||
break
|
||||
case "BYMONTH":
|
||||
rule.byMonth = value.split(",").map((v) => parseInt(v, 10))
|
||||
break
|
||||
case "WKST": {
|
||||
const day = value.trim().toUpperCase() as EventCalendarWeekday
|
||||
if (!WEEKDAYS.includes(day)) {
|
||||
throw new EventCalendarRecurrenceError(`WKST=${value}`)
|
||||
}
|
||||
rule.weekStart = day
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw new EventCalendarRecurrenceError(key ?? pair)
|
||||
}
|
||||
}
|
||||
|
||||
if (!rule.freq) throw new EventCalendarRecurrenceError("missing FREQ")
|
||||
return rule as EventCalendarRecurrenceRule
|
||||
}
|
||||
|
||||
function parseRRuleDate(value: string, timeZone?: string): Date {
|
||||
// RFC 5545 basic formats: YYYYMMDD or YYYYMMDDTHHMMSS(Z). The T and Z
|
||||
// designators are case-insensitive too (RFC 5545 3.1), so fold before matching.
|
||||
const match = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/.exec(
|
||||
value.trim().toUpperCase()
|
||||
)
|
||||
if (!match) throw new EventCalendarRecurrenceError(`UNTIL=${value}`)
|
||||
const [, y, m, d, hh = "23", mm = "59", ss = "59", z] = match
|
||||
// Z-less values (including date-only ones, which mean end of that day
|
||||
// inclusive) are wall time in the display zone, not the machine zone.
|
||||
const date = z
|
||||
? new Date(`${y}-${m}-${d}T${hh}:${mm}:${ss}Z`)
|
||||
: timeZone
|
||||
? new Date(new TZDate(+y, +m - 1, +d, +hh, +mm, +ss, timeZone).getTime())
|
||||
: new Date(`${y}-${m}-${d}T${hh}:${mm}:${ss}`)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new EventCalendarRecurrenceError(`UNTIL=${value}`)
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
/** Serializes the structured subset back to an RRULE line (without prefix). */
|
||||
function formatRRuleString(rule: EventCalendarRecurrenceRule): string {
|
||||
const parts: string[] = [`FREQ=${rule.freq.toUpperCase()}`]
|
||||
if (rule.interval && rule.interval > 1)
|
||||
parts.push(`INTERVAL=${rule.interval}`)
|
||||
if (rule.count) parts.push(`COUNT=${rule.count}`)
|
||||
if (rule.until) {
|
||||
const u = rule.until
|
||||
const pad = (n: number) => String(n).padStart(2, "0")
|
||||
parts.push(
|
||||
`UNTIL=${u.getUTCFullYear()}${pad(u.getUTCMonth() + 1)}${pad(u.getUTCDate())}T${pad(u.getUTCHours())}${pad(u.getUTCMinutes())}${pad(u.getUTCSeconds())}Z`
|
||||
)
|
||||
}
|
||||
if (rule.byWeekday?.length) {
|
||||
parts.push(
|
||||
`BYDAY=${rule.byWeekday
|
||||
.map((d) => (typeof d === "string" ? d : `${d.ordinal}${d.day}`))
|
||||
.join(",")}`
|
||||
)
|
||||
}
|
||||
if (rule.byMonthDay?.length)
|
||||
parts.push(`BYMONTHDAY=${rule.byMonthDay.join(",")}`)
|
||||
if (rule.byMonth?.length) parts.push(`BYMONTH=${rule.byMonth.join(",")}`)
|
||||
if (rule.weekStart) parts.push(`WKST=${rule.weekStart}`)
|
||||
return parts.join(";")
|
||||
}
|
||||
|
||||
function resolveRule(
|
||||
recurrence: EventCalendarRecurrenceRule | string,
|
||||
timeZone?: string
|
||||
): EventCalendarRecurrenceRule {
|
||||
return typeof recurrence === "string"
|
||||
? parseRRuleString(recurrence, timeZone)
|
||||
: recurrence
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands one event into its occurrences intersecting the range.
|
||||
* Non-recurring events yield at most one occurrence. Recurrence iteration is
|
||||
* wall-time based in the display zone (DST-safe day/week/month steps), and a
|
||||
* span that is a whole number of local days keeps that day count across a DST
|
||||
* transition (timed spans keep their absolute length instead).
|
||||
*
|
||||
* exDates remove exactly-matching instants (after COUNT numbering,
|
||||
* Google-style: an exception still consumes its COUNT slot); rDates add extra
|
||||
* instants with the same duration. RECURRENCE-ID override replacement lives
|
||||
* in buildEventIndex, where the override event and its parent series meet.
|
||||
*/
|
||||
function expandRecurrence<TData>(
|
||||
event: CalendarEvent<TData>,
|
||||
range: EventCalendarDateRange,
|
||||
ctx: { timeZone: string }
|
||||
): EventCalendarOccurrence<TData>[] {
|
||||
const allDay = event.allDay ?? false
|
||||
|
||||
if (!event.recurrence) {
|
||||
// The exclusive `end > start` test is right for anything with duration,
|
||||
// but it also drops a zero-length milestone pinned to the first visible
|
||||
// instant - which reads as an event that randomly disappears until you
|
||||
// page one period back. A point occurrence only has to be inside.
|
||||
const isPoint = event.end.getTime() === event.start.getTime()
|
||||
if (
|
||||
event.start < range.end &&
|
||||
(event.end > range.start || (isPoint && event.start >= range.start))
|
||||
) {
|
||||
return [
|
||||
{
|
||||
key: `${event.id}::${event.start.toISOString()}`,
|
||||
eventId: event.id,
|
||||
event,
|
||||
start: event.start,
|
||||
end: event.end,
|
||||
allDay,
|
||||
isRecurring: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const rule = resolveRule(event.recurrence, ctx.timeZone)
|
||||
const interval = Math.max(1, rule.interval ?? 1)
|
||||
const durationMs = event.end.getTime() - event.start.getTime()
|
||||
const zonedStart = new TZDate(event.start.getTime(), ctx.timeZone)
|
||||
// A span of whole local days is wall time, not an absolute delta: a 3 day
|
||||
// all-day bar crossing spring forward would otherwise end at 01:00 and
|
||||
// occupy a fourth day in the month grid. Timed spans stay absolute so a two
|
||||
// hour meeting is still two hours.
|
||||
const daySpan = Math.round(durationMs / 86400000)
|
||||
const wallDaySpan =
|
||||
daySpan > 0 &&
|
||||
addDays(zonedStart, daySpan).getTime() === event.end.getTime()
|
||||
? daySpan
|
||||
: null
|
||||
const endFor = (start: Date): Date =>
|
||||
wallDaySpan === null
|
||||
? new Date(start.getTime() + durationMs)
|
||||
: new Date(
|
||||
addDays(
|
||||
new TZDate(start.getTime(), ctx.timeZone),
|
||||
wallDaySpan
|
||||
).getTime()
|
||||
)
|
||||
// Excluded instants matched exactly; filtering happens at push time so an
|
||||
// exception still consumes its COUNT slot (Google-style numbering).
|
||||
const exTimes = new Set((rule.exDates ?? []).map((d) => d.getTime()))
|
||||
|
||||
const weeklyDays: number[] | null =
|
||||
rule.freq === "weekly" && rule.byWeekday?.length
|
||||
? [
|
||||
...new Set(
|
||||
rule.byWeekday.map((d) => {
|
||||
if (typeof d !== "string") {
|
||||
throw new EventCalendarRecurrenceError(
|
||||
"BYDAY ordinal outside monthly/yearly"
|
||||
)
|
||||
}
|
||||
return WEEKDAYS.indexOf(d)
|
||||
})
|
||||
),
|
||||
]
|
||||
: null
|
||||
|
||||
// BYDAY resolved inside a month: monthly, and yearly within each BYMONTH
|
||||
// (FREQ=YEARLY;BYMONTH=11;BYDAY=4TH is Thanksgiving, not "the anchor's day")
|
||||
const monthlyByDay =
|
||||
(rule.freq === "monthly" || rule.freq === "yearly") &&
|
||||
rule.byWeekday?.length
|
||||
? rule.byWeekday
|
||||
: null
|
||||
|
||||
// RFC 5545 3.3.10 week numbering: with INTERVAL > 1 the week start decides
|
||||
// which selected days share a period, so an ignored WKST puts half of every
|
||||
// biweekly series a week off. Default MO, per the RFC.
|
||||
const weekStartIndex = WEEKDAYS.indexOf(rule.weekStart ?? "MO")
|
||||
/** Days from the WKST-aligned week start to `day` (0-6). */
|
||||
const fromWeekStart = (day: number) => (day - weekStartIndex + 7) % 7
|
||||
|
||||
// yearly BYMONTH filter (1-12), ascending; defaults to the anchor's month
|
||||
const validByMonth = rule.byMonth?.filter((m) => m >= 1 && m <= 12) ?? []
|
||||
const yearlyMonths: number[] =
|
||||
validByMonth.length > 0
|
||||
? [...new Set(validByMonth)].sort((a, b) => a - b)
|
||||
: [zonedStart.getMonth() + 1]
|
||||
|
||||
// month-shaped BY* parts make the per-period occurrence count variable
|
||||
const hasMonthDayParts =
|
||||
(rule.freq === "monthly" &&
|
||||
Boolean(rule.byMonthDay?.length || monthlyByDay)) ||
|
||||
(rule.freq === "yearly" &&
|
||||
Boolean(rule.byMonth?.length || rule.byMonthDay?.length || monthlyByDay))
|
||||
|
||||
const daysInMonth = (year: number, month: number) =>
|
||||
new Date(year, month + 1, 0).getDate()
|
||||
|
||||
// RFC 5545 3.3.10 LIMIT filters: at these frequencies a BY* part narrows the
|
||||
// set instead of reshaping it. Dropping them silently expanded the series as
|
||||
// if the part were absent (FREQ=DAILY;BYDAY=MO filled every day). Filtering
|
||||
// here rather than at push time keeps COUNT numbering RFC-correct: a
|
||||
// candidate the filter removes is not an occurrence and consumes no slot.
|
||||
const limitByMonth =
|
||||
rule.freq !== "yearly" && validByMonth.length > 0 ? validByMonth : null
|
||||
const limitByMonthDay =
|
||||
rule.freq === "daily" && rule.byMonthDay?.length ? rule.byMonthDay : null
|
||||
const limitByWeekday =
|
||||
rule.freq === "daily" && rule.byWeekday?.length
|
||||
? rule.byWeekday.map((d) =>
|
||||
WEEKDAYS.indexOf(typeof d === "string" ? d : d.day)
|
||||
)
|
||||
: null
|
||||
const hasLimits = Boolean(limitByMonth || limitByMonthDay || limitByWeekday)
|
||||
|
||||
const passesLimits = (candidate: TZDate): boolean => {
|
||||
if (limitByMonth && !limitByMonth.includes(candidate.getMonth() + 1)) {
|
||||
return false
|
||||
}
|
||||
if (limitByWeekday && !limitByWeekday.includes(candidate.getDay())) {
|
||||
return false
|
||||
}
|
||||
if (limitByMonthDay) {
|
||||
const total = daysInMonth(candidate.getFullYear(), candidate.getMonth())
|
||||
const day = candidate.getDate()
|
||||
// negative BYMONTHDAY counts back from month end, as in monthDays
|
||||
if (!limitByMonthDay.some((n) => (n < 0 ? total + 1 + n : n) === day)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Wall-clock instant in the display zone carrying DTSTART's time-of-day. */
|
||||
const zonedDate = (year: number, month: number, day: number) =>
|
||||
new TZDate(
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
zonedStart.getHours(),
|
||||
zonedStart.getMinutes(),
|
||||
zonedStart.getSeconds(),
|
||||
zonedStart.getMilliseconds(),
|
||||
ctx.timeZone
|
||||
)
|
||||
|
||||
/** Selected days-of-month, ascending: BYMONTHDAY (and) BYDAY, else the clamped anchor day. */
|
||||
const monthDays = (year: number, month: number): number[] => {
|
||||
const total = daysInMonth(year, month)
|
||||
let days: number[] | null = null
|
||||
if (rule.byMonthDay?.length) {
|
||||
// negative BYMONTHDAY counts back from month end; nonexistent days skip
|
||||
days = rule.byMonthDay
|
||||
.map((n) => (n < 0 ? total + 1 + n : n))
|
||||
.filter((n) => n >= 1 && n <= total)
|
||||
}
|
||||
if (monthlyByDay) {
|
||||
const byDayMatches: number[] = []
|
||||
for (const entry of monthlyByDay) {
|
||||
const day = typeof entry === "string" ? entry : entry.day
|
||||
const ordinal = typeof entry === "string" ? 0 : entry.ordinal
|
||||
const weekday = WEEKDAYS.indexOf(day)
|
||||
const matches: number[] = []
|
||||
for (let d = 1; d <= total; d++) {
|
||||
if (new Date(year, month, d).getDay() === weekday) matches.push(d)
|
||||
}
|
||||
if (ordinal === 0) {
|
||||
byDayMatches.push(...matches) // plain BYDAY: every matching weekday
|
||||
} else {
|
||||
// 2TU = 2nd Tuesday, -1FR = last Friday; absent ordinals skip
|
||||
const pick =
|
||||
ordinal > 0
|
||||
? matches[ordinal - 1]
|
||||
: matches[matches.length + ordinal]
|
||||
if (pick !== undefined) byDayMatches.push(pick)
|
||||
}
|
||||
}
|
||||
days = days ? days.filter((n) => byDayMatches.includes(n)) : byDayMatches
|
||||
}
|
||||
if (!days) days = [Math.min(zonedStart.getDate(), total)] // clamp to month end
|
||||
return [...new Set(days)].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
/** Chronological candidates of one period, derived from the DTSTART anchor by index (no drift). */
|
||||
const periodCandidates = (period: number): TZDate[] => {
|
||||
if (rule.freq === "daily") return [addDays(zonedStart, period * interval)]
|
||||
if (rule.freq === "weekly") {
|
||||
const base = addWeeks(zonedStart, period * interval)
|
||||
if (!weeklyDays) return [base]
|
||||
const week: TZDate[] = []
|
||||
// walk the WKST-aligned week so candidates stay chronological
|
||||
const baseOffset = fromWeekStart(base.getDay())
|
||||
for (let offset = 0; offset < 7; offset++) {
|
||||
const candidate = addDays(base, offset - baseOffset)
|
||||
if (!weeklyDays.includes(candidate.getDay())) continue
|
||||
// days of the DTSTART week before DTSTART are not part of the series
|
||||
if (candidate.getTime() < zonedStart.getTime()) continue
|
||||
week.push(candidate)
|
||||
}
|
||||
return week
|
||||
}
|
||||
if (rule.freq === "monthly") {
|
||||
const anchor = addMonths(zonedStart, period * interval)
|
||||
const year = anchor.getFullYear()
|
||||
const month = anchor.getMonth()
|
||||
return monthDays(year, month)
|
||||
.map((day) => zonedDate(year, month, day))
|
||||
.filter((c) => c.getTime() >= zonedStart.getTime())
|
||||
}
|
||||
// yearly
|
||||
const year = addYears(zonedStart, period * interval).getFullYear()
|
||||
const dates: TZDate[] = []
|
||||
for (const month of yearlyMonths) {
|
||||
for (const day of monthDays(year, month - 1)) {
|
||||
dates.push(zonedDate(year, month - 1, day))
|
||||
}
|
||||
}
|
||||
return dates.filter((c) => c.getTime() >= zonedStart.getTime())
|
||||
}
|
||||
|
||||
/** Candidates of one period with the LIMIT filters applied. */
|
||||
const candidatesFor = (period: number): TZDate[] =>
|
||||
hasLimits
|
||||
? periodCandidates(period).filter(passesLimits)
|
||||
: periodCandidates(period)
|
||||
|
||||
/** Earliest/latest instant a period can produce - loop bounds without expanding it. */
|
||||
const periodEdge = (period: number, edge: "first" | "last"): TZDate => {
|
||||
if (rule.freq === "daily") return addDays(zonedStart, period * interval)
|
||||
if (rule.freq === "weekly") {
|
||||
const base = addWeeks(zonedStart, period * interval)
|
||||
if (!weeklyDays) return base
|
||||
return addDays(
|
||||
base,
|
||||
(edge === "first" ? 0 : 6) - fromWeekStart(base.getDay())
|
||||
)
|
||||
}
|
||||
if (rule.freq === "monthly") {
|
||||
const anchor = addMonths(zonedStart, period * interval)
|
||||
const year = anchor.getFullYear()
|
||||
const month = anchor.getMonth()
|
||||
return zonedDate(
|
||||
year,
|
||||
month,
|
||||
edge === "first" ? 1 : daysInMonth(year, month)
|
||||
)
|
||||
}
|
||||
const year = addYears(zonedStart, period * interval).getFullYear()
|
||||
const month =
|
||||
(edge === "first"
|
||||
? yearlyMonths[0]
|
||||
: yearlyMonths[yearlyMonths.length - 1]) - 1
|
||||
return zonedDate(
|
||||
year,
|
||||
month,
|
||||
edge === "first" ? 1 : daysInMonth(year, month)
|
||||
)
|
||||
}
|
||||
|
||||
// O(1) fast-forward: land a couple of periods before the window instead of
|
||||
// iterating from DTSTART, so years-old series still reach the visible range.
|
||||
const aheadMs = range.start.getTime() - durationMs - zonedStart.getTime()
|
||||
const stepMs = PERIOD_MS[rule.freq] * interval
|
||||
let startPeriod =
|
||||
aheadMs > 0 ? Math.max(0, Math.floor(aheadMs / stepMs) - 2) : 0
|
||||
// mean-length drift is bounded well under one period - refine forward
|
||||
while (
|
||||
periodEdge(startPeriod, "last").getTime() + durationMs <=
|
||||
range.start.getTime()
|
||||
) {
|
||||
startPeriod++
|
||||
}
|
||||
|
||||
// series ordinal at startPeriod, so COUNT and recurrenceIndex stay exact
|
||||
let index = 0
|
||||
if (startPeriod > 0) {
|
||||
if (hasMonthDayParts || hasLimits) {
|
||||
// per-period counts vary (skipped days, 4-vs-5 weekday months, a LIMIT
|
||||
// filter that empties a whole period) - sum them
|
||||
for (let period = 0; period < startPeriod; period++) {
|
||||
index += candidatesFor(period).length
|
||||
}
|
||||
} else if (weeklyDays) {
|
||||
// week 0 only counts selected weekdays at/after DTSTART's, inside the
|
||||
// WKST-aligned week that holds it
|
||||
const anchorOffset = fromWeekStart(zonedStart.getDay())
|
||||
const firstWeek = weeklyDays.filter(
|
||||
(d) => fromWeekStart(d) >= anchorOffset
|
||||
).length
|
||||
index = firstWeek + (startPeriod - 1) * weeklyDays.length
|
||||
} else {
|
||||
index = startPeriod // one occurrence per period
|
||||
}
|
||||
}
|
||||
|
||||
const occurrences: EventCalendarOccurrence<TData>[] = []
|
||||
|
||||
const pushIfVisible = (rawStart: Date) => {
|
||||
// normalize to a plain instant so consumers never receive zone-carrying
|
||||
// TZDate instances (mixed-zone formatting bugs)
|
||||
const start = new Date(rawStart.getTime())
|
||||
if (exTimes.has(start.getTime())) return
|
||||
const end = endFor(start)
|
||||
if (start < range.end && end > range.start) {
|
||||
occurrences.push({
|
||||
key: `${event.id}::${start.toISOString()}`,
|
||||
eventId: event.id,
|
||||
event,
|
||||
start,
|
||||
end,
|
||||
allDay,
|
||||
isRecurring: true,
|
||||
recurrenceIndex: index,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let iterations = 0
|
||||
let period = startPeriod
|
||||
while (iterations < MAX_ITERATIONS && occurrences.length < MAX_OCCURRENCES) {
|
||||
iterations++
|
||||
if (rule.count !== undefined && index >= rule.count) break
|
||||
// whole-period bounds: never break on a mid-period weekday/day-of-month,
|
||||
// so earlier candidates of the final period are still emitted
|
||||
const earliest = periodEdge(period, "first")
|
||||
if (earliest.getTime() >= range.end.getTime()) break
|
||||
if (rule.until && earliest.getTime() > rule.until.getTime()) break
|
||||
|
||||
let ended = false
|
||||
for (const candidate of candidatesFor(period)) {
|
||||
if (rule.count !== undefined && index >= rule.count) {
|
||||
ended = true
|
||||
break
|
||||
}
|
||||
if (rule.until && candidate.getTime() > rule.until.getTime()) {
|
||||
ended = true
|
||||
break
|
||||
}
|
||||
pushIfVisible(candidate)
|
||||
index++
|
||||
if (occurrences.length >= MAX_OCCURRENCES) {
|
||||
ended = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (ended) break
|
||||
period++
|
||||
}
|
||||
|
||||
// RDATE: extra instants join the set (deduped against generated starts and
|
||||
// exclusions) with the same wall-time duration. Sorted so direct consumers
|
||||
// still receive chronological order (buildEventIndex re-sorts regardless).
|
||||
if (rule.rDates?.length) {
|
||||
const seen = new Set(occurrences.map((o) => o.start.getTime()))
|
||||
for (const rDate of rule.rDates) {
|
||||
const start = new Date(rDate.getTime())
|
||||
if (seen.has(start.getTime()) || exTimes.has(start.getTime())) continue
|
||||
const end = endFor(start)
|
||||
if (start >= range.end || end <= range.start) continue
|
||||
seen.add(start.getTime())
|
||||
occurrences.push({
|
||||
key: `${event.id}::${start.toISOString()}`,
|
||||
eventId: event.id,
|
||||
event,
|
||||
start,
|
||||
end,
|
||||
allDay,
|
||||
isRecurring: true,
|
||||
// keep counting past the generated instants: an RDATE with no index
|
||||
// would fall back to undefined and break any consumer that identifies
|
||||
// an instance by its position in the series
|
||||
recurrenceIndex: index++,
|
||||
})
|
||||
}
|
||||
occurrences.sort((a, b) => a.start.getTime() - b.start.getTime())
|
||||
}
|
||||
|
||||
return occurrences
|
||||
}
|
||||
|
||||
export {
|
||||
EventCalendarRecurrenceError,
|
||||
expandRecurrence,
|
||||
formatRRuleString,
|
||||
MAX_OCCURRENCES,
|
||||
parseRRuleString,
|
||||
}
|
||||
@@ -1,812 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"
|
||||
import {
|
||||
EventCalendarViewContext,
|
||||
useEventCalendar,
|
||||
useEventCalendarDay,
|
||||
useEventCalendarSelector,
|
||||
useEventCalendarSettings,
|
||||
useEventCalendarViewConfig,
|
||||
useEventCalendarViewSettings,
|
||||
} from "@/components/reui/event-calendar/event-calendar"
|
||||
import {
|
||||
useEventCalendarGestures,
|
||||
wasRecentChipPress,
|
||||
wasRecentDrag,
|
||||
} from "@/components/reui/event-calendar/event-calendar-dnd"
|
||||
import {
|
||||
EVENT_CALENDAR_GHOST,
|
||||
EVENT_CALENDAR_SLOT_DRAFT,
|
||||
EventCalendarEvent,
|
||||
} from "@/components/reui/event-calendar/event-calendar-event"
|
||||
import {
|
||||
flattenResources,
|
||||
getDayKey,
|
||||
getDayTotalMinutes,
|
||||
packTimedSegments,
|
||||
resolveOffDay,
|
||||
snapMinutes,
|
||||
toZoned,
|
||||
zonedStartOfDay,
|
||||
} from "@/components/reui/event-calendar/event-calendar-lib"
|
||||
import {
|
||||
EventCalendarNowIndicator,
|
||||
EventCalendarTimeGutter,
|
||||
minuteBlockStyle,
|
||||
} from "@/components/reui/event-calendar/event-calendar-time-grid"
|
||||
import type {
|
||||
EventCalendarResource,
|
||||
EventCalendarSegment,
|
||||
} from "@/components/reui/event-calendar/event-calendar-types"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { addDays, addMinutes } from "date-fns"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { ScrollArea } from "@evobgp/ui/components/scroll-area"
|
||||
|
||||
const EMPTY_ALL_DAY_SEGMENTS: EventCalendarSegment[] = []
|
||||
|
||||
interface EventCalendarResourceViewProps extends useRender.ComponentProps<"div"> {
|
||||
dayStartHour?: number
|
||||
dayEndHour?: number
|
||||
showAllDay?: boolean
|
||||
/** Gutter/gridline interval in minutes; defaults to the interval view config. */
|
||||
interval?: number
|
||||
}
|
||||
|
||||
/** Leaf resources become booking columns for the anchor day. */
|
||||
function EventCalendarResourceView({
|
||||
className,
|
||||
render,
|
||||
dayStartHour,
|
||||
dayEndHour,
|
||||
showAllDay = true,
|
||||
interval: intervalProp,
|
||||
...props
|
||||
}: EventCalendarResourceViewProps) {
|
||||
const instance = useEventCalendar()
|
||||
const settings = useEventCalendarSettings()
|
||||
const viewConfig = useEventCalendarViewConfig()
|
||||
const { effective } = useEventCalendarViewSettings()
|
||||
const anchorDate = useEventCalendarSelector((state) => state.date, {
|
||||
isEqual: (a, b) => a.getTime() === b.getTime(),
|
||||
})
|
||||
|
||||
const startHour = dayStartHour ?? settings.dayStartHour
|
||||
const endHour = dayEndHour ?? settings.dayEndHour
|
||||
const interval = Math.min(
|
||||
Math.max(intervalProp ?? viewConfig.interval, 5),
|
||||
240
|
||||
)
|
||||
const contained = viewConfig.scrollMode !== "page"
|
||||
const day = zonedStartOfDay(anchorDate, settings.timeZone)
|
||||
|
||||
const resources = useMemo(
|
||||
() =>
|
||||
flattenResources(settings.resources)
|
||||
.filter(({ resource }) => !resource.children?.length)
|
||||
.map(({ resource }) => resource),
|
||||
[settings.resources]
|
||||
)
|
||||
|
||||
// Initial scroll + api.scrollToTime (same contract as the time grid)
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
useEffect(() => {
|
||||
if (!contained) return
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const viewport = el.querySelector<HTMLElement>(
|
||||
"[data-slot=scroll-area-viewport]"
|
||||
)
|
||||
const slotRow = el.querySelector<HTMLElement>(
|
||||
"[data-slot=event-calendar-time-gutter] > div"
|
||||
)
|
||||
const slotPx = slotRow?.getBoundingClientRect().height || 64
|
||||
const pxPerMinute = slotPx / interval
|
||||
const scrollTo = (minutes: number) => {
|
||||
// keep the hour label above the target line visible (it hangs -top-2)
|
||||
viewport?.scrollTo({
|
||||
top: Math.max(0, (minutes - startHour * 60) * pxPerMinute - 12),
|
||||
})
|
||||
}
|
||||
scrollTo(viewConfig.scrollToHour * 60)
|
||||
instance.internals.registerScrollHandler((time) => {
|
||||
const minutes =
|
||||
typeof time === "number"
|
||||
? time
|
||||
: toZoned(time, settings.timeZone).getHours() * 60 +
|
||||
toZoned(time, settings.timeZone).getMinutes()
|
||||
scrollTo(minutes)
|
||||
})
|
||||
// Classic (width-consuming) scrollbars squeeze the scrolling track while
|
||||
// the header/all-day rows outside keep full width, drifting the column
|
||||
// borders. Mirror the measured gutter onto those rows via a CSS var -
|
||||
// 0px for overlay scrollbars and the custom ScrollArea, so both modes
|
||||
// lay out identically.
|
||||
const root = el.closest<HTMLElement>(
|
||||
"[data-slot=event-calendar-time-grid], [data-slot=event-calendar-resource-view]"
|
||||
)
|
||||
const syncScrollbarGutter = () => {
|
||||
root?.style.setProperty(
|
||||
"--ec-scrollbar-w",
|
||||
`${viewport ? viewport.offsetWidth - viewport.clientWidth : 0}px`
|
||||
)
|
||||
}
|
||||
syncScrollbarGutter()
|
||||
const gutterObserver = viewport
|
||||
? new ResizeObserver(syncScrollbarGutter)
|
||||
: null
|
||||
if (viewport) gutterObserver?.observe(viewport)
|
||||
return () => {
|
||||
instance.internals.registerScrollHandler(null)
|
||||
gutterObserver?.disconnect()
|
||||
}
|
||||
}, [
|
||||
contained,
|
||||
instance,
|
||||
settings.timeZone,
|
||||
startHour,
|
||||
interval,
|
||||
viewConfig.scrollToHour,
|
||||
// scrollbars custom<->native swaps the scroller DOM: re-bind the
|
||||
// viewport, the scroll wiring, and the measured --ec-scrollbar-w
|
||||
viewConfig.scrollbars,
|
||||
])
|
||||
|
||||
const slots = useMemo(() => {
|
||||
const result: number[] = []
|
||||
for (let m = startHour * 60; m < endHour * 60; m += interval) {
|
||||
result.push(m)
|
||||
}
|
||||
return result
|
||||
}, [startHour, endHour, interval])
|
||||
|
||||
// All-day segments for renderAllDaySection - the same index bucket the
|
||||
// cells read; inert (stable empty array, so the subscription never
|
||||
// re-renders) while the override is unset.
|
||||
const allDaySegments = useEventCalendarSelector<
|
||||
unknown,
|
||||
EventCalendarSegment[]
|
||||
>(
|
||||
() =>
|
||||
viewConfig.renderAllDaySection
|
||||
? (instance.internals
|
||||
.getIndex()
|
||||
.byDay.get(getDayKey(day, settings.timeZone))?.allDay ??
|
||||
EMPTY_ALL_DAY_SEGMENTS)
|
||||
: EMPTY_ALL_DAY_SEGMENTS,
|
||||
{
|
||||
isEqual: (a, b) =>
|
||||
a === b ||
|
||||
(a.length === b.length && a.every((segment, i) => segment === b[i])),
|
||||
}
|
||||
)
|
||||
|
||||
const gridTemplateColumns = `repeat(${resources.length || 1}, minmax(var(--ec-resource-col-min,8rem), 1fr))`
|
||||
|
||||
const track = (
|
||||
<div className="relative flex">
|
||||
{/* shared gutter component, so renderTimeGutterSlot and
|
||||
classNames.timeGutter customizations apply here too */}
|
||||
<EventCalendarTimeGutter
|
||||
days={[day]}
|
||||
slots={slots}
|
||||
startHour={startHour}
|
||||
interval={interval}
|
||||
/>
|
||||
<div className="grid min-w-0 flex-1" style={{ gridTemplateColumns }}>
|
||||
{resources.map((resource) => (
|
||||
<EventCalendarResourceColumn
|
||||
key={resource.id}
|
||||
resource={resource}
|
||||
day={day}
|
||||
startHour={startHour}
|
||||
endHour={endHour}
|
||||
interval={interval}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{effective.nowIndicator && (
|
||||
<EventCalendarNowIndicator
|
||||
days={[day]}
|
||||
startHour={startHour}
|
||||
endHour={endHour}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const defaultProps = {
|
||||
"data-slot": "event-calendar-resource-view",
|
||||
"data-view": "resource",
|
||||
className: cn(
|
||||
"flex flex-col border-t",
|
||||
contained && "min-h-0 flex-1 overflow-hidden",
|
||||
viewConfig.classNames?.timeGrid,
|
||||
className
|
||||
),
|
||||
style: { "--ec-hour-height": "4rem" } as CSSProperties,
|
||||
children: (
|
||||
<>
|
||||
{/* Resource header row */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex border-b pe-(--ec-scrollbar-w,0px)",
|
||||
!contained &&
|
||||
"bg-background sticky top-(--ec-sticky-offset,0px) z-20",
|
||||
viewConfig.classNames?.timeGridHeader
|
||||
)}
|
||||
>
|
||||
<div className="w-(--ec-gutter-width,4.5rem) shrink-0 border-e" />
|
||||
<div className="grid min-w-0 flex-1" style={{ gridTemplateColumns }}>
|
||||
{resources.map((resource) => (
|
||||
<div
|
||||
key={resource.id}
|
||||
data-slot="event-calendar-resource-header"
|
||||
className={cn(
|
||||
"min-w-0 truncate border-e px-2 py-1.5 text-center font-medium last:border-e-0",
|
||||
viewConfig.classNames?.resourceHeader
|
||||
)}
|
||||
>
|
||||
{viewConfig.renderResourceHeader?.({ resource }) ??
|
||||
resource.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* All-day row per resource */}
|
||||
{showAllDay && (
|
||||
<div
|
||||
data-slot="event-calendar-all-day-section"
|
||||
className={cn(
|
||||
"flex border-b pe-(--ec-scrollbar-w,0px)",
|
||||
viewConfig.classNames?.allDaySection
|
||||
)}
|
||||
>
|
||||
{viewConfig.renderAllDaySection?.({
|
||||
days: [day],
|
||||
segments: allDaySegments,
|
||||
}) ?? (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
// pt-1.5 matches the all-day cell's top inset; the inner box is
|
||||
// one bar-row tall and centers the label so it sits on the SAME
|
||||
// baseline as the first all-day chip and stays top-aligned when
|
||||
// the chips wrap onto more lanes (mirrors the time-grid label)
|
||||
"text-muted-foreground w-(--ec-gutter-width,4.5rem) shrink-0 border-e ps-2 pe-2.5 pt-1.5",
|
||||
viewConfig.classNames?.allDayLabel
|
||||
)}
|
||||
>
|
||||
<span className="flex h-[calc(var(--ec-month-bar-h,1.625rem)-0.125rem)] items-center justify-end">
|
||||
{settings.i18n.labels.allDay}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="grid min-w-0 flex-1"
|
||||
style={{ gridTemplateColumns }}
|
||||
>
|
||||
{resources.map((resource) => (
|
||||
<EventCalendarResourceAllDayCell
|
||||
key={resource.id}
|
||||
resource={resource}
|
||||
day={day}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{contained ? (
|
||||
<div ref={scrollRef} className="min-h-0 flex-1">
|
||||
{viewConfig.scrollbars === "native" ? (
|
||||
<div
|
||||
data-slot="scroll-area-viewport"
|
||||
data-ec-native-scroll=""
|
||||
className="h-full overflow-y-auto"
|
||||
>
|
||||
{track}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-full">{track}</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
track
|
||||
)}
|
||||
</>
|
||||
),
|
||||
}
|
||||
|
||||
return (
|
||||
<EventCalendarViewContext.Provider value={{ view: "resource" }}>
|
||||
{useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})}
|
||||
</EventCalendarViewContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function EventCalendarResourceAllDayCell({
|
||||
resource,
|
||||
day,
|
||||
}: {
|
||||
resource: EventCalendarResource
|
||||
day: Date
|
||||
}) {
|
||||
const settings = useEventCalendarSettings()
|
||||
const viewConfig = useEventCalendarViewConfig()
|
||||
const { effective } = useEventCalendarViewSettings()
|
||||
const gestures = useEventCalendarGestures()
|
||||
const { segments } = useEventCalendarDay(day)
|
||||
const mine = segments.allDay.filter(
|
||||
(segment) => segment.occurrence.event.resourceId === resource.id
|
||||
)
|
||||
const dayStart = zonedStartOfDay(day, settings.timeZone)
|
||||
const dayEnd = addDays(toZoned(dayStart, settings.timeZone), 1)
|
||||
const isOff = resolveOffDay(
|
||||
day,
|
||||
settings.timeZone,
|
||||
effective.offDays
|
||||
? typeof viewConfig.offDays === "object"
|
||||
? viewConfig.offDays
|
||||
: true
|
||||
: false,
|
||||
settings.weekendDays
|
||||
)
|
||||
const offClassName =
|
||||
(typeof viewConfig.offDays === "object" && viewConfig.offDays.className) ||
|
||||
"bg-muted/25"
|
||||
const isDropTarget = useEventCalendarSelector<
|
||||
unknown,
|
||||
"valid" | "invalid" | null
|
||||
>((state) => {
|
||||
const drag = state.drag
|
||||
if (!drag || !drag.proposedDayGranular) return null
|
||||
const covered = drag.proposedStart < dayEnd && drag.proposedEnd > dayStart
|
||||
if (!covered) return null
|
||||
return drag.valid ? "valid" : "invalid"
|
||||
})
|
||||
// Slot-draft highlight, mirroring the time-grid all-day cell. The dnd
|
||||
// layer's all-day create branch does not plumb resourceId into the draft,
|
||||
// so every resource cell covering the day highlights together.
|
||||
const inDraft = useEventCalendarSelector<unknown, boolean>((state) => {
|
||||
const draft = state.slotDraft
|
||||
if (!draft || !draft.allDay) return false
|
||||
return draft.start < dayEnd && draft.end > dayStart
|
||||
})
|
||||
return (
|
||||
<div
|
||||
data-slot="event-calendar-all-day-cell"
|
||||
// data-ec-day makes this a DnD day target: without it collectSurface()
|
||||
// finds no cells and dragging an all-day chip silently converts it to
|
||||
// a timed event via the column branch
|
||||
data-ec-day={dayStart.getTime()}
|
||||
data-drop-target={isDropTarget ?? undefined}
|
||||
data-off={isOff || undefined}
|
||||
className={cn(
|
||||
// reserve one bar row so the all-day row keeps the same height with or
|
||||
// without events, matching the time-grid all-day row (which reserves
|
||||
// the same via its bars-grid minHeight)
|
||||
"relative flex min-h-[calc(var(--ec-month-bar-h,1.625rem)+0.625rem)] min-w-0 flex-col gap-0.5 border-e px-1 py-1.5 last:border-e-0",
|
||||
isOff && offClassName,
|
||||
viewConfig.dayClassName?.(day),
|
||||
inDraft &&
|
||||
cn(
|
||||
EVENT_CALENDAR_SLOT_DRAFT.surface,
|
||||
EVENT_CALENDAR_SLOT_DRAFT.segment,
|
||||
EVENT_CALENDAR_SLOT_DRAFT.segmentStart,
|
||||
EVENT_CALENDAR_SLOT_DRAFT.segmentEnd,
|
||||
viewConfig.classNames?.slotDraft
|
||||
),
|
||||
viewConfig.classNames?.allDayCell
|
||||
)}
|
||||
onPointerDown={(e) => {
|
||||
if (e.target === e.currentTarget) gestures.beginCreate(e, day, true)
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (
|
||||
e.target === e.currentTarget &&
|
||||
!wasRecentDrag() &&
|
||||
!wasRecentChipPress()
|
||||
) {
|
||||
settings.onSlotClick?.(
|
||||
{
|
||||
date: dayStart,
|
||||
allDay: true,
|
||||
view: "resource",
|
||||
resourceId: resource.id,
|
||||
},
|
||||
e
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{mine.map((segment) => (
|
||||
<EventCalendarEvent
|
||||
key={segment.occurrence.key}
|
||||
segment={segment}
|
||||
// one bar-row tall, matching the time-grid all-day bars so the row
|
||||
// height stays identical across views (and equals the reserved min)
|
||||
className="h-[calc(var(--ec-month-bar-h,1.625rem)-0.125rem)]"
|
||||
/>
|
||||
))}
|
||||
{isDropTarget && (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="event-calendar-drop-indicator"
|
||||
data-drop-target={isDropTarget}
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0.5 z-10 rounded-sm border border-dashed",
|
||||
isDropTarget === "valid"
|
||||
? "border-primary/50"
|
||||
: "border-destructive/60",
|
||||
viewConfig.classNames?.dropIndicator
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EventCalendarResourceColumn({
|
||||
resource,
|
||||
day,
|
||||
startHour,
|
||||
endHour,
|
||||
interval,
|
||||
}: {
|
||||
resource: EventCalendarResource
|
||||
day: Date
|
||||
startHour: number
|
||||
endHour: number
|
||||
interval: number
|
||||
}) {
|
||||
const settings = useEventCalendarSettings()
|
||||
const viewConfig = useEventCalendarViewConfig()
|
||||
const { effective } = useEventCalendarViewSettings()
|
||||
const gestures = useEventCalendarGestures()
|
||||
const { segments, isToday } = useEventCalendarDay(day)
|
||||
const isOff = resolveOffDay(
|
||||
day,
|
||||
settings.timeZone,
|
||||
effective.offDays
|
||||
? typeof viewConfig.offDays === "object"
|
||||
? viewConfig.offDays
|
||||
: true
|
||||
: false,
|
||||
settings.weekendDays
|
||||
)
|
||||
const offClassName =
|
||||
(typeof viewConfig.offDays === "object" && viewConfig.offDays.className) ||
|
||||
"bg-muted/25"
|
||||
|
||||
const timeZone = settings.timeZone
|
||||
const dayStart = zonedStartOfDay(day, timeZone)
|
||||
const totalMinutes = getDayTotalMinutes(day, timeZone)
|
||||
const boundsStartMin = startHour * 60
|
||||
const boundsEndMin = Math.min(endHour * 60, totalMinutes)
|
||||
const boundsMinutes = Math.max(60, boundsEndMin - boundsStartMin)
|
||||
|
||||
// Filter this resource's timed segments and repack per column.
|
||||
// Clones keep the shared index cache untouched. Segments the day bounds clip
|
||||
// away are dropped here too, otherwise they hold a column nobody can see and
|
||||
// leave a phantom empty half beside the first in-bounds chip.
|
||||
const packed = useMemo(() => {
|
||||
const mine = segments.timed
|
||||
.filter((segment) => {
|
||||
if (segment.occurrence.event.resourceId !== resource.id) return false
|
||||
const startMin = Math.max(segment.startMin ?? 0, boundsStartMin)
|
||||
const endMin = Math.min(segment.endMin ?? startMin, boundsEndMin)
|
||||
return endMin > boundsStartMin && startMin < boundsEndMin
|
||||
})
|
||||
.map((segment) => ({ ...segment }) as EventCalendarSegment)
|
||||
packTimedSegments(mine)
|
||||
return mine
|
||||
}, [segments.timed, resource.id, boundsStartMin, boundsEndMin])
|
||||
|
||||
const dragGhost = useEventCalendarSelector<
|
||||
unknown,
|
||||
{
|
||||
window: [number, number]
|
||||
valid: boolean
|
||||
kind: string
|
||||
color?: string
|
||||
title: string
|
||||
occurrence: EventCalendarSegment["occurrence"]
|
||||
proposedStart: Date
|
||||
proposedEnd: Date
|
||||
} | null
|
||||
>(
|
||||
(state) => {
|
||||
const drag = state.drag
|
||||
if (!drag || drag.proposedDayGranular) return null
|
||||
// Moves carry a proposedResourceId (they can cross columns); resizes stay
|
||||
// in place and leave it undefined, so fall back to the event's own
|
||||
// resource - otherwise the resize ghost is filtered out of every column.
|
||||
const targetResourceId =
|
||||
drag.proposedResourceId ?? drag.occurrence.event.resourceId
|
||||
if (targetResourceId !== resource.id) return null
|
||||
const from = Math.max(
|
||||
(drag.proposedStart.getTime() - dayStart.getTime()) / 60000,
|
||||
boundsStartMin
|
||||
)
|
||||
const to = Math.min(
|
||||
(drag.proposedEnd.getTime() - dayStart.getTime()) / 60000,
|
||||
boundsEndMin
|
||||
)
|
||||
if (to <= from) return null
|
||||
return {
|
||||
window: [from, to] as [number, number],
|
||||
valid: drag.valid,
|
||||
kind: drag.kind,
|
||||
color: drag.occurrence.event.color,
|
||||
title: drag.occurrence.event.title,
|
||||
occurrence: drag.occurrence,
|
||||
proposedStart: drag.proposedStart,
|
||||
proposedEnd: drag.proposedEnd,
|
||||
}
|
||||
},
|
||||
{
|
||||
isEqual: (a, b) =>
|
||||
a === b ||
|
||||
(a !== null &&
|
||||
b !== null &&
|
||||
a.window[0] === b.window[0] &&
|
||||
a.window[1] === b.window[1] &&
|
||||
a.valid === b.valid &&
|
||||
a.proposedStart.getTime() === b.proposedStart.getTime() &&
|
||||
a.proposedEnd.getTime() === b.proposedEnd.getTime()),
|
||||
}
|
||||
)
|
||||
|
||||
// Instants behind `draftWindow` below, for the range readout. Same
|
||||
// resource filter, so a draft on a neighbouring resource never labels this
|
||||
// column.
|
||||
const draftRange = useEventCalendarSelector<
|
||||
unknown,
|
||||
{ start: Date; end: Date } | null
|
||||
>(
|
||||
(state) => {
|
||||
const draft = state.slotDraft
|
||||
if (!draft || draft.allDay || draft.resourceId !== resource.id) {
|
||||
return null
|
||||
}
|
||||
return { start: draft.start, end: draft.end }
|
||||
},
|
||||
{
|
||||
isEqual: (a, b) =>
|
||||
a === b ||
|
||||
(a !== null &&
|
||||
b !== null &&
|
||||
a.start.getTime() === b.start.getTime() &&
|
||||
a.end.getTime() === b.end.getTime()),
|
||||
}
|
||||
)
|
||||
|
||||
const draftWindow = useEventCalendarSelector<
|
||||
unknown,
|
||||
[number, number] | null
|
||||
>(
|
||||
(state) => {
|
||||
const draft = state.slotDraft
|
||||
if (!draft || draft.allDay || draft.resourceId !== resource.id) {
|
||||
return null
|
||||
}
|
||||
const from = Math.max(
|
||||
(draft.start.getTime() - dayStart.getTime()) / 60000,
|
||||
boundsStartMin
|
||||
)
|
||||
const to = Math.min(
|
||||
(draft.end.getTime() - dayStart.getTime()) / 60000,
|
||||
boundsEndMin
|
||||
)
|
||||
return to > from ? [from, to] : null
|
||||
},
|
||||
{
|
||||
isEqual: (a, b) =>
|
||||
a === b || (a !== null && b !== null && a[0] === b[0] && a[1] === b[1]),
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="event-calendar-day-column"
|
||||
data-today={isToday || undefined}
|
||||
data-off={isOff || undefined}
|
||||
data-ec-day={dayStart.getTime()}
|
||||
data-ec-bounds-start={boundsStartMin}
|
||||
data-ec-bounds-end={boundsEndMin}
|
||||
data-ec-resource={resource.id}
|
||||
data-drop-target={
|
||||
dragGhost ? (dragGhost.valid ? "valid" : "invalid") : undefined
|
||||
}
|
||||
role="group"
|
||||
aria-label={resource.title}
|
||||
className={cn(
|
||||
"relative min-w-0 border-e last:border-e-0",
|
||||
isOff && offClassName,
|
||||
// the resource view is a single day, so today gets no column tint (the
|
||||
// header marks it); only a consumer todayClassName can tint it
|
||||
isToday && viewConfig.todayClassName,
|
||||
viewConfig.dayClassName?.(day),
|
||||
viewConfig.classNames?.dayColumn
|
||||
)}
|
||||
style={{
|
||||
height: `calc(var(--ec-hour-height) * ${boundsMinutes / 60})`,
|
||||
backgroundImage: `repeating-linear-gradient(to bottom, transparent, transparent calc(var(--ec-hour-height) * ${interval / 60} - var(--ec-slot-line-width, 1px)), var(--ec-slot-line-color, var(--color-border)) calc(var(--ec-hour-height) * ${interval / 60} - var(--ec-slot-line-width, 1px)), var(--ec-slot-line-color, var(--color-border)) calc(var(--ec-hour-height) * ${interval / 60}))`,
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.target === e.currentTarget) gestures.beginCreate(e, day, false)
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (
|
||||
e.target !== e.currentTarget ||
|
||||
wasRecentDrag() ||
|
||||
wasRecentChipPress()
|
||||
)
|
||||
return
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const pxPerMinute = rect.height / boundsMinutes
|
||||
const minutes = snapMinutes(
|
||||
boundsStartMin + (e.clientY - rect.top) / pxPerMinute,
|
||||
settings.snapDuration
|
||||
)
|
||||
const clamped = Math.min(
|
||||
Math.max(minutes, boundsStartMin),
|
||||
boundsEndMin - settings.slotDuration
|
||||
)
|
||||
settings.onSlotClick?.(
|
||||
{
|
||||
date: addMinutes(dayStart, clamped),
|
||||
end: addMinutes(dayStart, clamped + settings.slotDuration),
|
||||
allDay: false,
|
||||
view: "resource",
|
||||
resourceId: resource.id,
|
||||
},
|
||||
e
|
||||
)
|
||||
}}
|
||||
>
|
||||
{packed.map((segment) => {
|
||||
const startMin = Math.max(segment.startMin ?? 0, boundsStartMin)
|
||||
const endMin = Math.min(segment.endMin ?? startMin, boundsEndMin)
|
||||
if (endMin <= boundsStartMin || startMin >= boundsEndMin) return null
|
||||
const columnCount = segment.columnCount ?? 1
|
||||
const column = segment.column ?? 0
|
||||
const span = segment.columnSpan ?? 1
|
||||
const zIndex = segment.occurrence.event.zIndex ?? 10 + column
|
||||
// Strict side-by-side columns - no cascade overlap (fade-truncate +
|
||||
// hover reveal carry the legibility); the ring separates neighbors.
|
||||
const colPct = 100 / columnCount
|
||||
return (
|
||||
<div
|
||||
key={segment.occurrence.key}
|
||||
// min-h keeps 15-min chips readable (Google-style: the block may
|
||||
// slightly outgrow its true window); hover raises a squeezed
|
||||
// cascade chip above its overlapping neighbors
|
||||
className="absolute z-(--ec-z) min-h-(--ec-event-min-h,1.5rem) px-0.5 hover:z-40"
|
||||
style={
|
||||
{
|
||||
...minuteBlockStyle(startMin, endMin, boundsStartMin),
|
||||
left: `${column * colPct}%`,
|
||||
width: `${span * colPct}%`,
|
||||
"--ec-z": zIndex,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<EventCalendarEvent
|
||||
segment={segment}
|
||||
className={cn(
|
||||
columnCount > 1 && "ring-background ring-1",
|
||||
// short chips: single centered row, exact-fit line height so
|
||||
// the title never slices mid-glyph
|
||||
endMin - startMin < viewConfig.compactEventMinutes
|
||||
? "h-full gap-1 py-0 leading-4"
|
||||
: "h-full flex-col items-start justify-start gap-0 py-1",
|
||||
viewConfig.classNames?.timedChip
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* Standardized ghost (EVENT_CALENDAR_GHOST): faint drop placeholder
|
||||
for moves (the cursor-attached carry clone owns the visual), dashed
|
||||
clone for resizes, destructive marking when invalid. */}
|
||||
{dragGhost && (
|
||||
<div
|
||||
data-slot="event-calendar-drag-ghost"
|
||||
data-kind={dragGhost.kind}
|
||||
data-drop-invalid={!dragGhost.valid || undefined}
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0.5 z-50 min-h-(--ec-event-min-h,1.5rem)",
|
||||
dragGhost.kind === "move"
|
||||
? cn(
|
||||
EVENT_CALENDAR_GHOST.move,
|
||||
!dragGhost.valid && EVENT_CALENDAR_GHOST.invalid
|
||||
)
|
||||
: cn(
|
||||
EVENT_CALENDAR_GHOST.resize,
|
||||
!dragGhost.valid && EVENT_CALENDAR_GHOST.invalidResize
|
||||
),
|
||||
viewConfig.classNames?.dragGhost
|
||||
)}
|
||||
style={
|
||||
{
|
||||
...minuteBlockStyle(
|
||||
dragGhost.window[0],
|
||||
dragGhost.window[1],
|
||||
boundsStartMin
|
||||
),
|
||||
"--ec-event-color": dragGhost.color ?? "var(--color-primary)",
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{dragGhost.kind !== "move" && (
|
||||
<EventCalendarEvent
|
||||
preview
|
||||
segment={{
|
||||
occurrence: {
|
||||
...dragGhost.occurrence,
|
||||
start: dragGhost.proposedStart,
|
||||
end: dragGhost.proposedEnd,
|
||||
allDay: false,
|
||||
},
|
||||
day,
|
||||
isStart: true,
|
||||
isEnd: true,
|
||||
continuesBefore: false,
|
||||
continuesAfter: false,
|
||||
startMin: dragGhost.window[0],
|
||||
endMin: dragGhost.window[1],
|
||||
}}
|
||||
className={cn(
|
||||
dragGhost.window[1] - dragGhost.window[0] <
|
||||
viewConfig.compactEventMinutes
|
||||
? "h-full gap-1 py-0 leading-4"
|
||||
: "h-full flex-col items-start justify-start gap-0 py-1",
|
||||
viewConfig.classNames?.timedChip,
|
||||
"inset-ring-0",
|
||||
!dragGhost.valid && EVENT_CALENDAR_GHOST.invalidContent
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{draftWindow && (
|
||||
<div
|
||||
data-slot="event-calendar-slot-draft"
|
||||
className={cn(
|
||||
EVENT_CALENDAR_SLOT_DRAFT.box,
|
||||
"pointer-events-none absolute inset-x-0.5 z-40 overflow-hidden",
|
||||
viewConfig.classNames?.slotDraft
|
||||
)}
|
||||
style={minuteBlockStyle(
|
||||
draftWindow[0],
|
||||
draftWindow[1],
|
||||
boundsStartMin
|
||||
)}
|
||||
>
|
||||
{draftRange && (
|
||||
<span className={cn("block", EVENT_CALENDAR_SLOT_DRAFT.label)}>
|
||||
{settings.i18n.functions.formatEventTime(
|
||||
toZoned(draftRange.start, settings.timeZone),
|
||||
toZoned(draftRange.end, settings.timeZone),
|
||||
false,
|
||||
{ locale: settings.locale }
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { EventCalendarResourceView }
|
||||
export type { EventCalendarResourceViewProps }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,240 +0,0 @@
|
||||
type EventCalendarEventId = string
|
||||
|
||||
type CalendarView = "month" | "week" | "day" | "days" | "agenda" | "resource"
|
||||
|
||||
/** Bookable resource. The resource view flattens children and renders leaves
|
||||
* only, as booking columns; a parent's own title never renders. */
|
||||
interface EventCalendarResource {
|
||||
id: string
|
||||
title: string
|
||||
color?: string
|
||||
children?: EventCalendarResource[]
|
||||
}
|
||||
|
||||
/** Half-open: start is inclusive, end is exclusive. */
|
||||
interface EventCalendarDateRange {
|
||||
start: Date
|
||||
end: Date
|
||||
}
|
||||
|
||||
type EventCalendarWeekday = "MO" | "TU" | "WE" | "TH" | "FR" | "SA" | "SU"
|
||||
|
||||
interface EventCalendarRecurrenceRule {
|
||||
freq: "daily" | "weekly" | "monthly" | "yearly"
|
||||
interval?: number
|
||||
count?: number
|
||||
/** Inclusive instant. */
|
||||
until?: Date
|
||||
byWeekday?: Array<
|
||||
EventCalendarWeekday | { day: EventCalendarWeekday; ordinal: number }
|
||||
>
|
||||
byMonthDay?: number[]
|
||||
byMonth?: number[]
|
||||
weekStart?: EventCalendarWeekday
|
||||
exDates?: Date[]
|
||||
rDates?: Date[]
|
||||
}
|
||||
|
||||
interface CalendarEvent<TData = unknown> {
|
||||
id: EventCalendarEventId
|
||||
title: string
|
||||
start: Date
|
||||
/** Exclusive; must be >= start. */
|
||||
end: Date
|
||||
/** Start and end must be midnights in the display time zone: segmentation
|
||||
* walks raw instants, so another zone's midnight paints the wrong days. */
|
||||
allDay?: boolean
|
||||
/** Structured rule or a raw "RRULE:..." line. */
|
||||
recurrence?: EventCalendarRecurrenceRule | string
|
||||
/** This event is an edited single occurrence of that series. */
|
||||
recurringEventId?: EventCalendarEventId
|
||||
/** Which occurrence it replaces (RECURRENCE-ID semantics). */
|
||||
originalStart?: Date
|
||||
/** Token or css color; flows to the --ec-event-color css var. */
|
||||
color?: string
|
||||
/** Excluded from drag and resize regardless of interactions state. */
|
||||
readOnly?: boolean
|
||||
/** Per-event overrides; defaults come from interactions.drag / .resize. */
|
||||
draggable?: boolean
|
||||
resizable?: boolean
|
||||
/** Packing prominence; feeds getEventPriority ordering. */
|
||||
priority?: number
|
||||
/** Verbatim stacking override; replaces the computed 10 + column. */
|
||||
zIndex?: number
|
||||
resourceId?: string
|
||||
data?: TData
|
||||
}
|
||||
|
||||
interface EventCalendarOccurrence<TData = unknown> {
|
||||
/** Stable per instance: `${event.id}::${startISO}`. */
|
||||
key: string
|
||||
eventId: EventCalendarEventId
|
||||
event: CalendarEvent<TData>
|
||||
start: Date
|
||||
end: Date
|
||||
allDay: boolean
|
||||
isRecurring: boolean
|
||||
recurrenceIndex?: number
|
||||
}
|
||||
|
||||
interface EventCalendarSegment<TData = unknown> {
|
||||
occurrence: EventCalendarOccurrence<TData>
|
||||
/** Zoned midnight of the segment's day; startMin/endMin count from it. */
|
||||
day: Date
|
||||
isStart: boolean
|
||||
isEnd: boolean
|
||||
continuesBefore: boolean
|
||||
continuesAfter: boolean
|
||||
/** Timed only: minutes from the zoned day start. */
|
||||
startMin?: number
|
||||
endMin?: number
|
||||
/** Layout output: lane stacks month bars and all-day lanes;
|
||||
* column/columnCount/columnSpan pack time-grid overlaps;
|
||||
* rowIndex/colStart/colSpan place a bar in the week row. */
|
||||
lane?: number
|
||||
column?: number
|
||||
columnCount?: number
|
||||
columnSpan?: number
|
||||
rowIndex?: number
|
||||
colStart?: number
|
||||
colSpan?: number
|
||||
}
|
||||
|
||||
interface EventCalendarSelection {
|
||||
eventKeys: string[]
|
||||
/** Committed slot selection; EventCalendarSlotDraft holds the in-gesture one. */
|
||||
slot: { start: Date; end: Date; allDay: boolean } | null
|
||||
}
|
||||
|
||||
interface EventCalendarInteractions {
|
||||
drag: boolean
|
||||
resize: boolean
|
||||
selectSlot: boolean
|
||||
}
|
||||
|
||||
interface EventCalendarDragState<TData = unknown> {
|
||||
kind: "move" | "resize-start" | "resize-end"
|
||||
occurrence: EventCalendarOccurrence<TData>
|
||||
proposedStart: Date
|
||||
proposedEnd: Date
|
||||
proposedAllDay: boolean
|
||||
/** Day-granular proposal (month cells, all-day lane), not minute columns; the
|
||||
* all-day ghost keys on it: proposedAllDay misses timed MULTI-DAY bars. */
|
||||
proposedDayGranular: boolean
|
||||
proposedResourceId?: string
|
||||
/** Last canDropEvent verdict; drives data-drop-invalid styling. */
|
||||
valid: boolean
|
||||
}
|
||||
|
||||
/** The in-progress drag-create rectangle only; cleared on commit or cancel. */
|
||||
interface EventCalendarSlotDraft {
|
||||
start: Date
|
||||
end: Date
|
||||
allDay: boolean
|
||||
view: CalendarView
|
||||
resourceId?: string
|
||||
}
|
||||
|
||||
/** "View settings" toggles; undefined defers to the root view-config prop. */
|
||||
interface EventCalendarViewSettings {
|
||||
weekends?: boolean
|
||||
weekNumbers?: boolean
|
||||
nowIndicator?: boolean
|
||||
offDays?: boolean
|
||||
}
|
||||
|
||||
interface EventCalendarState<TData = unknown> {
|
||||
view: CalendarView
|
||||
/** Anchor date; navigation steps this, and both ranges derive from it. */
|
||||
date: Date
|
||||
/** Read by the "days" view only; other views ignore it. */
|
||||
dayCount: number
|
||||
/** Full rendered grid incl. outside days - fetch remote data for THIS. */
|
||||
visibleRange: EventCalendarDateRange
|
||||
/** The logical period (the month/week itself). */
|
||||
activeRange: EventCalendarDateRange
|
||||
events: CalendarEvent<TData>[]
|
||||
selection: EventCalendarSelection
|
||||
interactions: EventCalendarInteractions
|
||||
loading: boolean
|
||||
drag: EventCalendarDragState<TData> | null
|
||||
slotDraft: EventCalendarSlotDraft | null
|
||||
viewSettings: EventCalendarViewSettings
|
||||
}
|
||||
|
||||
interface EventCalendarRangeInfo {
|
||||
range: EventCalendarDateRange
|
||||
activeRange: EventCalendarDateRange
|
||||
view: CalendarView
|
||||
date: Date
|
||||
timeZone: string
|
||||
}
|
||||
|
||||
interface EventCalendarProposedUpdate<TData = unknown> {
|
||||
event: CalendarEvent<TData>
|
||||
/** null when source === "api". */
|
||||
occurrence: EventCalendarOccurrence<TData> | null
|
||||
start: Date
|
||||
end: Date
|
||||
allDay: boolean
|
||||
resourceId?: string
|
||||
source: "drag" | "resize-start" | "resize-end" | "keyboard" | "api"
|
||||
}
|
||||
|
||||
/** false = reject/revert; void or true = accept; object = accept with adjustment. */
|
||||
type EventCalendarUpdateResult =
|
||||
| boolean
|
||||
| void
|
||||
| { start?: Date; end?: Date; allDay?: boolean }
|
||||
|
||||
/** A click is a point, not a range; `end` is present for timed slots. */
|
||||
interface EventCalendarSlotInfo {
|
||||
date: Date
|
||||
end?: Date
|
||||
allDay: boolean
|
||||
view: CalendarView
|
||||
resourceId?: string
|
||||
}
|
||||
|
||||
/** Off-days; `true` = weekend defaults. Marked cells carry `data-off`. */
|
||||
interface EventCalendarOffDaysConfig {
|
||||
/** Weekday numbers treated as off (0 = Sunday). Default [0, 6]. */
|
||||
weekendDays?: number[]
|
||||
/** Additional explicit off dates (compared by day in the display zone). */
|
||||
dates?: Date[]
|
||||
/** Full custom predicate; runs in addition to weekendDays/dates. */
|
||||
isOffDay?: (day: Date) => boolean
|
||||
/** Marker classes; default "bg-muted/25". */
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface EventCalendarDataAdapter<TData = unknown> {
|
||||
getEvents(
|
||||
range: EventCalendarDateRange,
|
||||
signal?: AbortSignal
|
||||
): Promise<CalendarEvent<TData>[]>
|
||||
}
|
||||
|
||||
export type {
|
||||
CalendarEvent,
|
||||
CalendarView,
|
||||
EventCalendarDataAdapter,
|
||||
EventCalendarDateRange,
|
||||
EventCalendarDragState,
|
||||
EventCalendarEventId,
|
||||
EventCalendarInteractions,
|
||||
EventCalendarOccurrence,
|
||||
EventCalendarOffDaysConfig,
|
||||
EventCalendarProposedUpdate,
|
||||
EventCalendarRangeInfo,
|
||||
EventCalendarRecurrenceRule,
|
||||
EventCalendarResource,
|
||||
EventCalendarSegment,
|
||||
EventCalendarSelection,
|
||||
EventCalendarSlotDraft,
|
||||
EventCalendarSlotInfo,
|
||||
EventCalendarState,
|
||||
EventCalendarViewSettings,
|
||||
EventCalendarUpdateResult,
|
||||
EventCalendarWeekday,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,143 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { addHours, parseISO } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { EventCalendar } from '@/components/reui/event-calendar/event-calendar'
|
||||
import { EventCalendarContent } from '@/components/reui/event-calendar/event-calendar-content'
|
||||
import { EventCalendarNav } from '@/components/reui/event-calendar/event-calendar-nav'
|
||||
import type { CalendarEvent } from '@/components/reui/event-calendar/event-calendar-types'
|
||||
import { jobKindRu } from '@/lib/ui-labels'
|
||||
import type { JobRow } from '@/types/api'
|
||||
|
||||
/**
|
||||
* Schedule calendar — ReUI EventCalendar (month + agenda).
|
||||
* @see https://reui.io/preview/base/components/c-event-calendar-1
|
||||
* @see https://reui.io/docs/components/base/event-calendar
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
|
||||
const CALENDAR_I18N = {
|
||||
labels: {
|
||||
today: 'Сегодня',
|
||||
previous: 'Назад',
|
||||
next: 'Вперёд',
|
||||
addEvent: 'Добавить',
|
||||
allDay: 'Весь день',
|
||||
more: (count: number) => `ещё ${count}`,
|
||||
noEvents: 'Нет задач',
|
||||
loading: 'Загрузка…',
|
||||
event: 'задача',
|
||||
events: (count: number) =>
|
||||
count === 1 ? '1 задача' : count < 5 ? `${count} задачи` : `${count} задач`,
|
||||
selectView: 'Вид',
|
||||
week: (weekNumber: number) => `Н${weekNumber}`,
|
||||
resources: 'Ресурсы',
|
||||
goToDate: 'Перейти к дате',
|
||||
dropNotAllowed: 'Сюда нельзя',
|
||||
continues: 'продолжается',
|
||||
timeFrom: (time: string) => `с ${time}`,
|
||||
timeUntil: (time: string) => `до ${time}`,
|
||||
moreCompact: (count: number) => `+${count}`,
|
||||
timeRange: (from: string, to: string) => `${from}–${to}`,
|
||||
},
|
||||
viewNames: {
|
||||
month: 'Месяц',
|
||||
week: 'Неделя',
|
||||
day: 'День',
|
||||
agenda: 'Повестка',
|
||||
resource: 'Сетка',
|
||||
},
|
||||
formats: {
|
||||
monthTitle: 'LLLL yyyy',
|
||||
dayTitle: 'EEEE, d MMMM yyyy',
|
||||
monthDayHeader: 'EEEEE',
|
||||
agendaDayHeader: 'EEEE, d MMMM',
|
||||
eventTime: 'HH:mm',
|
||||
timeGutter: 'HH:mm',
|
||||
},
|
||||
}
|
||||
|
||||
function jobTimestamp(job: JobRow): string | undefined {
|
||||
return job.created_at ?? job.started_at ?? job.finished_at ?? undefined
|
||||
}
|
||||
|
||||
function jobEventColor(status: string): string {
|
||||
const s = status.toLowerCase()
|
||||
if (s === 'succeeded') return 'var(--success)'
|
||||
if (s === 'running' || s === 'queued') return 'var(--warning)'
|
||||
if (s === 'failed' || s === 'error' || s === 'cancelled') return 'var(--destructive)'
|
||||
return 'var(--primary)'
|
||||
}
|
||||
|
||||
function jobsToEvents(jobs: JobRow[]): CalendarEvent<JobRow>[] {
|
||||
const events: CalendarEvent<JobRow>[] = []
|
||||
for (const job of jobs) {
|
||||
const raw = jobTimestamp(job)
|
||||
if (!raw) continue
|
||||
let start: Date
|
||||
try {
|
||||
start = parseISO(raw)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (Number.isNaN(start.getTime())) continue
|
||||
let end = job.finished_at ? parseISO(job.finished_at) : addHours(start, 1)
|
||||
if (Number.isNaN(end.getTime()) || end <= start) {
|
||||
end = addHours(start, 1)
|
||||
}
|
||||
events.push({
|
||||
id: job.job_id,
|
||||
title: jobKindRu(job.kind),
|
||||
start,
|
||||
end,
|
||||
readOnly: true,
|
||||
draggable: false,
|
||||
resizable: false,
|
||||
color: jobEventColor(job.status),
|
||||
data: job,
|
||||
})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
export function ScheduleAgendaPanel({
|
||||
jobs,
|
||||
isLoading,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const events = useMemo(() => jobsToEvents(jobs), [jobs])
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm" className="w-full min-w-0">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Календарь задач</FrameTitle>
|
||||
<FrameDescription>Задачи обновления и применения по дням</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<EventCalendar
|
||||
className="h-[560px]"
|
||||
events={events}
|
||||
loading={isLoading}
|
||||
defaultView="month"
|
||||
views={['month', 'agenda']}
|
||||
locale={ru}
|
||||
weekStartsOn={1}
|
||||
interactions={{ drag: false, resize: false, selectSlot: false }}
|
||||
i18n={CALENDAR_I18N}
|
||||
>
|
||||
<EventCalendarNav />
|
||||
<EventCalendarContent />
|
||||
</EventCalendar>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -96,6 +96,7 @@ export function ScheduleModulesGrid({
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
size: 104,
|
||||
header: () => null,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">
|
||||
|
||||
+12
-14
@@ -5,7 +5,7 @@ import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
|
||||
export type BadgeTabItem = {
|
||||
export type SegmentedTabItem = {
|
||||
value: string
|
||||
label: string
|
||||
count?: number
|
||||
@@ -13,8 +13,8 @@ export type BadgeTabItem = {
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
interface BadgeTabsProps {
|
||||
items: BadgeTabItem[]
|
||||
interface SegmentedTabsProps {
|
||||
items: SegmentedTabItem[]
|
||||
value?: string
|
||||
defaultValue?: string
|
||||
onValueChange?: (value: string) => void
|
||||
@@ -24,8 +24,12 @@ interface BadgeTabsProps {
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
/** Underline tabs with optional badge counts (ReUI c-tabs-7 pattern). */
|
||||
export function BadgeTabs({
|
||||
/**
|
||||
* Page-level section switcher — native shadcn Tabs, default (segmented) list.
|
||||
* @see https://reui.io/preview/base/components/c-tabs-9
|
||||
* @see https://reui.io/components/tabs
|
||||
*/
|
||||
export function SegmentedTabs({
|
||||
items,
|
||||
value,
|
||||
defaultValue,
|
||||
@@ -34,22 +38,16 @@ export function BadgeTabs({
|
||||
className,
|
||||
listClassName,
|
||||
contentClassName,
|
||||
}: BadgeTabsProps) {
|
||||
}: SegmentedTabsProps) {
|
||||
return (
|
||||
<Tabs
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={onValueChange}
|
||||
orientation="horizontal"
|
||||
className={cn('w-full min-w-0', className)}
|
||||
className={cn('w-full min-w-0 gap-4', className)}
|
||||
>
|
||||
<TabsList
|
||||
variant="line"
|
||||
className={cn(
|
||||
'mb-3.5 w-full min-w-0 justify-start gap-4',
|
||||
listClassName,
|
||||
)}
|
||||
>
|
||||
<TabsList className={cn('h-auto w-full min-w-0 justify-start', listClassName)}>
|
||||
{items.map((item) => (
|
||||
<TabsTrigger key={item.value} value={item.value} className="shrink-0 gap-2">
|
||||
{item.icon}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
|
||||
import { KpiStatGrid } from '@/components/kpi-stat-grid'
|
||||
import { panelCardInsetClassName } from '@/components/panel-card'
|
||||
import { KpiStatGrid, panelCardInsetClassName } from '@/components/reui-kit'
|
||||
import {
|
||||
chartPanelGridClassName,
|
||||
dashboardMainSidebarClassName,
|
||||
@@ -10,23 +9,14 @@ import {
|
||||
export function KpiStatGridSkeleton({ count = 4 }: { count?: number }) {
|
||||
return (
|
||||
<KpiStatGrid
|
||||
items={Array.from({ length: count }, (_, index) => ({
|
||||
id: `kpi-skeleton-${index}`,
|
||||
icon: <Skeleton className="size-4 rounded-sm" />,
|
||||
value: <Skeleton className="h-8 w-16" />,
|
||||
label: <Skeleton className="h-4 w-28" />,
|
||||
footer: <Skeleton className="h-5 w-32 rounded-full" />,
|
||||
}))}
|
||||
items={[]}
|
||||
isLoading
|
||||
skeletonCount={count}
|
||||
aria-label="Загрузка показателей"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** @deprecated Use KpiStatGridSkeleton */
|
||||
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
|
||||
return <KpiStatGridSkeleton count={count} />
|
||||
}
|
||||
|
||||
/** Matches dashboard layout: KPI row, charts, main+sidebar, bottom grids. */
|
||||
export function AnalyticsDashboardSkeleton() {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
@@ -42,6 +42,13 @@ const DOT_COLOR: Record<string, string> = {
|
||||
outline: 'bg-muted-foreground',
|
||||
}
|
||||
|
||||
const TONE_VARIANT: Record<string, BadgeVariant> = {
|
||||
neutral: 'outline',
|
||||
info: 'info-light',
|
||||
warning: 'warning-light',
|
||||
success: 'success-light',
|
||||
}
|
||||
|
||||
export function jobStatusVariant(status: string): BadgeVariant {
|
||||
return STATUS_VARIANT[status.toLowerCase()] ?? 'outline'
|
||||
}
|
||||
@@ -70,3 +77,41 @@ export function StatusBadge({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModeBadge({
|
||||
enabled,
|
||||
onLabel = 'включён',
|
||||
offLabel = 'выключен',
|
||||
className,
|
||||
}: {
|
||||
enabled: boolean
|
||||
onLabel?: string
|
||||
offLabel?: string
|
||||
className?: string
|
||||
}) {
|
||||
return enabled ? (
|
||||
<Badge variant="success-light" size="sm" radius="full" className={className}>
|
||||
{onLabel}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" size="sm" radius="full" className={className}>
|
||||
{offLabel}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export function CategoryBadge({
|
||||
children,
|
||||
tone = 'neutral',
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
tone?: keyof typeof TONE_VARIANT
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Badge variant={TONE_VARIANT[tone]} size="sm" radius="full" className={className}>
|
||||
{children}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ToggleGroup, ToggleGroupItem } from '@evobgp/ui/components/toggle-group'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export type StatusToggleItem = {
|
||||
id: string
|
||||
label: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
interface StatusToggleGroupProps {
|
||||
items: StatusToggleItem[]
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
className?: string
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid status filter — native ToggleGroup in the ResourcePage toolbar.
|
||||
* @see https://reui.io/preview/base/components/c-toggle-group-5
|
||||
* @see https://reui.io/components/toggle-group
|
||||
*/
|
||||
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="sm"
|
||||
spacing={0}
|
||||
aria-label={ariaLabel}
|
||||
className={cn('w-fit', className)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<ToggleGroupItem key={item.id} value={item.id} className="gap-1.5 px-2.5">
|
||||
<span>{item.label}</span>
|
||||
{item.count !== undefined ? (
|
||||
<span className="text-muted-foreground tabular-nums">{item.count}</span>
|
||||
) : 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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
const Loom = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} viewBox="0 0 256 256" preserveAspectRatio="xMidYMid">
|
||||
<path
|
||||
fill="#625DF5"
|
||||
d="M256 113.765h-74.858l64.83-37.43-14.237-24.667-64.83 37.43 37.421-64.825-24.667-14.246-37.421 64.826V0h-28.476v74.86L76.326 10.027 51.667 24.266 89.096 89.09 24.265 51.668l-14.238 24.66 64.83 37.43H0v28.477h74.85l-64.823 37.43 14.238 24.667 64.824-37.423-37.43 64.825 24.667 14.239 37.429-64.832V256h28.476v-74.853l37.422 64.826 24.665-14.239-37.428-64.832 64.83 37.43 14.24-24.667-64.825-37.423h74.85v-28.477H256ZM128 166.73c-21.472 0-38.876-17.403-38.876-38.876 0-21.472 17.404-38.876 38.876-38.876 21.472 0 38.875 17.404 38.875 38.876 0 21.473-17.403 38.876-38.875 38.876Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export { Loom };
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
const Mintlify = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} viewBox="0 0 19 19" fill="none">
|
||||
<path
|
||||
d="M18.367 7.28888V1.59755C18.367 0.986819 17.8715 0.5 17.2699 0.5H11.5812C10.6877 0.5 9.80295 0.677018 8.98017 1.01336C8.15738 1.35856 7.40539 1.85424 6.77724 2.49152L6.733 2.53578C5.90137 3.37664 5.30862 4.42108 5.00781 5.57174C5.54749 5.43012 6.10483 5.35931 6.6622 5.35046C8.14852 5.33276 9.60831 5.81073 10.7938 6.7047C11.8643 7.50131 12.6783 8.59885 13.1206 9.86458C13.5807 11.148 13.6337 12.5465 13.2887 13.8653C14.43 13.5644 15.4828 12.9714 16.3233 12.1393L16.3675 12.0951C16.9957 11.4667 17.4999 10.7143 17.845 9.89114C18.19 9.06797 18.3581 8.18285 18.3581 7.28888H18.367Z"
|
||||
fill="#18E299"
|
||||
/>
|
||||
<path
|
||||
d="M4.83793 7.193C4.84674 5.44706 5.54303 3.77167 6.76814 2.51953L2.03511 7.25472C2.01749 7.27236 1.99985 7.28117 1.98222 7.29881C0.827615 8.44513 0.131342 9.97945 0.0167623 11.6019C-0.0890033 13.1186 0.307609 14.6176 1.15373 15.8698C1.23444 15.9892 1.45343 16.0285 1.57682 15.9139L4.47656 13.0216C5.38438 12.1134 5.66643 10.7642 5.23455 9.55618C4.96132 8.80666 4.82912 8.00424 4.83793 7.193Z"
|
||||
fill="#0C8C5E"
|
||||
/>
|
||||
<path
|
||||
d="M16.341 12.0938C15.4332 12.9844 14.2962 13.6016 13.0623 13.875C11.8195 14.1483 10.5327 14.0689 9.33405 13.6457C9.33405 13.6457 9.32522 13.6457 9.31641 13.6457C8.10892 13.2136 6.76042 13.4958 5.8526 14.3952L2.95282 17.2875C2.82943 17.4109 2.84706 17.6137 2.99689 17.7107C4.24845 18.5484 5.74683 18.954 7.26281 18.8482C8.88455 18.7336 10.4093 18.037 11.5639 16.8818L11.608 16.8378L16.341 12.1026V12.0938Z"
|
||||
fill="#0C8C5E"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export { Mintlify };
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
const N8n = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} viewBox="0 0 228 120">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M204 48C192.817 48 183.42 40.3514 180.756 30H153.248C147.382 30 142.376 34.241 141.412 40.0272L140.425 45.9456C139.489 51.5648 136.646 56.4554 132.626 60C136.646 63.5446 139.489 68.4352 140.425 74.0544L141.412 79.9728C142.376 85.759 147.382 90 153.248 90H156.756C159.42 79.6486 168.817 72 180 72C193.255 72 204 82.7452 204 96C204 109.255 193.255 120 180 120C168.817 120 159.42 112.351 156.756 102H153.248C141.516 102 131.504 93.5181 129.575 81.9456L128.588 76.0272C127.624 70.241 122.618 66 116.752 66H107.244C104.58 76.3514 95.183 84 84 84C72.817 84 63.4204 76.3514 60.7561 66H47.2439C44.5796 76.3514 35.183 84 24 84C10.7452 84 0 73.2548 0 60C0 46.7452 10.7452 36 24 36C35.183 36 44.5796 43.6486 47.2439 54H60.7561C63.4204 43.6486 72.817 36 84 36C95.183 36 104.58 43.6486 107.244 54H116.752C122.618 54 127.624 49.759 128.588 43.9728L129.575 38.0544C131.504 26.4819 141.516 18 153.248 18L180.756 18C183.42 7.64864 192.817 0 204 0C217.255 0 228 10.7452 228 24C228 37.2548 217.255 48 204 48ZM204 36C210.627 36 216 30.6274 216 24C216 17.3726 210.627 12 204 12C197.373 12 192 17.3726 192 24C192 30.6274 197.373 36 204 36ZM24 72C30.6274 72 36 66.6274 36 60C36 53.3726 30.6274 48 24 48C17.3726 48 12 53.3726 12 60C12 66.6274 17.3726 72 24 72ZM96 60C96 66.6274 90.6274 72 84 72C77.3726 72 72 66.6274 72 60C72 53.3726 77.3726 48 84 48C90.6274 48 96 53.3726 96 60ZM192 96C192 102.627 186.627 108 180 108C173.373 108 168 102.627 168 96C168 89.3726 173.373 84 180 84C186.627 84 192 89.3726 192 96Z"
|
||||
fill="#ea4b71"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export { N8n };
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
const Neon = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} viewBox="0 0 256 256" preserveAspectRatio="xMidYMid">
|
||||
<defs>
|
||||
<linearGradient id="a" x1="100%" x2="12.069%" y1="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#62F755" />
|
||||
<stop offset="100%" stopColor="#8FF986" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient id="b" x1="100%" x2="40.603%" y1="100%" y2="76.897%">
|
||||
<stop offset="0%" stopOpacity=".9" />
|
||||
<stop offset="100%" stopColor="#1A1A1A" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
fill="#00E0D9"
|
||||
d="M0 44.139C0 19.762 19.762 0 44.139 0H211.86C236.238 0 256 19.762 256 44.139v142.649c0 25.216-31.915 36.16-47.388 16.256l-48.392-62.251v75.484c0 21.939-17.784 39.723-39.722 39.723h-76.36C19.763 256 0 236.238 0 211.861V44.14Zm44.139-8.825c-4.879 0-8.825 3.946-8.825 8.818v167.73c0 4.878 3.946 8.831 8.818 8.831h77.688c2.44 0 3.087-1.977 3.087-4.416v-101.22c0-25.222 31.914-36.166 47.395-16.255l48.391 62.243V44.14c0-4.879.455-8.825-4.416-8.825H44.14Z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#a)"
|
||||
d="M0 44.139C0 19.762 19.762 0 44.139 0H211.86C236.238 0 256 19.762 256 44.139v142.649c0 25.216-31.915 36.16-47.388 16.256l-48.392-62.251v75.484c0 21.939-17.784 39.723-39.722 39.723h-76.36C19.763 256 0 236.238 0 211.861V44.14Zm44.139-8.825c-4.879 0-8.825 3.946-8.825 8.818v167.73c0 4.878 3.946 8.831 8.818 8.831h77.688c2.44 0 3.087-1.977 3.087-4.416v-101.22c0-25.222 31.914-36.166 47.395-16.255l48.391 62.243V44.14c0-4.879.455-8.825-4.416-8.825H44.14Z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#b)"
|
||||
fillOpacity=".4"
|
||||
d="M0 44.139C0 19.762 19.762 0 44.139 0H211.86C236.238 0 256 19.762 256 44.139v142.649c0 25.216-31.915 36.16-47.388 16.256l-48.392-62.251v75.484c0 21.939-17.784 39.723-39.722 39.723h-76.36C19.763 256 0 236.238 0 211.861V44.14Zm44.139-8.825c-4.879 0-8.825 3.946-8.825 8.818v167.73c0 4.878 3.946 8.831 8.818 8.831h77.688c2.44 0 3.087-1.977 3.087-4.416v-101.22c0-25.222 31.914-36.166 47.395-16.255l48.391 62.243V44.14c0-4.879.455-8.825-4.416-8.825H44.14Z"
|
||||
/>
|
||||
<path
|
||||
fill="#63F655"
|
||||
d="M211.861 0C236.238 0 256 19.762 256 44.139v142.649c0 25.216-31.915 36.16-47.388 16.256l-48.392-62.251v75.484c0 21.939-17.784 39.723-39.722 39.723a4.409 4.409 0 0 0 4.409-4.409V115.058c0-25.223 31.914-36.167 47.395-16.256l48.391 62.243V8.825c0-4.871-3.953-8.825-8.832-8.825Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export { Neon };
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
const Openai = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} preserveAspectRatio="xMidYMid" viewBox="0 0 256 260">
|
||||
<path d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export { Openai };
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
const OpenaiDark = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} preserveAspectRatio="xMidYMid" viewBox="0 0 256 260">
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export { OpenaiDark };
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
const Paper = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} viewBox="0 0 39 39" fill="none">
|
||||
<path d="M39 24H24V6H6V24H24V39H0V6H6V0H39V24Z" fill="#81ADEC" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export { Paper };
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
const Planetscale = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} preserveAspectRatio="xMidYMid" viewBox="0 0 256 256">
|
||||
<path d="M256 128a128 128 0 01-128 128zM128 0c52 0 96.7 31 116.8 75.5L75.5 244.8c-7.3-3.3-14.2-7.2-20.7-11.7L160 128h-32l-90.5 90.5A128 128 0 01128 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export { Planetscale };
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
const PlanetscaleDark = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} preserveAspectRatio="xMidYMid" viewBox="0 0 256 256">
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M256 128a128 128 0 01-128 128zM128 0c52 0 96.7 31 116.8 75.5L75.5 244.8c-7.3-3.3-14.2-7.2-20.7-11.7L160 128h-32l-90.5 90.5A128 128 0 01128 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export { PlanetscaleDark };
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
const Prisma = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} viewBox="0 0 256 310" preserveAspectRatio="xMidYMid">
|
||||
<path d="M254.313 235.519L148 9.749A17.063 17.063 0 00133.473.037a16.87 16.87 0 00-15.533 8.052L2.633 194.848a17.465 17.465 0 00.193 18.747L59.2 300.896a18.13 18.13 0 0020.363 7.489l163.599-48.392a17.929 17.929 0 0011.26-9.722 17.542 17.542 0 00-.101-14.76l-.008.008zm-23.802 9.683l-138.823 41.05c-4.235 1.26-8.3-2.411-7.419-6.685l49.598-237.484c.927-4.443 7.063-5.147 9.003-1.035l91.814 194.973a6.63 6.63 0 01-4.18 9.18h.007z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export { Prisma };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user