Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b028d04b4 |
No files matched your search
@@ -93,15 +93,11 @@ export function CertKpiStats({
|
|||||||
]
|
]
|
||||||
}, [summary, total, activeTab, onSelectTab])
|
}, [summary, total, activeTab, onSelectTab])
|
||||||
|
|
||||||
const hasData = (total ?? totalFromSummary(summary)) > 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
cards={hasData ? cards : []}
|
cards={cards}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
skeletonCount={4}
|
skeletonCount={4}
|
||||||
emptyIcon={<ShieldCheckIcon className="text-muted-foreground size-5" aria-hidden />}
|
|
||||||
emptyMessage="Нет данных — запустите проверку сертификатов"
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -207,7 +207,7 @@ export function useDomainColumns({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'status',
|
id: 'status',
|
||||||
header: 'Зона CF',
|
header: 'Статус',
|
||||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ColumnDef } from '@tanstack/react-table'
|
|||||||
import { MoreHorizontalIcon, SearchIcon } from 'lucide-react'
|
import { MoreHorizontalIcon, SearchIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
|
import { formatRelative, sqliteUtcToIso } from '@/lib/format'
|
||||||
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
|
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,8 @@ export interface GroupCatalogRow {
|
|||||||
name: string
|
name: string
|
||||||
slug: string
|
slug: string
|
||||||
domainCount: number
|
domainCount: number
|
||||||
|
serviceCount: number
|
||||||
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GROUP_TABS = [
|
export const GROUP_TABS = [
|
||||||
@@ -114,9 +117,32 @@ export function useGroupColumns({
|
|||||||
<span className="tabular-nums">{row.original.domainCount}</span>
|
<span className="tabular-nums">{row.original.domainCount}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'serviceCount',
|
||||||
|
accessorKey: 'serviceCount',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Сервисы" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="tabular-nums">{row.original.serviceCount}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'createdAt',
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Создана" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-muted-foreground tabular-nums">
|
||||||
|
{formatRelative(sqliteUtcToIso(row.original.createdAt) ?? row.original.createdAt)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
header: () => <span className="sr-only">Действия</span>,
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|||||||
@@ -287,12 +287,12 @@ export function DomainDetailChrome({
|
|||||||
label: 'Поддомены',
|
label: 'Поддомены',
|
||||||
count: page.subdomainRows.length,
|
count: page.subdomainRows.length,
|
||||||
},
|
},
|
||||||
{ id: 'bindings', label: 'Привязки', count: bindings.length },
|
|
||||||
{
|
{
|
||||||
id: 'dns',
|
id: 'dns',
|
||||||
label: 'DNS',
|
label: 'DNS',
|
||||||
count: dnsQuery.data ? dnsQuery.data.length : undefined,
|
count: dnsQuery.data ? dnsQuery.data.length : undefined,
|
||||||
},
|
},
|
||||||
|
{ id: 'bindings', label: 'Привязки', count: bindings.length },
|
||||||
]}
|
]}
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={handleTabChange}
|
onValueChange={handleTabChange}
|
||||||
|
|||||||
@@ -140,6 +140,20 @@ export function VpsTrackerIntegrationCard({
|
|||||||
<LoadingButton type="submit" isLoading={isSaving} disabled={!form.formState.isDirty}>
|
<LoadingButton type="submit" isLoading={isSaving} disabled={!form.formState.isDirty}>
|
||||||
Сохранить
|
Сохранить
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!hasUnsaved}
|
||||||
|
onClick={() =>
|
||||||
|
form.reset({
|
||||||
|
vpsTrackerUrl: settings?.vpsTrackerUrl ?? '',
|
||||||
|
vpsTrackerIntegrationToken: '',
|
||||||
|
vpsTrackerSyncEnabled: settings?.vpsTrackerSyncEnabled ?? false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
<Button type="button" variant="outline" onClick={() => void handleTest()}>
|
<Button type="button" variant="outline" onClick={() => void handleTest()}>
|
||||||
Проверить связь
|
Проверить связь
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Fragment, useMemo } from 'react'
|
import { Fragment, useMemo } from 'react'
|
||||||
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
|
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
|
||||||
import { MenuIcon } from 'lucide-react'
|
import { SearchIcon } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from '@cfdm/ui/components/breadcrumb'
|
} from '@cfdm/ui/components/breadcrumb'
|
||||||
import { Separator } from '@cfdm/ui/components/separator'
|
import { Separator } from '@cfdm/ui/components/separator'
|
||||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
|
||||||
import { DataFreshness } from '@/components/layout/data-freshness'
|
import { DataFreshness } from '@/components/layout/data-freshness'
|
||||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
@@ -36,7 +35,7 @@ function useDynamicBreadcrumbLabels() {
|
|||||||
}, [matches])
|
}, [matches])
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Header chrome — freshness + AppsMenu + SystemMonitor; theme in NavUser (app-shell-1). */
|
/** Шапка макета: триггер, крошки, свежесть и состояние. Приложения — в сайдбаре. */
|
||||||
export function SiteHeader() {
|
export function SiteHeader() {
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
const dynamicLabels = useDynamicBreadcrumbLabels()
|
const dynamicLabels = useDynamicBreadcrumbLabels()
|
||||||
@@ -48,15 +47,6 @@ export function SiteHeader() {
|
|||||||
return (
|
return (
|
||||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
||||||
<SidebarTrigger className="-ml-1" />
|
<SidebarTrigger className="-ml-1" />
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Меню и поиск"
|
|
||||||
onClick={() => window.dispatchEvent(new Event(OPEN_COMMAND_PALETTE_EVENT))}
|
|
||||||
>
|
|
||||||
<MenuIcon aria-hidden />
|
|
||||||
</Button>
|
|
||||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
@@ -85,7 +75,16 @@ export function SiteHeader() {
|
|||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<DataFreshness />
|
<DataFreshness />
|
||||||
<AppsMenu />
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Командная палитра"
|
||||||
|
className="md:hidden"
|
||||||
|
onClick={() => window.dispatchEvent(new Event(OPEN_COMMAND_PALETTE_EVENT))}
|
||||||
|
>
|
||||||
|
<SearchIcon aria-hidden />
|
||||||
|
</Button>
|
||||||
<SystemMonitorPopover />
|
<SystemMonitorPopover />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ export function SystemMonitorPopover() {
|
|||||||
|
|
||||||
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
|
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
|
||||||
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
|
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
|
||||||
<span className="text-foreground text-xs font-medium">Монитор CFDM</span>
|
<span className="text-foreground text-sm font-semibold">Состояние системы</span>
|
||||||
<span className="text-muted-foreground text-[11px] tabular-nums">
|
<span className="text-muted-foreground text-[11px] tabular-nums">
|
||||||
{new Date().toLocaleTimeString('ru-RU')}
|
{new Date().toLocaleTimeString('ru-RU')}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="flex flex-col gap-px">
|
<div className="flex flex-col gap-px">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
|
||||||
{description ? (
|
{description ? (
|
||||||
<p className="text-muted-foreground text-sm">{description}</p>
|
<p className="text-muted-foreground text-sm">{description}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import type { KeyboardEvent, ReactNode } from 'react'
|
|||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
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 type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
||||||
@@ -39,17 +39,69 @@ export type OpsKpiCard = KpiStatCardData
|
|||||||
/** @deprecated Use KpiStatCardData — type alias for CFDM kit parity */
|
/** @deprecated Use KpiStatCardData — type alias for CFDM kit parity */
|
||||||
export type KpiStatCard = KpiStatCardData
|
export type KpiStatCard = KpiStatCardData
|
||||||
|
|
||||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground'
|
type KpiTone = 'neutral' | 'ok' | 'in' | 'wn' | 'er' | 'primary'
|
||||||
|
|
||||||
const HINT_VARIANT: Record<KpiStatVariant, 'outline' | 'warning-light' | 'destructive-light'> = {
|
const TILE: Record<KpiTone, string> = {
|
||||||
default: 'outline',
|
neutral: 'text-muted-foreground',
|
||||||
warning: 'warning-light',
|
ok: 'text-success',
|
||||||
destructive: 'destructive-light',
|
in: 'text-info',
|
||||||
|
wn: 'text-warning',
|
||||||
|
er: 'text-destructive',
|
||||||
|
primary: 'text-primary',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** stats-12 tile: https://reui.io/preview/base/stats-12 */
|
const VALUE: Record<KpiTone, string> = {
|
||||||
const STAT_ICON_CLASS =
|
neutral: 'text-foreground',
|
||||||
'border-background bg-muted flex size-10.5 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5'
|
ok: 'text-foreground',
|
||||||
|
in: 'text-foreground',
|
||||||
|
primary: 'text-foreground',
|
||||||
|
wn: 'text-warning',
|
||||||
|
er: 'text-destructive',
|
||||||
|
}
|
||||||
|
|
||||||
|
const BADGE: Record<KpiTone, 'outline' | 'success-light' | 'info-light' | 'warning-light' | 'destructive-light' | 'primary-light'> = {
|
||||||
|
neutral: 'outline',
|
||||||
|
ok: 'success-light',
|
||||||
|
in: 'info-light',
|
||||||
|
wn: 'warning-light',
|
||||||
|
er: 'destructive-light',
|
||||||
|
primary: 'primary-light',
|
||||||
|
}
|
||||||
|
|
||||||
|
const GLYPH: Record<KpiTone, string> = {
|
||||||
|
neutral: '',
|
||||||
|
ok: '●',
|
||||||
|
in: '',
|
||||||
|
primary: '',
|
||||||
|
wn: '▲',
|
||||||
|
er: '✕',
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLS: Record<number, string> = {
|
||||||
|
1: 'grid-cols-1',
|
||||||
|
2: 'grid-cols-2 max-[640px]:grid-cols-1',
|
||||||
|
3: 'grid-cols-1 min-[1240px]:grid-cols-3',
|
||||||
|
4: 'grid-cols-1 min-[640px]:grid-cols-2 min-[1240px]:grid-cols-4',
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconTone(item: KpiStatItem): KpiTone {
|
||||||
|
if (item.iconClassName?.includes('text-destructive')) return 'er'
|
||||||
|
if (item.iconClassName?.includes('text-warning')) return 'wn'
|
||||||
|
if (item.iconClassName?.includes('text-success')) return 'ok'
|
||||||
|
if (item.iconClassName?.includes('text-info')) return 'in'
|
||||||
|
if (item.iconClassName?.includes('text-primary')) return 'primary'
|
||||||
|
if (item.variant === 'destructive') return 'er'
|
||||||
|
if (item.variant === 'warning') return 'wn'
|
||||||
|
return 'neutral'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(item: KpiStatItem): KpiTone {
|
||||||
|
if (item.variant === 'destructive') return 'er'
|
||||||
|
if (item.variant === 'warning') return 'wn'
|
||||||
|
return iconTone(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Полоса KPI: одна рамка, иконка слева, значение и чип. https://reui.io/preview/base/stats-12 */
|
||||||
|
|
||||||
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
@@ -66,15 +118,16 @@ function isSelected(item: KpiStatItem): boolean {
|
|||||||
return Boolean(item.selected ?? item.active)
|
return Boolean(item.selected ?? item.active)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveFooter(item: KpiStatItem): ReactNode {
|
function resolveFooter(item: KpiStatItem, tone: KpiTone): ReactNode {
|
||||||
if (item.footer) return item.footer
|
if (item.footer) return item.footer
|
||||||
if (typeof item.hint === 'string') {
|
if (typeof item.hint === 'string') {
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge variant={BADGE[tone]} size="sm" className="max-w-full whitespace-nowrap">
|
||||||
variant={HINT_VARIANT[item.variant ?? 'default']}
|
{GLYPH[tone] ? (
|
||||||
size="sm"
|
<span aria-hidden className="text-[0.55rem]">
|
||||||
className="max-w-full truncate"
|
{GLYPH[tone]}
|
||||||
>
|
</span>
|
||||||
|
) : null}
|
||||||
{item.hint}
|
{item.hint}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
@@ -84,45 +137,43 @@ function resolveFooter(item: KpiStatItem): ReactNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||||
const footer = resolveFooter(item)
|
const tone = iconTone(item)
|
||||||
|
const status = statusTone(item)
|
||||||
|
const footer = resolveFooter(item, status)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative z-10 flex h-full min-w-0 flex-col items-start gap-6">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
{item.icon ? (
|
{item.icon ? (
|
||||||
<Item
|
<IconTile variant="outline" aria-hidden className={cn('shrink-0', TILE[tone])}>
|
||||||
className={cn(STAT_ICON_CLASS, item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
{item.icon}
|
||||||
aria-hidden="true"
|
</IconTile>
|
||||||
>
|
|
||||||
<ItemMedia variant="icon" className="size-auto">
|
|
||||||
{item.icon}
|
|
||||||
</ItemMedia>
|
|
||||||
</Item>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
<div className="text-foreground min-w-0 text-2xl leading-none font-bold break-all tabular-nums">
|
<div className="text-muted-foreground truncate text-[13px]">{item.label}</div>
|
||||||
{item.value}
|
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||||
</div>
|
<span className={cn('text-2xl font-semibold tracking-tight tabular-nums', VALUE[status])}>
|
||||||
<div className="text-muted-foreground min-w-0 text-sm font-medium">
|
{item.value}
|
||||||
{item.label}
|
</span>
|
||||||
|
{footer}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{footer}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function panelClassName(item: KpiStatItem, className?: string) {
|
function panelClassName(item: KpiStatItem, className?: string, flush = false) {
|
||||||
const onActivate = resolveActivate(item)
|
const onActivate = resolveActivate(item)
|
||||||
const clickable = Boolean(item.to || onActivate)
|
const clickable = Boolean(item.to || onActivate)
|
||||||
const selected = isSelected(item)
|
const selected = isSelected(item)
|
||||||
|
|
||||||
return cn(
|
return cn(
|
||||||
'relative isolate flex h-full min-w-0 flex-col',
|
flush
|
||||||
clickable &&
|
? 'bg-card flex h-full min-w-0 items-center'
|
||||||
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
|
: 'relative isolate flex h-full min-w-0 flex-col',
|
||||||
selected && 'ring-primary/30 bg-muted/30 ring-1',
|
clickable && 'hover:bg-muted/40 cursor-pointer transition-colors',
|
||||||
|
!flush && clickable && 'focus-within:ring-ring focus-within:ring-2',
|
||||||
|
selected && (flush ? 'bg-accent/70' : 'ring-primary/30 bg-muted/30 ring-1'),
|
||||||
className,
|
className,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -131,28 +182,31 @@ function panelClassName(item: KpiStatItem, className?: string) {
|
|||||||
export function KpiStatCardTile({
|
export function KpiStatCardTile({
|
||||||
item,
|
item,
|
||||||
embedded = false,
|
embedded = false,
|
||||||
|
flush = false,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
item: KpiStatItem
|
item: KpiStatItem
|
||||||
embedded?: boolean
|
embedded?: boolean
|
||||||
|
flush?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const onActivate = resolveActivate(item)
|
const onActivate = resolveActivate(item)
|
||||||
const panelClass = panelClassName(item, className)
|
const panelClass = panelClassName(item, className, flush)
|
||||||
|
const Shell = flush ? 'div' : FramePanel
|
||||||
|
|
||||||
let panel: ReactNode
|
let panel: ReactNode
|
||||||
|
|
||||||
if (item.to) {
|
if (item.to) {
|
||||||
panel = (
|
panel = (
|
||||||
<FramePanel className={panelClass}>
|
<Shell className={panelClass}>
|
||||||
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
<Link to={item.to} search={item.search} className="focus-visible:outline-none w-full">
|
||||||
<KpiStatCardBody item={item} />
|
<KpiStatCardBody item={item} />
|
||||||
</Link>
|
</Link>
|
||||||
</FramePanel>
|
</Shell>
|
||||||
)
|
)
|
||||||
} else if (onActivate) {
|
} else if (onActivate) {
|
||||||
panel = (
|
panel = (
|
||||||
<FramePanel
|
<Shell
|
||||||
className={panelClass}
|
className={panelClass}
|
||||||
onClick={onActivate}
|
onClick={onActivate}
|
||||||
role="button"
|
role="button"
|
||||||
@@ -160,16 +214,18 @@ export function KpiStatCardTile({
|
|||||||
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||||
>
|
>
|
||||||
<KpiStatCardBody item={item} />
|
<KpiStatCardBody item={item} />
|
||||||
</FramePanel>
|
</Shell>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
panel = (
|
panel = (
|
||||||
<FramePanel className={panelClass}>
|
<Shell className={panelClass}>
|
||||||
<KpiStatCardBody item={item} />
|
<KpiStatCardBody item={item} />
|
||||||
</FramePanel>
|
</Shell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (flush) return panel
|
||||||
|
|
||||||
if (embedded) {
|
if (embedded) {
|
||||||
return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
|
return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
|
||||||
}
|
}
|
||||||
@@ -191,27 +247,26 @@ export function KpiStatCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function statGridClass(count: number) {
|
function statGridClass(count: number) {
|
||||||
if (count >= 4) return 'grid-cols-1 gap-5 @xl:grid-cols-2 @5xl:grid-cols-4'
|
return COLS[Math.min(Math.max(count, 1), 4)] ?? COLS[4]
|
||||||
if (count === 3) return 'grid-cols-1 gap-5 @3xl:grid-cols-3'
|
|
||||||
return 'grid-cols-1 gap-5 @3xl:grid-cols-2'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function KpiStatGridSkeleton({ count }: { count: number }) {
|
function KpiStatGridSkeleton({ count }: { count: number }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('@container grid w-full', statGridClass(count))}>
|
<Frame spacing="sm" className="w-full min-w-0">
|
||||||
{Array.from({ length: count }).map((_, index) => (
|
<FramePanel className="overflow-hidden p-0">
|
||||||
<Frame key={index}>
|
<div className={cn('bg-border grid gap-px', statGridClass(count))}>
|
||||||
<FramePanel className="flex flex-col items-start gap-6">
|
{Array.from({ length: count }).map((_, index) => (
|
||||||
<Skeleton className="size-10.5 rounded-lg" />
|
<div key={index} className="bg-card flex items-center gap-3 px-[18px] py-3.5">
|
||||||
<div className="flex flex-col gap-1.5">
|
<Skeleton className="size-10 rounded-lg" />
|
||||||
<Skeleton className="h-7 w-16" />
|
<div className="flex flex-col gap-1.5">
|
||||||
<Skeleton className="h-4 w-24" />
|
<Skeleton className="h-3.5 w-16" />
|
||||||
|
<Skeleton className="h-7 w-12" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-5 w-28 rounded-full" />
|
))}
|
||||||
</FramePanel>
|
</div>
|
||||||
</Frame>
|
</FramePanel>
|
||||||
))}
|
</Frame>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +298,6 @@ export function KpiStatGrid({
|
|||||||
emptyIcon,
|
emptyIcon,
|
||||||
className,
|
className,
|
||||||
skeletonCount = 4,
|
skeletonCount = 4,
|
||||||
embedded = false,
|
|
||||||
'aria-label': ariaLabel,
|
'aria-label': ariaLabel,
|
||||||
}: KpiStatGridProps) {
|
}: KpiStatGridProps) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -266,17 +320,21 @@ export function KpiStatGrid({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
|
<Frame spacing="sm" aria-label={ariaLabel} className={cn('w-full min-w-0', className)}>
|
||||||
<div className={cn('grid', statGridClass(list.length || 1))}>
|
<FramePanel className="overflow-hidden p-0">
|
||||||
{list.map((item, index) => (
|
<div className={cn('bg-border grid gap-px', statGridClass(list.length || 1))}>
|
||||||
<KpiStatCardTile
|
{list.map((item, index) => (
|
||||||
key={kpiStatItemKey(item, index)}
|
<div key={kpiStatItemKey(item, index)} className="bg-card min-w-0">
|
||||||
item={item}
|
<KpiStatCardTile
|
||||||
embedded={embedded}
|
item={item}
|
||||||
/>
|
flush
|
||||||
))}
|
className="bg-card px-[18px] py-3.5 shadow-none ring-0"
|
||||||
</div>
|
/>
|
||||||
</section>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,17 +8,21 @@ import {
|
|||||||
type PaginationState,
|
type PaginationState,
|
||||||
type RowSelectionState,
|
type RowSelectionState,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
|
type VisibilityState,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table'
|
||||||
import {
|
import {
|
||||||
CircleAlertIcon,
|
CircleAlertIcon,
|
||||||
|
Columns3Icon,
|
||||||
FilterIcon,
|
FilterIcon,
|
||||||
FunnelXIcon,
|
FunnelXIcon,
|
||||||
|
SearchIcon,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||||
|
import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
|
||||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||||
@@ -36,6 +40,7 @@ import {
|
|||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
import { Separator } from '@cfdm/ui/components/separator'
|
import { Separator } from '@cfdm/ui/components/separator'
|
||||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||||
import {
|
import {
|
||||||
@@ -157,6 +162,8 @@ export function ResourcePage<T extends object>({
|
|||||||
const activeTab = controlledTab ?? internalTab
|
const activeTab = controlledTab ?? internalTab
|
||||||
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([])
|
const [sorting, setSorting] = useState<SortingState>([])
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
@@ -171,11 +178,15 @@ export function ResourcePage<T extends object>({
|
|||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
let result = applyFiltersToData(data, filters, getFilterFieldValue)
|
let result = applyFiltersToData(data, filters, getFilterFieldValue)
|
||||||
|
const needle = query.trim().toLowerCase()
|
||||||
|
if (needle) {
|
||||||
|
result = result.filter((item) => JSON.stringify(item).toLowerCase().includes(needle))
|
||||||
|
}
|
||||||
if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') {
|
if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') {
|
||||||
result = result.filter((item) => tabFilter(item, activeTab))
|
result = result.filter((item) => tabFilter(item, activeTab))
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab])
|
}, [data, filters, getFilterFieldValue, query, tabs, tabFilter, activeTab])
|
||||||
|
|
||||||
const tabCounts = useMemo(() => {
|
const tabCounts = useMemo(() => {
|
||||||
if (!tabs?.length || !tabFilter) return {}
|
if (!tabs?.length || !tabFilter) return {}
|
||||||
@@ -205,9 +216,10 @@ export function ResourcePage<T extends object>({
|
|||||||
data: filteredData,
|
data: filteredData,
|
||||||
columns,
|
columns,
|
||||||
getRowId,
|
getRowId,
|
||||||
state: { sorting, rowSelection, pagination },
|
state: { sorting, columnVisibility, rowSelection, pagination },
|
||||||
enableRowSelection,
|
enableRowSelection,
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -409,19 +421,46 @@ export function ResourcePage<T extends object>({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
||||||
<Filters
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||||
filters={filters}
|
<div className="relative w-full max-w-xs">
|
||||||
fields={filterFields}
|
<SearchIcon
|
||||||
onChange={handleFiltersChange}
|
className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2"
|
||||||
size="default"
|
aria-hidden
|
||||||
trigger={
|
/>
|
||||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
<Input
|
||||||
<FilterIcon className="size-4" aria-hidden="true" />
|
value={query}
|
||||||
Фильтры
|
onChange={(event) => {
|
||||||
</Button>
|
setQuery(event.target.value)
|
||||||
}
|
resetPagination()
|
||||||
/>
|
}}
|
||||||
|
aria-label="Поиск"
|
||||||
|
placeholder="Поиск"
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<DataGridColumnVisibility
|
||||||
|
table={table}
|
||||||
|
trigger={
|
||||||
|
<Button type="button" variant="outline" aria-label="Колонки">
|
||||||
|
<Columns3Icon className="size-4" aria-hidden />
|
||||||
|
Колонки
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{toolbarExtra}
|
{toolbarExtra}
|
||||||
{selectedCount > 0 ? (
|
{selectedCount > 0 ? (
|
||||||
<Badge size="sm" variant="secondary">
|
<Badge size="sm" variant="secondary">
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ function DataGridColumnVisibility<TData>({
|
|||||||
<DropdownMenuContent align="end" className="min-w-[150px]">
|
<DropdownMenuContent align="end" className="min-w-[150px]">
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuLabel className="font-medium">
|
<DropdownMenuLabel className="font-medium">
|
||||||
Toggle Columns
|
Колонки
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
{table
|
{table
|
||||||
.getAllColumns()
|
.getAllColumns()
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@cfdm/ui/components/select'
|
} from '@cfdm/ui/components/select'
|
||||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||||
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/domains/')({
|
export const Route = createFileRoute('/_auth/domains/')({
|
||||||
loader: ({ context: { queryClient } }) =>
|
loader: ({ context: { queryClient } }) =>
|
||||||
@@ -55,10 +56,9 @@ export const Route = createFileRoute('/_auth/domains/')({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function DomainsPage() {
|
function DomainsPage() {
|
||||||
const [groupOpen, setGroupOpen] = useState(false)
|
|
||||||
const [groupValue, setGroupValue] = useState('none')
|
|
||||||
const selectedIdsRef = useRef<string[]>([])
|
const selectedIdsRef = useRef<string[]>([])
|
||||||
const [syncOpen, setSyncOpen] = useState(false)
|
const [syncOpen, setSyncOpen] = useState(false)
|
||||||
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
const [deleteTarget, setDeleteTarget] = useState<DomainListItem | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<DomainListItem | null>(null)
|
||||||
const [filters, setFilters] = useState<Filter[]>(createDefaultDomainFilters)
|
const [filters, setFilters] = useState<Filter[]>(createDefaultDomainFilters)
|
||||||
const [activeTab, setActiveTab] = useState('all')
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
@@ -84,7 +84,8 @@ function DomainsPage() {
|
|||||||
|
|
||||||
const filterFields = useDomainFilterFields(groupItems)
|
const filterFields = useDomainFilterFields(groupItems)
|
||||||
|
|
||||||
const form = useForm({ defaultValues: { group_id: '' } })
|
const [importGroup, setImportGroup] = useState('none')
|
||||||
|
const importForm = useForm({ defaultValues: { zone_name: '' } })
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: number) => api.delete(`/api/v1/domains/${id}`),
|
mutationFn: (id: number) => api.delete(`/api/v1/domains/${id}`),
|
||||||
@@ -99,6 +100,21 @@ function DomainsPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const importMutation = useMutation({
|
||||||
|
mutationFn: (body: { zone_name: string; group_id: number | null }) =>
|
||||||
|
api.post('/api/v1/domains', body),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: domainKeys.all })
|
||||||
|
setImportOpen(false)
|
||||||
|
setImportGroup('none')
|
||||||
|
importForm.reset({ zone_name: '' })
|
||||||
|
toast.success('Домен импортирован')
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Не удалось импортировать домен')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const syncMutation = useMutation({
|
const syncMutation = useMutation({
|
||||||
mutationFn: () => api.post<{ job_id: string }>('/api/v1/sync'),
|
mutationFn: () => api.post<{ job_id: string }>('/api/v1/sync'),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -134,18 +150,10 @@ function DomainsPage() {
|
|||||||
|
|
||||||
const freshness = domainFreshnessLine(domains)
|
const freshness = domainFreshnessLine(domains)
|
||||||
|
|
||||||
function openGroupSheet() {
|
|
||||||
if (selectedIdsRef.current.length === 0) {
|
|
||||||
toast.message('Выберите домены в таблице')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setGroupOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const primaryAction = (
|
const primaryAction = (
|
||||||
<>
|
<>
|
||||||
<Button type="button" variant="outline" onClick={openGroupSheet}>
|
<Button type="button" variant="outline" onClick={() => setImportOpen(true)}>
|
||||||
Сменить группу
|
Импортировать домен
|
||||||
</Button>
|
</Button>
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
type="button"
|
type="button"
|
||||||
@@ -153,7 +161,7 @@ function DomainsPage() {
|
|||||||
isLoading={syncMutation.isPending}
|
isLoading={syncMutation.isPending}
|
||||||
loadingLabel="Синхронизация…"
|
loadingLabel="Синхронизация…"
|
||||||
>
|
>
|
||||||
Синхронизировать с Cloudflare
|
Синхронизировать
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -262,41 +270,43 @@ function DomainsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<FormSheet
|
<FormSheet
|
||||||
open={groupOpen}
|
open={importOpen}
|
||||||
onOpenChange={setGroupOpen}
|
onOpenChange={setImportOpen}
|
||||||
title="Сменить группу"
|
title="Импортировать домен"
|
||||||
description="Группа применится к выбранным доменам"
|
description="Зона должна уже существовать в Cloudflare"
|
||||||
form={form}
|
form={importForm}
|
||||||
onSubmit={() => {
|
onSubmit={(values) => {
|
||||||
const ids = selectedIdsRef.current.map(Number)
|
importMutation.mutate({
|
||||||
if (ids.length === 0) return
|
zone_name: values.zone_name.trim(),
|
||||||
bulkMutation.mutate(
|
group_id: importGroup === 'none' ? null : Number(importGroup),
|
||||||
{
|
})
|
||||||
ids,
|
|
||||||
group_id: groupValue === 'none' ? null : Number(groupValue),
|
|
||||||
},
|
|
||||||
{ onSuccess: () => setGroupOpen(false) },
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
footer={
|
footer={
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
isLoading={bulkMutation.isPending}
|
isLoading={importMutation.isPending}
|
||||||
loadingLabel="Применение…"
|
loadingLabel="Импорт…"
|
||||||
>
|
>
|
||||||
Применить
|
Импортировать
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<FormFieldSimple label="Группа" htmlFor="change_group_id" hint="Домены без группы можно оставить без изменений">
|
<FormFieldSimple label="Зона" htmlFor="import_zone_name">
|
||||||
|
<Input
|
||||||
|
id="import_zone_name"
|
||||||
|
placeholder="example.com"
|
||||||
|
{...importForm.register('zone_name', { required: true })}
|
||||||
|
/>
|
||||||
|
</FormFieldSimple>
|
||||||
|
<FormFieldSimple label="Группа" htmlFor="import_group_id">
|
||||||
<Select
|
<Select
|
||||||
items={groupItems}
|
items={groupItems}
|
||||||
value={groupValue}
|
value={importGroup}
|
||||||
onValueChange={(value) => setGroupValue(value ?? 'none')}
|
onValueChange={(value) => setImportGroup(value ?? 'none')}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="change_group_id" className="w-full">
|
<SelectTrigger id="import_group_id" className="w-full">
|
||||||
<SelectValue placeholder="Без группы" />
|
<SelectValue placeholder="Без группы" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|||||||
@@ -101,8 +101,14 @@ function GroupsPage() {
|
|||||||
name: group.name,
|
name: group.name,
|
||||||
slug: group.slug,
|
slug: group.slug,
|
||||||
domainCount: (domains ?? []).filter((d) => d.group_id === group.id).length,
|
domainCount: (domains ?? []).filter((d) => d.group_id === group.id).length,
|
||||||
|
serviceCount: new Set(
|
||||||
|
(bindings ?? [])
|
||||||
|
.filter((binding) => binding.group_id === group.id)
|
||||||
|
.map((binding) => binding.service_id),
|
||||||
|
).size,
|
||||||
|
createdAt: group.created_at,
|
||||||
}))
|
}))
|
||||||
}, [groups, domains])
|
}, [groups, domains, bindings])
|
||||||
|
|
||||||
const groupById = useMemo(() => {
|
const groupById = useMemo(() => {
|
||||||
const map = new Map((groups ?? []).map((g) => [g.id, g]))
|
const map = new Map((groups ?? []).map((g) => [g.id, g]))
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { LogOutIcon, PaletteIcon } from 'lucide-react'
|
import { LogOutIcon, PaletteIcon, TriangleAlertIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||||
|
|
||||||
type SettingsResponse = {
|
type SettingsResponse = {
|
||||||
id: string
|
id: string
|
||||||
@@ -38,6 +40,7 @@ export const Route = createFileRoute('/_auth/settings/appearance')({
|
|||||||
function AppearanceSettingsPage() {
|
function AppearanceSettingsPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const [draftQuickActions, setDraftQuickActions] = useState<boolean | null>(null)
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['app-settings'],
|
queryKey: ['app-settings'],
|
||||||
queryFn: () => api.get<SettingsResponse>('/api/v1/settings'),
|
queryFn: () => api.get<SettingsResponse>('/api/v1/settings'),
|
||||||
@@ -47,6 +50,7 @@ function AppearanceSettingsPage() {
|
|||||||
mutationFn: (patch: { showQuickActions: boolean }) =>
|
mutationFn: (patch: { showQuickActions: boolean }) =>
|
||||||
api.patch<SettingsResponse>('/api/v1/settings', patch),
|
api.patch<SettingsResponse>('/api/v1/settings', patch),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
setDraftQuickActions(null)
|
||||||
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
|
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
|
||||||
toast.success('Настройки интерфейса сохранены')
|
toast.success('Настройки интерфейса сохранены')
|
||||||
},
|
},
|
||||||
@@ -54,7 +58,9 @@ function AppearanceSettingsPage() {
|
|||||||
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const showQuickActions = data?.showQuickActions !== false
|
const serverQuickActions = data?.showQuickActions !== false
|
||||||
|
const showQuickActions = draftQuickActions ?? serverQuickActions
|
||||||
|
const appearanceDirty = data != null && draftQuickActions != null && draftQuickActions !== serverQuickActions
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
clearToken()
|
clearToken()
|
||||||
@@ -68,6 +74,33 @@ function AppearanceSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-4">
|
<div className="flex w-full flex-col gap-4">
|
||||||
|
{appearanceDirty ? (
|
||||||
|
<Alert variant="warning" role="status">
|
||||||
|
<TriangleAlertIcon aria-hidden />
|
||||||
|
<AlertTitle>Есть несохранённые изменения</AlertTitle>
|
||||||
|
<AlertDescription className="flex flex-col gap-2">
|
||||||
|
<span>Внешний вид отличается от последнего ответа сервера.</span>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={patchMut.isPending}
|
||||||
|
onClick={() => patchMut.mutate({ showQuickActions })}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDraftQuickActions(null)}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
<Frame dense spacing="sm" className="w-full">
|
<Frame dense spacing="sm" className="w-full">
|
||||||
<FrameHeader>
|
<FrameHeader>
|
||||||
<FrameTitle className="flex items-center gap-2">
|
<FrameTitle className="flex items-center gap-2">
|
||||||
@@ -86,9 +119,7 @@ function AppearanceSettingsPage() {
|
|||||||
<Switch
|
<Switch
|
||||||
checked={showQuickActions}
|
checked={showQuickActions}
|
||||||
disabled={isLoading || patchMut.isPending}
|
disabled={isLoading || patchMut.isPending}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) => setDraftQuickActions(checked)}
|
||||||
patchMut.mutate({ showQuickActions: checked })
|
|
||||||
}
|
|
||||||
aria-label="Показывать быстрые действия"
|
aria-label="Показывать быстрые действия"
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|||||||
@@ -8,37 +8,37 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(0.973 0.004 247.858);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.141 0.006 264);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.141 0.006 264);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.141 0.006 264);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.205 0 0);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.958 0.005 247.858);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.958 0.005 247.858);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.52 0.01 264);
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.942 0.016 264.376);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.904 0.006 264);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.904 0.006 264);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.488 0.243 264.376);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.546 0.215 264.376);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.527 0.154 150.069);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.625 0.162 41.5);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--chart-4: oklch(0.591 0.224 296.4);
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.577 0.245 27.325);
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.152 0.01 264.376);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.92 0 0);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.24 0.012 264.376);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.95 0 0);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(1 0 0 / 8%);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||||
--destructive-foreground: var(--color-red-800);
|
--destructive-foreground: var(--color-red-800);
|
||||||
--success: var(--color-emerald-500);
|
--success: var(--color-emerald-500);
|
||||||
--success-foreground: var(--color-emerald-900);
|
--success-foreground: var(--color-emerald-900);
|
||||||
@@ -53,37 +53,37 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.133 0.011 264.376);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.205 0 0);
|
--card: oklch(0.191 0.011 264.376);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.191 0.011 264.376);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: oklch(0.922 0 0);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.252 0.01 264.376);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.252 0.01 264.376);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.648 0.008 264.376);
|
||||||
--accent: oklch(0.269 0 0);
|
--accent: oklch(0.285 0.022 264.376);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.488 0.243 264.376);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: oklch(0.637 0.222 264.376);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.696 0.17 151.328);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.713 0.176 47.6);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.714 0.173 303.9);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.704 0.191 22.216);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.191 0.011 264.376);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.262 0.014 264.376);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||||
--destructive-foreground: var(--color-red-600);
|
--destructive-foreground: var(--color-red-600);
|
||||||
--success: var(--color-emerald-500);
|
--success: var(--color-emerald-500);
|
||||||
--success-foreground: var(--color-emerald-600);
|
--success-foreground: var(--color-emerald-600);
|
||||||
|
|||||||
Reference in new issue
Block a user