From b5300b9082d474ec4f3aefbe26f3645ec37a993e Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 6 Nov 2026 21:57:47 +0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B8=20shell=20=D0=B8=20=D0=B4=D0=B0=D1=88?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=B4=20=D0=BD=D0=B0=20=D0=B4=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D0=B9=D0=BD=20VPS-Tracker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сворачиваемые группы меню с точками проблем, приложения в AppSwitcher вместо AppsMenu в шапке, пилюля монитора со счётчиками, KPI со значением и бейджем в одной строке, вкладки дашборда на уровне страницы. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/app-switcher.tsx | 16 ++ apps/web/src/components/counted-line-tabs.tsx | 4 +- apps/web/src/components/layout/app-shell.tsx | 122 +++++++----- apps/web/src/components/layout/apps-menu.tsx | 105 ---------- apps/web/src/components/layout/nav-user.tsx | 10 +- .../layout/system-monitor-popover.tsx | 68 ++++--- apps/web/src/components/reui-kit/kpi-cols.ts | 7 +- .../src/components/reui-kit/kpi-stat-grid.tsx | 35 ++-- .../src/components/reui-kit/ops-dashboard.tsx | 37 +--- apps/web/src/routes/_auth/dashboard.tsx | 182 ++++++++---------- 10 files changed, 256 insertions(+), 330 deletions(-) delete mode 100644 apps/web/src/components/layout/apps-menu.tsx diff --git a/apps/web/src/components/app-switcher.tsx b/apps/web/src/components/app-switcher.tsx index 8f76bf2..dd09f2a 100644 --- a/apps/web/src/components/app-switcher.tsx +++ b/apps/web/src/components/app-switcher.tsx @@ -2,6 +2,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger, } from '@cfdm/ui/components/dropdown-menu' @@ -11,8 +12,10 @@ import { SidebarMenuItem, useSidebar, } from '@cfdm/ui/components/sidebar' +import { Link } from '@tanstack/react-router' import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react' +import { authPortalUrl, isAuthEnabled } from '@/lib/auth' import { APP_SWITCHER_ICONS, CURRENT_APP_ID, @@ -88,6 +91,19 @@ export function AppSwitcher() { ) })} + + {isAuthEnabled() ? ( + } + > + Настроить на портале + + ) : ( + }> + Интеграции + + )} diff --git a/apps/web/src/components/counted-line-tabs.tsx b/apps/web/src/components/counted-line-tabs.tsx index f6cfe20..53419e7 100644 --- a/apps/web/src/components/counted-line-tabs.tsx +++ b/apps/web/src/components/counted-line-tabs.tsx @@ -17,7 +17,7 @@ interface CountedLineTabsProps { children?: ReactNode } -/** Line tabs with count pills (c-tabs-2 / data-grid-filtering-2). */ +/** Page line tabs with mono counts (design «VPS-Tracker» ptabs; c-tabs-2). */ export function CountedLineTabs({ tabs, value, @@ -37,7 +37,7 @@ export function CountedLineTabs({ > {tab.label} {tab.count !== undefined ? ( - + {tab.count} ) : null} diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index d04c25a..b075ce6 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -16,6 +16,7 @@ import { Network, ShieldAlert, Globe, + ChevronDownIcon, } from 'lucide-react' import { @@ -32,7 +33,13 @@ import { SidebarMenuItem, SidebarProvider, SidebarTrigger, + useSidebar, } from '@cfdm/ui/components/sidebar' +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@cfdm/ui/components/collapsible' import { Breadcrumb, BreadcrumbItem, @@ -41,8 +48,6 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from '@cfdm/ui/components/breadcrumb' -import { Separator } from '@cfdm/ui/components/separator' -import { Badge } from '@cfdm/ui/components/badge' import { TooltipProvider } from '@cfdm/ui/components/tooltip' import { Link, useRouterState } from '@tanstack/react-router' @@ -50,7 +55,6 @@ import { useQuery } from '@tanstack/react-query' import { useState, type CSSProperties, type ReactNode } from 'react' import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover' -import { AppsMenu } from '@/components/layout/apps-menu' import { SpaceSwitcher } from '@/components/layout/space-switcher' import { NavUser } from '@/components/layout/nav-user' import { AppSwitcher } from '@/components/app-switcher' @@ -62,12 +66,14 @@ import { } from '@/lib/auth' import { SpaceProvider, useSpaceId } from '@/lib/space' import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface' +import { cn } from '@cfdm/ui/lib/utils' interface NavItem { to: string label: string icon: typeof LayoutDashboard - badge?: number + /** Problem dot (design `pdot`): destructive / warning. */ + dot?: { tone: 'destructive' | 'warning'; label: string } } interface NavGroup { @@ -162,9 +168,21 @@ export function AppShell({ children }: { children: ReactNode }) { const perm = permissionForPath(item.to) return !perm || can(perm) }) - .map((item) => { + .map((item): NavItem => { if (item.to === '/dashboard' && stats?.issuesCount) { - return { ...item, badge: stats.issuesCount } + return { + ...item, + dot: { tone: 'destructive', label: `Проблем в инвентаре: ${stats.issuesCount}` }, + } + } + if (item.to === '/accounts' && stats?.lowBalanceAccountCount) { + return { + ...item, + dot: { + tone: 'warning', + label: `Низкий баланс: ${stats.lowBalanceAccountCount}`, + }, + } } return item }), @@ -190,38 +208,7 @@ export function AppShell({ children }: { children: ReactNode }) { {navGroups.map((group) => ( - - {group.label} - - - {group.items.map((item) => { - const Icon = item.icon - const isActive = - pathname === item.to || pathname.startsWith(`${item.to}/`) - return ( - - } - isActive={isActive} - tooltip={item.label} - > - - {item.label} - {item.badge ? ( - - {item.badge} - - ) : null} - - - ) - })} - - - + ))} @@ -231,10 +218,6 @@ export function AppShell({ children }: { children: ReactNode }) {
- {parentLabel && parentTo ? ( @@ -255,7 +238,6 @@ export function AppShell({ children }: { children: ReactNode }) {
-
@@ -270,6 +252,60 @@ export function AppShell({ children }: { children: ReactNode }) { ) } +/** Collapsible nav group (design `grph`): uppercase label + chevron; always open in icon rail. */ +function NavGroupSection({ group, pathname }: { group: NavGroup; pathname: string }) { + const { state, isMobile } = useSidebar() + const [open, setOpen] = useState(true) + const iconRail = state === 'collapsed' && !isMobile + + return ( + + + } + className="group/label hover:text-sidebar-foreground cursor-pointer uppercase tracking-wider transition-colors" + > + {group.label} + + + + + + {group.items.map((item) => { + const Icon = item.icon + const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`) + return ( + + } + isActive={isActive} + tooltip={item.label} + > + + {item.label} + {item.dot ? ( + + ) : null} + + + ) + })} + + + + + + ) +} + /** Remount route content when space changes so snapshot queries pick new X-Space-Id. */ function SpaceScopedMain({ children }: { children: ReactNode }) { const { spaceId } = useSpaceId() diff --git a/apps/web/src/components/layout/apps-menu.tsx b/apps/web/src/components/layout/apps-menu.tsx deleted file mode 100644 index c6c9f23..0000000 --- a/apps/web/src/components/layout/apps-menu.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { Link } from '@tanstack/react-router' -import { LayoutGridIcon } from 'lucide-react' -import { Button } from '@cfdm/ui/components/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@cfdm/ui/components/dropdown-menu' -import { authPortalUrl, isAuthEnabled } from '@/lib/auth' -import { - APP_SWITCHER_ICONS, - CURRENT_APP_ID, -} from '@/lib/app-switcher-config' -import { useAppSwitcherConfig } from '@/hooks/use-app-switcher' - -/** Header apps grid — app-shell-12 AppsMenu, wired to auth-portal App Switcher. */ -export function AppsMenu() { - const { config, isLoading } = useAppSwitcherConfig() - const portalAppsUrl = `${authPortalUrl().replace(/\/$/, '')}/admin/apps` - - return ( - - - } - > - - - - - - {isLoading ? 'Загрузка…' : config.menuLabel} - -
- {config.apps.map((app) => { - const Icon = APP_SWITCHER_ICONS[app.icon] ?? APP_SWITCHER_ICONS.server - const isCurrent = app.id === CURRENT_APP_ID - - if (isCurrent) { - return ( - - - - {app.name} - - ) - } - - return ( - } - className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5" - > - - - {app.name} - - ) - })} -
- - {isAuthEnabled() ? ( - } - className="justify-center text-sm font-medium" - > - Настроить на портале - - ) : ( - } - className="justify-center text-sm font-medium" - > - Интеграции - - )} -
-
-
- ) -} diff --git a/apps/web/src/components/layout/nav-user.tsx b/apps/web/src/components/layout/nav-user.tsx index 9548e67..7a610a0 100644 --- a/apps/web/src/components/layout/nav-user.tsx +++ b/apps/web/src/components/layout/nav-user.tsx @@ -141,12 +141,12 @@ export function NavUser() { render={ } > - - + + {fallback} @@ -166,8 +166,8 @@ export function NavUser() { > - - + + {fallback} diff --git a/apps/web/src/components/layout/system-monitor-popover.tsx b/apps/web/src/components/layout/system-monitor-popover.tsx index e5d2aa4..239d39d 100644 --- a/apps/web/src/components/layout/system-monitor-popover.tsx +++ b/apps/web/src/components/layout/system-monitor-popover.tsx @@ -3,12 +3,16 @@ import { useQuery } from '@tanstack/react-query' import { Activity, Bell, + ChevronRight, RefreshCw, Server, Wallet, } from 'lucide-react' +import { Link } from '@tanstack/react-router' + import { Badge } from '@/components/reui/badge' +import { Button } from '@cfdm/ui/components/button' import { cn } from '@cfdm/ui/lib/utils' import { Item, ItemMedia } from '@cfdm/ui/components/item' import { Popover, PopoverContent, PopoverTrigger } from '@cfdm/ui/components/popover' @@ -214,42 +218,43 @@ export function SystemMonitorPopover() { ], ) - const spiking = metrics.some((m) => m.alert) || !apiOk + const errorCount = metrics.filter((m) => m.alert && m.tone === 'destructive').length + (apiOk ? 0 : 1) + const warningCount = metrics.filter((m) => m.alert && m.tone === 'warning').length + const spiking = errorCount + warningCount > 0 + const statusLabel = spiking ? 'Внимание' : 'Норма' return ( } > - - - {spiking ? ( - - ) : null} - - Система - - {spiking ? 'Внимание' : 'Норма'} - + 0 ? 'text-destructive' : warningCount > 0 ? 'text-warning' : 'text-muted-foreground', + )} + /> + {statusLabel} + {errorCount > 0 ? ( + + {errorCount} + + ) : null} + {warningCount > 0 ? ( + + {warningCount} + + ) : null} + {!spiking ? ( + + OK + + ) : null} @@ -290,6 +295,13 @@ export function SystemMonitorPopover() { ) : null} + + Журнал синка + + ) diff --git a/apps/web/src/components/reui-kit/kpi-cols.ts b/apps/web/src/components/reui-kit/kpi-cols.ts index cd8054a..16f4555 100644 --- a/apps/web/src/components/reui-kit/kpi-cols.ts +++ b/apps/web/src/components/reui-kit/kpi-cols.ts @@ -3,8 +3,7 @@ export function kpiCols(count: number): string { if (count <= 1) return 'grid-cols-1' if (count === 2) return 'grid-cols-1 @xl:grid-cols-2' if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3' - if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4' - if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5' - if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6' - return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4' + if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @5xl:grid-cols-4' + // Design «VPS-Tracker»: at most 4 KPI columns, wrapping to the next row. + return 'grid-cols-1 @xl:grid-cols-2 @5xl:grid-cols-4' } 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 e75db71..56e83fb 100644 --- a/apps/web/src/components/reui-kit/kpi-stat-grid.tsx +++ b/apps/web/src/components/reui-kit/kpi-stat-grid.tsx @@ -63,11 +63,17 @@ function isSelected(item: KpiStatItem): boolean { return Boolean(item.selected ?? item.active) } +const HINT_BADGE_VARIANT = { + default: 'outline', + warning: 'warning-light', + destructive: 'destructive-light', +} as const + function resolveFooter(item: KpiStatItem): ReactNode { if (item.footer) return item.footer if (typeof item.hint === 'string') { return ( - + {item.hint} ) @@ -81,7 +87,7 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) { const valueVariant = item.variant ?? 'default' return ( -
+
{item.icon ? ( ) : null} -
-
-
{item.label}
- {footer ?
{footer}
: null} -
-
- {item.value} + {/* Design «VPS-Tracker» KPI: label, then value + hint Badge on one baseline. */} +
+
{item.label}
+
+ + {item.value} + + {footer ? {footer} : null}
diff --git a/apps/web/src/components/reui-kit/ops-dashboard.tsx b/apps/web/src/components/reui-kit/ops-dashboard.tsx index 26e2362..ae02659 100644 --- a/apps/web/src/components/reui-kit/ops-dashboard.tsx +++ b/apps/web/src/components/reui-kit/ops-dashboard.tsx @@ -1,11 +1,4 @@ import type { ReactNode } from 'react' -import { - Frame, - FrameDescription, - FrameHeader, - FramePanel, - FrameTitle, -} from '@/components/reui/frame' import { Skeleton } from '@cfdm/ui/components/skeleton' import { PageHeader } from '@/components/page-header' import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid' @@ -16,13 +9,15 @@ interface OpsDashboardProps { title?: string description?: string kpiCards: OpsKpiCard[] - charts: ReactNode - queue: ReactNode + /** Alert between the page header and KPI (design order: title → banner → KPI → tabs). */ + banner?: ReactNode + /** Page body after KPI — design «VPS-Tracker»: line tabs (Проблемы / Последние VPS / Аккаунты). */ + children: ReactNode isLoading?: boolean } const rootClassName = - 'text-foreground @container flex w-full flex-col gap-2 md:gap-3' + 'text-foreground @container flex w-full flex-col gap-4 md:gap-6' function OpsDashboardSkeleton() { return ( @@ -45,8 +40,8 @@ export function OpsDashboard({ title = 'Панель управления', description = 'Обзор VPS, платежей, тарифов и синхронизации', kpiCards, - charts, - queue, + banner, + children, isLoading = false, }: OpsDashboardProps) { if (isLoading) { @@ -57,25 +52,13 @@ export function OpsDashboard({
+ {banner} +
-
- {charts} -
- -
- - - Требуют внимания - - Проблемы оплаты, истекающие VPS и ошибки синхронизации - - - {queue} - -
+ {children}
) } diff --git a/apps/web/src/routes/_auth/dashboard.tsx b/apps/web/src/routes/_auth/dashboard.tsx index 44bbdec..a18dc97 100644 --- a/apps/web/src/routes/_auth/dashboard.tsx +++ b/apps/web/src/routes/_auth/dashboard.tsx @@ -29,9 +29,9 @@ import { dataGridCellStack } from '@/components/data-grid-cells' import { KpiStatGridSkeleton, TableSkeleton } from '@/components/skeletons' import { Button } from '@cfdm/ui/components/button' import { Badge } from '@/components/reui/badge' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs' +import { TabsContent } from '@cfdm/ui/components/tabs' +import { CountedLineTabs } from '@/components/counted-line-tabs' import { StatusBadge } from '@/components/status-badge' -import { cn } from '@cfdm/ui/lib/utils' import { computeInventoryHealth } from '@/lib/inventory-health' import { buildAtRiskAccounts, type AtRiskAccount } from '@/lib/account-health' @@ -47,9 +47,6 @@ import { DashboardInventoryAlert } from '@/components/domain/dashboard-inventory import type { Vps } from '@/types/entities' -const DASHBOARD_TAB_TRIGGER_CLASS = - 'flex-none rounded-none border-0 border-b-2 border-transparent px-3 pb-2.5 pt-2 shadow-none after:hidden data-active:border-foreground data-active:bg-transparent data-active:shadow-none dark:data-active:border-foreground dark:data-active:bg-transparent' - const DASHBOARD_QUICK_ACTIONS: QuickActionItem[] = [ { id: 'vps', @@ -371,111 +368,92 @@ function DashboardPage() { return (
- - + } title="Дашборд" description="Сводка по VPS, балансам и здоровью инвентаря" kpiCards={statsLoading ? [] : kpiCards} isLoading={statsLoading} - charts={ - - - - - } - queue={ -
- - - - Проблемы - {issues.length > 0 ? ( - - {issues.length} - - ) : null} - - - Последние VPS - - - Аккаунты - {atRisk.length > 0 ? ( - - {atRisk.length} - - ) : null} - - - - i.key} - emptyTitle="Проблем не найдено" - emptyDescription="Критичных проблем в инвентаре не обнаружено" - pagination={false} + > +
+ + + {snap.settings?.[0]?.showQuickActions !== false ? ( + - - - }> - Все VPS - - } - columns={columnDefFromDataGrid(vpsColumns)} - data={activeVps.slice(0, 8)} - getRowId={(v) => v.id} - pagination={false} - onRowClick={(v) => navigate({ to: '/vps', search: { edit: v.id } })} + ) : null} + + - - - r.id} - emptyTitle="Рисков нет" - emptyDescription="Балансы и синхронизация в норме" - pagination={false} + - - -
- } - /> - - {snap.settings?.[0]?.showQuickActions !== false ? ( - - ) : null} + + i.key} + emptyTitle="Проблем не найдено" + emptyDescription="Критичных проблем в инвентаре не обнаружено" + pagination={false} + /> +
+ + }> + Все VPS + + } + columns={columnDefFromDataGrid(vpsColumns)} + data={activeVps.slice(0, 8)} + getRowId={(v) => v.id} + pagination={false} + onRowClick={(v) => navigate({ to: '/vps', search: { edit: v.id } })} + /> + + + r.id} + emptyTitle="Рисков нет" + emptyDescription="Балансы и синхронизация в норме" + pagination={false} + /> + + +
+