feat(web): enhance UI components and improve documentation
Build, Test, and Push CFDM Docker Image / test (push) Failing after 37s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
Build, Test, and Push CFDM Docker Image / test (push) Failing after 37s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
- Updated frontend UI patterns documentation to reflect new components and their usage. - Enhanced CertKpiCards for better KPI visualization and streamlined data handling. - Refactored OpsDashboard and ResourcePage for improved layout consistency and loading states. - Introduced CatalogBoardToggle for better navigation between views in the Groups and Services pages. - Improved Tabs component styles for better responsiveness and accessibility. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -35,7 +35,8 @@ apps/web/src/components/ ← shared + domain + layout
|
|||||||
kanban-board.tsx ← kanban + KanbanBoardSkeleton
|
kanban-board.tsx ← kanban + KanbanBoardSkeleton
|
||||||
detail-panel.tsx ← detail: Frame header/metrics
|
detail-panel.tsx ← detail: Frame header/metrics
|
||||||
settings-shell.tsx
|
settings-shell.tsx
|
||||||
ops-dashboard.tsx
|
ops-dashboard.tsx ← KPI card-35 + charts
|
||||||
|
catalog-board-toggle.tsx
|
||||||
empty-state.tsx
|
empty-state.tsx
|
||||||
query-state.tsx
|
query-state.tsx
|
||||||
confirm-dialog.tsx
|
confirm-dialog.tsx
|
||||||
@@ -52,11 +53,12 @@ apps/web/src/components/ ← shared + domain + layout
|
|||||||
| Элемент | Shared | Primitive |
|
| Элемент | Shared | Primitive |
|
||||||
|---------|--------|-----------|
|
|---------|--------|-----------|
|
||||||
| Page wrapper | `PageShell` | — |
|
| Page wrapper | `PageShell` | — |
|
||||||
| List page | `ResourcePage` | ReUI `Frame` + `data-grid` + `filters` + shadcn `Tabs` |
|
| List page | `ResourcePage` | ReUI `Frame` + `data-grid` + `filters` + shadcn `Tabs` `variant="line"` |
|
||||||
|
| Catalog / Board | `CatalogBoardToggle` + `ResourcePage` / `KanbanBoard` | `?view=board` на `/groups`, `/services` |
|
||||||
| Kanban | `KanbanBoard` / `KanbanBoardSkeleton` | ReUI `kanban` + `Frame` |
|
| Kanban | `KanbanBoard` / `KanbanBoardSkeleton` | ReUI `kanban` + `Frame` |
|
||||||
| Detail | `DetailPanel` | ReUI `Frame` |
|
| Detail | `DetailPanel` | ReUI `Frame` |
|
||||||
| Settings | `SettingsShell` | — |
|
| Settings | `SettingsShell` | — |
|
||||||
| Dashboard | `OpsDashboard` / `KpiStatGrid` | ReUI `Frame` |
|
| Dashboard KPI | `OpsDashboard` / `KpiStatGrid` | ReUI Frame [card-35](https://reui.io/preview/base/card-35) |
|
||||||
| Empty | `EmptyState` | `Empty` |
|
| Empty | `EmptyState` | `Empty` |
|
||||||
| Loading / Error | `QueryState` / kit skeletons | `Skeleton`, `Alert` |
|
| Loading / Error | `QueryState` / kit skeletons | `Skeleton`, `Alert` |
|
||||||
| Status | `StatusBadge` | ReUI `Badge` (`success`/`info`/`warning`) |
|
| Status | `StatusBadge` | ReUI `Badge` (`success`/`info`/`warning`) |
|
||||||
@@ -76,32 +78,41 @@ apps/web/src/components/ ← shared + domain + layout
|
|||||||
|
|
||||||
Max 1 primary (`default`) на экран; остальные `outline` / `ghost`.
|
Max 1 primary (`default`) на экран; остальные `outline` / `ghost`.
|
||||||
|
|
||||||
## Line tabs (ResourcePage)
|
Toggle «Доска» / «К каталогу» — всегда `outline` в `primaryAction` / `KanbanBoard.toolbarActions` (не отдельный Frame-shell).
|
||||||
|
|
||||||
Эталон: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2).
|
## Line tabs (project standard)
|
||||||
|
|
||||||
|
Эталон: [c-tabs-2](https://reui.io/preview/base/components/c-tabs-2) + counted [filtering-2](https://reui.io/preview/base/data-grid-filtering-2).
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<TabsList variant="line" className="h-auto gap-5 bg-transparent p-0">
|
<TabsList variant="line" className="gap-5">
|
||||||
<TabsTrigger
|
<TabsTrigger value="…" className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3">
|
||||||
value="…"
|
|
||||||
className={cn(
|
|
||||||
'h-auto flex-none gap-2 rounded-none bg-transparent px-0 pb-3 text-sm shadow-none',
|
|
||||||
'text-muted-foreground hover:text-foreground',
|
|
||||||
'data-active:bg-transparent data-active:text-foreground data-active:shadow-none',
|
|
||||||
'dark:data-active:border-transparent dark:data-active:bg-transparent',
|
|
||||||
'after:bottom-0 after:h-0.5',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span>Label</span>
|
<span>Label</span>
|
||||||
<span className="bg-muted text-muted-foreground … tabular-nums rounded-md">{count}</span>
|
<span className="bg-muted text-muted-foreground … tabular-nums rounded-md">{count}</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
```
|
```
|
||||||
|
|
||||||
- Active = яркий текст + **белый underline** (`after:`), без фона/инвертированного badge
|
- Примитив: [`packages/ui/.../tabs.tsx`](../../packages/ui/src/components/tabs.tsx) — line = `h-auto`, без `flex-1`, без `dark:data-active:bg-input/30`, underline `after:bottom-0`
|
||||||
- Count pill всегда `bg-muted` (как в preview)
|
- Active = яркий текст + **foreground underline**, без фона
|
||||||
- `h-auto` / `flex-none` / `data-active:bg-transparent` — снять клип `h-8` и `dark:bg-input/30` у примитива
|
- Count pill всегда `bg-muted`
|
||||||
- Active state — Base UI `data-active`, не Radix `data-[state=active]`
|
- Active state — Base UI `data-active`, не Radix `data-[state=active]`
|
||||||
|
- Не трогать internals `reui/date-selector`
|
||||||
|
|
||||||
|
## Catalog / Board (`/groups`, `/services`)
|
||||||
|
|
||||||
|
| Режим | UI | Search |
|
||||||
|
|-------|-----|--------|
|
||||||
|
| Catalog (default) | `ResourcePage` + primary create | omit / `view=catalog` |
|
||||||
|
| Board | `KanbanBoard` DnD | `?view=board` |
|
||||||
|
|
||||||
|
- Groups tabs: Все / С доменами / Пустые
|
||||||
|
- Services tabs: Все / Включены / Выключены / Без группы
|
||||||
|
- DnD только на board; kanban hooks/cards не удалять
|
||||||
|
|
||||||
|
## Dashboard KPI
|
||||||
|
|
||||||
|
Эталон: [card-35](https://reui.io/preview/base/card-35) — muted **label** + крупный **value** + optional **hint**; клик по карточке через `to` (`Link`), без отдельных hint-links.
|
||||||
|
|
||||||
## Sidebar
|
## Sidebar
|
||||||
|
|
||||||
@@ -122,6 +133,8 @@ Max 1 primary (`default`) на экран; остальные `outline` / `ghost
|
|||||||
|------|----------------|
|
|------|----------------|
|
||||||
| Shell | [app-shell-12](https://reui.io/preview/base/app-shell-12) |
|
| Shell | [app-shell-12](https://reui.io/preview/base/app-shell-12) |
|
||||||
| List + tabs + filters | [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) |
|
| List + tabs + filters | [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) |
|
||||||
|
| Line tabs | [c-tabs-2](https://reui.io/preview/base/components/c-tabs-2) |
|
||||||
|
| KPI | [card-35](https://reui.io/preview/base/card-35) |
|
||||||
| Login | auth-13 / login-03 |
|
| Login | auth-13 / login-03 |
|
||||||
|
|
||||||
## Spacing
|
## Spacing
|
||||||
@@ -130,7 +143,7 @@ Max 1 primary (`default`) на экран; остальные `outline` / `ghost
|
|||||||
AppShell main: gap-4 md:gap-6, px-4 md:px-6, py-4 md:py-5
|
AppShell main: gap-4 md:gap-6, px-4 md:px-6, py-4 md:py-5
|
||||||
PageShell: gap-4 md:gap-6
|
PageShell: gap-4 md:gap-6
|
||||||
Title/desc: gap-px
|
Title/desc: gap-px
|
||||||
Card/Frame grid: gap-4
|
Card/Frame grid: gap-4 (dashboard denser: gap-2 md:gap-3)
|
||||||
FieldGroup: gap-4
|
FieldGroup: gap-4
|
||||||
Item list: gap-2
|
Item list: gap-2
|
||||||
Toolbar / header actions: gap-2
|
Toolbar / header actions: gap-2
|
||||||
@@ -143,7 +156,7 @@ Tabs list (line): gap-5
|
|||||||
|
|
||||||
Каждый блок: **default, hover, focus, disabled, empty, loading, error**.
|
Каждый блок: **default, hover, focus, disabled, empty, loading, error**.
|
||||||
|
|
||||||
- **Loading** — `Skeleton` / `ResourcePage` skeleton / `KanbanBoardSkeleton`, не Spinner на странице
|
- **Loading** — `Skeleton` / `ResourcePage` skeleton / `KanbanBoardSkeleton` / `OpsDashboard` skeleton, не Spinner на странице
|
||||||
- **Empty** — `EmptyState` с CTA
|
- **Empty** — `EmptyState` с CTA
|
||||||
- **Zero-results** — message внутри DataGrid (+ «Сбросить»)
|
- **Zero-results** — message внутри DataGrid (+ «Сбросить»)
|
||||||
- **Error** — `QueryState` / `Alert` + `onRetry`
|
- **Error** — `QueryState` / `Alert` + `onRetry`
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Frame } from "@/components/reui/frame"
|
||||||
|
|
||||||
|
import { CardItem } from "./card-item"
|
||||||
|
import { CARDS } from "./data"
|
||||||
|
|
||||||
|
export function CardGrid() {
|
||||||
|
return (
|
||||||
|
<Frame className="@container w-full">
|
||||||
|
{/* Grid */}
|
||||||
|
<div className="grid gap-1 @2xl:grid-cols-2 @4xl:grid-cols-4">
|
||||||
|
{CARDS.map((card) => (
|
||||||
|
<CardItem key={card.title} card={card} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { useId } from "react"
|
||||||
|
import { FramePanel } from "@/components/reui/frame"
|
||||||
|
|
||||||
|
import { ICard } from "./data"
|
||||||
|
|
||||||
|
function CardDotPatternRtl() {
|
||||||
|
const id = useId().replace(/:/g, "")
|
||||||
|
const width = 240
|
||||||
|
const height = 136
|
||||||
|
const patternId = `${id}-pattern`
|
||||||
|
const maskId = `${id}-mask`
|
||||||
|
const gradientId = `${id}-gradient`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-0 z-0 [--card-dot-pattern-fill:var(--color-foreground)] [--card-dot-pattern-opacity:0.12] dark:[--card-dot-pattern-opacity:0.2]"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
className="size-full"
|
||||||
|
fill="none"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
shapeRendering="crispEdges"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style={{ opacity: "var(--card-dot-pattern-opacity)" }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<mask
|
||||||
|
id={maskId}
|
||||||
|
style={{ maskType: "alpha" }}
|
||||||
|
maskUnits="userSpaceOnUse"
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
>
|
||||||
|
<rect width={width} height={height} fill={`url(#${gradientId})`} />
|
||||||
|
</mask>
|
||||||
|
|
||||||
|
<linearGradient
|
||||||
|
id={gradientId}
|
||||||
|
x1={width}
|
||||||
|
y1={0}
|
||||||
|
x2={width - 83.8613}
|
||||||
|
y2={95.7483}
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<stop stopColor="white" />
|
||||||
|
<stop offset="0.538873" stopColor="white" />
|
||||||
|
<stop offset="1" stopColor="white" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<pattern
|
||||||
|
id={patternId}
|
||||||
|
patternUnits="userSpaceOnUse"
|
||||||
|
patternTransform="matrix(48 0 0 48 0 0)"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
width="1"
|
||||||
|
height="1"
|
||||||
|
>
|
||||||
|
<g fill="var(--card-dot-pattern-fill)">
|
||||||
|
<rect x="0" y="0" width="2" height="2" fillOpacity="0.216" />
|
||||||
|
<rect x="4" y="0" width="2" height="2" fillOpacity="0.489" />
|
||||||
|
<rect x="8" y="0" width="2" height="2" fillOpacity="0.366" />
|
||||||
|
<rect x="12" y="0" width="2" height="2" fillOpacity="0.305" />
|
||||||
|
<rect x="16" y="0" width="2" height="2" fillOpacity="0.514" />
|
||||||
|
<rect x="20" y="0" width="2" height="2" fillOpacity="0.129" />
|
||||||
|
<rect x="24" y="0" width="2" height="2" fillOpacity="0.431" />
|
||||||
|
<rect x="28" y="0" width="2" height="2" fillOpacity="0.142" />
|
||||||
|
<rect x="32" y="0" width="2" height="2" fillOpacity="0.288" />
|
||||||
|
<rect x="36" y="0" width="2" height="2" fillOpacity="0.144" />
|
||||||
|
<rect x="40" y="0" width="2" height="2" fillOpacity="0.313" />
|
||||||
|
<rect x="44" y="0" width="2" height="2" fillOpacity="0.214" />
|
||||||
|
<rect x="0" y="4" width="2" height="2" fillOpacity="0.4" />
|
||||||
|
<rect x="4" y="4" width="2" height="2" fillOpacity="0.13" />
|
||||||
|
<rect x="8" y="4" width="2" height="2" fillOpacity="0.498" />
|
||||||
|
<rect x="12" y="4" width="2" height="2" fillOpacity="0.473" />
|
||||||
|
<rect x="16" y="4" width="2" height="2" fillOpacity="0.386" />
|
||||||
|
<rect x="20" y="4" width="2" height="2" fillOpacity="0.248" />
|
||||||
|
<rect x="24" y="4" width="2" height="2" fillOpacity="0.168" />
|
||||||
|
<rect x="28" y="4" width="2" height="2" fillOpacity="0.193" />
|
||||||
|
<rect x="32" y="4" width="2" height="2" fillOpacity="0.528" />
|
||||||
|
<rect x="36" y="4" width="2" height="2" fillOpacity="0.307" />
|
||||||
|
<rect x="40" y="4" width="2" height="2" fillOpacity="0.314" />
|
||||||
|
<rect x="44" y="4" width="2" height="2" fillOpacity="0.444" />
|
||||||
|
<rect x="0" y="8" width="2" height="2" fillOpacity="0.33" />
|
||||||
|
<rect x="4" y="8" width="2" height="2" fillOpacity="0.438" />
|
||||||
|
<rect x="8" y="8" width="2" height="2" fillOpacity="0.477" />
|
||||||
|
<rect x="12" y="8" width="2" height="2" fillOpacity="0.434" />
|
||||||
|
<rect x="16" y="8" width="2" height="2" fillOpacity="0.47" />
|
||||||
|
<rect x="20" y="8" width="2" height="2" fillOpacity="0.383" />
|
||||||
|
<rect x="24" y="8" width="2" height="2" fillOpacity="0.514" />
|
||||||
|
<rect x="28" y="8" width="2" height="2" fillOpacity="0.287" />
|
||||||
|
<rect x="32" y="8" width="2" height="2" fillOpacity="0.346" />
|
||||||
|
<rect x="36" y="8" width="2" height="2" fillOpacity="0.52" />
|
||||||
|
<rect x="40" y="8" width="2" height="2" fillOpacity="0.524" />
|
||||||
|
<rect x="44" y="8" width="2" height="2" fillOpacity="0.126" />
|
||||||
|
<rect x="0" y="12" width="2" height="2" fillOpacity="0.259" />
|
||||||
|
<rect x="4" y="12" width="2" height="2" fillOpacity="0.281" />
|
||||||
|
<rect x="8" y="12" width="2" height="2" fillOpacity="0.311" />
|
||||||
|
<rect x="12" y="12" width="2" height="2" fillOpacity="0.212" />
|
||||||
|
<rect x="16" y="12" width="2" height="2" fillOpacity="0.524" />
|
||||||
|
<rect x="20" y="12" width="2" height="2" fillOpacity="0.353" />
|
||||||
|
<rect x="24" y="12" width="2" height="2" fillOpacity="0.34" />
|
||||||
|
<rect x="28" y="12" width="2" height="2" fillOpacity="0.417" />
|
||||||
|
<rect x="32" y="12" width="2" height="2" fillOpacity="0.258" />
|
||||||
|
<rect x="36" y="12" width="2" height="2" fillOpacity="0.158" />
|
||||||
|
<rect x="40" y="12" width="2" height="2" fillOpacity="0.388" />
|
||||||
|
<rect x="44" y="12" width="2" height="2" fillOpacity="0.184" />
|
||||||
|
<rect x="0" y="16" width="2" height="2" fillOpacity="0.491" />
|
||||||
|
<rect x="4" y="16" width="2" height="2" fillOpacity="0.417" />
|
||||||
|
<rect x="8" y="16" width="2" height="2" fillOpacity="0.231" />
|
||||||
|
<rect x="12" y="16" width="2" height="2" fillOpacity="0.209" />
|
||||||
|
<rect x="16" y="16" width="2" height="2" fillOpacity="0.305" />
|
||||||
|
<rect x="20" y="16" width="2" height="2" fillOpacity="0.287" />
|
||||||
|
<rect x="24" y="16" width="2" height="2" fillOpacity="0.353" />
|
||||||
|
<rect x="28" y="16" width="2" height="2" fillOpacity="0.513" />
|
||||||
|
<rect x="32" y="16" width="2" height="2" fillOpacity="0.483" />
|
||||||
|
<rect x="36" y="16" width="2" height="2" fillOpacity="0.455" />
|
||||||
|
<rect x="40" y="16" width="2" height="2" fillOpacity="0.483" />
|
||||||
|
<rect x="44" y="16" width="2" height="2" fillOpacity="0.189" />
|
||||||
|
<rect x="0" y="20" width="2" height="2" fillOpacity="0.264" />
|
||||||
|
<rect x="4" y="20" width="2" height="2" fillOpacity="0.295" />
|
||||||
|
<rect x="8" y="20" width="2" height="2" fillOpacity="0.51" />
|
||||||
|
<rect x="12" y="20" width="2" height="2" fillOpacity="0.305" />
|
||||||
|
<rect x="16" y="20" width="2" height="2" fillOpacity="0.438" />
|
||||||
|
<rect x="20" y="20" width="2" height="2" fillOpacity="0.289" />
|
||||||
|
<rect x="24" y="20" width="2" height="2" fillOpacity="0.322" />
|
||||||
|
<rect x="28" y="20" width="2" height="2" fillOpacity="0.196" />
|
||||||
|
<rect x="32" y="20" width="2" height="2" fillOpacity="0.486" />
|
||||||
|
<rect x="36" y="20" width="2" height="2" fillOpacity="0.388" />
|
||||||
|
<rect x="40" y="20" width="2" height="2" fillOpacity="0.489" />
|
||||||
|
<rect x="44" y="20" width="2" height="2" fillOpacity="0.208" />
|
||||||
|
<rect x="0" y="24" width="2" height="2" fillOpacity="0.239" />
|
||||||
|
<rect x="4" y="24" width="2" height="2" fillOpacity="0.463" />
|
||||||
|
<rect x="8" y="24" width="2" height="2" fillOpacity="0.539" />
|
||||||
|
<rect x="12" y="24" width="2" height="2" fillOpacity="0.408" />
|
||||||
|
<rect x="16" y="24" width="2" height="2" fillOpacity="0.527" />
|
||||||
|
<rect x="20" y="24" width="2" height="2" fillOpacity="0.357" />
|
||||||
|
<rect x="24" y="24" width="2" height="2" fillOpacity="0.362" />
|
||||||
|
<rect x="28" y="24" width="2" height="2" fillOpacity="0.151" />
|
||||||
|
<rect x="32" y="24" width="2" height="2" fillOpacity="0.324" />
|
||||||
|
<rect x="36" y="24" width="2" height="2" fillOpacity="0.347" />
|
||||||
|
<rect x="40" y="24" width="2" height="2" fillOpacity="0.299" />
|
||||||
|
<rect x="44" y="24" width="2" height="2" fillOpacity="0.127" />
|
||||||
|
<rect x="0" y="28" width="2" height="2" fillOpacity="0.476" />
|
||||||
|
<rect x="4" y="28" width="2" height="2" fillOpacity="0.461" />
|
||||||
|
<rect x="8" y="28" width="2" height="2" fillOpacity="0.377" />
|
||||||
|
<rect x="12" y="28" width="2" height="2" fillOpacity="0.415" />
|
||||||
|
<rect x="16" y="28" width="2" height="2" fillOpacity="0.217" />
|
||||||
|
<rect x="20" y="28" width="2" height="2" fillOpacity="0.297" />
|
||||||
|
<rect x="24" y="28" width="2" height="2" fillOpacity="0.258" />
|
||||||
|
<rect x="28" y="28" width="2" height="2" fillOpacity="0.296" />
|
||||||
|
<rect x="32" y="28" width="2" height="2" fillOpacity="0.276" />
|
||||||
|
<rect x="36" y="28" width="2" height="2" fillOpacity="0.182" />
|
||||||
|
<rect x="40" y="28" width="2" height="2" fillOpacity="0.422" />
|
||||||
|
<rect x="44" y="28" width="2" height="2" fillOpacity="0.392" />
|
||||||
|
<rect x="0" y="32" width="2" height="2" fillOpacity="0.273" />
|
||||||
|
<rect x="4" y="32" width="2" height="2" fillOpacity="0.143" />
|
||||||
|
<rect x="8" y="32" width="2" height="2" fillOpacity="0.338" />
|
||||||
|
<rect x="12" y="32" width="2" height="2" fillOpacity="0.264" />
|
||||||
|
<rect x="16" y="32" width="2" height="2" fillOpacity="0.218" />
|
||||||
|
<rect x="20" y="32" width="2" height="2" fillOpacity="0.29" />
|
||||||
|
<rect x="24" y="32" width="2" height="2" fillOpacity="0.336" />
|
||||||
|
<rect x="28" y="32" width="2" height="2" fillOpacity="0.313" />
|
||||||
|
<rect x="32" y="32" width="2" height="2" fillOpacity="0.514" />
|
||||||
|
<rect x="36" y="32" width="2" height="2" fillOpacity="0.289" />
|
||||||
|
<rect x="40" y="32" width="2" height="2" fillOpacity="0.25" />
|
||||||
|
<rect x="44" y="32" width="2" height="2" fillOpacity="0.507" />
|
||||||
|
<rect x="0" y="36" width="2" height="2" fillOpacity="0.346" />
|
||||||
|
<rect x="4" y="36" width="2" height="2" fillOpacity="0.208" />
|
||||||
|
<rect x="8" y="36" width="2" height="2" fillOpacity="0.313" />
|
||||||
|
<rect x="12" y="36" width="2" height="2" fillOpacity="0.365" />
|
||||||
|
<rect x="16" y="36" width="2" height="2" fillOpacity="0.491" />
|
||||||
|
<rect x="20" y="36" width="2" height="2" fillOpacity="0.121" />
|
||||||
|
<rect x="24" y="36" width="2" height="2" fillOpacity="0.472" />
|
||||||
|
<rect x="28" y="36" width="2" height="2" fillOpacity="0.281" />
|
||||||
|
<rect x="32" y="36" width="2" height="2" fillOpacity="0.297" />
|
||||||
|
<rect x="36" y="36" width="2" height="2" fillOpacity="0.33" />
|
||||||
|
<rect x="40" y="36" width="2" height="2" fillOpacity="0.463" />
|
||||||
|
<rect x="44" y="36" width="2" height="2" fillOpacity="0.447" />
|
||||||
|
<rect x="0" y="40" width="2" height="2" fillOpacity="0.217" />
|
||||||
|
<rect x="4" y="40" width="2" height="2" fillOpacity="0.467" />
|
||||||
|
<rect x="8" y="40" width="2" height="2" fillOpacity="0.297" />
|
||||||
|
<rect x="12" y="40" width="2" height="2" fillOpacity="0.303" />
|
||||||
|
<rect x="16" y="40" width="2" height="2" fillOpacity="0.497" />
|
||||||
|
<rect x="20" y="40" width="2" height="2" fillOpacity="0.201" />
|
||||||
|
<rect x="24" y="40" width="2" height="2" fillOpacity="0.5" />
|
||||||
|
<rect x="28" y="40" width="2" height="2" fillOpacity="0.458" />
|
||||||
|
<rect x="32" y="40" width="2" height="2" fillOpacity="0.165" />
|
||||||
|
<rect x="36" y="40" width="2" height="2" fillOpacity="0.36" />
|
||||||
|
<rect x="40" y="40" width="2" height="2" fillOpacity="0.329" />
|
||||||
|
<rect x="44" y="40" width="2" height="2" fillOpacity="0.419" />
|
||||||
|
<rect x="0" y="44" width="2" height="2" fillOpacity="0.47" />
|
||||||
|
<rect x="4" y="44" width="2" height="2" fillOpacity="0.161" />
|
||||||
|
<rect x="8" y="44" width="2" height="2" fillOpacity="0.191" />
|
||||||
|
<rect x="12" y="44" width="2" height="2" fillOpacity="0.341" />
|
||||||
|
<rect x="16" y="44" width="2" height="2" fillOpacity="0.279" />
|
||||||
|
<rect x="20" y="44" width="2" height="2" fillOpacity="0.387" />
|
||||||
|
<rect x="24" y="44" width="2" height="2" fillOpacity="0.173" />
|
||||||
|
<rect x="28" y="44" width="2" height="2" fillOpacity="0.537" />
|
||||||
|
<rect x="32" y="44" width="2" height="2" fillOpacity="0.218" />
|
||||||
|
<rect x="36" y="44" width="2" height="2" fillOpacity="0.368" />
|
||||||
|
<rect x="40" y="44" width="2" height="2" fillOpacity="0.5" />
|
||||||
|
<rect x="44" y="44" width="2" height="2" fillOpacity="0.414" />
|
||||||
|
</g>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<g mask={`url(#${maskId})`}>
|
||||||
|
<rect width={width} height={height} fill={`url(#${patternId})`} />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export function CardItem({ card }: { card: ICard }) {
|
||||||
|
return (
|
||||||
|
<FramePanel className="relative isolate flex flex-col gap-3">
|
||||||
|
{/* Card */}
|
||||||
|
<CardDotPatternRtl />
|
||||||
|
<div className="relative z-10 flex flex-col gap-3">
|
||||||
|
<span className="text-muted-foreground text-relaxed text-sm">
|
||||||
|
{card.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-foreground text-xl leading-tight font-semibold">
|
||||||
|
{card.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export interface ICard {
|
||||||
|
title: string
|
||||||
|
total: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CARDS: ICard[] = [
|
||||||
|
{
|
||||||
|
title: "Avg. Deal Size",
|
||||||
|
total: "$14,9M",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Due Deals",
|
||||||
|
total: "16",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Pending Invoices",
|
||||||
|
total: "$2.7M",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Active Clients",
|
||||||
|
total: "36",
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { CardGrid } from "./components/card-grid"
|
||||||
|
|
||||||
|
export function Page() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh w-full max-w-5xl items-center justify-center p-10">
|
||||||
|
<CardGrid />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
|
||||||
|
interface CatalogBoardToggleProps {
|
||||||
|
view: 'catalog' | 'board'
|
||||||
|
onViewChange: (view: 'catalog' | 'board') => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CatalogBoardToggle({
|
||||||
|
view,
|
||||||
|
onViewChange,
|
||||||
|
}: CatalogBoardToggleProps) {
|
||||||
|
if (view === 'board') {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onViewChange('catalog')}
|
||||||
|
>
|
||||||
|
К каталогу
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onViewChange('board')}
|
||||||
|
>
|
||||||
|
Доска
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,15 +1,7 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import {
|
import { ShieldCheckIcon } from 'lucide-react'
|
||||||
AlertTriangleIcon,
|
|
||||||
CheckCircle2Icon,
|
|
||||||
ShieldAlertIcon,
|
|
||||||
ShieldCheckIcon,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
|
||||||
import { KpiStatGrid, type KpiStatCard } from '@/components/reui-kit/kpi-stat-grid'
|
import { KpiStatGrid, type KpiStatCard } from '@/components/reui-kit/kpi-stat-grid'
|
||||||
|
|
||||||
const KPI_ICON_BG = ['bg-chart-1', 'bg-chart-2', 'bg-chart-3', 'bg-chart-4'] as const
|
|
||||||
|
|
||||||
const ACTIVE_STATUSES = new Set(['active', 'ok', 'synced'])
|
const ACTIVE_STATUSES = new Set(['active', 'ok', 'synced'])
|
||||||
const PROBLEM_STATUSES = new Set(['expired', 'error', 'conflict'])
|
const PROBLEM_STATUSES = new Set(['expired', 'error', 'conflict'])
|
||||||
|
|
||||||
@@ -52,49 +44,33 @@ export function CertKpiCards({
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'all',
|
id: 'all',
|
||||||
typeLabel: 'Мониторинг',
|
label: 'Всего',
|
||||||
title: 'Всего',
|
|
||||||
metricLabel: 'Проверенные хосты',
|
|
||||||
value: allCount,
|
value: allCount,
|
||||||
icon: <ShieldCheckIcon aria-hidden="true" />,
|
hint: 'Проверенные хосты',
|
||||||
iconBg: KPI_ICON_BG[0],
|
|
||||||
badge: <Badge variant="info-light">Все статусы</Badge>,
|
|
||||||
onSelect: () => onSelectTab('all'),
|
onSelect: () => onSelectTab('all'),
|
||||||
selected: activeTab === 'all',
|
selected: activeTab === 'all',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'active',
|
id: 'active',
|
||||||
typeLabel: 'Безопасность',
|
label: 'OK',
|
||||||
title: 'OK',
|
|
||||||
metricLabel: 'Активные сертификаты',
|
|
||||||
value: okCount,
|
value: okCount,
|
||||||
icon: <CheckCircle2Icon aria-hidden="true" />,
|
hint: 'Активные сертификаты',
|
||||||
iconBg: KPI_ICON_BG[1],
|
|
||||||
badge: <Badge variant="success-light">В норме</Badge>,
|
|
||||||
onSelect: () => onSelectTab('active'),
|
onSelect: () => onSelectTab('active'),
|
||||||
selected: activeTab === 'active',
|
selected: activeTab === 'active',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'warning',
|
id: 'warning',
|
||||||
typeLabel: 'Срок действия',
|
label: 'Предупреждение',
|
||||||
title: 'Предупреждение',
|
|
||||||
metricLabel: 'Истекают скоро',
|
|
||||||
value: warningCount,
|
value: warningCount,
|
||||||
icon: <AlertTriangleIcon aria-hidden="true" />,
|
hint: 'Истекают скоро',
|
||||||
iconBg: KPI_ICON_BG[2],
|
|
||||||
badge: <Badge variant="warning-light">Требуют внимания</Badge>,
|
|
||||||
onSelect: () => onSelectTab('warning'),
|
onSelect: () => onSelectTab('warning'),
|
||||||
selected: activeTab === 'warning',
|
selected: activeTab === 'warning',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'expired',
|
id: 'expired',
|
||||||
typeLabel: 'Риски',
|
label: 'Проблемы',
|
||||||
title: 'Проблемы',
|
|
||||||
metricLabel: 'Ошибка или истёк',
|
|
||||||
value: problemCount,
|
value: problemCount,
|
||||||
icon: <ShieldAlertIcon aria-hidden="true" />,
|
hint: 'Ошибка или истёк',
|
||||||
iconBg: KPI_ICON_BG[3],
|
|
||||||
badge: <Badge variant="destructive-light">Критично</Badge>,
|
|
||||||
onSelect: () => onSelectTab('expired'),
|
onSelect: () => onSelectTab('expired'),
|
||||||
selected: activeTab === 'expired',
|
selected: activeTab === 'expired',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
|
import { MoreHorizontalIcon, SearchIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
|
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@cfdm/ui/components/dropdown-menu'
|
||||||
|
|
||||||
|
export interface GroupCatalogRow {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
domainCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GROUP_TABS = [
|
||||||
|
{ id: 'all', label: 'Все' },
|
||||||
|
{ id: 'with_domains', label: 'С доменами' },
|
||||||
|
{ id: 'empty', label: 'Пустые' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export function groupTabFilter(item: GroupCatalogRow, tabId: string) {
|
||||||
|
if (tabId === 'with_domains') return item.domainCount > 0
|
||||||
|
if (tabId === 'empty') return item.domainCount === 0
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultGroupFilters() {
|
||||||
|
return [createFilter('name', 'contains', [''])]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGroupFilterFields() {
|
||||||
|
return useMemo<FilterFieldConfig[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
label: 'Название',
|
||||||
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
|
type: 'text',
|
||||||
|
className: 'w-52',
|
||||||
|
placeholder: 'Поиск…',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupFilterFieldValue(item: GroupCatalogRow, field: string) {
|
||||||
|
switch (field) {
|
||||||
|
case 'name':
|
||||||
|
return `${item.name} ${item.slug}`.toLowerCase()
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGroupColumns({
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
onEdit: (group: GroupCatalogRow) => void
|
||||||
|
onDelete: (group: GroupCatalogRow) => void
|
||||||
|
}) {
|
||||||
|
const columns = useMemo<ColumnDef<GroupCatalogRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Название" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="h-auto max-w-64 truncate p-0 font-medium"
|
||||||
|
nativeButton={false}
|
||||||
|
render={
|
||||||
|
<Link
|
||||||
|
to="/groups/$groupId"
|
||||||
|
params={{ groupId: String(row.original.id) }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row.original.name}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'slug',
|
||||||
|
accessorKey: 'slug',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Slug" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-muted-foreground font-mono text-xs">
|
||||||
|
{row.original.slug}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'domainCount',
|
||||||
|
accessorKey: 'domainCount',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Домены" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="tabular-nums">{row.original.domainCount}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
enableSorting: false,
|
||||||
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
aria-label="Действия"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
render={
|
||||||
|
<Link
|
||||||
|
to="/groups/$groupId"
|
||||||
|
params={{ groupId: String(row.original.id) }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Открыть
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onEdit(row.original)}>
|
||||||
|
Изменить
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => onDelete(row.original)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[onEdit, onDelete],
|
||||||
|
)
|
||||||
|
|
||||||
|
return { columns }
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
|
import { MoreHorizontalIcon, SearchIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
|
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||||
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import type { ServiceView } from '@/lib/schemas'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { Switch } from '@cfdm/ui/components/switch'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@cfdm/ui/components/dropdown-menu'
|
||||||
|
|
||||||
|
export interface ServiceCatalogRow {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
enabled: boolean
|
||||||
|
groupId: number | null
|
||||||
|
groupName: string | null
|
||||||
|
domainIds: number[]
|
||||||
|
service: ServiceView
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SERVICE_TABS = [
|
||||||
|
{ id: 'all', label: 'Все' },
|
||||||
|
{ id: 'enabled', label: 'Включены' },
|
||||||
|
{ id: 'disabled', label: 'Выключены' },
|
||||||
|
{ id: 'ungrouped', label: 'Без группы' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export function serviceTabFilter(item: ServiceCatalogRow, tabId: string) {
|
||||||
|
if (tabId === 'enabled') return item.enabled
|
||||||
|
if (tabId === 'disabled') return !item.enabled
|
||||||
|
if (tabId === 'ungrouped') return item.groupId == null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultServiceFilters() {
|
||||||
|
return [createFilter('name', 'contains', [''])]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServiceFilterFields() {
|
||||||
|
return useMemo<FilterFieldConfig[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
label: 'Название',
|
||||||
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
|
type: 'text',
|
||||||
|
className: 'w-52',
|
||||||
|
placeholder: 'Поиск…',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serviceFilterFieldValue(item: ServiceCatalogRow, field: string) {
|
||||||
|
switch (field) {
|
||||||
|
case 'name':
|
||||||
|
return `${item.name} ${item.slug} ${item.groupName ?? ''}`.toLowerCase()
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServiceColumns({
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onToggle,
|
||||||
|
togglingId,
|
||||||
|
}: {
|
||||||
|
onEdit: (service: ServiceView) => void
|
||||||
|
onDelete: (service: ServiceView) => void
|
||||||
|
onToggle: (serviceId: number, enabled: boolean) => void
|
||||||
|
togglingId: number | null
|
||||||
|
}) {
|
||||||
|
const columns = useMemo<ColumnDef<ServiceCatalogRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Название" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-foreground max-w-64 truncate text-left font-medium hover:underline"
|
||||||
|
onClick={() => onEdit(row.original.service)}
|
||||||
|
>
|
||||||
|
{row.original.name}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'group',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Группа" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{row.original.groupName ?? 'Без группы'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'enabled',
|
||||||
|
accessorKey: 'enabled',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Статус" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={row.original.enabled}
|
||||||
|
disabled={togglingId === row.original.id}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onToggle(row.original.id, Boolean(checked))
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
row.original.enabled ? 'Выключить сервис' : 'Включить сервис'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatusBadge
|
||||||
|
status={row.original.enabled ? 'active' : 'disabled'}
|
||||||
|
label={row.original.enabled ? 'Вкл' : 'Выкл'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
enableSorting: false,
|
||||||
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
aria-label="Действия"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => onEdit(row.original.service)}>
|
||||||
|
Изменить
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => onDelete(row.original.service)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[onEdit, onDelete, onToggle, togglingId],
|
||||||
|
)
|
||||||
|
|
||||||
|
return { columns }
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { Button } from "@cfdm/ui/components/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@cfdm/ui/components/card"
|
||||||
|
import { Input } from "@cfdm/ui/components/input"
|
||||||
|
import { Label } from "@cfdm/ui/components/label"
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "@cfdm/ui/components/tabs"
|
||||||
|
|
||||||
|
export function Pattern() {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full max-w-xs flex-col gap-6">
|
||||||
|
<Tabs defaultValue="account">
|
||||||
|
<TabsList variant="line" className="mb-3.5 w-full">
|
||||||
|
<TabsTrigger value="account">Account</TabsTrigger>
|
||||||
|
<TabsTrigger value="password">Password</TabsTrigger>
|
||||||
|
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="account">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">Account</CardTitle>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Update your account information.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="underline-name" className="text-sm">
|
||||||
|
Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="underline-name"
|
||||||
|
defaultValue="Alex Chen"
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="underline-email" className="text-sm">
|
||||||
|
Email
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="underline-email"
|
||||||
|
type="email"
|
||||||
|
defaultValue="[email protected]"
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="pt-3">
|
||||||
|
<Button size="sm">Save changes</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="password">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">Password</CardTitle>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Change your password here.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="underline-current" className="text-sm">
|
||||||
|
Current password
|
||||||
|
</Label>
|
||||||
|
<Input id="underline-current" type="password" className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="underline-new" className="text-sm">
|
||||||
|
New password
|
||||||
|
</Label>
|
||||||
|
<Input id="underline-new" type="password" className="h-9" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="pt-3">
|
||||||
|
<Button size="sm">Update password</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="settings">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">Settings</CardTitle>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Manage your preferences.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="underline-theme" className="text-sm">
|
||||||
|
Theme
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="underline-theme"
|
||||||
|
defaultValue="Light"
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="underline-language" className="text-sm">
|
||||||
|
Language
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="underline-language"
|
||||||
|
defaultValue="English"
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="pt-3">
|
||||||
|
<Button size="sm">Save settings</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from
|
|||||||
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
||||||
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
||||||
export { KpiStatGrid, type KpiStatCard, type OpsKpiCard } from './kpi-stat-grid'
|
export { KpiStatGrid, type KpiStatCard, type OpsKpiCard } from './kpi-stat-grid'
|
||||||
export { OpsDashboard, OpsDashboardHintLink } from './ops-dashboard'
|
export { OpsDashboard } from './ops-dashboard'
|
||||||
export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
|
export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
|
||||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||||
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
import { Item, ItemMedia } from '@cfdm/ui/components/item'
|
|
||||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||||
|
|
||||||
export interface KpiStatCard {
|
export interface KpiStatCard {
|
||||||
id: string
|
id: string
|
||||||
typeLabel: string
|
label: string
|
||||||
title: string
|
|
||||||
metricLabel: string
|
|
||||||
value: string | number
|
value: string | number
|
||||||
icon: ReactNode
|
hint?: string
|
||||||
iconBg?: string
|
to?: string
|
||||||
hint?: ReactNode
|
search?: Record<string, unknown>
|
||||||
detail?: ReactNode
|
|
||||||
badge?: ReactNode
|
|
||||||
onSelect?: () => void
|
onSelect?: () => void
|
||||||
selected?: boolean
|
selected?: boolean
|
||||||
}
|
}
|
||||||
@@ -31,75 +27,70 @@ interface KpiStatGridProps {
|
|||||||
skeletonCount?: number
|
skeletonCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const ICON_TILE_CLASS =
|
function KpiStatCardBody({ card }: { card: KpiStatCard }) {
|
||||||
'border-background flex size-10.5 items-center justify-center border-2 p-0 [background-image:radial-gradient(48.05%_48.05%_at_50%_5.95%,rgba(255,255,255,0.4)_0%,rgba(255,255,255,0)_100%)] shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5 [&_svg]:text-white'
|
return (
|
||||||
|
<div className="relative z-10 flex flex-col gap-3">
|
||||||
|
<span className="text-muted-foreground text-sm">{card.label}</span>
|
||||||
|
<span className="text-foreground text-xl leading-tight font-semibold tabular-nums">
|
||||||
|
{card.value}
|
||||||
|
</span>
|
||||||
|
{card.hint ? (
|
||||||
|
<span className="text-muted-foreground text-xs leading-snug">{card.hint}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function KpiStatCardItem({ card }: { card: KpiStatCard }) {
|
function KpiStatCardItem({ card }: { card: KpiStatCard }) {
|
||||||
const interactive = Boolean(card.onSelect)
|
const panelClass = cn(
|
||||||
const Panel = interactive ? 'button' : 'div'
|
'relative isolate flex h-full flex-col gap-3',
|
||||||
|
card.selected && 'ring-primary/30 bg-muted/30 ring-1',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (card.to) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={card.to}
|
||||||
|
search={card.search}
|
||||||
|
className="hover:bg-muted/40 focus-visible:ring-ring rounded-[calc(var(--frame-radius)-2px)] transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<KpiStatCardBody card={card} />
|
||||||
|
</FramePanel>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (card.onSelect) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={card.onSelect}
|
||||||
|
className="hover:bg-muted/40 focus-visible:ring-ring rounded-[calc(var(--frame-radius)-2px)] text-start transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<KpiStatCardBody card={card} />
|
||||||
|
</FramePanel>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel
|
<FramePanel className={panelClass}>
|
||||||
type={interactive ? 'button' : undefined}
|
<KpiStatCardBody card={card} />
|
||||||
onClick={card.onSelect}
|
</FramePanel>
|
||||||
className={cn(
|
|
||||||
'text-start',
|
|
||||||
interactive &&
|
|
||||||
'hover:bg-muted/40 focus-visible:ring-ring rounded-[calc(var(--frame-radius)-2px)] transition-colors focus-visible:ring-2 focus-visible:outline-none',
|
|
||||||
card.selected && 'bg-muted/30 ring-primary/30 ring-1',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FramePanel className="flex h-full flex-col items-start gap-4">
|
|
||||||
<div className="flex w-full items-center gap-2.5">
|
|
||||||
<Item className={cn('p-0', ICON_TILE_CLASS, card.iconBg ?? 'bg-chart-1')}>
|
|
||||||
<ItemMedia variant="icon" className="size-auto">
|
|
||||||
{card.icon}
|
|
||||||
</ItemMedia>
|
|
||||||
</Item>
|
|
||||||
<p className="text-muted-foreground min-w-0 flex-1 text-xs leading-tight">
|
|
||||||
{card.typeLabel}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="text-foreground text-2xl leading-none font-bold tabular-nums">
|
|
||||||
{card.value}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm leading-tight font-medium">{card.title}</div>
|
|
||||||
<div className="text-muted-foreground text-xs leading-snug">{card.metricLabel}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{card.badge || card.hint ? (
|
|
||||||
<div className="mt-auto flex flex-wrap items-center gap-2">
|
|
||||||
{card.badge}
|
|
||||||
{card.hint}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{card.detail ? (
|
|
||||||
<p className="text-muted-foreground text-xs leading-snug">{card.detail}</p>
|
|
||||||
) : null}
|
|
||||||
</FramePanel>
|
|
||||||
</Panel>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function KpiStatGridSkeleton({ count }: { count: number }) {
|
function KpiStatGridSkeleton({ count }: { count: number }) {
|
||||||
return (
|
return (
|
||||||
<Frame className="@container w-full">
|
<Frame className="@container w-full">
|
||||||
<div className="grid gap-1 @2xl:grid-cols-2 @5xl:grid-cols-4">
|
<div className="grid gap-1 @2xl:grid-cols-2 @4xl:grid-cols-4">
|
||||||
{Array.from({ length: count }).map((_, index) => (
|
{Array.from({ length: count }).map((_, index) => (
|
||||||
<FramePanel key={index} className="flex flex-col gap-4">
|
<FramePanel key={index} className="flex flex-col gap-3">
|
||||||
<div className="flex items-center gap-2.5">
|
<Skeleton className="h-4 w-20" />
|
||||||
<Skeleton className="size-10.5 rounded-lg" />
|
<Skeleton className="h-7 w-14" />
|
||||||
<Skeleton className="h-3 w-20" />
|
<Skeleton className="h-3 w-28" />
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Skeleton className="h-8 w-16" />
|
|
||||||
<Skeleton className="h-4 w-24" />
|
|
||||||
<Skeleton className="h-3 w-32" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-5 w-28" />
|
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -134,7 +125,7 @@ export function KpiStatGrid({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame className={cn('@container w-full', className)}>
|
<Frame className={cn('@container w-full', className)}>
|
||||||
<div className="grid gap-1 @2xl:grid-cols-2 @5xl:grid-cols-4">
|
<div className="grid gap-1 @2xl:grid-cols-2 @4xl:grid-cols-4">
|
||||||
{cards.map((card) => (
|
{cards.map((card) => (
|
||||||
<KpiStatCardItem key={card.id} card={card} />
|
<KpiStatCardItem key={card.id} card={card} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useRef, type ReactNode } from 'react'
|
import { useEffect, useRef, type ReactNode } from 'react'
|
||||||
import { Link } from '@tanstack/react-router'
|
|
||||||
import {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
FrameDescription,
|
FrameDescription,
|
||||||
@@ -7,6 +6,7 @@ import {
|
|||||||
FramePanel,
|
FramePanel,
|
||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
|
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||||
import { debugAgentLog } from '@/lib/debug-agent-log'
|
import { debugAgentLog } from '@/lib/debug-agent-log'
|
||||||
import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid'
|
import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid'
|
||||||
|
|
||||||
@@ -18,6 +18,24 @@ interface OpsDashboardProps {
|
|||||||
kpiCards: OpsKpiCard[]
|
kpiCards: OpsKpiCard[]
|
||||||
charts: ReactNode
|
charts: ReactNode
|
||||||
queue: ReactNode
|
queue: ReactNode
|
||||||
|
isLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function OpsDashboardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="text-foreground @container mx-auto flex w-full max-w-7xl flex-col gap-2 md:gap-3">
|
||||||
|
<header className="px-1">
|
||||||
|
<Skeleton className="h-7 w-56" />
|
||||||
|
<Skeleton className="mt-2 h-4 w-80 max-w-full" />
|
||||||
|
</header>
|
||||||
|
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
|
||||||
|
<div className="grid gap-2 @3xl:grid-cols-2">
|
||||||
|
<Skeleton className="h-64 w-full rounded-xl" />
|
||||||
|
<Skeleton className="h-64 w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-48 w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OpsDashboard({
|
export function OpsDashboard({
|
||||||
@@ -26,10 +44,12 @@ export function OpsDashboard({
|
|||||||
kpiCards,
|
kpiCards,
|
||||||
charts,
|
charts,
|
||||||
queue,
|
queue,
|
||||||
|
isLoading = false,
|
||||||
}: OpsDashboardProps) {
|
}: OpsDashboardProps) {
|
||||||
const rootRef = useRef<HTMLDivElement>(null)
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isLoading) return
|
||||||
const el = rootRef.current
|
const el = rootRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
debugAgentLog(
|
debugAgentLog(
|
||||||
@@ -42,12 +62,16 @@ export function OpsDashboard({
|
|||||||
},
|
},
|
||||||
'A',
|
'A',
|
||||||
)
|
)
|
||||||
}, [])
|
}, [isLoading])
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <OpsDashboardSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
className="text-foreground @container mx-auto flex w-full max-w-7xl flex-col gap-4 md:gap-6"
|
className="text-foreground @container mx-auto flex w-full max-w-7xl flex-col gap-2 md:gap-3"
|
||||||
>
|
>
|
||||||
<header className="px-1">
|
<header className="px-1">
|
||||||
<div className="flex flex-col gap-px">
|
<div className="flex flex-col gap-px">
|
||||||
@@ -64,7 +88,7 @@ export function OpsDashboard({
|
|||||||
|
|
||||||
<section
|
<section
|
||||||
aria-label="Аналитика"
|
aria-label="Аналитика"
|
||||||
className="grid min-w-0 auto-rows-fr items-start gap-4 @3xl:grid-cols-2"
|
className="grid min-w-0 auto-rows-fr items-start gap-2 @3xl:grid-cols-2"
|
||||||
>
|
>
|
||||||
{charts}
|
{charts}
|
||||||
</section>
|
</section>
|
||||||
@@ -83,23 +107,3 @@ export function OpsDashboard({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OpsDashboardHintLink({
|
|
||||||
to,
|
|
||||||
search,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
to: string
|
|
||||||
search?: Record<string, unknown>
|
|
||||||
children: ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to={to}
|
|
||||||
search={search}
|
|
||||||
className="text-primary text-sm font-medium hover:underline"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import { Separator } from '@cfdm/ui/components/separator'
|
|||||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
|
||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { applyFiltersToData } from './filter-utils'
|
import { applyFiltersToData } from './filter-utils'
|
||||||
import { debugAgentLog } from '@/lib/debug-agent-log'
|
import { debugAgentLog } from '@/lib/debug-agent-log'
|
||||||
@@ -290,24 +289,15 @@ export function ResourcePage<T extends object>({
|
|||||||
<FramePanel className="p-0 shadow-none!">
|
<FramePanel className="p-0 shadow-none!">
|
||||||
{tabs && tabs.length > 0 ? (
|
{tabs && tabs.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
{/* Line tabs как @reui/data-grid-filtering-2: muted count + underline */}
|
{/* Line tabs: c-tabs-2 + filtering-2 (muted count + underline) */}
|
||||||
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
|
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
|
||||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||||
<TabsList
|
<TabsList variant="line" className="gap-5">
|
||||||
variant="line"
|
|
||||||
className="h-auto gap-5 bg-transparent p-0"
|
|
||||||
>
|
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
value={tab.id}
|
value={tab.id}
|
||||||
className={cn(
|
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3"
|
||||||
'h-auto flex-none gap-2 rounded-none bg-transparent px-0 pb-3 text-sm shadow-none',
|
|
||||||
'text-muted-foreground hover:text-foreground',
|
|
||||||
'data-active:bg-transparent data-active:text-foreground data-active:shadow-none',
|
|
||||||
'dark:data-active:border-transparent dark:data-active:bg-transparent',
|
|
||||||
'after:bottom-0 after:h-0.5',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<span>{tab.label}</span>
|
<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">
|
<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">
|
||||||
|
|||||||
@@ -404,13 +404,15 @@ export function ServiceEditSheet({
|
|||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
className="flex w-full flex-col gap-4"
|
className="flex w-full flex-col gap-4"
|
||||||
>
|
>
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList variant="line" className="w-full gap-5">
|
||||||
<TabsTrigger value="general">Основное</TabsTrigger>
|
<TabsTrigger value="general" className="px-0 pb-2">
|
||||||
<TabsTrigger value="bindings">
|
Основное
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="bindings" className="gap-2 px-0 pb-2">
|
||||||
Привязки
|
Привязки
|
||||||
{bindings.length > 0 ? (
|
{bindings.length > 0 ? (
|
||||||
<span className="text-muted-foreground tabular-nums">
|
<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">
|
||||||
({bindings.length})
|
{bindings.length}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ function DomainsPage() {
|
|||||||
const primaryAction = (
|
const primaryAction = (
|
||||||
<>
|
<>
|
||||||
<Button type="button" variant="outline" nativeButton={false} render={<Link to="/groups" />}>
|
<Button type="button" variant="outline" nativeButton={false} render={<Link to="/groups" />}>
|
||||||
Канбан групп
|
Группы
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" onClick={() => setSheetOpen(true)}>
|
<Button type="button" onClick={() => setSheetOpen(true)}>
|
||||||
Импортировать домен
|
Импортировать домен
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { FolderTreeIcon } from 'lucide-react'
|
import { FolderTreeIcon } from 'lucide-react'
|
||||||
@@ -17,11 +17,22 @@ import { PageShell } from '@/components/page-shell'
|
|||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { DomainGroupEditSheet } from '@/components/domain-group-edit-sheet'
|
import { DomainGroupEditSheet } from '@/components/domain-group-edit-sheet'
|
||||||
import { KanbanBoard, KanbanBoardSkeleton } from '@/components/reui-kit'
|
import { CatalogBoardToggle } from '@/components/catalog-board-toggle'
|
||||||
|
import {
|
||||||
|
createDefaultGroupFilters,
|
||||||
|
GROUP_TABS,
|
||||||
|
groupFilterFieldValue,
|
||||||
|
groupTabFilter,
|
||||||
|
useGroupColumns,
|
||||||
|
useGroupFilterFields,
|
||||||
|
type GroupCatalogRow,
|
||||||
|
} from '@/components/columns/groups-columns'
|
||||||
|
import { ResourcePage, KanbanBoard, KanbanBoardSkeleton } from '@/components/reui-kit'
|
||||||
import { DomainKanbanCard } from '@/components/kanban/domain-kanban-card'
|
import { DomainKanbanCard } from '@/components/kanban/domain-kanban-card'
|
||||||
import { useDomainGroupsKanban } from '@/hooks/use-domain-groups-kanban'
|
import { useDomainGroupsKanban } from '@/hooks/use-domain-groups-kanban'
|
||||||
import { groupColumnId } from '@/components/board/column-ids'
|
import { groupColumnId } from '@/components/board/column-ids'
|
||||||
import { UNGROUPED_COLUMN_ID } from '@/components/groups-board/column-ids'
|
import { UNGROUPED_COLUMN_ID } from '@/components/groups-board/column-ids'
|
||||||
|
import type { Filter } from '@/components/reui/filters'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
|
||||||
const GROUP_DOT_COLORS = [
|
const GROUP_DOT_COLORS = [
|
||||||
@@ -36,6 +47,8 @@ const GROUP_DOT_COLORS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/groups')({
|
export const Route = createFileRoute('/_auth/groups')({
|
||||||
|
validateSearch: (search: Record<string, unknown>): { view?: 'board' } =>
|
||||||
|
search.view === 'board' ? { view: 'board' } : {},
|
||||||
loader: ({ context: { queryClient } }) =>
|
loader: ({ context: { queryClient } }) =>
|
||||||
Promise.all([
|
Promise.all([
|
||||||
queryClient.ensureQueryData(groupsQueryOptions()),
|
queryClient.ensureQueryData(groupsQueryOptions()),
|
||||||
@@ -46,9 +59,13 @@ export const Route = createFileRoute('/_auth/groups')({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function GroupsPage() {
|
function GroupsPage() {
|
||||||
|
const { view: viewParam } = Route.useSearch()
|
||||||
|
const view = viewParam === 'board' ? 'board' : 'catalog'
|
||||||
|
const navigate = useNavigate({ from: Route.fullPath })
|
||||||
const [createSheetOpen, setCreateSheetOpen] = useState(false)
|
const [createSheetOpen, setCreateSheetOpen] = useState(false)
|
||||||
const [editingGroup, setEditingGroup] = useState<Group | null>(null)
|
const [editingGroup, setEditingGroup] = useState<Group | null>(null)
|
||||||
const [deletingGroup, setDeletingGroup] = useState<Group | null>(null)
|
const [deletingGroup, setDeletingGroup] = useState<Group | null>(null)
|
||||||
|
const [filters, setFilters] = useState<Filter[]>(() => createDefaultGroupFilters())
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -66,6 +83,32 @@ function GroupsPage() {
|
|||||||
domains,
|
domains,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const catalogRows = useMemo<GroupCatalogRow[]>(() => {
|
||||||
|
return (groups ?? []).map((group) => ({
|
||||||
|
id: group.id,
|
||||||
|
name: group.name,
|
||||||
|
slug: group.slug,
|
||||||
|
domainCount: (domains ?? []).filter((d) => d.group_id === group.id).length,
|
||||||
|
}))
|
||||||
|
}, [groups, domains])
|
||||||
|
|
||||||
|
const groupById = useMemo(() => {
|
||||||
|
const map = new Map((groups ?? []).map((g) => [g.id, g]))
|
||||||
|
return map
|
||||||
|
}, [groups])
|
||||||
|
|
||||||
|
const filterFields = useGroupFilterFields()
|
||||||
|
const { columns } = useGroupColumns({
|
||||||
|
onEdit: (row) => {
|
||||||
|
const group = groupById.get(row.id)
|
||||||
|
if (group) setEditingGroup(group)
|
||||||
|
},
|
||||||
|
onDelete: (row) => {
|
||||||
|
const group = groupById.get(row.id)
|
||||||
|
if (group) setDeletingGroup(group)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const serviceLabelsByDomain = useMemo(() => {
|
const serviceLabelsByDomain = useMemo(() => {
|
||||||
const map = new Map<number, string[]>()
|
const map = new Map<number, string[]>()
|
||||||
for (const binding of bindings ?? []) {
|
for (const binding of bindings ?? []) {
|
||||||
@@ -98,11 +141,14 @@ function GroupsPage() {
|
|||||||
]
|
]
|
||||||
}, [groups])
|
}, [groups])
|
||||||
|
|
||||||
const isEmpty = useMemo(() => {
|
function setView(next: 'catalog' | 'board') {
|
||||||
if (!groups || !domains) return true
|
navigate({
|
||||||
if (domains.length > 0) return false
|
search: (prev) => ({
|
||||||
return groups.length === 0
|
...prev,
|
||||||
}, [groups, domains])
|
view: next === 'board' ? 'board' : undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function invalidateAll() {
|
function invalidateAll() {
|
||||||
queryClient.invalidateQueries({ queryKey: groupKeys.all })
|
queryClient.invalidateQueries({ queryKey: groupKeys.all })
|
||||||
@@ -147,10 +193,31 @@ function GroupsPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const createButton = (
|
||||||
|
<Button type="button" onClick={() => setCreateSheetOpen(true)}>
|
||||||
|
Создать группу
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<KanbanBoardSkeleton columns={3} />
|
{view === 'board' ? (
|
||||||
|
<KanbanBoardSkeleton columns={3} />
|
||||||
|
) : (
|
||||||
|
<ResourcePage
|
||||||
|
title="Группы"
|
||||||
|
description="Каталог групп доменов"
|
||||||
|
isLoading
|
||||||
|
filterFields={[]}
|
||||||
|
filters={[]}
|
||||||
|
onFiltersChange={() => {}}
|
||||||
|
getFilterFieldValue={() => ''}
|
||||||
|
columns={[]}
|
||||||
|
data={[]}
|
||||||
|
getRowId={() => ''}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -173,36 +240,66 @@ function GroupsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
{isEmpty ? (
|
{view === 'board' ? (
|
||||||
<EmptyState
|
(groups?.length ?? 0) === 0 && (domains?.length ?? 0) === 0 ? (
|
||||||
icon={FolderTreeIcon}
|
<EmptyState
|
||||||
title="Группы не найдены"
|
icon={FolderTreeIcon}
|
||||||
description="Создайте группу и назначьте домены при импорте или перетаскиванием на доске."
|
title="Группы не найдены"
|
||||||
action={
|
description="Создайте группу, чтобы организовать домены."
|
||||||
<Button type="button" onClick={() => setCreateSheetOpen(true)}>
|
action={
|
||||||
Добавить группу
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
</Button>
|
{createButton}
|
||||||
}
|
<CatalogBoardToggle view="board" onViewChange={setView} />
|
||||||
/>
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<KanbanBoard
|
||||||
|
title="Распределение доменов"
|
||||||
|
description="Перетащите домен в группу или создайте новую группу"
|
||||||
|
columns={columnConfigs}
|
||||||
|
value={kanbanValue}
|
||||||
|
onValueChange={handleKanbanValueChange}
|
||||||
|
toolbarActions={
|
||||||
|
<>
|
||||||
|
{createButton}
|
||||||
|
<CatalogBoardToggle view="board" onViewChange={setView} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
renderCard={(domain, ctx) => (
|
||||||
|
<DomainKanbanCard
|
||||||
|
domain={domain}
|
||||||
|
serviceLabels={serviceLabelsByDomain.get(domain.id)}
|
||||||
|
isOverlay={ctx.isOverlay}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<KanbanBoard
|
<ResourcePage
|
||||||
title="Группы доменов"
|
title="Группы"
|
||||||
description="Перетащите домен в группу или создайте новую группу для организации зон"
|
description="Каталог групп доменов"
|
||||||
columns={columnConfigs}
|
tabs={GROUP_TABS.map((tab) => ({ ...tab }))}
|
||||||
value={kanbanValue}
|
tabFilter={groupTabFilter}
|
||||||
onValueChange={handleKanbanValueChange}
|
filterFields={filterFields}
|
||||||
toolbarActions={
|
filters={filters}
|
||||||
<Button type="button" onClick={() => setCreateSheetOpen(true)}>
|
onFiltersChange={setFilters}
|
||||||
Добавить группу
|
onClearFilters={() => setFilters(createDefaultGroupFilters())}
|
||||||
</Button>
|
getFilterFieldValue={groupFilterFieldValue}
|
||||||
|
columns={columns}
|
||||||
|
data={catalogRows}
|
||||||
|
getRowId={(row) => String(row.id)}
|
||||||
|
primaryAction={
|
||||||
|
<>
|
||||||
|
{createButton}
|
||||||
|
<CatalogBoardToggle view="catalog" onViewChange={setView} />
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
renderCard={(domain, ctx) => (
|
emptyState={{
|
||||||
<DomainKanbanCard
|
title: 'Группы не найдены',
|
||||||
domain={domain}
|
description: 'Создайте группу для организации доменов.',
|
||||||
serviceLabels={serviceLabelsByDomain.get(domain.id)}
|
action: createButton,
|
||||||
isOverlay={ctx.isOverlay}
|
}}
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ import { useMemo, useState, useEffect } from 'react'
|
|||||||
import {
|
import {
|
||||||
AlertTriangleIcon,
|
AlertTriangleIcon,
|
||||||
FolderTreeIcon,
|
FolderTreeIcon,
|
||||||
GlobeIcon,
|
|
||||||
ServerIcon,
|
|
||||||
ShieldCheckIcon,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
certificatesQueryOptions,
|
certificatesQueryOptions,
|
||||||
@@ -20,7 +17,7 @@ import {
|
|||||||
CertStatusChart,
|
CertStatusChart,
|
||||||
GroupDomainsChart,
|
GroupDomainsChart,
|
||||||
OpsDashboard,
|
OpsDashboard,
|
||||||
OpsDashboardHintLink,
|
type KpiStatCard,
|
||||||
} from '@/components/reui-kit'
|
} from '@/components/reui-kit'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import {
|
import {
|
||||||
@@ -44,8 +41,6 @@ export const Route = createFileRoute('/_auth/')({
|
|||||||
component: DashboardPage,
|
component: DashboardPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
const KPI_ICON_BG = ['bg-chart-1', 'bg-chart-2', 'bg-chart-3', 'bg-chart-4']
|
|
||||||
|
|
||||||
function countByStatus(summary: [string, number][] | undefined, statuses: string[]) {
|
function countByStatus(summary: [string, number][] | undefined, statuses: string[]) {
|
||||||
if (!summary) return 0
|
if (!summary) return 0
|
||||||
return summary
|
return summary
|
||||||
@@ -64,9 +59,6 @@ function DashboardPage() {
|
|||||||
(serviceData?.groups.reduce((sum, g) => sum + g.services.length, 0) ?? 0) +
|
(serviceData?.groups.reduce((sum, g) => sum + g.services.length, 0) ?? 0) +
|
||||||
(serviceData?.ungrouped.length ?? 0)
|
(serviceData?.ungrouped.length ?? 0)
|
||||||
|
|
||||||
const groupedServiceCount =
|
|
||||||
serviceData?.groups.reduce((sum, g) => sum + g.services.length, 0) ?? 0
|
|
||||||
|
|
||||||
const isLoading = domainsLoading || summaryLoading
|
const isLoading = domainsLoading || summaryLoading
|
||||||
|
|
||||||
const statusChartData = useMemo(
|
const statusChartData = useMemo(
|
||||||
@@ -129,77 +121,63 @@ function DashboardPage() {
|
|||||||
)
|
)
|
||||||
}, [isLoading, summary, statusChartData, groupChartData, domains, groups])
|
}, [isLoading, summary, statusChartData, groupChartData, domains, groups])
|
||||||
|
|
||||||
const kpiCards = [
|
const kpiCards = useMemo<KpiStatCard[]>(
|
||||||
{
|
() => [
|
||||||
id: 'domains',
|
{
|
||||||
typeLabel: 'Инфраструктура',
|
id: 'domains',
|
||||||
title: 'Домены',
|
label: 'Домены',
|
||||||
metricLabel: 'Импортированные зоны',
|
value: domains?.length ?? 0,
|
||||||
value: domains?.length ?? 0,
|
hint:
|
||||||
detail:
|
ungroupedCount > 0
|
||||||
ungroupedCount > 0
|
? `${ungroupedCount} без группы`
|
||||||
? `${ungroupedCount} без группы`
|
: 'Все в группах',
|
||||||
: 'Все домены распределены по группам',
|
to: '/domains',
|
||||||
icon: <GlobeIcon aria-hidden="true" />,
|
},
|
||||||
iconBg: KPI_ICON_BG[0],
|
{
|
||||||
hint: <OpsDashboardHintLink to="/domains">Управление</OpsDashboardHintLink>,
|
id: 'groups',
|
||||||
},
|
label: 'Группы',
|
||||||
{
|
value: groups?.length ?? 0,
|
||||||
id: 'groups',
|
hint: `${groupChartData.filter((g) => g.count > 0).length} с зонами`,
|
||||||
typeLabel: 'Организация',
|
to: '/groups',
|
||||||
title: 'Группы',
|
},
|
||||||
metricLabel: 'Группы доменов',
|
{
|
||||||
value: groups?.length ?? 0,
|
id: 'services',
|
||||||
detail: `${groupChartData.filter((g) => g.count > 0).length} с активными зонами`,
|
label: 'Сервисы',
|
||||||
icon: <FolderTreeIcon aria-hidden="true" />,
|
value: serviceCount,
|
||||||
iconBg: KPI_ICON_BG[1],
|
hint: serviceData?.ungrouped.length
|
||||||
hint: <OpsDashboardHintLink to="/groups">Канбан</OpsDashboardHintLink>,
|
? `${serviceData.ungrouped.length} без группы`
|
||||||
},
|
: undefined,
|
||||||
{
|
to: '/services',
|
||||||
id: 'services',
|
search: { domainId: undefined },
|
||||||
typeLabel: 'Операции',
|
},
|
||||||
title: 'Сервисы',
|
{
|
||||||
metricLabel: 'Привязки и сервисы',
|
id: 'certs',
|
||||||
value: serviceCount,
|
label: 'Сертификаты',
|
||||||
detail:
|
value: certs?.length ?? 0,
|
||||||
serviceData?.ungrouped.length
|
hint:
|
||||||
? `${serviceData.ungrouped.length} без группы · ${groupedServiceCount} в группах`
|
certWarnings > 0
|
||||||
: `${groupedServiceCount} в группах`,
|
? `${certOk} в норме · ${certWarnings} внимания`
|
||||||
icon: <ServerIcon aria-hidden="true" />,
|
: `${certOk} в норме`,
|
||||||
iconBg: KPI_ICON_BG[2],
|
to: '/certificates',
|
||||||
hint: (
|
},
|
||||||
<OpsDashboardHintLink to="/services" search={{ domainId: undefined }}>
|
],
|
||||||
Управление
|
[
|
||||||
</OpsDashboardHintLink>
|
domains?.length,
|
||||||
),
|
ungroupedCount,
|
||||||
},
|
groups?.length,
|
||||||
{
|
groupChartData,
|
||||||
id: 'certs',
|
serviceCount,
|
||||||
typeLabel: 'Безопасность',
|
serviceData?.ungrouped.length,
|
||||||
title: 'Сертификаты',
|
certs?.length,
|
||||||
metricLabel: 'Мониторинг TLS',
|
certWarnings,
|
||||||
value: certs?.length ?? 0,
|
certOk,
|
||||||
detail:
|
],
|
||||||
certWarnings > 0
|
)
|
||||||
? `${certOk} в норме · ${certWarnings} требуют внимания`
|
|
||||||
: `${certOk} в норме`,
|
|
||||||
icon: <ShieldCheckIcon aria-hidden="true" />,
|
|
||||||
iconBg: KPI_ICON_BG[3],
|
|
||||||
hint: <OpsDashboardHintLink to="/certificates">Мониторинг</OpsDashboardHintLink>,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<PageShell>
|
|
||||||
<div className="text-muted-foreground text-sm">Загрузка панели…</div>
|
|
||||||
</PageShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<OpsDashboard
|
<OpsDashboard
|
||||||
|
isLoading={isLoading}
|
||||||
kpiCards={kpiCards}
|
kpiCards={kpiCards}
|
||||||
charts={
|
charts={
|
||||||
<>
|
<>
|
||||||
@@ -208,7 +186,7 @@ function DashboardPage() {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
queue={
|
queue={
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-3 lg:grid-cols-2">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AlertTriangleIcon
|
<AlertTriangleIcon
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { ServerIcon } from 'lucide-react'
|
import { ServerIcon } from 'lucide-react'
|
||||||
@@ -26,18 +26,35 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
|
|||||||
import { ServiceEditSheet } from '@/components/service-edit-sheet'
|
import { ServiceEditSheet } from '@/components/service-edit-sheet'
|
||||||
import { ServiceGroupEditSheet } from '@/components/service-group-edit-sheet'
|
import { ServiceGroupEditSheet } from '@/components/service-group-edit-sheet'
|
||||||
import { ServicesBulkToolbar } from '@/components/services-board/services-bulk-toolbar'
|
import { ServicesBulkToolbar } from '@/components/services-board/services-bulk-toolbar'
|
||||||
|
import { CatalogBoardToggle } from '@/components/catalog-board-toggle'
|
||||||
|
import {
|
||||||
|
createDefaultServiceFilters,
|
||||||
|
SERVICE_TABS,
|
||||||
|
serviceFilterFieldValue,
|
||||||
|
serviceTabFilter,
|
||||||
|
useServiceColumns,
|
||||||
|
useServiceFilterFields,
|
||||||
|
type ServiceCatalogRow,
|
||||||
|
} from '@/components/columns/services-columns'
|
||||||
import { useServicesKanban } from '@/hooks/use-services-kanban'
|
import { useServicesKanban } from '@/hooks/use-services-kanban'
|
||||||
import { useServicesSelection } from '@/hooks/use-services-selection'
|
import { useServicesSelection } from '@/hooks/use-services-selection'
|
||||||
import { KanbanBoard, KanbanBoardSkeleton } from '@/components/reui-kit'
|
import {
|
||||||
|
ResourcePage,
|
||||||
|
KanbanBoard,
|
||||||
|
KanbanBoardSkeleton,
|
||||||
|
} from '@/components/reui-kit'
|
||||||
import { ServiceKanbanCard } from '@/components/kanban/service-kanban-card'
|
import { ServiceKanbanCard } from '@/components/kanban/service-kanban-card'
|
||||||
|
import type { Filter } from '@/components/reui/filters'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/services')({
|
export const Route = createFileRoute('/_auth/services')({
|
||||||
validateSearch: (search: Record<string, unknown>) => ({
|
validateSearch: (
|
||||||
domainId:
|
search: Record<string, unknown>,
|
||||||
search.domainId != null && search.domainId !== ''
|
): { domainId?: number; view?: 'board' } => ({
|
||||||
? Number(search.domainId)
|
...(search.domainId != null && search.domainId !== ''
|
||||||
: undefined,
|
? { domainId: Number(search.domainId) }
|
||||||
|
: {}),
|
||||||
|
...(search.view === 'board' ? { view: 'board' as const } : {}),
|
||||||
}),
|
}),
|
||||||
loader: ({ context: { queryClient } }) =>
|
loader: ({ context: { queryClient } }) =>
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -76,8 +93,53 @@ const GROUP_DOT_COLORS = [
|
|||||||
'bg-destructive',
|
'bg-destructive',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function flattenServices(
|
||||||
|
data: ServiceGroupsResponse | undefined,
|
||||||
|
domainId?: number,
|
||||||
|
): ServiceCatalogRow[] {
|
||||||
|
if (!data) return []
|
||||||
|
|
||||||
|
const rows: ServiceCatalogRow[] = []
|
||||||
|
|
||||||
|
for (const group of data.groups) {
|
||||||
|
for (const service of group.services) {
|
||||||
|
const domainIds = service.domains.map((d) => d.domain_id)
|
||||||
|
if (domainId != null && !domainIds.includes(domainId)) continue
|
||||||
|
rows.push({
|
||||||
|
id: service.id,
|
||||||
|
name: service.name,
|
||||||
|
slug: service.slug,
|
||||||
|
enabled: service.enabled,
|
||||||
|
groupId: group.id,
|
||||||
|
groupName: group.name,
|
||||||
|
domainIds,
|
||||||
|
service,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const service of data.ungrouped) {
|
||||||
|
const domainIds = service.domains.map((d) => d.domain_id)
|
||||||
|
if (domainId != null && !domainIds.includes(domainId)) continue
|
||||||
|
rows.push({
|
||||||
|
id: service.id,
|
||||||
|
name: service.name,
|
||||||
|
slug: service.slug,
|
||||||
|
enabled: service.enabled,
|
||||||
|
groupId: null,
|
||||||
|
groupName: null,
|
||||||
|
domainIds,
|
||||||
|
service,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
function ServicesPage() {
|
function ServicesPage() {
|
||||||
const { domainId } = Route.useSearch()
|
const { domainId, view: viewParam } = Route.useSearch()
|
||||||
|
const view = viewParam === 'board' ? 'board' : 'catalog'
|
||||||
|
const navigate = useNavigate({ from: Route.fullPath })
|
||||||
const [createSheetOpen, setCreateSheetOpen] = useState(false)
|
const [createSheetOpen, setCreateSheetOpen] = useState(false)
|
||||||
const [createGroupSheetOpen, setCreateGroupSheetOpen] = useState(false)
|
const [createGroupSheetOpen, setCreateGroupSheetOpen] = useState(false)
|
||||||
const [defaultGroupId, setDefaultGroupId] = useState<number | null>(null)
|
const [defaultGroupId, setDefaultGroupId] = useState<number | null>(null)
|
||||||
@@ -90,6 +152,7 @@ function ServicesPage() {
|
|||||||
const [deletingGroupId, setDeletingGroupId] = useState<number | null>(null)
|
const [deletingGroupId, setDeletingGroupId] = useState<number | null>(null)
|
||||||
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
|
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
|
||||||
const [bulkToggling, setBulkToggling] = useState(false)
|
const [bulkToggling, setBulkToggling] = useState(false)
|
||||||
|
const [filters, setFilters] = useState<Filter[]>(() => createDefaultServiceFilters())
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const selection = useServicesSelection()
|
const selection = useServicesSelection()
|
||||||
const {
|
const {
|
||||||
@@ -118,21 +181,21 @@ function ServicesPage() {
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const groups = useMemo(() => data?.groups ?? [], [data?.groups])
|
const groups = useMemo(() => data?.groups ?? [], [data?.groups])
|
||||||
|
const catalogRows = useMemo(
|
||||||
|
() => flattenServices(data, domainId),
|
||||||
|
[data, domainId],
|
||||||
|
)
|
||||||
|
|
||||||
const isEmpty = useMemo(() => {
|
const filterFields = useServiceFilterFields()
|
||||||
if (!data) return true
|
|
||||||
if (domainId) {
|
function setView(next: 'catalog' | 'board') {
|
||||||
return (
|
navigate({
|
||||||
board.columns.length === 0 ||
|
search: (prev) => ({
|
||||||
board.columns.every((column) => column.items.length === 0)
|
...prev,
|
||||||
)
|
view: next === 'board' ? 'board' : undefined,
|
||||||
}
|
}),
|
||||||
const hasServices =
|
})
|
||||||
data.groups.some((group) => group.services.length > 0) ||
|
}
|
||||||
data.ungrouped.length > 0
|
|
||||||
if (hasServices) return false
|
|
||||||
return data.groups.length === 0
|
|
||||||
}, [data, domainId, board.columns])
|
|
||||||
|
|
||||||
function invalidateAll() {
|
function invalidateAll() {
|
||||||
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all })
|
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all })
|
||||||
@@ -303,6 +366,13 @@ function ServicesPage() {
|
|||||||
deleteGroupMutation.mutate(id)
|
deleteGroupMutation.mutate(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { columns } = useServiceColumns({
|
||||||
|
onEdit: setEditingService,
|
||||||
|
onDelete: setDeletingService,
|
||||||
|
onToggle: handleServiceToggle,
|
||||||
|
togglingId: togglingServiceId,
|
||||||
|
})
|
||||||
|
|
||||||
async function handleBulkToggle(enabled: boolean) {
|
async function handleBulkToggle(enabled: boolean) {
|
||||||
const ids = Array.from(selection.selectedIds)
|
const ids = Array.from(selection.selectedIds)
|
||||||
if (ids.length === 0) return
|
if (ids.length === 0) return
|
||||||
@@ -353,7 +423,7 @@ function ServicesPage() {
|
|||||||
title: column.title,
|
title: column.title,
|
||||||
description: column.domain ?? undefined,
|
description: column.domain ?? undefined,
|
||||||
dotClassName: GROUP_DOT_COLORS[index % GROUP_DOT_COLORS.length],
|
dotClassName: GROUP_DOT_COLORS[index % GROUP_DOT_COLORS.length],
|
||||||
addLabel: column.groupId ? 'Добавить сервис' : 'Добавить сервис',
|
addLabel: 'Добавить сервис',
|
||||||
onAdd: () => handleOpenCreateService(column.groupId),
|
onAdd: () => handleOpenCreateService(column.groupId),
|
||||||
})),
|
})),
|
||||||
[board.columns],
|
[board.columns],
|
||||||
@@ -363,79 +433,19 @@ function ServicesPage() {
|
|||||||
? 'Перетаскивание отключено при фильтре по домену'
|
? 'Перетаскивание отключено при фильтре по домену'
|
||||||
: 'Перетащите сервис между группами или измените порядок в списке'
|
: 'Перетащите сервис между группами или измените порядок в списке'
|
||||||
|
|
||||||
return (
|
const createServiceButton = (
|
||||||
<PageShell>
|
<Button type="button" onClick={() => handleOpenCreateService()}>
|
||||||
<ServicesBulkToolbar
|
Добавить сервис
|
||||||
count={selection.count}
|
</Button>
|
||||||
isPending={bulkToggling}
|
)
|
||||||
onEnable={() => handleBulkToggle(true)}
|
const createGroupButton = (
|
||||||
onDisable={() => handleBulkToggle(false)}
|
<Button type="button" variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
|
||||||
onClear={selection.clear}
|
Добавить группу
|
||||||
/>
|
</Button>
|
||||||
|
)
|
||||||
{isLoading ? (
|
|
||||||
<KanbanBoardSkeleton columns={3} />
|
|
||||||
) : isError ? (
|
|
||||||
<EmptyState
|
|
||||||
title="Не удалось загрузить сервисы"
|
|
||||||
description={error?.message}
|
|
||||||
action={
|
|
||||||
<Button type="button" variant="outline" onClick={() => refetch()}>
|
|
||||||
Повторить
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : isEmpty ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={ServerIcon}
|
|
||||||
title="Сервисы не найдены"
|
|
||||||
description={
|
|
||||||
filteredDomain
|
|
||||||
? `Нет сервисов с привязками к ${filteredDomain.zone_name}`
|
|
||||||
: 'Создайте группу и сервис — например VPN Panel или Home Assistant.'
|
|
||||||
}
|
|
||||||
action={
|
|
||||||
<div className="flex flex-wrap justify-center gap-2">
|
|
||||||
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
|
|
||||||
Добавить группу
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => handleOpenCreateService()}>Добавить сервис</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<KanbanBoard
|
|
||||||
title="Сервисы"
|
|
||||||
description={
|
|
||||||
filteredDomain
|
|
||||||
? `Сервисы с привязками к домену ${filteredDomain.zone_name}. ${boardHint}`
|
|
||||||
: `Сервисы и группы — FQDN синхронизируются в Cloudflare при включении. ${boardHint}`
|
|
||||||
}
|
|
||||||
columns={columnConfigs}
|
|
||||||
value={kanbanValue}
|
|
||||||
onValueChange={dragDisabled ? () => {} : handleKanbanValueChange}
|
|
||||||
toolbarActions={
|
|
||||||
<>
|
|
||||||
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
|
|
||||||
Добавить группу
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => handleOpenCreateService()}>Добавить сервис</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
renderCard={(service, ctx) => (
|
|
||||||
<ServiceKanbanCard
|
|
||||||
service={service}
|
|
||||||
isOverlay={ctx.isOverlay}
|
|
||||||
dragDisabled={dragDisabled}
|
|
||||||
isToggling={togglingServiceId === service.id}
|
|
||||||
onToggle={handleServiceToggle}
|
|
||||||
onEdit={setEditingService}
|
|
||||||
onDelete={setDeletingService}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
const sheets = (
|
||||||
|
<>
|
||||||
<ServiceEditSheet
|
<ServiceEditSheet
|
||||||
mode="edit"
|
mode="edit"
|
||||||
service={editingService}
|
service={editingService}
|
||||||
@@ -519,6 +529,150 @@ function ServicesPage() {
|
|||||||
}}
|
}}
|
||||||
disabled={deleteGroupMutation.isPending || deletingGroupId === deletingGroup?.id}
|
disabled={deleteGroupMutation.isPending || deletingGroupId === deletingGroup?.id}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
{view === 'board' ? (
|
||||||
|
<KanbanBoardSkeleton columns={3} />
|
||||||
|
) : (
|
||||||
|
<ResourcePage
|
||||||
|
title="Сервисы"
|
||||||
|
description="Каталог сервисов"
|
||||||
|
isLoading
|
||||||
|
filterFields={[]}
|
||||||
|
filters={[]}
|
||||||
|
onFiltersChange={() => {}}
|
||||||
|
getFilterFieldValue={() => ''}
|
||||||
|
columns={[]}
|
||||||
|
data={[]}
|
||||||
|
getRowId={() => ''}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{sheets}
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<EmptyState
|
||||||
|
title="Не удалось загрузить сервисы"
|
||||||
|
description={error?.message}
|
||||||
|
action={
|
||||||
|
<Button type="button" variant="outline" onClick={() => refetch()}>
|
||||||
|
Повторить
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{sheets}
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
{view === 'board' ? (
|
||||||
|
<>
|
||||||
|
<ServicesBulkToolbar
|
||||||
|
count={selection.count}
|
||||||
|
isPending={bulkToggling}
|
||||||
|
onEnable={() => handleBulkToggle(true)}
|
||||||
|
onDisable={() => handleBulkToggle(false)}
|
||||||
|
onClear={selection.clear}
|
||||||
|
/>
|
||||||
|
{catalogRows.length === 0 && (data?.groups.length ?? 0) === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={ServerIcon}
|
||||||
|
title="Сервисы не найдены"
|
||||||
|
description={
|
||||||
|
filteredDomain
|
||||||
|
? `Нет сервисов с привязками к ${filteredDomain.zone_name}`
|
||||||
|
: 'Создайте сервис — например VPN Panel или Home Assistant.'
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
|
{createServiceButton}
|
||||||
|
{createGroupButton}
|
||||||
|
<CatalogBoardToggle view="board" onViewChange={setView} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<KanbanBoard
|
||||||
|
title="Распределение сервисов"
|
||||||
|
description={
|
||||||
|
filteredDomain
|
||||||
|
? `Сервисы с привязками к домену ${filteredDomain.zone_name}. ${boardHint}`
|
||||||
|
: `Сервисы и группы — FQDN синхронизируются в Cloudflare при включении. ${boardHint}`
|
||||||
|
}
|
||||||
|
columns={columnConfigs}
|
||||||
|
value={kanbanValue}
|
||||||
|
onValueChange={dragDisabled ? () => {} : handleKanbanValueChange}
|
||||||
|
toolbarActions={
|
||||||
|
<>
|
||||||
|
{createServiceButton}
|
||||||
|
{createGroupButton}
|
||||||
|
<CatalogBoardToggle view="board" onViewChange={setView} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
renderCard={(service, ctx) => (
|
||||||
|
<ServiceKanbanCard
|
||||||
|
service={service}
|
||||||
|
isOverlay={ctx.isOverlay}
|
||||||
|
dragDisabled={dragDisabled}
|
||||||
|
isToggling={togglingServiceId === service.id}
|
||||||
|
onToggle={handleServiceToggle}
|
||||||
|
onEdit={setEditingService}
|
||||||
|
onDelete={setDeletingService}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<ResourcePage
|
||||||
|
title="Сервисы"
|
||||||
|
description={
|
||||||
|
filteredDomain
|
||||||
|
? `Каталог сервисов с привязками к ${filteredDomain.zone_name}`
|
||||||
|
: 'Каталог сервисов и привязок'
|
||||||
|
}
|
||||||
|
tabs={SERVICE_TABS.map((tab) => ({ ...tab }))}
|
||||||
|
tabFilter={serviceTabFilter}
|
||||||
|
filterFields={filterFields}
|
||||||
|
filters={filters}
|
||||||
|
onFiltersChange={setFilters}
|
||||||
|
onClearFilters={() => setFilters(createDefaultServiceFilters())}
|
||||||
|
getFilterFieldValue={serviceFilterFieldValue}
|
||||||
|
columns={columns}
|
||||||
|
data={catalogRows}
|
||||||
|
getRowId={(row) => String(row.id)}
|
||||||
|
primaryAction={
|
||||||
|
<>
|
||||||
|
{createServiceButton}
|
||||||
|
{createGroupButton}
|
||||||
|
<CatalogBoardToggle view="catalog" onViewChange={setView} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
emptyState={{
|
||||||
|
title: 'Сервисы не найдены',
|
||||||
|
description: filteredDomain
|
||||||
|
? `Нет сервисов с привязками к ${filteredDomain.zone_name}`
|
||||||
|
: 'Создайте сервис или группу сервисов.',
|
||||||
|
action: (
|
||||||
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
|
{createServiceButton}
|
||||||
|
{createGroupButton}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{sheets}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ function Tabs({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabsListVariants = cva(
|
const tabsListVariants = cva(
|
||||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-muted",
|
default: "bg-muted group-data-horizontal/tabs:h-8",
|
||||||
line: "gap-1 bg-transparent",
|
line: "h-auto gap-1 bg-transparent p-0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -58,10 +58,12 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
|||||||
<TabsPrimitive.Tab
|
<TabsPrimitive.Tab
|
||||||
data-slot="tabs-trigger"
|
data-slot="tabs-trigger"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"relative inline-flex items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
"group-data-[variant=default]/tabs-list:h-[calc(100%-1px)] group-data-[variant=default]/tabs-list:flex-1 group-data-[variant=default]/tabs-list:data-active:shadow-sm",
|
||||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
"group-data-[variant=line]/tabs-list:flex-none group-data-[variant=line]/tabs-list:rounded-none group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:shadow-none group-data-[variant=line]/tabs-list:data-active:bg-transparent group-data-[variant=line]/tabs-list:data-active:shadow-none dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
"group-data-[variant=default]/tabs-list:data-active:bg-background group-data-[variant=default]/tabs-list:data-active:text-foreground dark:group-data-[variant=default]/tabs-list:data-active:border-input dark:group-data-[variant=default]/tabs-list:data-active:bg-input/30 dark:group-data-[variant=default]/tabs-list:data-active:text-foreground",
|
||||||
|
"data-active:text-foreground",
|
||||||
|
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-0 group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
Reference in New Issue
Block a user