refactor(repo): переход на pnpm monorepo с shadcn/ui и Fastify+Drizzle
Frontend:
- apps/web (Vite+TS, TanStack Router/Query, shadcn/ui @cfdm/ui base-nova)
- 10 страниц в routes/_auth/, Recharts через shadcn Chart, lucide-react
- формы на RHF + Zod (FormSheet/FormField)
- удалены Tabler, Chart.js, react-router-dom
Backend (параллельный трек):
- apps/api (Fastify 5 + Drizzle + better-sqlite3)
- packages/db: Drizzle-схема и repositories по сущностям
- packages/shared: Zod-контракты
- роуты с валидацией и единым форматом ошибок { error: { code, message } }
- sync/backup — заглушки 501 (billmanager-адаптеры переносятся отдельно)
- legacy Express оставлен как runtime по умолчанию (RUNTIME=express)
Infra:
- Dockerfile multi-stage под pnpm workspaces
- .dockerignore и docker-compose обновлены под monorepo
Rules:
- удалены нерелевантные правила (rust, cloudflare, server/frontend-conventions)
- project-structure.mdc и AGENTS.md переписаны под monorepo
- frontend-shadcn.mdc, shadcn-ui-production.mdc, sqlite.mdc обновлены
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@cfdm/ui/components/alert-dialog'
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
trigger: ReactElement
|
||||
title: string
|
||||
description?: ReactNode
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
destructive?: boolean
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
trigger,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Подтвердить',
|
||||
cancelLabel = 'Отмена',
|
||||
destructive,
|
||||
onConfirm,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger render={trigger} />
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
{description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { ReactNode } from '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
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Cell,
|
||||
Pie,
|
||||
PieChart,
|
||||
Tooltip as RechartsTooltip,
|
||||
} from 'recharts'
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@cfdm/ui/components/chart'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
||||
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'
|
||||
|
||||
const EXPENSE_CONFIG: ChartConfig = {
|
||||
expense: { label: 'Расход', color: 'var(--chart-1)' },
|
||||
}
|
||||
|
||||
export function MonthlyExpenseChart({
|
||||
vps,
|
||||
providers,
|
||||
settings,
|
||||
ratesData,
|
||||
className,
|
||||
}: {
|
||||
vps: Vps[]
|
||||
providers: Provider[]
|
||||
settings: Settings[]
|
||||
ratesData: RatesData | null
|
||||
className?: string
|
||||
}) {
|
||||
const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
|
||||
const providerById = providerByIdMap(providers)
|
||||
|
||||
const monthlyByAccount = new Map<string, number>()
|
||||
for (const v of vps) {
|
||||
if (v.status !== 'active') continue
|
||||
const provider = providerById.get(v.providerId)
|
||||
const monthly = Number(v.monthlyRate || 0)
|
||||
const daily = Number(v.dailyRate || 0)
|
||||
const burn = v.tariffType === 'daily' ? daily * 30 : monthly
|
||||
const fromCurrency = toIsoCurrency(provider?.baseCurrency || v.currency || baseCurrency)
|
||||
const converted = convertCurrency(burn, fromCurrency, baseCurrency, ratesData)
|
||||
monthlyByAccount.set(v.providerAccountId, (monthlyByAccount.get(v.providerAccountId) ?? 0) + converted)
|
||||
}
|
||||
|
||||
const data = Array.from(monthlyByAccount.entries())
|
||||
.map(([accountId, value]) => ({
|
||||
accountId,
|
||||
name: providerById.get(accountId)?.name ?? accountId,
|
||||
expense: Math.round(value),
|
||||
}))
|
||||
.sort((a, b) => b.expense - a.expense)
|
||||
.slice(0, 10)
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Расходы по хостерам (мес)</CardTitle>
|
||||
<CardDescription>Топ-10 по monthly rate, в {baseCurrency}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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" />
|
||||
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<YAxis tickLine={false} axisLine={false} width={48} />
|
||||
<RechartsTooltip cursor={false} content={<ChartTooltipContent formatter={(v) => formatCurrency(Number(v), baseCurrency)} />} />
|
||||
<Bar dataKey="expense" fill="var(--color-expense)" radius={4} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const PIE_COLORS = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)']
|
||||
|
||||
const PAYMENTS_CONFIG: ChartConfig = {
|
||||
amount: { label: 'Платежи', color: 'var(--chart-2)' },
|
||||
}
|
||||
|
||||
export function PaymentsPieChart({
|
||||
payments,
|
||||
settings,
|
||||
ratesData,
|
||||
className,
|
||||
}: {
|
||||
payments: Payment[]
|
||||
settings: Settings[]
|
||||
ratesData: RatesData | null
|
||||
className?: string
|
||||
}) {
|
||||
const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
|
||||
const byType = new Map<string, number>()
|
||||
for (const p of payments) {
|
||||
const converted = convertCurrency(Number(p.amount), toIsoCurrency(p.currency), baseCurrency, ratesData)
|
||||
byType.set(p.type, (byType.get(p.type) ?? 0) + converted)
|
||||
}
|
||||
const data = Array.from(byType.entries()).map(([type, amount]) => ({ type, amount: Math.round(amount) }))
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Платежи по типам</CardTitle>
|
||||
<CardDescription>Структура в {baseCurrency}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={PAYMENTS_CONFIG} className="mx-auto h-72 w-full">
|
||||
<PieChart>
|
||||
<RechartsTooltip content={<ChartTooltipContent nameKey="type" formatter={(v) => formatCurrency(Number(v), baseCurrency)} />} />
|
||||
<Pie data={data} dataKey="amount" nameKey="type" innerRadius={50} outerRadius={90} strokeWidth={2}>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function MonthlyTrendChart({
|
||||
payments,
|
||||
settings,
|
||||
ratesData,
|
||||
className,
|
||||
}: {
|
||||
payments: Payment[]
|
||||
settings: Settings[]
|
||||
ratesData: RatesData | null
|
||||
className?: string
|
||||
}) {
|
||||
const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
|
||||
const byMonth = new Map<string, number>()
|
||||
for (const p of payments) {
|
||||
const key = monthKey(p.date)
|
||||
if (!key) continue
|
||||
const converted = convertCurrency(Number(p.amount), toIsoCurrency(p.currency), baseCurrency, ratesData)
|
||||
byMonth.set(key, (byMonth.get(key) ?? 0) + converted)
|
||||
}
|
||||
const data = Array.from(byMonth.entries())
|
||||
.map(([month, amount]) => ({ month, amount: Math.round(amount) }))
|
||||
.sort((a, b) => a.month.localeCompare(b.month))
|
||||
.slice(-12)
|
||||
|
||||
const trendConfig: ChartConfig = { amount: { label: 'Платежи', color: 'var(--chart-3)' } }
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Динамика платежей</CardTitle>
|
||||
<CardDescription>Последние 12 месяцев, {baseCurrency}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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" />
|
||||
<XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<YAxis tickLine={false} axisLine={false} width={48} />
|
||||
<RechartsTooltip cursor={false} content={<ChartTooltipContent formatter={(v) => formatCurrency(Number(v), baseCurrency)} />} />
|
||||
<Bar dataKey="amount" fill="var(--color-amount)" radius={4} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChartsGrid({ children }: { children: ReactNode }) {
|
||||
return <div className="grid gap-4 lg:grid-cols-2">{children}</div>
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string
|
||||
description?: string
|
||||
icon?: ReactNode
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description, icon, action, className }: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon ? <div className="text-muted-foreground">{icon}</div> : null}
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{action ? <div className="mt-2">{action}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Field, FieldError, FieldLabel } from '@cfdm/ui/components/field'
|
||||
|
||||
interface FormFieldProps {
|
||||
label: string
|
||||
htmlFor?: string
|
||||
error?: string
|
||||
invalid?: boolean
|
||||
description?: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function FormField({ label, htmlFor, error, invalid, description, children }: FormFieldProps) {
|
||||
return (
|
||||
<Field data-invalid={invalid || Boolean(error)}>
|
||||
<FieldLabel htmlFor={htmlFor}>{label}</FieldLabel>
|
||||
{children}
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
{error ? <FieldError>{error}</FieldError> : null}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
useForm,
|
||||
type DefaultValues,
|
||||
type FieldValues,
|
||||
type SubmitHandler,
|
||||
type UseFormReturn,
|
||||
} from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import type { ZodType } from 'zod'
|
||||
|
||||
import { FormSheet } from './form-sheet'
|
||||
|
||||
interface FormSheetRhfProps<TField extends FieldValues> {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description?: string
|
||||
schema: ZodType<TField>
|
||||
defaultValues: DefaultValues<TField>
|
||||
onSubmit: (values: TField) => void
|
||||
submitting?: boolean
|
||||
submitLabel?: string
|
||||
children: (form: UseFormReturn<TField>) => ReactNode
|
||||
}
|
||||
|
||||
export function FormSheetRhf<TField extends FieldValues>({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
schema,
|
||||
defaultValues,
|
||||
onSubmit,
|
||||
submitting,
|
||||
submitLabel,
|
||||
children,
|
||||
}: FormSheetRhfProps<TField>) {
|
||||
const form = useForm<TField>({
|
||||
resolver: zodResolver(schema) as never,
|
||||
defaultValues: defaultValues as DefaultValues<TField>,
|
||||
mode: 'onBlur',
|
||||
})
|
||||
|
||||
const submit: SubmitHandler<TField> = (values) => onSubmit(values)
|
||||
|
||||
return (
|
||||
<FormSheet
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) form.reset()
|
||||
onOpenChange(o)
|
||||
}}
|
||||
trigger={null}
|
||||
title={title}
|
||||
description={description}
|
||||
submitLabel={submitLabel}
|
||||
submitting={submitting}
|
||||
onSubmit={() => void form.handleSubmit(submit)()}
|
||||
>
|
||||
{children(form)}
|
||||
</FormSheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@cfdm/ui/components/sheet'
|
||||
import { LoadingButton } from './loading-button'
|
||||
|
||||
interface FormSheetProps {
|
||||
trigger?: ReactElement | null
|
||||
title: string
|
||||
description?: string
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onSubmit?: () => void
|
||||
submitLabel?: string
|
||||
submitting?: boolean
|
||||
submitDisabled?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function FormSheet({
|
||||
trigger,
|
||||
title,
|
||||
description,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
submitLabel = 'Сохранить',
|
||||
submitting,
|
||||
submitDisabled,
|
||||
children,
|
||||
}: FormSheetProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
{trigger ? <SheetTrigger render={trigger} /> : null}
|
||||
<SheetContent side="right" className="w-full sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
{description ? <SheetDescription>{description}</SheetDescription> : null}
|
||||
</SheetHeader>
|
||||
<form
|
||||
className="flex flex-1 flex-col gap-4 overflow-y-auto p-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
onSubmit?.()
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{onSubmit ? (
|
||||
<SheetFooter className="mt-auto pt-4">
|
||||
<LoadingButton type="submit" loading={submitting} disabled={submitDisabled}>
|
||||
{submitLabel}
|
||||
</LoadingButton>
|
||||
</SheetFooter>
|
||||
) : null}
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Server,
|
||||
ServerCog,
|
||||
Building2,
|
||||
Wallet,
|
||||
CreditCard,
|
||||
Coins,
|
||||
ChartColumnBig,
|
||||
ChartBar,
|
||||
Settings,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from '@cfdm/ui/components/sidebar'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
} from '@cfdm/ui/components/breadcrumb'
|
||||
import { Separator } from '@cfdm/ui/components/separator'
|
||||
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface NavItem {
|
||||
to: string
|
||||
label: string
|
||||
icon: typeof LayoutDashboard
|
||||
}
|
||||
|
||||
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 },
|
||||
]
|
||||
|
||||
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
|
||||
NAV_ITEMS.map((i) => [i.to, i.label]),
|
||||
)
|
||||
|
||||
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]
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||
<Server className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<span className="font-semibold">VPS Tracker</span>
|
||||
<span className="text-xs text-muted-foreground">Учёт виртуальных серверов</span>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</SidebarContent>
|
||||
<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">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{ROUTE_LABELS[activeItem.to] ?? ''}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</header>
|
||||
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Loader2Icon } from 'lucide-react'
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
|
||||
type LoadingButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
loading?: boolean
|
||||
variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
|
||||
size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function LoadingButton({ loading, disabled, children, ...props }: LoadingButtonProps) {
|
||||
return (
|
||||
<Button disabled={disabled || loading} {...props}>
|
||||
{loading ? <Loader2Icon className="animate-spin" data-icon="inline-start" /> : null}
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string
|
||||
description?: string
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
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">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
export function PageShell({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { AlertCircle, RefreshCwIcon } from 'lucide-react'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
import { EmptyState } from './empty-state'
|
||||
|
||||
interface QueryStateProps<T> {
|
||||
data: T | undefined
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
error?: unknown
|
||||
empty?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
emptyAction?: ReactNode
|
||||
onRetry?: () => void
|
||||
skeleton?: ReactNode
|
||||
children: (data: T) => ReactNode
|
||||
}
|
||||
|
||||
export function QueryState<T>({
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
empty,
|
||||
emptyTitle = 'Нет данных',
|
||||
emptyDescription,
|
||||
emptyAction,
|
||||
onRetry,
|
||||
skeleton,
|
||||
children,
|
||||
}: QueryStateProps<T>) {
|
||||
if (isLoading) {
|
||||
return <>{skeleton ?? <DefaultSkeleton />}</>
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<AlertCircle className="size-8" />}
|
||||
title="Ошибка загрузки"
|
||||
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
|
||||
action={
|
||||
onRetry ? (
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
<RefreshCwIcon data-icon="inline-start" />
|
||||
Повторить
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (empty || data == null) {
|
||||
return <EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||
}
|
||||
return <>{children(data)}</>
|
||||
}
|
||||
|
||||
function DefaultSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import { Card, CardContent } from '@cfdm/ui/components/card'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
export interface SectionCardItem {
|
||||
label: ReactNode
|
||||
value: string | number | ReactElement
|
||||
hint?: ReactNode
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
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">
|
||||
<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>
|
||||
{item.icon ? <span className="text-muted-foreground">{item.icon}</span> : null}
|
||||
</div>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { SectionCards } from './section-cards'
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
import { Card, CardContent } from '@cfdm/ui/components/card'
|
||||
|
||||
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
|
||||
return (
|
||||
<SectionCards
|
||||
items={Array.from({ length: count }, (_, i) => ({
|
||||
label: <Skeleton className="h-4 w-24" key={`label-${i}`} />,
|
||||
value: <Skeleton className="h-7 w-20" key={`value-${i}`} />,
|
||||
}))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
|
||||
return (
|
||||
<Card className="gap-0">
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex gap-2 border-b p-3">
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<Skeleton className="h-4 flex-1" key={`h-${i}`} />
|
||||
))}
|
||||
</div>
|
||||
{Array.from({ length: rows }).map((_, r) => (
|
||||
<div className="flex gap-2 border-b p-3" key={`r-${r}`}>
|
||||
{Array.from({ length: cols }).map((_, c) => (
|
||||
<Skeleton className="h-4 flex-1" key={`c-${r}-${c}`} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
||||
|
||||
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||
active: 'default',
|
||||
ok: 'default',
|
||||
paid: 'default',
|
||||
paused: 'secondary',
|
||||
archived: 'outline',
|
||||
error: 'destructive',
|
||||
running: 'secondary',
|
||||
overdue: 'destructive',
|
||||
stale: 'destructive',
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, label }: { status: string; label?: string }) {
|
||||
const variant = STATUS_VARIANT[status] ?? 'outline'
|
||||
return <Badge variant={variant}>{label ?? status}</Badge>
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
interface TableCardProps {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
export function TableCard({ title, description, actions, children, className, contentClassName }: TableCardProps) {
|
||||
return (
|
||||
<Card className={cn('gap-0', className)}>
|
||||
{(title || actions) && (
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
{title ? <CardTitle>{title}</CardTitle> : null}
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent className={cn('p-0', contentClassName)}>{children}</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user