feat: integrate ReUI components and update MCP configuration
Build, Test, and Push CFDM Docker Image / test (push) Failing after 49s
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 49s
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
- Added ReUI configuration to .mcp.json and .cursor/mcp.json for component integration. - Updated pnpm-lock.yaml with new dependencies including react-phone-number-input and adjustments to existing packages. - Enhanced SKILL.md documentation for ReUI to clarify usage and features. - Removed unused components (ChartCard, DataGridCard, etc.) to streamline the codebase. - Adjusted domain-related components and filters for improved functionality and UI consistency. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
export interface DetailMetricCard {
|
||||
id: string
|
||||
icon: ReactNode
|
||||
label: string
|
||||
description: string
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
interface DetailPanelProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function DetailPanelRoot({ children, className }: DetailPanelProps) {
|
||||
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
|
||||
}
|
||||
|
||||
interface DetailPanelHeaderProps {
|
||||
title: string
|
||||
description?: string
|
||||
actions?: ReactNode
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
function DetailPanelHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
}: DetailPanelHeaderProps) {
|
||||
return (
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1 flex-col gap-px">
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
{description ? (
|
||||
<FrameDescription>{description}</FrameDescription>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</FrameHeader>
|
||||
{children ? <FramePanel className="space-y-4">{children}</FramePanel> : null}
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailPanelMetrics({ cards }: { cards: DetailMetricCard[] }) {
|
||||
return (
|
||||
<div className="@container w-full">
|
||||
<div className="grid gap-4 @2xl:grid-cols-3">
|
||||
{cards.map((card) => (
|
||||
<Frame key={card.id} spacing="sm">
|
||||
<FrameHeader className="px-1! py-1!">
|
||||
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
|
||||
{card.icon}
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{card.label}
|
||||
</span>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<FramePanel className="space-y-2">
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{card.description}
|
||||
</p>
|
||||
{card.footer}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailPanelSection({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title?: string
|
||||
description?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
{title ? (
|
||||
<header className="px-1">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
{description ? (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
) : null}
|
||||
</header>
|
||||
) : null}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export const DetailPanel = Object.assign(DetailPanelRoot, {
|
||||
Header: DetailPanelHeader,
|
||||
Metrics: DetailPanelMetrics,
|
||||
Section: DetailPanelSection,
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
|
||||
export function getActiveFilters(filters: Filter[]) {
|
||||
return filters.filter((filter) => {
|
||||
const { values } = filter
|
||||
if (!values || values.length === 0) return false
|
||||
if (values.every((value) => typeof value === 'string' && value.trim() === '')) {
|
||||
return false
|
||||
}
|
||||
if (values.every((value) => value === null || value === undefined)) {
|
||||
return false
|
||||
}
|
||||
if (values.every((value) => Array.isArray(value) && value.length === 0)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function applyFiltersToData<T>(
|
||||
data: T[],
|
||||
filters: Filter[],
|
||||
getFieldValue: (item: T, field: string) => unknown,
|
||||
): T[] {
|
||||
const active = getActiveFilters(filters)
|
||||
let result = [...data]
|
||||
|
||||
for (const filter of active) {
|
||||
const { field, operator, values } = filter
|
||||
result = result.filter((item) => {
|
||||
const raw = getFieldValue(item, field)
|
||||
const fieldValue = raw != null ? raw : ''
|
||||
|
||||
switch (operator) {
|
||||
case 'is':
|
||||
return values.includes(fieldValue)
|
||||
case 'is_not':
|
||||
return !values.includes(fieldValue)
|
||||
case 'is_any_of':
|
||||
return values.some((value) => fieldValue === value)
|
||||
case 'is_not_any_of':
|
||||
return !values.some((value) => fieldValue === value)
|
||||
case 'contains': {
|
||||
const tokens = values
|
||||
.map((value) => String(value).trim())
|
||||
.filter(Boolean)
|
||||
if (tokens.length === 0) return true
|
||||
return tokens.some((token) =>
|
||||
String(fieldValue).toLowerCase().includes(token.toLowerCase()),
|
||||
)
|
||||
}
|
||||
case 'not_contains':
|
||||
return !values.some((value) =>
|
||||
String(fieldValue).toLowerCase().includes(String(value).toLowerCase()),
|
||||
)
|
||||
case 'starts_with':
|
||||
return values.some((value) =>
|
||||
String(fieldValue).toLowerCase().startsWith(String(value).toLowerCase()),
|
||||
)
|
||||
case 'ends_with':
|
||||
return values.some((value) =>
|
||||
String(fieldValue).toLowerCase().endsWith(String(value).toLowerCase()),
|
||||
)
|
||||
case 'empty':
|
||||
return fieldValue === '' || fieldValue == null
|
||||
case 'not_empty':
|
||||
return fieldValue !== '' && fieldValue != null
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function renderSelectedCount(values: unknown[]) {
|
||||
if (values.length === 0) return 'Выберите…'
|
||||
if (values.length > 1) return `${values.length} выбрано`
|
||||
return null
|
||||
}
|
||||
|
||||
export function renderSingleSelectedLabel(
|
||||
values: unknown[],
|
||||
options: { value: string; label: string }[],
|
||||
) {
|
||||
const state = renderSelectedCount(values)
|
||||
if (state) return state
|
||||
const option = options.find((item) => item.value === values[0])
|
||||
return option?.label ?? String(values[0])
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
|
||||
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
||||
export { OpsDashboard, OpsDashboardHintLink, type OpsKpiCard } from './ops-dashboard'
|
||||
export { KanbanBoard, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
|
||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
||||
@@ -0,0 +1,277 @@
|
||||
import { useMemo, type ComponentProps, type ReactNode } from 'react'
|
||||
import {
|
||||
Frame,
|
||||
FrameHeader,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import {
|
||||
Kanban,
|
||||
KanbanBoard as KanbanBoardPrimitive,
|
||||
KanbanColumn,
|
||||
KanbanColumnContent,
|
||||
KanbanColumnHandle,
|
||||
KanbanItem,
|
||||
KanbanItemHandle,
|
||||
KanbanOverlay,
|
||||
} from '@/components/reui/kanban'
|
||||
import { ScrollArea as ScrollAreaPrimitive } from '@base-ui/react/scroll-area'
|
||||
import { GripVerticalIcon, PlusIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
|
||||
export interface KanbanColumnConfig {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
dotClassName?: string
|
||||
addLabel?: string
|
||||
onAdd?: () => void
|
||||
}
|
||||
|
||||
export interface KanbanBoardProps<T extends { id: string | number }> {
|
||||
title: string
|
||||
description?: string
|
||||
columns: KanbanColumnConfig[]
|
||||
value: Record<string, T[]>
|
||||
onValueChange: (value: Record<string, T[]>) => void
|
||||
renderCard: (item: T, ctx: { isOverlay?: boolean }) => ReactNode
|
||||
toolbarActions?: ReactNode
|
||||
emptyColumnAction?: (column: KanbanColumnConfig) => ReactNode
|
||||
}
|
||||
|
||||
function BoardScrollArea({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className="relative w-full min-w-0 pb-3"
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 w-full rounded-lg transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
<ScrollAreaPrimitive.Content
|
||||
data-slot="scroll-area-content"
|
||||
className="w-max min-w-full"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Content>
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation="horizontal"
|
||||
orientation="horizontal"
|
||||
className="flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent"
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-foreground/15 relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const COLUMN_HEADER_ACTION_BUTTON_CLASSNAME =
|
||||
'text-muted-foreground hover:border-border! hover:bg-background! hover:text-foreground border border-transparent bg-transparent'
|
||||
|
||||
interface KanbanColumnViewProps<T extends { id: string | number }>
|
||||
extends Omit<ComponentProps<typeof KanbanColumn>, 'value' | 'children'> {
|
||||
column: KanbanColumnConfig
|
||||
items: T[]
|
||||
renderCard: (item: T, ctx: { isOverlay?: boolean }) => ReactNode
|
||||
isOverlay?: boolean
|
||||
emptyColumnAction?: (column: KanbanColumnConfig) => ReactNode
|
||||
}
|
||||
|
||||
function KanbanColumnView<T extends { id: string | number }>({
|
||||
column,
|
||||
items,
|
||||
renderCard,
|
||||
isOverlay,
|
||||
emptyColumnAction,
|
||||
...props
|
||||
}: KanbanColumnViewProps<T>) {
|
||||
return (
|
||||
<KanbanColumn
|
||||
value={column.id}
|
||||
className="w-[calc(100vw-3rem)] max-w-[19rem] shrink-0 sm:w-[19rem]"
|
||||
{...props}
|
||||
>
|
||||
<Frame
|
||||
spacing="sm"
|
||||
className={cn('group/column', isOverlay && 'shadow-lg')}
|
||||
aria-label={column.description ?? column.title}
|
||||
>
|
||||
<FrameHeader className="flex min-h-10 flex-row items-center gap-2 px-2 py-1.5">
|
||||
{column.dotClassName ? (
|
||||
<span
|
||||
className={cn('size-2.5 shrink-0 rounded-full', column.dotClassName)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<FrameTitle className="truncate text-sm leading-5" title={column.title}>
|
||||
{column.title}
|
||||
</FrameTitle>
|
||||
<span className="text-muted-foreground shrink-0 text-sm font-medium tabular-nums">
|
||||
{items.length}
|
||||
</span>
|
||||
{!isOverlay ? (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-focus-within/column:opacity-100 group-hover/column:opacity-100">
|
||||
{column.onAdd ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={COLUMN_HEADER_ACTION_BUTTON_CLASSNAME}
|
||||
aria-label={column.addLabel ?? 'Добавить'}
|
||||
title={column.addLabel ?? 'Добавить'}
|
||||
onClick={column.onAdd}
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
</Button>
|
||||
) : null}
|
||||
<KanbanColumnHandle
|
||||
className="group-focus-within/column:opacity-100"
|
||||
render={({ className, ...handleProps }) => (
|
||||
<Button
|
||||
{...handleProps}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Переместить колонку ${column.title}`}
|
||||
title={`Переместить колонку ${column.title}`}
|
||||
className={cn(COLUMN_HEADER_ACTION_BUTTON_CLASSNAME, className)}
|
||||
>
|
||||
<GripVerticalIcon aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
|
||||
<KanbanColumnContent value={column.id} className="gap-2 p-0.5">
|
||||
{items.map((item) => (
|
||||
<KanbanItem key={String(item.id)} value={String(item.id)}>
|
||||
<KanbanItemHandle className="block">
|
||||
{renderCard(item, { isOverlay: false })}
|
||||
</KanbanItemHandle>
|
||||
</KanbanItem>
|
||||
))}
|
||||
{items.length === 0
|
||||
? (emptyColumnAction?.(column) ?? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="text-muted-foreground hover:text-foreground bg-background/70 h-20 w-full border-dashed text-sm"
|
||||
aria-label={column.addLabel ?? 'Пусто'}
|
||||
disabled={!column.onAdd}
|
||||
onClick={column.onAdd}
|
||||
>
|
||||
{column.addLabel ?? 'Пусто'}
|
||||
</Button>
|
||||
))
|
||||
: null}
|
||||
</KanbanColumnContent>
|
||||
</Frame>
|
||||
</KanbanColumn>
|
||||
)
|
||||
}
|
||||
|
||||
function findItem<T extends { id: string | number }>(
|
||||
columns: Record<string, T[]>,
|
||||
itemId: string,
|
||||
): T | null {
|
||||
for (const items of Object.values(columns)) {
|
||||
const found = items.find((item) => String(item.id) === itemId)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function KanbanBoard<T extends { id: string | number }>({
|
||||
title,
|
||||
description,
|
||||
columns: columnConfigs,
|
||||
value,
|
||||
onValueChange,
|
||||
renderCard,
|
||||
toolbarActions,
|
||||
emptyColumnAction,
|
||||
}: KanbanBoardProps<T>) {
|
||||
const columnById = useMemo(
|
||||
() => new Map(columnConfigs.map((c) => [c.id, c])),
|
||||
[columnConfigs],
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="mx-auto flex w-full flex-col gap-4">
|
||||
<header className="px-1 py-1" aria-label={title}>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-lg leading-7 font-semibold">{title}</h2>
|
||||
{description ? (
|
||||
<p className="text-muted-foreground mt-0.5 line-clamp-2 max-w-[52ch] text-sm leading-5">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{toolbarActions ? (
|
||||
<div
|
||||
className="flex w-full flex-wrap items-center gap-2 lg:w-auto lg:justify-end"
|
||||
role="group"
|
||||
>
|
||||
{toolbarActions}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Kanban
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
getItemValue={(item) => String(item.id)}
|
||||
className="w-full"
|
||||
>
|
||||
<BoardScrollArea>
|
||||
<KanbanBoardPrimitive className="grid min-w-max auto-cols-[19rem] grid-flow-col grid-cols-none items-start gap-3 p-1">
|
||||
{Object.entries(value).map(([columnId, items]) => {
|
||||
const column = columnById.get(columnId)
|
||||
if (!column) return null
|
||||
return (
|
||||
<KanbanColumnView
|
||||
key={columnId}
|
||||
column={column}
|
||||
items={items}
|
||||
renderCard={renderCard}
|
||||
emptyColumnAction={emptyColumnAction}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</KanbanBoardPrimitive>
|
||||
</BoardScrollArea>
|
||||
|
||||
<KanbanOverlay>
|
||||
{({ value: overlayValue, variant }) => {
|
||||
if (variant === 'column') {
|
||||
const column = columnById.get(String(overlayValue))
|
||||
if (!column) return null
|
||||
return (
|
||||
<KanbanColumnView
|
||||
column={column}
|
||||
items={value[column.id] ?? []}
|
||||
renderCard={renderCard}
|
||||
isOverlay
|
||||
/>
|
||||
)
|
||||
}
|
||||
const item = findItem(value, String(overlayValue))
|
||||
return item ? renderCard(item, { isOverlay: true }) : null
|
||||
}}
|
||||
</KanbanOverlay>
|
||||
</Kanban>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@cfdm/ui/components/item'
|
||||
|
||||
export interface OpsKpiCard {
|
||||
id: string
|
||||
typeLabel: string
|
||||
title: string
|
||||
metricLabel: string
|
||||
value: string | number
|
||||
hint?: ReactNode
|
||||
icon: ReactNode
|
||||
iconBg?: string
|
||||
}
|
||||
|
||||
interface OpsDashboardProps {
|
||||
title?: string
|
||||
description?: string
|
||||
kpiCards: OpsKpiCard[]
|
||||
charts: ReactNode
|
||||
queue: ReactNode
|
||||
}
|
||||
|
||||
function KpiCardItem({ card }: { card: OpsKpiCard }) {
|
||||
return (
|
||||
<FramePanel>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Item
|
||||
className={cn(
|
||||
'p-0',
|
||||
'border-background flex size-10 items-center justify-center border-2 [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',
|
||||
card.iconBg ?? 'bg-chart-1',
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{card.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<p className="text-muted-foreground text-sm leading-tight">
|
||||
{card.typeLabel}
|
||||
</p>
|
||||
<h3 className="truncate text-sm leading-tight font-medium">{card.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 space-y-1.5">
|
||||
<p className="text-muted-foreground text-sm leading-tight">
|
||||
{card.metricLabel}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xl font-medium tracking-tight tabular-nums">
|
||||
{card.value}
|
||||
</span>
|
||||
{card.hint}
|
||||
</div>
|
||||
</div>
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
export function OpsDashboard({
|
||||
title = 'Панель управления',
|
||||
description = 'Обзор доменов, групп, сервисов и сертификатов Cloudflare',
|
||||
kpiCards,
|
||||
charts,
|
||||
queue,
|
||||
}: OpsDashboardProps) {
|
||||
return (
|
||||
<div className="text-foreground @container mx-auto flex w-full flex-col gap-4 md:gap-6">
|
||||
<header className="px-1">
|
||||
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
|
||||
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section aria-label="Ключевые метрики">
|
||||
<Frame className="@container w-full">
|
||||
<div className="grid gap-1 @2xl:grid-cols-2 @5xl:grid-cols-4">
|
||||
{kpiCards.map((card) => (
|
||||
<KpiCardItem key={card.id} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-label="Аналитика"
|
||||
className="grid min-w-0 items-stretch gap-4 @5xl:grid-cols-2"
|
||||
>
|
||||
{charts}
|
||||
</section>
|
||||
|
||||
<section aria-label="Требуют внимания">
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Требуют внимания</FrameTitle>
|
||||
<FrameDescription>
|
||||
Истекающие сертификаты и домены без группы
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>{queue}</FramePanel>
|
||||
</Frame>
|
||||
</section>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import { useCallback, useMemo, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table'
|
||||
import { FilterIcon, FunnelXIcon } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import {
|
||||
Filters,
|
||||
type Filter,
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/reui/filters'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Separator } from '@cfdm/ui/components/separator'
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { applyFiltersToData } from './filter-utils'
|
||||
|
||||
export interface ResourcePageTab {
|
||||
id: string
|
||||
label: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
export interface ResourcePageProps<T extends object> {
|
||||
title: string
|
||||
description?: string
|
||||
tabs?: ResourcePageTab[]
|
||||
activeTab?: string
|
||||
onTabChange?: (tabId: string) => void
|
||||
tabFilter?: (item: T, tabId: string) => boolean
|
||||
filterFields: FilterFieldConfig[]
|
||||
filters: Filter[]
|
||||
onFiltersChange: (filters: Filter[]) => void
|
||||
onClearFilters?: () => void
|
||||
getFilterFieldValue: (item: T, field: string) => unknown
|
||||
columns: ColumnDef<T, unknown>[]
|
||||
data: T[]
|
||||
getRowId: (row: T) => string
|
||||
isLoading?: boolean
|
||||
isError?: boolean
|
||||
error?: Error | null
|
||||
onRetry?: () => void
|
||||
primaryAction?: ReactNode
|
||||
emptyState?: { title: string; description?: string; action?: ReactNode }
|
||||
pageSize?: number
|
||||
enableRowSelection?: boolean
|
||||
toolbarExtra?: ReactNode
|
||||
hideHeader?: boolean
|
||||
}
|
||||
|
||||
function ResourcePageSkeleton() {
|
||||
return (
|
||||
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="mt-1 h-4 w-72" />
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<Skeleton className="h-9 w-full max-w-md" />
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
export function ResourcePage<T extends object>({
|
||||
title,
|
||||
description,
|
||||
tabs,
|
||||
activeTab: controlledTab,
|
||||
onTabChange,
|
||||
tabFilter,
|
||||
filterFields,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
onClearFilters,
|
||||
getFilterFieldValue,
|
||||
columns,
|
||||
data,
|
||||
getRowId,
|
||||
isLoading = false,
|
||||
isError = false,
|
||||
error = null,
|
||||
onRetry,
|
||||
primaryAction,
|
||||
emptyState,
|
||||
pageSize = 10,
|
||||
enableRowSelection = false,
|
||||
toolbarExtra,
|
||||
hideHeader = false,
|
||||
}: ResourcePageProps<T>) {
|
||||
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
|
||||
const activeTab = controlledTab ?? internalTab
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize,
|
||||
})
|
||||
|
||||
const resetPagination = useCallback(() => {
|
||||
setPagination((current) =>
|
||||
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 },
|
||||
)
|
||||
}, [])
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
let result = applyFiltersToData(data, filters, getFilterFieldValue)
|
||||
if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') {
|
||||
result = result.filter((item) => tabFilter(item, activeTab))
|
||||
}
|
||||
return result
|
||||
}, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab])
|
||||
|
||||
const tabCounts = useMemo(() => {
|
||||
if (!tabs?.length || !tabFilter) return {}
|
||||
const base = applyFiltersToData(data, filters, getFilterFieldValue)
|
||||
const counts: Record<string, number> = {}
|
||||
for (const tab of tabs) {
|
||||
counts[tab.id] =
|
||||
tab.id === 'all'
|
||||
? base.length
|
||||
: base.filter((item) => tabFilter(item, tab.id)).length
|
||||
}
|
||||
return counts
|
||||
}, [tabs, tabFilter, data, filters, getFilterFieldValue])
|
||||
|
||||
const selectedCount = useMemo(
|
||||
() => Object.keys(rowSelection).length,
|
||||
[rowSelection],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns,
|
||||
getRowId,
|
||||
state: { sorting, rowSelection, pagination },
|
||||
enableRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
})
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(value: string) => {
|
||||
if (onTabChange) onTabChange(value)
|
||||
else setInternalTab(value)
|
||||
resetPagination()
|
||||
},
|
||||
[onTabChange, resetPagination],
|
||||
)
|
||||
|
||||
const handleFiltersChange = useCallback(
|
||||
(next: Filter[]) => {
|
||||
onFiltersChange(next)
|
||||
resetPagination()
|
||||
},
|
||||
[onFiltersChange, resetPagination],
|
||||
)
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
onClearFilters?.()
|
||||
resetPagination()
|
||||
}, [onClearFilters, resetPagination])
|
||||
|
||||
if (isLoading) {
|
||||
return <ResourcePageSkeleton />
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
<span>{error?.message ?? 'Не удалось загрузить данные'}</span>
|
||||
{onRetry ? (
|
||||
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
|
||||
Повторить
|
||||
</Button>
|
||||
) : null}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
if (data.length === 0 && emptyState) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={emptyState.title}
|
||||
description={emptyState.description}
|
||||
action={emptyState.action}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMessage = 'Нет записей по выбранным фильтрам.'
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filteredData.length}
|
||||
emptyMessage={emptyMessage}
|
||||
tableLayout={{ dense: true }}
|
||||
>
|
||||
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||
{!hideHeader ? (
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<FrameTitle className="text-balance">{title}</FrameTitle>
|
||||
{description ? (
|
||||
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
|
||||
<span>{description}</span>
|
||||
<span
|
||||
className="bg-input size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="tabular-nums">
|
||||
{filteredData.length} записей
|
||||
</span>
|
||||
{selectedCount > 0 ? (
|
||||
<>
|
||||
<span
|
||||
className="bg-input size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{selectedCount} выбрано</span>
|
||||
</>
|
||||
) : null}
|
||||
</FrameDescription>
|
||||
) : null}
|
||||
</div>
|
||||
{primaryAction ? (
|
||||
<div className="shrink-0">{primaryAction}</div>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
) : null}
|
||||
|
||||
<FramePanel className="p-0 shadow-none!">
|
||||
{tabs && tabs.length > 0 ? (
|
||||
<>
|
||||
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<TabsList variant="line" className="gap-5">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className="gap-2 px-0 pb-3 text-sm"
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
|
||||
{tabCounts[tab.id] ?? tab.count ?? 0}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
size="default"
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
Фильтры
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{toolbarExtra}
|
||||
{selectedCount > 0 ? (
|
||||
<Badge size="sm" variant="secondary">
|
||||
{selectedCount} выбрано
|
||||
</Badge>
|
||||
) : null}
|
||||
{onClearFilters ? (
|
||||
<Button type="button" variant="outline" onClick={handleClear}>
|
||||
<FunnelXIcon className="size-4" aria-hidden="true" />
|
||||
Сбросить
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
|
||||
<Separator />
|
||||
|
||||
<FrameFooter>
|
||||
<DataGridPagination
|
||||
sizes={[5, 10, 20, 50]}
|
||||
rowsPerPageLabel="Строк на странице"
|
||||
info="{from} - {to} of {count}"
|
||||
previousPageLabel="Предыдущая"
|
||||
nextPageLabel="Следующая"
|
||||
/>
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
||||
import { SettingsIcon } from 'lucide-react'
|
||||
|
||||
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@cfdm/ui/components/tabs'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
|
||||
export interface SettingsTabConfig {
|
||||
id: string
|
||||
to: string
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
const DEFAULT_TABS: SettingsTabConfig[] = [
|
||||
{
|
||||
id: 'integrations',
|
||||
to: '/settings/integrations',
|
||||
label: 'Интеграции',
|
||||
icon: <SettingsIcon className="size-4" aria-hidden="true" />,
|
||||
},
|
||||
]
|
||||
|
||||
interface SettingsShellProps {
|
||||
title?: string
|
||||
description?: string
|
||||
tabs?: SettingsTabConfig[]
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function SettingsShell({
|
||||
title = 'Настройки',
|
||||
description = 'Интеграции и конфигурация приложения',
|
||||
tabs = DEFAULT_TABS,
|
||||
children,
|
||||
}: SettingsShellProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const activeTab =
|
||||
tabs.find((tab) => pathname.startsWith(tab.to))?.id ?? tabs[0]?.id ?? ''
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="w-full max-w-4xl space-y-6">
|
||||
<header className="px-1">
|
||||
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
|
||||
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Tabs value={activeTab} orientation={isMobile ? 'horizontal' : 'vertical'}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-6',
|
||||
isMobile ? 'flex-col' : 'flex-row items-start',
|
||||
)}
|
||||
>
|
||||
<TabsList
|
||||
variant="line"
|
||||
className={cn(
|
||||
'h-auto w-full justify-start gap-1 bg-transparent p-0',
|
||||
!isMobile && 'w-48 shrink-0 flex-col items-stretch',
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className={cn(
|
||||
'justify-start gap-2 px-3 py-2',
|
||||
!isMobile && 'w-full',
|
||||
)}
|
||||
render={<Link to={tab.to} />}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent key={tab.id} value={tab.id} className="mt-0">
|
||||
{children ?? <Outlet />}
|
||||
</TabsContent>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user