feat(web): перевести shell и дашборд на дизайн VPS-Tracker

Сворачиваемые группы меню с точками проблем, приложения в AppSwitcher вместо
AppsMenu в шапке, пилюля монитора со счётчиками, KPI со значением и бейджем
в одной строке, вкладки дашборда на уровне страницы.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-11-06 21:57:47 +07:00
1 parent 10b20b9d55
commit b5300b9082
10 files changed
+256 -330

No files matched your search

+16
View File
@@ -2,6 +2,7 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut, DropdownMenuShortcut,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu' } from '@cfdm/ui/components/dropdown-menu'
@@ -11,8 +12,10 @@ import {
SidebarMenuItem, SidebarMenuItem,
useSidebar, useSidebar,
} from '@cfdm/ui/components/sidebar' } from '@cfdm/ui/components/sidebar'
import { Link } from '@tanstack/react-router'
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react' import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
import { import {
APP_SWITCHER_ICONS, APP_SWITCHER_ICONS,
CURRENT_APP_ID, CURRENT_APP_ID,
@@ -88,6 +91,19 @@ export function AppSwitcher() {
</DropdownMenuItem> </DropdownMenuItem>
) )
})} })}
<DropdownMenuSeparator />
{isAuthEnabled() ? (
<DropdownMenuItem
nativeButton={false}
render={<a href={`${authPortalUrl().replace(/\/$/, '')}/admin/apps`} />}
>
Настроить на портале
</DropdownMenuItem>
) : (
<DropdownMenuItem nativeButton={false} render={<Link to="/settings/integrations" />}>
Интеграции
</DropdownMenuItem>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</SidebarMenuItem> </SidebarMenuItem>
@@ -17,7 +17,7 @@ interface CountedLineTabsProps {
children?: ReactNode 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({ export function CountedLineTabs({
tabs, tabs,
value, value,
@@ -37,7 +37,7 @@ export function CountedLineTabs({
> >
<span>{tab.label}</span> <span>{tab.label}</span>
{tab.count !== undefined ? ( {tab.count !== undefined ? (
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums"> <span className="text-muted-foreground font-mono text-xs tabular-nums">
{tab.count} {tab.count}
</span> </span>
) : null} ) : null}
+79 -43
View File
@@ -16,6 +16,7 @@ import {
Network, Network,
ShieldAlert, ShieldAlert,
Globe, Globe,
ChevronDownIcon,
} from 'lucide-react' } from 'lucide-react'
import { import {
@@ -32,7 +33,13 @@ import {
SidebarMenuItem, SidebarMenuItem,
SidebarProvider, SidebarProvider,
SidebarTrigger, SidebarTrigger,
useSidebar,
} from '@cfdm/ui/components/sidebar' } from '@cfdm/ui/components/sidebar'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@cfdm/ui/components/collapsible'
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
@@ -41,8 +48,6 @@ import {
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
} from '@cfdm/ui/components/breadcrumb' } 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 { TooltipProvider } from '@cfdm/ui/components/tooltip'
import { Link, useRouterState } from '@tanstack/react-router' 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 { useState, type CSSProperties, type ReactNode } from 'react'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover' import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { AppsMenu } from '@/components/layout/apps-menu'
import { SpaceSwitcher } from '@/components/layout/space-switcher' import { SpaceSwitcher } from '@/components/layout/space-switcher'
import { NavUser } from '@/components/layout/nav-user' import { NavUser } from '@/components/layout/nav-user'
import { AppSwitcher } from '@/components/app-switcher' import { AppSwitcher } from '@/components/app-switcher'
@@ -62,12 +66,14 @@ import {
} from '@/lib/auth' } from '@/lib/auth'
import { SpaceProvider, useSpaceId } from '@/lib/space' import { SpaceProvider, useSpaceId } from '@/lib/space'
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface' import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
import { cn } from '@cfdm/ui/lib/utils'
interface NavItem { interface NavItem {
to: string to: string
label: string label: string
icon: typeof LayoutDashboard icon: typeof LayoutDashboard
badge?: number /** Problem dot (design `pdot`): destructive / warning. */
dot?: { tone: 'destructive' | 'warning'; label: string }
} }
interface NavGroup { interface NavGroup {
@@ -162,9 +168,21 @@ export function AppShell({ children }: { children: ReactNode }) {
const perm = permissionForPath(item.to) const perm = permissionForPath(item.to)
return !perm || can(perm) return !perm || can(perm)
}) })
.map((item) => { .map((item): NavItem => {
if (item.to === '/dashboard' && stats?.issuesCount) { 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 return item
}), }),
@@ -190,38 +208,7 @@ export function AppShell({ children }: { children: ReactNode }) {
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
{navGroups.map((group) => ( {navGroups.map((group) => (
<SidebarGroup key={group.label}> <NavGroupSection key={group.label} group={group} pathname={pathname} />
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => {
const Icon = item.icon
const isActive =
pathname === item.to || pathname.startsWith(`${item.to}/`)
return (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
render={<Link to={item.to} />}
isActive={isActive}
tooltip={item.label}
>
<Icon className="size-4" />
<span>{item.label}</span>
{item.badge ? (
<Badge
variant="destructive"
className="ml-auto size-5 justify-center p-0 text-xs"
>
{item.badge}
</Badge>
) : null}
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))} ))}
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter>
@@ -231,10 +218,6 @@ export function AppShell({ children }: { children: ReactNode }) {
<SidebarInset id="main-content"> <SidebarInset id="main-content">
<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" />
<Separator
orientation="vertical"
className="mr-2 data-[orientation=vertical]:h-4"
/>
<Breadcrumb> <Breadcrumb>
<BreadcrumbList> <BreadcrumbList>
{parentLabel && parentTo ? ( {parentLabel && parentTo ? (
@@ -255,7 +238,6 @@ export function AppShell({ children }: { children: ReactNode }) {
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<AppsMenu />
<SystemMonitorPopover /> <SystemMonitorPopover />
</div> </div>
</header> </header>
@@ -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 (
<Collapsible open={open || iconRail} onOpenChange={setOpen}>
<SidebarGroup>
<SidebarGroupLabel
render={<CollapsibleTrigger />}
className="group/label hover:text-sidebar-foreground cursor-pointer uppercase tracking-wider transition-colors"
>
{group.label}
<ChevronDownIcon className="ml-auto transition-transform group-not-data-panel-open/label:-rotate-90" />
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => {
const Icon = item.icon
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
return (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
render={<Link to={item.to} />}
isActive={isActive}
tooltip={item.label}
>
<Icon className="size-4" />
<span>{item.label}</span>
{item.dot ? (
<span
role="img"
aria-label={item.dot.label}
className={cn(
'ml-auto size-2 shrink-0 rounded-full',
'group-data-[collapsible=icon]:absolute group-data-[collapsible=icon]:top-1 group-data-[collapsible=icon]:right-1',
item.dot.tone === 'destructive' ? 'bg-destructive' : 'bg-warning',
)}
/>
) : null}
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
)
}
/** Remount route content when space changes so snapshot queries pick new X-Space-Id. */ /** Remount route content when space changes so snapshot queries pick new X-Space-Id. */
function SpaceScopedMain({ children }: { children: ReactNode }) { function SpaceScopedMain({ children }: { children: ReactNode }) {
const { spaceId } = useSpaceId() const { spaceId } = useSpaceId()
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon" aria-label="Приложения" />
}
>
<LayoutGridIcon
className="size-4.5 transition-colors"
aria-hidden="true"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
sideOffset={8}
className="w-72"
>
<DropdownMenuGroup>
<DropdownMenuLabel>
{isLoading ? 'Загрузка…' : config.menuLabel}
</DropdownMenuLabel>
<div className="grid grid-cols-3 gap-1 p-1">
{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 (
<DropdownMenuItem
key={app.id}
disabled
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden="true" />
</span>
<span className="text-xs font-medium">{app.name}</span>
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem
key={app.id}
nativeButton={false}
render={<a href={app.url} />}
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden="true" />
</span>
<span className="text-xs font-medium">{app.name}</span>
</DropdownMenuItem>
)
})}
</div>
<DropdownMenuSeparator />
{isAuthEnabled() ? (
<DropdownMenuItem
nativeButton={false}
render={<a href={portalAppsUrl} />}
className="justify-center text-sm font-medium"
>
Настроить на портале
</DropdownMenuItem>
) : (
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings/integrations" />}
className="justify-center text-sm font-medium"
>
Интеграции
</DropdownMenuItem>
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
+5 -5
View File
@@ -141,12 +141,12 @@ export function NavUser() {
render={ render={
<SidebarMenuButton <SidebarMenuButton
size="lg" size="lg"
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground" className="bg-muted/50 data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
/> />
} }
> >
<Avatar className="size-8 rounded-lg"> <Avatar className="size-8 rounded-full border">
<AvatarFallback className="rounded-lg text-xs"> <AvatarFallback className="bg-background text-xs">
{fallback} {fallback}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
@@ -166,8 +166,8 @@ export function NavUser() {
> >
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground"> <DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
<Avatar className="size-8 rounded-lg"> <Avatar className="size-8 rounded-full border">
<AvatarFallback className="rounded-lg text-xs"> <AvatarFallback className="bg-background text-xs">
{fallback} {fallback}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
@@ -3,12 +3,16 @@ import { useQuery } from '@tanstack/react-query'
import { import {
Activity, Activity,
Bell, Bell,
ChevronRight,
RefreshCw, RefreshCw,
Server, Server,
Wallet, Wallet,
} from 'lucide-react' } from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { Button } from '@cfdm/ui/components/button'
import { cn } from '@cfdm/ui/lib/utils' import { cn } from '@cfdm/ui/lib/utils'
import { Item, ItemMedia } from '@cfdm/ui/components/item' import { Item, ItemMedia } from '@cfdm/ui/components/item'
import { Popover, PopoverContent, PopoverTrigger } from '@cfdm/ui/components/popover' 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 ( return (
<Popover> <Popover>
<PopoverTrigger <PopoverTrigger
render={ render={
<button <Button
type="button" variant="outline"
aria-label="Монитор системы" aria-label={`Монитор VPS Tracker: ${statusLabel.toLowerCase()}, ошибок ${errorCount}, предупреждений ${warningCount}`}
className={cn(
'relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none',
'border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
)}
/> />
} }
> >
<span className="relative flex size-3.5 items-center justify-center"> <Activity
<Activity aria-hidden
aria-hidden className={cn(
className={cn( errorCount > 0 ? 'text-destructive' : warningCount > 0 ? 'text-warning' : 'text-muted-foreground',
'size-3.5 transition-colors', )}
spiking ? 'text-destructive' : 'text-muted-foreground', />
)} <span className="hidden sm:inline">{statusLabel}</span>
/> {errorCount > 0 ? (
{spiking ? ( <Badge variant="destructive-light" size="sm" className="tabular-nums">
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden /> {errorCount}
) : null} </Badge>
</span> ) : null}
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span> {warningCount > 0 ? (
<Badge <Badge variant="warning-light" size="sm" className="tabular-nums">
variant={spiking ? 'destructive-light' : 'success-light'} {warningCount}
size="xs" </Badge>
className="h-4 px-1.5 text-[10px]" ) : null}
> {!spiking ? (
{spiking ? 'Внимание' : 'Норма'} <Badge variant="success-light" size="sm">
</Badge> OK
</Badge>
) : null}
</PopoverTrigger> </PopoverTrigger>
<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!">
@@ -290,6 +295,13 @@ export function SystemMonitorPopover() {
</> </>
) : null} ) : null}
</div> </div>
<Link
to="/sync-journal"
className="border-border text-muted-foreground hover:text-foreground flex items-center justify-between border-t px-3 py-2 text-xs transition-colors"
>
Журнал синка
<ChevronRight aria-hidden className="size-3.5" />
</Link>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) )
+3 -4
View File
@@ -3,8 +3,7 @@ export function kpiCols(count: number): string {
if (count <= 1) return 'grid-cols-1' if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2' if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3' 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 === 4) return 'grid-cols-1 @3xl:grid-cols-2 @5xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5' // Design «VPS-Tracker»: at most 4 KPI columns, wrapping to the next row.
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6' return 'grid-cols-1 @xl:grid-cols-2 @5xl:grid-cols-4'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
} }
@@ -63,11 +63,17 @@ function isSelected(item: KpiStatItem): boolean {
return Boolean(item.selected ?? item.active) 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 { function resolveFooter(item: KpiStatItem): 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 variant="outline" size="sm"> <Badge variant={HINT_BADGE_VARIANT[item.variant ?? 'default']} size="sm">
{item.hint} {item.hint}
</Badge> </Badge>
) )
@@ -81,7 +87,7 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const valueVariant = item.variant ?? 'default' const valueVariant = item.variant ?? 'default'
return ( return (
<div className="relative z-10 flex h-full items-start gap-3"> <div className="relative z-10 flex h-full items-center gap-3">
{item.icon ? ( {item.icon ? (
<IconTile <IconTile
variant="elevated" variant="elevated"
@@ -92,18 +98,19 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
</IconTile> </IconTile>
) : null} ) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> {/* Design «VPS-Tracker» KPI: label, then value + hint Badge on one baseline. */}
<div className="flex items-start justify-between gap-2"> <div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="text-muted-foreground text-sm font-medium">{item.label}</div> <div className="text-muted-foreground truncate text-sm font-medium">{item.label}</div>
{footer ? <div className="shrink-0">{footer}</div> : null} <div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
</div> <span
<div className={cn(
className={cn( 'text-2xl leading-none font-semibold tracking-tight tabular-nums',
'text-2xl leading-none font-bold tabular-nums', VALUE_VARIANT_CLASS[valueVariant],
VALUE_VARIANT_CLASS[valueVariant], )}
)} >
> {item.value}
{item.value} </span>
{footer ? <span className="shrink-0">{footer}</span> : null}
</div> </div>
</div> </div>
</div> </div>
@@ -1,11 +1,4 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Skeleton } from '@cfdm/ui/components/skeleton' import { Skeleton } from '@cfdm/ui/components/skeleton'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid' import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid'
@@ -16,13 +9,15 @@ interface OpsDashboardProps {
title?: string title?: string
description?: string description?: string
kpiCards: OpsKpiCard[] kpiCards: OpsKpiCard[]
charts: ReactNode /** Alert between the page header and KPI (design order: title → banner → KPI → tabs). */
queue: ReactNode banner?: ReactNode
/** Page body after KPI — design «VPS-Tracker»: line tabs (Проблемы / Последние VPS / Аккаунты). */
children: ReactNode
isLoading?: boolean isLoading?: boolean
} }
const rootClassName = 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() { function OpsDashboardSkeleton() {
return ( return (
@@ -45,8 +40,8 @@ export function OpsDashboard({
title = 'Панель управления', title = 'Панель управления',
description = 'Обзор VPS, платежей, тарифов и синхронизации', description = 'Обзор VPS, платежей, тарифов и синхронизации',
kpiCards, kpiCards,
charts, banner,
queue, children,
isLoading = false, isLoading = false,
}: OpsDashboardProps) { }: OpsDashboardProps) {
if (isLoading) { if (isLoading) {
@@ -57,25 +52,13 @@ export function OpsDashboard({
<div className={rootClassName}> <div className={rootClassName}>
<PageHeader title={title} description={description} /> <PageHeader title={title} description={description} />
{banner}
<section aria-label="Ключевые метрики"> <section aria-label="Ключевые метрики">
<KpiStatGrid cards={kpiCards} skeletonCount={4} /> <KpiStatGrid cards={kpiCards} skeletonCount={4} />
</section> </section>
<section aria-label="Аналитика" className="grid w-full min-w-0 gap-2"> {children}
{charts}
</section>
<section aria-label="Требуют внимания">
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Требуют внимания</FrameTitle>
<FrameDescription>
Проблемы оплаты, истекающие VPS и ошибки синхронизации
</FrameDescription>
</FrameHeader>
<FramePanel>{queue}</FramePanel>
</Frame>
</section>
</div> </div>
) )
} }
+80 -102
View File
@@ -29,9 +29,9 @@ import { dataGridCellStack } from '@/components/data-grid-cells'
import { KpiStatGridSkeleton, TableSkeleton } from '@/components/skeletons' import { KpiStatGridSkeleton, TableSkeleton } from '@/components/skeletons'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@/components/reui/badge' 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 { StatusBadge } from '@/components/status-badge'
import { cn } from '@cfdm/ui/lib/utils'
import { computeInventoryHealth } from '@/lib/inventory-health' import { computeInventoryHealth } from '@/lib/inventory-health'
import { buildAtRiskAccounts, type AtRiskAccount } from '@/lib/account-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' 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[] = [ const DASHBOARD_QUICK_ACTIONS: QuickActionItem[] = [
{ {
id: 'vps', id: 'vps',
@@ -371,111 +368,92 @@ function DashboardPage() {
return ( return (
<div className="flex flex-col gap-4 md:gap-6"> <div className="flex flex-col gap-4 md:gap-6">
<DashboardInventoryAlert issuesCount={issuesCount} onGoToIssues={handleGoToIssues} />
<OpsDashboard <OpsDashboard
banner={
<DashboardInventoryAlert issuesCount={issuesCount} onGoToIssues={handleGoToIssues} />
}
title="Дашборд" title="Дашборд"
description="Сводка по VPS, балансам и здоровью инвентаря" description="Сводка по VPS, балансам и здоровью инвентаря"
kpiCards={statsLoading ? [] : kpiCards} kpiCards={statsLoading ? [] : kpiCards}
isLoading={statsLoading} isLoading={statsLoading}
charts={ >
<ChartsGrid> <div ref={tabsRef} className="scroll-mt-16">
<DashboardPaymentsChart <CountedLineTabs
payments={snap.payments} tabs={[
settings={snap.settings} { id: 'issues', label: 'Проблемы', count: issues.length || undefined },
ratesData={ratesData} { id: 'recent', label: 'Последние VPS' },
className="h-full" { id: 'risk', label: 'Аккаунты', count: atRisk.length || undefined },
/> ]}
<DashboardExpensesChart value={activeTab}
payments={snap.payments} onValueChange={setActiveTab}
balanceLedger={snap.balanceLedger ?? []} className="flex w-full flex-col gap-4 md:gap-6"
vps={snap.vps} >
providers={snap.providers} <TabsContent value="issues" className="mt-0 flex flex-col gap-4 md:gap-6">
settings={snap.settings} {snap.settings?.[0]?.showQuickActions !== false ? (
ratesData={ratesData} <QuickActionGrid
className="h-full" actions={DASHBOARD_QUICK_ACTIONS}
/> description="Частые разделы учёта и аналитики"
</ChartsGrid>
}
queue={
<div ref={tabsRef}>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex w-full flex-col gap-4"
>
<TabsList variant="line" className="mb-0 h-auto w-fit gap-1 border-b border-border p-0">
<TabsTrigger value="issues" className={cn(DASHBOARD_TAB_TRIGGER_CLASS, 'gap-2')}>
Проблемы
{issues.length > 0 ? (
<Badge variant="secondary" size="sm" radius="full">
{issues.length}
</Badge>
) : null}
</TabsTrigger>
<TabsTrigger value="recent" className={DASHBOARD_TAB_TRIGGER_CLASS}>
Последние VPS
</TabsTrigger>
<TabsTrigger value="risk" className={cn(DASHBOARD_TAB_TRIGGER_CLASS, 'gap-2')}>
Аккаунты
{atRisk.length > 0 ? (
<Badge variant="outline" size="sm" radius="full">
{atRisk.length}
</Badge>
) : null}
</TabsTrigger>
</TabsList>
<TabsContent value="issues" className="mt-0">
<ResourcePage
title="Здоровье инвентаря"
description="Ставка, оплата, синк, баланс аккаунтов"
columns={columnDefFromDataGrid(issueColumns)}
data={issues}
getRowId={(i) => i.key}
emptyTitle="Проблем не найдено"
emptyDescription="Критичных проблем в инвентаре не обнаружено"
pagination={false}
/> />
</TabsContent> ) : null}
<TabsContent value="recent" className="mt-0"> <ChartsGrid>
<ResourcePage <DashboardPaymentsChart
title="Последние VPS" payments={snap.payments}
description="Активные серверы" settings={snap.settings}
actions={ ratesData={ratesData}
<Button variant="outline" size="sm" render={<Link to="/vps" />}> className="h-full"
Все VPS
</Button>
}
columns={columnDefFromDataGrid(vpsColumns)}
data={activeVps.slice(0, 8)}
getRowId={(v) => v.id}
pagination={false}
onRowClick={(v) => navigate({ to: '/vps', search: { edit: v.id } })}
/> />
</TabsContent> <DashboardExpensesChart
<TabsContent value="risk" className="mt-0"> payments={snap.payments}
<ResourcePage balanceLedger={snap.balanceLedger ?? []}
title="Аккаунты под риском" vps={snap.vps}
description="Низкий баланс или устаревший синк BILLmanager" providers={snap.providers}
columns={columnDefFromDataGrid(riskColumns)} settings={snap.settings}
data={atRisk} ratesData={ratesData}
getRowId={(r) => r.id} className="h-full"
emptyTitle="Рисков нет"
emptyDescription="Балансы и синхронизация в норме"
pagination={false}
/> />
</TabsContent> </ChartsGrid>
</Tabs> <ResourcePage
</div> title="Здоровье инвентаря"
} description="Ставка, оплата, синк, баланс аккаунтов"
/> columns={columnDefFromDataGrid(issueColumns)}
data={issues}
{snap.settings?.[0]?.showQuickActions !== false ? ( getRowId={(i) => i.key}
<QuickActionGrid emptyTitle="Проблем не найдено"
actions={DASHBOARD_QUICK_ACTIONS} emptyDescription="Критичных проблем в инвентаре не обнаружено"
description="Частые разделы учёта и аналитики" pagination={false}
/> />
) : null} </TabsContent>
<TabsContent value="recent" className="mt-0">
<ResourcePage
title="Последние VPS"
description="Активные серверы"
actions={
<Button variant="outline" size="sm" render={<Link to="/vps" />}>
Все VPS
</Button>
}
columns={columnDefFromDataGrid(vpsColumns)}
data={activeVps.slice(0, 8)}
getRowId={(v) => v.id}
pagination={false}
onRowClick={(v) => navigate({ to: '/vps', search: { edit: v.id } })}
/>
</TabsContent>
<TabsContent value="risk" className="mt-0">
<ResourcePage
title="Аккаунты под риском"
description="Низкий баланс или устаревший синк BILLmanager"
columns={columnDefFromDataGrid(riskColumns)}
data={atRisk}
getRowId={(r) => r.id}
emptyTitle="Рисков нет"
emptyDescription="Балансы и синхронизация в норме"
pagination={false}
/>
</TabsContent>
</CountedLineTabs>
</div>
</OpsDashboard>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button