From 8b028d04b4d88f6cd9f5ac9b650b258d996cdb16 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 2 Nov 2026 21:36:39 +0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=B2=D1=8B=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=BD=D1=8F=D1=82=D1=8C=20=D0=BF=D0=B0=D0=BB=D0=B8=D1=82=D1=80?= =?UTF-8?q?=D1=83=20=D0=B8=20KPI=20=D0=BF=D0=BE=D0=B4=20=D0=BC=D0=B0=D0=BA?= =?UTF-8?q?=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Светлая тема и сайдбар совпадают с артбордом, показатели собраны в одну рамку с цветом статуса. Co-authored-by: Cursor --- apps/web/src/components/cert-kpi-stats.tsx | 6 +- .../components/columns/domains-columns.tsx | 2 +- .../src/components/columns/groups-columns.tsx | 26 +++ .../domains/domain-detail-chrome.tsx | 2 +- .../vps-tracker-integration-card.tsx | 14 ++ .../web/src/components/layout/site-header.tsx | 25 +-- .../layout/system-monitor-popover.tsx | 2 +- apps/web/src/components/page-header.tsx | 2 +- .../src/components/reui-kit/kpi-stat-grid.tsx | 206 +++++++++++------- .../src/components/reui-kit/resource-page.tsx | 67 ++++-- .../data-grid/data-grid-column-visibility.tsx | 2 +- apps/web/src/routes/_auth/domains/index.tsx | 82 ++++--- apps/web/src/routes/_auth/groups.tsx | 8 +- .../src/routes/_auth/settings/appearance.tsx | 41 +++- packages/ui/src/styles/globals.css | 78 +++---- 15 files changed, 371 insertions(+), 192 deletions(-) diff --git a/apps/web/src/components/cert-kpi-stats.tsx b/apps/web/src/components/cert-kpi-stats.tsx index 33a3eec..83047b5 100644 --- a/apps/web/src/components/cert-kpi-stats.tsx +++ b/apps/web/src/components/cert-kpi-stats.tsx @@ -93,15 +93,11 @@ export function CertKpiStats({ ] }, [summary, total, activeTab, onSelectTab]) - const hasData = (total ?? totalFromSummary(summary)) > 0 - return ( } - emptyMessage="Нет данных — запустите проверку сертификатов" /> ) } diff --git a/apps/web/src/components/columns/domains-columns.tsx b/apps/web/src/components/columns/domains-columns.tsx index 808400b..960c29f 100644 --- a/apps/web/src/components/columns/domains-columns.tsx +++ b/apps/web/src/components/columns/domains-columns.tsx @@ -207,7 +207,7 @@ export function useDomainColumns({ }, { id: 'status', - header: 'Зона CF', + header: 'Статус', cell: ({ row }) => , }, { diff --git a/apps/web/src/components/columns/groups-columns.tsx b/apps/web/src/components/columns/groups-columns.tsx index 26be562..1f1b005 100644 --- a/apps/web/src/components/columns/groups-columns.tsx +++ b/apps/web/src/components/columns/groups-columns.tsx @@ -4,6 +4,7 @@ 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 { formatRelative, sqliteUtcToIso } from '@/lib/format' import { createFilter, type FilterFieldConfig } from '@/components/reui/filters' import { Button } from '@cfdm/ui/components/button' import { @@ -18,6 +19,8 @@ export interface GroupCatalogRow { name: string slug: string domainCount: number + serviceCount: number + createdAt: string } export const GROUP_TABS = [ @@ -114,9 +117,32 @@ export function useGroupColumns({ {row.original.domainCount} ), }, + { + id: 'serviceCount', + accessorKey: 'serviceCount', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.serviceCount} + ), + }, + { + id: 'createdAt', + accessorKey: 'createdAt', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatRelative(sqliteUtcToIso(row.original.createdAt) ?? row.original.createdAt)} + + ), + }, { id: 'actions', enableSorting: false, + enableHiding: false, header: () => Действия, cell: ({ row }) => ( diff --git a/apps/web/src/components/domains/domain-detail-chrome.tsx b/apps/web/src/components/domains/domain-detail-chrome.tsx index aec07c6..14330f2 100644 --- a/apps/web/src/components/domains/domain-detail-chrome.tsx +++ b/apps/web/src/components/domains/domain-detail-chrome.tsx @@ -287,12 +287,12 @@ export function DomainDetailChrome({ label: 'Поддомены', count: page.subdomainRows.length, }, - { id: 'bindings', label: 'Привязки', count: bindings.length }, { id: 'dns', label: 'DNS', count: dnsQuery.data ? dnsQuery.data.length : undefined, }, + { id: 'bindings', label: 'Привязки', count: bindings.length }, ]} value={activeTab} onValueChange={handleTabChange} diff --git a/apps/web/src/components/integrations/vps-tracker-integration-card.tsx b/apps/web/src/components/integrations/vps-tracker-integration-card.tsx index 2589c86..1dee0b6 100644 --- a/apps/web/src/components/integrations/vps-tracker-integration-card.tsx +++ b/apps/web/src/components/integrations/vps-tracker-integration-card.tsx @@ -140,6 +140,20 @@ export function VpsTrackerIntegrationCard({ Сохранить + diff --git a/apps/web/src/components/layout/site-header.tsx b/apps/web/src/components/layout/site-header.tsx index c371d4e..72eb1c6 100644 --- a/apps/web/src/components/layout/site-header.tsx +++ b/apps/web/src/components/layout/site-header.tsx @@ -1,6 +1,6 @@ import { Fragment, useMemo } from 'react' import { Link, useMatches, useRouterState } from '@tanstack/react-router' -import { MenuIcon } from 'lucide-react' +import { SearchIcon } from 'lucide-react' import { Breadcrumb, BreadcrumbItem, @@ -10,7 +10,6 @@ import { BreadcrumbSeparator, } from '@cfdm/ui/components/breadcrumb' import { Separator } from '@cfdm/ui/components/separator' -import { AppsMenu } from '@/components/layout/apps-menu' import { DataFreshness } from '@/components/layout/data-freshness' import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover' import { Button } from '@cfdm/ui/components/button' @@ -36,7 +35,7 @@ function useDynamicBreadcrumbLabels() { }, [matches]) } -/** Header chrome — freshness + AppsMenu + SystemMonitor; theme in NavUser (app-shell-1). */ +/** Шапка макета: триггер, крошки, свежесть и состояние. Приложения — в сайдбаре. */ export function SiteHeader() { const pathname = useRouterState({ select: (s) => s.location.pathname }) const dynamicLabels = useDynamicBreadcrumbLabels() @@ -48,15 +47,6 @@ export function SiteHeader() { return (
- @@ -85,7 +75,16 @@ export function SiteHeader() {
- +
diff --git a/apps/web/src/components/layout/system-monitor-popover.tsx b/apps/web/src/components/layout/system-monitor-popover.tsx index 3e86ace..eebcc6d 100644 --- a/apps/web/src/components/layout/system-monitor-popover.tsx +++ b/apps/web/src/components/layout/system-monitor-popover.tsx @@ -204,7 +204,7 @@ export function SystemMonitorPopover() {
- Монитор CFDM + Состояние системы {new Date().toLocaleTimeString('ru-RU')} diff --git a/apps/web/src/components/page-header.tsx b/apps/web/src/components/page-header.tsx index 9faf0bc..c1dd3f1 100644 --- a/apps/web/src/components/page-header.tsx +++ b/apps/web/src/components/page-header.tsx @@ -11,7 +11,7 @@ export function PageHeader({ title, description, actions }: PageHeaderProps) { return (
-

{title}

+

{title}

{description ? (

{description}

) : null} diff --git a/apps/web/src/components/reui-kit/kpi-stat-grid.tsx b/apps/web/src/components/reui-kit/kpi-stat-grid.tsx index dc3680b..019cea3 100644 --- a/apps/web/src/components/reui-kit/kpi-stat-grid.tsx +++ b/apps/web/src/components/reui-kit/kpi-stat-grid.tsx @@ -2,9 +2,9 @@ import type { KeyboardEvent, ReactNode } from 'react' import { Link } from '@tanstack/react-router' import { Frame, FramePanel } from '@/components/reui/frame' +import { IconTile } from '@/components/reui/icon-tile' import { Badge } from '@/components/reui/badge' import { cn } from '@cfdm/ui/lib/utils' -import { Item, ItemMedia } from '@cfdm/ui/components/item' import { Skeleton } from '@cfdm/ui/components/skeleton' export type KpiStatVariant = 'default' | 'warning' | 'destructive' @@ -39,17 +39,69 @@ export type OpsKpiCard = KpiStatCardData /** @deprecated Use KpiStatCardData — type alias for CFDM kit parity */ export type KpiStatCard = KpiStatCardData -const DEFAULT_ICON_CLASS = 'text-muted-foreground' +type KpiTone = 'neutral' | 'ok' | 'in' | 'wn' | 'er' | 'primary' -const HINT_VARIANT: Record = { - default: 'outline', - warning: 'warning-light', - destructive: 'destructive-light', +const TILE: Record = { + neutral: 'text-muted-foreground', + ok: 'text-success', + in: 'text-info', + wn: 'text-warning', + er: 'text-destructive', + primary: 'text-primary', } -/** stats-12 tile: https://reui.io/preview/base/stats-12 */ -const STAT_ICON_CLASS = - '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' +const VALUE: Record = { + neutral: 'text-foreground', + ok: 'text-foreground', + in: 'text-foreground', + primary: 'text-foreground', + wn: 'text-warning', + er: 'text-destructive', +} + +const BADGE: Record = { + neutral: 'outline', + ok: 'success-light', + in: 'info-light', + wn: 'warning-light', + er: 'destructive-light', + primary: 'primary-light', +} + +const GLYPH: Record = { + neutral: '', + ok: '●', + in: '', + primary: '', + wn: '▲', + er: '✕', +} + +const COLS: Record = { + 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) { if (event.key === 'Enter' || event.key === ' ') { @@ -66,15 +118,16 @@ function isSelected(item: KpiStatItem): boolean { 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 (typeof item.hint === 'string') { return ( - + + {GLYPH[tone] ? ( + + {GLYPH[tone]} + + ) : null} {item.hint} ) @@ -84,45 +137,43 @@ function resolveFooter(item: KpiStatItem): ReactNode { } function KpiStatCardBody({ item }: { item: KpiStatItem }) { - const footer = resolveFooter(item) + const tone = iconTone(item) + const status = statusTone(item) + const footer = resolveFooter(item, status) return ( -
+
{item.icon ? ( - + + {item.icon} + ) : null} -
-
- {item.value} -
-
- {item.label} +
+
{item.label}
+
+ + {item.value} + + {footer}
- - {footer}
) } -function panelClassName(item: KpiStatItem, className?: string) { +function panelClassName(item: KpiStatItem, className?: string, flush = false) { const onActivate = resolveActivate(item) const clickable = Boolean(item.to || onActivate) const selected = isSelected(item) return cn( - 'relative isolate flex h-full min-w-0 flex-col', - clickable && - 'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2', - selected && 'ring-primary/30 bg-muted/30 ring-1', + flush + ? 'bg-card flex h-full min-w-0 items-center' + : 'relative isolate flex h-full min-w-0 flex-col', + 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, ) } @@ -131,28 +182,31 @@ function panelClassName(item: KpiStatItem, className?: string) { export function KpiStatCardTile({ item, embedded = false, + flush = false, className, }: { item: KpiStatItem embedded?: boolean + flush?: boolean className?: string }) { const onActivate = resolveActivate(item) - const panelClass = panelClassName(item, className) + const panelClass = panelClassName(item, className, flush) + const Shell = flush ? 'div' : FramePanel let panel: ReactNode if (item.to) { panel = ( - - + + - + ) } else if (onActivate) { panel = ( - handleCardKeyDown(onActivate, e)} > - + ) } else { panel = ( - + - + ) } + if (flush) return panel + if (embedded) { return {panel} } @@ -191,27 +247,26 @@ export function KpiStatCard({ } function statGridClass(count: number) { - if (count >= 4) return 'grid-cols-1 gap-5 @xl:grid-cols-2 @5xl:grid-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' + return COLS[Math.min(Math.max(count, 1), 4)] ?? COLS[4] } function KpiStatGridSkeleton({ count }: { count: number }) { return ( -
- {Array.from({ length: count }).map((_, index) => ( - - - -
- - + + +
+ {Array.from({ length: count }).map((_, index) => ( +
+ +
+ + +
- - - - ))} -
+ ))} +
+
+ ) } @@ -243,7 +298,6 @@ export function KpiStatGrid({ emptyIcon, className, skeletonCount = 4, - embedded = false, 'aria-label': ariaLabel, }: KpiStatGridProps) { if (isLoading) { @@ -266,17 +320,21 @@ export function KpiStatGrid({ } return ( -
-
- {list.map((item, index) => ( - - ))} -
-
+ + +
+ {list.map((item, index) => ( +
+ +
+ ))} +
+
+ ) } diff --git a/apps/web/src/components/reui-kit/resource-page.tsx b/apps/web/src/components/reui-kit/resource-page.tsx index b7d836f..4dd7341 100644 --- a/apps/web/src/components/reui-kit/resource-page.tsx +++ b/apps/web/src/components/reui-kit/resource-page.tsx @@ -8,17 +8,21 @@ import { type PaginationState, type RowSelectionState, type SortingState, + type VisibilityState, } from '@tanstack/react-table' import { CircleAlertIcon, + Columns3Icon, FilterIcon, FunnelXIcon, + SearchIcon, type LucideIcon, } from 'lucide-react' import { CountedLineTabs } from '@/components/counted-line-tabs' import { Badge } from '@/components/reui/badge' 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 { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area' import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' @@ -36,6 +40,7 @@ import { FrameTitle, } from '@/components/reui/frame' import { Button } from '@cfdm/ui/components/button' +import { Input } from '@cfdm/ui/components/input' import { Separator } from '@cfdm/ui/components/separator' import { Skeleton } from '@cfdm/ui/components/skeleton' import { @@ -157,6 +162,8 @@ export function ResourcePage({ const activeTab = controlledTab ?? internalTab const [sorting, setSorting] = useState([]) + const [columnVisibility, setColumnVisibility] = useState({}) + const [query, setQuery] = useState('') const [rowSelection, setRowSelection] = useState({}) const [pagination, setPagination] = useState({ pageIndex: 0, @@ -171,11 +178,15 @@ export function ResourcePage({ const filteredData = useMemo(() => { 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') { result = result.filter((item) => tabFilter(item, activeTab)) } return result - }, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab]) + }, [data, filters, getFilterFieldValue, query, tabs, tabFilter, activeTab]) const tabCounts = useMemo(() => { if (!tabs?.length || !tabFilter) return {} @@ -205,9 +216,10 @@ export function ResourcePage({ data: filteredData, columns, getRowId, - state: { sorting, rowSelection, pagination }, + state: { sorting, columnVisibility, rowSelection, pagination }, enableRowSelection, onSortingChange: setSorting, + onColumnVisibilityChange: setColumnVisibility, onRowSelectionChange: setRowSelection, onPaginationChange: setPagination, getCoreRowModel: getCoreRowModel(), @@ -409,19 +421,46 @@ export function ResourcePage({ ) : null}
- -