feat(web): доработка UX/UI — дашборд, навигация и data foundation
Docker / build (push) Has been cancelled

Добавлены syncLog в snapshot, API статистики дашборда и маппинг цен тарифов; переработаны shell, главная страница, empty states и новые экраны журнала синка и проектов.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-28 00:27:09 +07:00
co-authored by Cursor
parent 9df91bf2cc
commit 1e291b3759
35 changed files with 1527 additions and 264 deletions
+3 -3
View File
@@ -21,7 +21,7 @@ import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { EmptyState } from './empty-state'
import type { DataTableColumn } from './data-table-card'
import type { DataTableColumn } from './data-grid-types'
const PAGINATION_LABELS = {
rowsPerPageLabel: 'Строк на странице',
@@ -228,8 +228,8 @@ export function DataGridCard<TData extends object>({
return (
<Card className={cn('ring-0 shadow-none', className)}>
<CardHeader className="flex flex-row items-center justify-between gap-2 border-b border-border/50 pb-4">
<div className="space-y-1">
<CardHeader className="flex flex-row items-center justify-between gap-2 border-b border-border/50 pb-4">
<div className="flex flex-col gap-1">
{title ? <CardTitle>{title}</CardTitle> : null}
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
@@ -0,0 +1,14 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
export interface DataTableColumn<T> {
key: string
header: ReactNode
cell: (row: T, index: number) => ReactNode
icon?: LucideIcon
sortable?: boolean
sortValue?: (row: T) => string | number
headerTitle?: string
className?: string
headerClassName?: string
}
+2 -87
View File
@@ -1,87 +1,2 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@cfdm/ui/components/table'
import { TableCard } from './table-card'
import { EmptyState } from './empty-state'
export interface DataTableColumn<T> {
key: string
header: ReactNode
cell: (row: T, index: number) => ReactNode
icon?: LucideIcon
sortable?: boolean
sortValue?: (row: T) => string | number
headerTitle?: string
className?: string
headerClassName?: string
}
interface DataTableCardProps<T> {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
columns: DataTableColumn<T>[]
data: T[]
rowKey: (row: T, index: number) => string
emptyTitle?: string
emptyDescription?: string
emptyAction?: ReactNode
onRowClick?: (row: T) => void
}
export function DataTableCard<T>({
title,
description,
actions,
columns,
data,
rowKey,
emptyTitle = 'Нет записей',
emptyDescription,
emptyAction,
onRowClick,
}: DataTableCardProps<T>) {
return (
<TableCard title={title} description={description} actions={actions}>
{data.length === 0 ? (
<div className="p-4">
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
</div>
) : (
<Table>
<TableHeader>
<TableRow>
{columns.map((col) => (
<TableHead key={col.key} className={col.headerClassName}>
{col.header}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.map((row, index) => (
<TableRow
key={rowKey(row, index)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
className={onRowClick ? 'cursor-pointer' : undefined}
>
{columns.map((col) => (
<TableCell key={col.key} className={col.className}>
{col.cell(row, index)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
)}
</TableCard>
)
}
/** @deprecated Используйте DataGridCard. Тип колонок — data-grid-types. */
export type { DataTableColumn } from './data-grid-types'
+21 -1
View File
@@ -20,6 +20,11 @@ import type { ReactNode } from 'react'
import type { Vps, Provider, Payment, Settings, RatesData } from '@/types/entities'
import { convertCurrency, formatCurrency, monthKey, toIsoCurrency } from '@/lib/format'
import { providerByIdMap } from '@/lib/billmanager'
import { EmptyState } from '@/components/empty-state'
function ChartEmpty({ message }: { message: string }) {
return <EmptyState title={message} className="h-72 border-none" />
}
const EXPENSE_CONFIG: ChartConfig = {
expense: { label: 'Расход', color: 'var(--chart-1)' },
@@ -28,18 +33,21 @@ const EXPENSE_CONFIG: ChartConfig = {
export function MonthlyExpenseChart({
vps,
providers,
providerAccounts,
settings,
ratesData,
className,
}: {
vps: Vps[]
providers: Provider[]
providerAccounts?: { id: string; name: string; providerId: string }[]
settings: Settings[]
ratesData: RatesData | null
className?: string
}) {
const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
const providerById = providerByIdMap(providers)
const accountById = new Map((providerAccounts ?? []).map((a) => [a.id, a]))
const monthlyByAccount = new Map<string, number>()
for (const v of vps) {
@@ -56,7 +64,7 @@ export function MonthlyExpenseChart({
const data = Array.from(monthlyByAccount.entries())
.map(([accountId, value]) => ({
accountId,
name: providerById.get(accountId)?.name ?? accountId,
name: accountById.get(accountId)?.name ?? accountId,
expense: Math.round(value),
}))
.sort((a, b) => b.expense - a.expense)
@@ -69,6 +77,9 @@ export function MonthlyExpenseChart({
<CardDescription>Топ-10 по monthly rate, в {baseCurrency}</CardDescription>
</CardHeader>
<CardContent>
{data.length === 0 ? (
<ChartEmpty message="Нет данных для графика" />
) : (
<ChartContainer config={EXPENSE_CONFIG} className="h-72 w-full">
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
@@ -78,6 +89,7 @@ export function MonthlyExpenseChart({
<Bar dataKey="expense" fill="var(--color-expense)" radius={4} />
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
)
@@ -115,6 +127,9 @@ export function PaymentsPieChart({
<CardDescription>Структура в {baseCurrency}</CardDescription>
</CardHeader>
<CardContent>
{data.length === 0 ? (
<ChartEmpty message="Нет данных о платежах" />
) : (
<ChartContainer config={PAYMENTS_CONFIG} className="mx-auto h-72 w-full">
<PieChart>
<RechartsTooltip content={<ChartTooltipContent nameKey="type" formatter={(v) => formatCurrency(Number(v), baseCurrency)} />} />
@@ -125,6 +140,7 @@ export function PaymentsPieChart({
</Pie>
</PieChart>
</ChartContainer>
)}
</CardContent>
</Card>
)
@@ -163,6 +179,9 @@ export function MonthlyTrendChart({
<CardDescription>Последние 12 месяцев, {baseCurrency}</CardDescription>
</CardHeader>
<CardContent>
{data.length === 0 ? (
<ChartEmpty message="Нет данных за выбранный период" />
) : (
<ChartContainer config={trendConfig} className="h-72 w-full">
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
@@ -172,6 +191,7 @@ export function MonthlyTrendChart({
<Bar dataKey="amount" fill="var(--color-amount)" radius={4} />
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
)
+1 -1
View File
@@ -18,7 +18,7 @@ export function EmptyState({ title, description, icon, action, className }: Empt
)}
>
{icon ? <div className="text-muted-foreground">{icon}</div> : null}
<div className="space-y-1">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">{title}</p>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
+145 -39
View File
@@ -9,6 +9,9 @@ import {
ChartColumnBig,
ChartBar,
Settings,
RefreshCwIcon,
FolderKanbanIcon,
HistoryIcon,
} from 'lucide-react'
import {
@@ -29,42 +32,108 @@ import {
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@cfdm/ui/components/breadcrumb'
import { Separator } from '@cfdm/ui/components/separator'
import { Badge } from '@cfdm/ui/components/badge'
import { Link, useRouterState } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import type { ReactNode } from 'react'
import { ModeToggle } from '@/components/mode-toggle'
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
import { formatRelativeSyncTime } from '@/lib/sync-format'
interface NavItem {
to: string
label: string
icon: typeof LayoutDashboard
badge?: number
}
const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard', label: 'Дашборд', icon: LayoutDashboard },
{ to: '/vps', label: 'VPS', icon: Server },
{ to: '/tariffs', label: 'Активные тарифы', icon: ServerCog },
{ to: '/providers', label: 'Хостеры', icon: Building2 },
{ to: '/accounts', label: 'Аккаунты хостеров', icon: Wallet },
{ to: '/payments', label: 'Платежи', icon: CreditCard },
{ to: '/balance', label: 'Баланс и списания', icon: Coins },
{ to: '/reports', label: 'Отчёты', icon: ChartColumnBig },
{ to: '/resources', label: 'Ресурсы', icon: ChartBar },
{ to: '/settings', label: 'Настройки', icon: Settings },
interface NavGroup {
label: string
items: NavItem[]
}
const NAV_GROUPS: NavGroup[] = [
{
label: 'Обзор',
items: [{ to: '/dashboard', label: 'Дашборд', icon: LayoutDashboard }],
},
{
label: 'Инфраструктура',
items: [
{ to: '/vps', label: 'VPS', icon: Server },
{ to: '/tariffs', label: 'Активные тарифы', icon: ServerCog },
{ to: '/providers', label: 'Хостеры', icon: Building2 },
{ to: '/accounts', label: 'Аккаунты хостеров', icon: Wallet },
{ to: '/projects', label: 'Проекты', icon: FolderKanbanIcon },
],
},
{
label: 'Финансы',
items: [
{ to: '/payments', label: 'Платежи', icon: CreditCard },
{ to: '/balance', label: 'Баланс и списания', icon: Coins },
],
},
{
label: 'Аналитика',
items: [
{ to: '/reports', label: 'Отчёты', icon: ChartColumnBig },
{ to: '/resources', label: 'Ресурсы', icon: ChartBar },
],
},
{
label: 'Система',
items: [
{ to: '/sync-journal', label: 'Журнал синка', icon: HistoryIcon },
{ to: '/settings', label: 'Настройки', icon: Settings },
],
},
]
const ALL_NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items)
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
NAV_ITEMS.map((i) => [i.to, i.label]),
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
)
const PARENT_ROUTE: Record<string, string> = {
'/vps': '/dashboard',
'/tariffs': '/dashboard',
'/providers': '/dashboard',
'/accounts': '/dashboard',
'/projects': '/dashboard',
'/payments': '/dashboard',
'/balance': '/dashboard',
'/reports': '/dashboard',
'/resources': '/dashboard',
'/sync-journal': '/settings',
}
export function AppShell({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const activeItem = NAV_ITEMS.find((i) => pathname.startsWith(i.to)) ?? NAV_ITEMS[0]
const activeItem = ALL_NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`)) ?? ALL_NAV_ITEMS[0]
const parentTo = PARENT_ROUTE[activeItem.to]
const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null
const { data: stats } = useQuery(dashboardStatsQueryOptions())
const navGroups: NavGroup[] = NAV_GROUPS.map((group) => ({
...group,
items: group.items.map((item) => {
if (item.to === '/dashboard' && stats?.issuesCount) {
return { ...item, badge: stats.issuesCount }
}
return item
}),
}))
return (
<SidebarProvider>
@@ -88,44 +157,81 @@ export function AppShell({ children }: { children: ReactNode }) {
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Меню</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{NAV_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 />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{navGroups.map((group) => (
<SidebarGroup key={group.label}>
<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 />
<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>
<SidebarFooter />
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton render={<Link to="/settings" />} tooltip="Настройки">
<Settings />
<span className="truncate text-xs text-muted-foreground">
Синк: {formatRelativeSyncTime(stats?.lastGlobalSyncAt)}
</span>
{stats?.staleSyncAccountCount ? (
<Badge variant="outline" className="ml-auto text-xs">
<RefreshCwIcon className="size-3" />
{stats.staleSyncAccountCount}
</Badge>
) : null}
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
<SidebarInset>
<header className="sticky top-0 flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-supports">
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<SidebarTrigger />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb>
<BreadcrumbList>
{parentLabel && parentTo ? (
<>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink render={<Link to={parentTo} />}>{parentLabel}</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
</>
) : null}
<BreadcrumbItem>
<BreadcrumbPage>{ROUTE_LABELS[activeItem.to] ?? ''}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto">
<div className="ml-auto flex items-center gap-2">
{stats?.issuesCount ? (
<Badge variant="destructive" className="hidden sm:inline-flex">
{stats.issuesCount} проблем
</Badge>
) : null}
<ModeToggle />
</div>
</header>
+1 -1
View File
@@ -9,7 +9,7 @@ interface PageHeaderProps {
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
+35 -5
View File
@@ -7,13 +7,22 @@ export interface SectionCardItem {
value: string | number | ReactElement
hint?: ReactNode
icon?: ReactNode
variant?: 'default' | 'warning' | 'destructive'
onClick?: () => void
}
const VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
default: '',
warning: 'border-amber-500/50',
destructive: 'border-destructive/50',
}
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
return (
<div className={cn('grid gap-4 sm:grid-cols-2 lg:grid-cols-4', className)}>
{items.map((item, idx) => (
<Card key={typeof item.label === 'string' ? item.label : idx} className="gap-0">
<div className={cn('grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6', className)}>
{items.map((item, idx) => {
const clickable = Boolean(item.onClick)
const content = (
<CardContent className="flex flex-col gap-1 p-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{item.label}</span>
@@ -22,8 +31,29 @@ export function SectionCards({ items, className }: { items: SectionCardItem[]; c
<span className="text-2xl font-semibold tabular-nums">{item.value}</span>
{item.hint ? <span className="text-xs text-muted-foreground">{item.hint}</span> : null}
</CardContent>
</Card>
))}
)
return (
<Card
key={typeof item.label === 'string' ? item.label : idx}
className={cn('gap-0', VARIANT_CLASS[item.variant ?? 'default'], clickable && 'cursor-pointer transition-colors hover:bg-muted/40')}
onClick={item.onClick}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
onKeyDown={
clickable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
item.onClick?.()
}
}
: undefined
}
>
{content}
</Card>
)
})}
</div>
)
}
+4
View File
@@ -236,6 +236,10 @@ export function countActiveFilters(filters: VpsFiltersState): number {
return n
}
export function hasActiveVpsFilters(filters: VpsFiltersState): boolean {
return countActiveFilters(filters) > 0 || filters.groupByProject || filters.tableCompact
}
export interface VpsFilterPreset {
name: string
filters: VpsFiltersState