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

- 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 <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-07-17 00:59:07 +07:00
co-authored by Cursor
parent 777a263f26
commit 6ceb7ff9c7
20 changed files with 1372 additions and 381 deletions
@@ -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 }
}