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:
Denozordec
2026-06-26 13:42:05 +07:00
co-authored by Cursor
parent 8408155be6
commit 6fbd1a9113
202 changed files with 16734 additions and 12145 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "../../packages/ui/src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"hooks": "@/hooks",
"lib": "@/lib",
"utils": "@cfdm/ui/lib/utils",
"ui": "@cfdm/ui/components"
}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VPS Tracker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
{
"name": "@cfdm/web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"test": "vitest run"
},
"dependencies": {
"@base-ui/react": "^1.0.0",
"@cfdm/shared": "workspace:*",
"@cfdm/ui": "workspace:*",
"@hookform/resolvers": "^3.10.0",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-router": "^1.130.2",
"@tanstack/react-router-devtools": "^1.130.2",
"lucide-react": "^0.468.0",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.60.0",
"recharts": "3.8.0",
"sonner": "^1.7.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.0",
"@tanstack/router-plugin": "^1.130.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"happy-dom": "^18.0.0",
"tailwindcss": "^4.1.0",
"tw-animate-css": "^1.0.0",
"typescript": "^5.9.2",
"vite": "^7.3.1",
"vitest": "^3.0.0"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -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>
)
}
+182
View File
@@ -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>
}
+28
View File
@@ -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>
)
}
+22
View File
@@ -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>
)
}
+65
View File
@@ -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>
)
}
+19
View File
@@ -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>
)
}
+6
View File
@@ -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>
}
+67
View File
@@ -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>
)
}
+29
View File
@@ -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>
)
}
+37
View File
@@ -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>
)
}
+21
View File
@@ -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>
}
+29
View File
@@ -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>
)
}
+153
View File
@@ -0,0 +1,153 @@
import type {
DataSnapshot,
RatesData,
Settings,
Vps,
Provider,
ProviderAccount,
Payment,
BalanceLedgerRow,
} from '@/types/entities'
const API_BASE = import.meta.env.VITE_API_URL ?? ''
export class ApiError extends Error {
status?: number
constructor(message: string, status?: number) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T> {
const url = `${API_BASE}${path.startsWith('/') ? path : `/${path}`}`
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...options.headers },
...options,
})
if (!res.ok) {
let message = res.statusText || 'API error'
try {
const data = (await res.json()) as { error?: string }
if (data?.error) message = data.error
} catch {
/* ignore */
}
throw new ApiError(message, res.status)
}
if (res.status === 204) return null as T
return (await res.json()) as T
}
export type CollectionName =
| 'vps'
| 'providers'
| 'providerAccounts'
| 'payments'
| 'balanceLedger'
| 'settings'
const COLLECTION_PATHS: Record<CollectionName, string> = {
vps: '/api/vps',
providers: '/api/providers',
providerAccounts: '/api/provider-accounts',
payments: '/api/payments',
balanceLedger: '/api/balance-ledger',
settings: '/api/settings',
}
function uid(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
return `id-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
export const api = {
fetchData: () => fetchApi<DataSnapshot>('/api/data'),
fetchCollection: <T>(name: CollectionName) => fetchApi<T[]>(COLLECTION_PATHS[name]),
create: <T extends { id?: string }>(name: CollectionName, record: T) =>
fetchApi<T[]>(COLLECTION_PATHS[name], {
method: 'POST',
body: JSON.stringify({ ...record, id: record.id || uid() }),
}),
update: <T>(name: CollectionName, id: string, patch: Partial<T>) =>
fetchApi<T[]>(`${COLLECTION_PATHS[name]}/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(patch),
}),
remove: <T>(name: CollectionName, id: string) =>
fetchApi<T[]>(`${COLLECTION_PATHS[name]}/${encodeURIComponent(id)}`, { method: 'DELETE' }),
bulkUpdateVps: (ids: string[], action: string, value: unknown) =>
fetchApi('/api/vps/bulk', {
method: 'PATCH',
body: JSON.stringify({ ids, action, value }),
}),
syncAccount: (accountId: string, opts: Record<string, unknown> = {}) =>
fetchApi(`/api/sync/${encodeURIComponent(accountId)}`, {
method: 'POST',
body: JSON.stringify(opts),
}),
fetchAccountBalance: (accountId: string) =>
fetchApi<{ balance: number; currency: string }>(
`/api/sync/${encodeURIComponent(accountId)}/balance`,
),
testConnection: (apiBaseUrl: string, apiCredentials: string) =>
fetchApi('/api/sync/test-connection', {
method: 'POST',
body: JSON.stringify({ apiBaseUrl, apiCredentials }),
}),
fetchSyncStatus: () => fetchApi('/api/sync/status'),
sendTelegramTest: () =>
fetchApi('/api/settings/telegram/test', { method: 'POST' }),
fetchProjectSuggestions: (q = '', limit = 25) => {
const params = new URLSearchParams()
if (q) params.set('q', q)
params.set('limit', String(limit))
return fetchApi<string[]>(`/api/projects/suggest?${params.toString()}`)
},
downloadBackupJson: async (): Promise<Blob> => {
const res = await fetch(`${API_BASE}/api/backup/json`)
if (!res.ok) throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status)
return res.blob()
},
downloadBackupDatabase: async (): Promise<Blob> => {
const res = await fetch(`${API_BASE}/api/backup/database`)
if (!res.ok) throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status)
return res.blob()
},
importBackupJson: (payload: unknown) =>
fetchApi('/api/backup/json', { method: 'POST', body: JSON.stringify(payload) }),
importBackupDatabase: async (buffer: ArrayBuffer) => {
const res = await fetch(`${API_BASE}/api/backup/database`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: buffer,
})
if (!res.ok) throw new ApiError(res.statusText || 'Ошибка восстановления', res.status)
return res.json()
},
}
export type {
DataSnapshot,
RatesData,
Settings,
Vps,
Provider,
ProviderAccount,
Payment,
BalanceLedgerRow,
}
+45
View File
@@ -0,0 +1,45 @@
import type { ProviderAccount, Provider } from '@/types/entities'
export function providerByIdMap(providers: Provider[]): Map<string, Provider> {
return new Map(providers.map((p) => [p.id, p]))
}
export function billmanagerSyncableAccounts(
providerAccounts: ProviderAccount[],
providers: Provider[],
): ProviderAccount[] {
const pmap = providerByIdMap(providers)
return providerAccounts.filter((a) => {
const p = pmap.get(a.providerId)
return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet
})
}
export function accountBillmanagerUiReady(
account: ProviderAccount,
provider?: Provider | null,
): boolean {
return (
provider?.apiType === 'billmanager' &&
Boolean((provider.apiBaseUrl || '').trim()) &&
Boolean(account.apiCredentialsSet)
)
}
export function accountUsesBillmanagerBalanceApi(
account: ProviderAccount,
provider?: Provider | null,
): boolean {
return provider?.apiType === 'billmanager' && account.balance_api != null
}
export function accountSelectLabel(
account: ProviderAccount,
providerById: Map<string, Provider>,
scopedProviderId?: string,
): string {
const name = account.name?.trim() || '—'
if (scopedProviderId) return name
const providerName = providerById.get(account.providerId)?.name ?? '—'
return `${providerName} / ${name}`
}
+264
View File
@@ -0,0 +1,264 @@
import type { Settings, RatesData, Vps, Provider } from '@/types/entities'
export function uid(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
return `id-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
export function normalizeWebsiteUrl(website?: string): string {
if (!website) return ''
if (website.startsWith('http://') || website.startsWith('https://')) return website
return `https://${website}`
}
export function faviconUrlFromWebsite(website?: string): string {
const normalized = normalizeWebsiteUrl(website)
if (!normalized) return ''
try {
const { hostname } = new URL(normalized)
return `https://www.google.com/s2/favicons?domain=${hostname}&sz=32`
} catch {
return ''
}
}
const COUNTRY_CODE_BY_NAME: Record<string, string> = {
germany: 'DE', netherlands: 'NL', russia: 'RU', usa: 'US', 'united states': 'US',
ukraine: 'UA', poland: 'PL', france: 'FR', spain: 'ES', italy: 'IT',
estonia: 'EE', finland: 'FI', sweden: 'SE', norway: 'NO', latvia: 'LV',
lithuania: 'LT', czechia: 'CZ', czech: 'CZ', singapore: 'SG', japan: 'JP',
canada: 'CA', brazil: 'BR', turkey: 'TR', georgia: 'GE', kazakhstan: 'KZ',
}
export function getCountryFlagEmoji(country?: string): string {
if (!country) return '🌐'
const code = COUNTRY_CODE_BY_NAME[country.trim().toLowerCase()]
if (!code) return '🌐'
return code.toUpperCase().split('').map((c) => String.fromCodePoint(127397 + c.charCodeAt(0))).join('')
}
const PAYMENT_TYPE_LABELS: Record<string, string> = {
direct_vps_payment: 'Прямой платеж за VPS',
provider_balance_topup: 'Пополнение баланса хостера',
daily_debit: 'Ежедневное списание',
monthly_debit: 'Ежемесячное списание',
}
export function paymentTypeLabel(type: string): string {
return PAYMENT_TYPE_LABELS[type] ?? type
}
const VPS_STATUS_LABELS: Record<string, string> = {
active: 'Активен', paused: 'Приостановлен', archived: 'Архив',
}
export function vpsStatusLabel(status: string): string {
return VPS_STATUS_LABELS[status] ?? status
}
const BILLING_MODE_LABELS: Record<string, string> = {
daily: 'Ежедневно', monthly: 'Ежемесячно',
}
export function billingModeLabel(mode: string): string {
return BILLING_MODE_LABELS[mode] ?? mode
}
const TARIFF_TYPE_LABELS: Record<string, string> = {
daily: 'Суточный', monthly: 'Месячный',
}
export function tariffTypeLabel(type: string): string {
return TARIFF_TYPE_LABELS[type] ?? type
}
const CURRENCY_SYMBOL_MAP: Record<string, string> = {
'€': 'EUR', '$': 'USD', '₽': 'RUB', '£': 'GBP', '¥': 'JPY', '₴': 'UAH', '₸': 'KZT',
}
export function toIsoCurrency(currency?: string | null): string {
if (!currency || typeof currency !== 'string') return 'USD'
const trimmed = currency.trim()
if (CURRENCY_SYMBOL_MAP[trimmed]) return CURRENCY_SYMBOL_MAP[trimmed]
const upper = trimmed.toUpperCase()
if (upper === 'RUR') return 'RUB'
if (trimmed.length === 3 && /^[A-Z]{3}$/i.test(trimmed)) return upper
return 'USD'
}
export function effectiveVpsTariffCurrency(vps: Vps, provider?: Provider | null): string {
const provRaw = (provider?.baseCurrency || '').trim()
if (provRaw) return toIsoCurrency(provRaw)
const ownRaw = (vps?.currency || '').trim()
if (ownRaw) return toIsoCurrency(ownRaw)
return 'RUB'
}
export function formatCurrency(amount: number, currency = 'USD'): string {
const safeAmount = Number.isFinite(Number(amount)) ? Number(amount) : 0
const isoCurrency = toIsoCurrency(currency)
return new Intl.NumberFormat('ru-RU', {
style: 'currency', currency: isoCurrency, minimumFractionDigits: 2,
}).format(safeAmount)
}
export function parseProviderFxRate(raw: unknown): number {
if (raw == null) return NaN
const s = String(raw).trim()
if (!s) return NaN
if (s.toLowerCase() === 'auto') return NaN
const n = Number(s.replace(',', '.'))
if (!Number.isFinite(n) || n <= 0) return NaN
return n
}
export function normalizeRatesPayload(payload: unknown): RatesData | null {
if (!payload || typeof payload !== 'object') return null
const p = payload as Record<string, unknown>
if (p.base && p.rates && typeof p.rates === 'object') return p as unknown as RatesData
const valute = p.Valute as Record<string, { CharCode?: string; Value?: number; Nominal?: number }> | undefined
if (valute && typeof valute === 'object') {
const rates: Record<string, number> = {}
for (const v of Object.values(valute)) {
const code = v?.CharCode
const val = Number(v?.Value)
const nom = Number(v?.Nominal) || 1
if (typeof code === 'string' && code.length === 3 && Number.isFinite(val) && val > 0 && nom > 0) {
rates[code] = nom / val
}
}
if (Object.keys(rates).length === 0) return null
return { base: 'RUB', rates, date: typeof p.Date === 'string' ? p.Date : '' }
}
return null
}
export function convertCurrency(
amount: number,
fromCurrency: string,
toCurrency: string,
ratesData: RatesData | null,
): number {
const safeAmount = Number(amount)
if (!Number.isFinite(safeAmount)) return 0
const from = toIsoCurrency(fromCurrency)
const to = toIsoCurrency(toCurrency)
if (!from || !to || from === to) return safeAmount
if (!ratesData || !ratesData.rates || !ratesData.base) return safeAmount
const apiBase = ratesData.base.toUpperCase()
const rates: Record<string, number> = { ...ratesData.rates, [apiBase]: 1 }
const rateFrom = rates[from]
const rateTo = rates[to]
if (!Number.isFinite(rateFrom) || rateFrom <= 0 || !Number.isFinite(rateTo) || rateTo <= 0) {
return safeAmount
}
return (safeAmount / rateFrom) * rateTo
}
export function formatInBaseCurrency(
amount: number,
currency: string,
appSettings: Settings[] | Settings | null | undefined,
ratesData: RatesData | null,
): string {
const settings: Partial<Settings> = Array.isArray(appSettings)
? (appSettings[0] ?? {})
: (appSettings ?? {})
const baseCurrency = settings.baseCurrency || 'RUB'
const autoConvert = settings.autoConvert !== false
if (!autoConvert) return formatCurrency(amount, currency)
const converted = convertCurrency(amount, currency, baseCurrency, ratesData)
return formatCurrency(converted, baseCurrency)
}
export interface ConvertedWithProvider {
value: number
currency: string
source: 'native' | 'provider' | 'global' | 'no-rates'
}
export function convertWithProviderRate(
amount: number,
currency: string,
provider: Provider | null | undefined,
appSettings: Settings[] | Settings | null,
ratesData: RatesData | null,
): ConvertedWithProvider {
const safeAmount = Number(amount)
const settings: Partial<Settings> = Array.isArray(appSettings)
? (appSettings[0] ?? {})
: (appSettings ?? {})
const appBase = (settings.baseCurrency || 'RUB').toUpperCase()
if (!Number.isFinite(safeAmount)) return { value: 0, currency: appBase, source: 'global' }
const fromCurrency = toIsoCurrency(currency || appBase)
if (fromCurrency === appBase) return { value: safeAmount, currency: appBase, source: 'native' }
const usdRate = parseProviderFxRate(provider?.usdRate)
const eurRate = parseProviderFxRate(provider?.eurRate)
if (fromCurrency === 'USD' && Number.isFinite(usdRate)) {
return { value: safeAmount * usdRate, currency: appBase, source: 'provider' }
}
if (fromCurrency === 'EUR' && Number.isFinite(eurRate)) {
return { value: safeAmount * eurRate, currency: appBase, source: 'provider' }
}
if (!ratesData || !ratesData.rates || !ratesData.base) {
return { value: safeAmount, currency: fromCurrency, source: 'no-rates' }
}
const converted = convertCurrency(safeAmount, fromCurrency, appBase, ratesData)
const oneConverted = convertCurrency(1, fromCurrency, appBase, ratesData)
const globalRatesWork = Number.isFinite(oneConverted) && Math.abs(oneConverted - 1) > 1e-8
return { value: converted, currency: appBase, source: globalRatesWork ? 'global' : 'no-rates' }
}
export function formatInProviderCurrency(
amount: number,
currency: string,
provider: Provider | null | undefined,
appSettings: Settings[] | Settings | null,
ratesData: RatesData | null,
): string {
const converted = convertWithProviderRate(amount, currency, provider, appSettings, ratesData)
return formatCurrency(converted.value, converted.currency)
}
export function monthKey(dateString: string): string {
const date = new Date(dateString)
if (Number.isNaN(date.getTime())) return ''
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
}
export function toCsv(rows: Record<string, unknown>[]): string {
if (!rows.length) return ''
const headers = Object.keys(rows[0])
const escapeValue = (value: unknown) => {
const str = `${value ?? ''}`
if (str.includes('"') || str.includes(',') || str.includes('\n')) {
return `"${str.replaceAll('"', '""')}"`
}
return str
}
const lines = [headers.join(',')]
for (const row of rows) {
lines.push(headers.map((h) => escapeValue(row[h])).join(','))
}
return lines.join('\n')
}
export function downloadTextFile(fileName: string, content: string): void {
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
anchor.click()
URL.revokeObjectURL(url)
}
export function downloadBlob(fileName: string, blob: Blob): void {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
anchor.click()
URL.revokeObjectURL(url)
}
+189
View File
@@ -0,0 +1,189 @@
import type {
Vps,
ProviderAccount,
Provider,
Payment,
BalanceLedgerRow,
SyncLogRow,
SyncSummary,
} from '@/types/entities'
import { getPaidUntilDate } from './paid-until'
const STALE_SYNC_HOURS = 48
function ledgerRowsInAccountCurrency(
account: ProviderAccount,
balanceLedger: BalanceLedgerRow[],
): BalanceLedgerRow[] {
const cur = (account.balance_currency || account.currency || '').trim()
const rows = balanceLedger.filter((row) => row.providerAccountId === account.id)
if (!cur) return rows
return rows.filter((row) => !row.currency || row.currency === cur)
}
function ledgerBalanceInCurrency(
account: ProviderAccount,
balanceLedger: BalanceLedgerRow[],
): number {
const filtered = ledgerRowsInAccountCurrency(account, balanceLedger)
const credits = filtered.filter((r) => r.direction === 'credit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
const debits = filtered.filter((r) => r.direction === 'debit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
return credits - debits
}
export function accountHasApiLedgerMismatch(
account: ProviderAccount,
balanceLedger: BalanceLedgerRow[],
): boolean {
if (account.balance_api == null || !Number.isFinite(Number(account.balance_api))) return false
const rows = ledgerRowsInAccountCurrency(account, balanceLedger)
if (rows.length === 0) return false
const ledger = ledgerBalanceInCurrency(account, balanceLedger)
if (!Number.isFinite(ledger)) return false
const apiBalance = Number(account.balance_api)
const diff = Math.abs(apiBalance - ledger)
const tol = Math.max(10, Math.abs(apiBalance) * 0.05)
return diff > tol
}
export function lastOkSyncFinishedAt(
accountId: string,
syncLog: SyncLogRow[] = [],
): number | null {
const rows = syncLog.filter((r) => r.accountId === accountId && r.status === 'ok' && r.finishedAt)
let best: number | null = null
for (const r of rows) {
const t = new Date(r.finishedAt as string).getTime()
if (!Number.isNaN(t) && (best == null || t > best)) best = t
}
return best
}
export interface InventoryIssue {
key: string
title: string
count: number
to: string
hint?: string
}
export interface InventoryHealthInput {
vps: Vps[]
providerAccounts: ProviderAccount[]
providers?: Provider[]
payments: Payment[]
balanceLedger: BalanceLedgerRow[]
syncLog?: SyncLogRow[]
}
export function computeInventoryHealth(input: InventoryHealthInput): InventoryIssue[] {
const { vps, providerAccounts, providers = [], payments, balanceLedger, syncLog = [] } = input
const providerById = new Map(providers.map((p) => [p.id, p]))
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const ctx = { vps, providerAccounts, payments, balanceLedger, now }
const issues: InventoryIssue[] = []
const noProject = vps.filter((v) => v.status === 'active' && !(v.project || '').trim())
if (noProject.length) {
issues.push({ key: 'no-project', title: 'Активные VPS без проекта', count: noProject.length, to: '/vps?health=no-project' })
}
const noRate = vps.filter((v) => {
if (v.status !== 'active') return false
const dr = Number(v.dailyRate || 0)
const mr = Number(v.monthlyRate || 0)
const noMoney = (!Number.isFinite(dr) || dr <= 0) && (!Number.isFinite(mr) || mr <= 0)
const noCur = !(v.currency || '').trim()
return noMoney || noCur
})
if (noRate.length) {
issues.push({ key: 'no-rate', title: 'Нет ставки или валюты', count: noRate.length, to: '/vps?health=no-rate' })
}
const paidOverdue = vps.filter((v) => {
if (v.status !== 'active') return false
const d = getPaidUntilDate(v, ctx)
return d != null && d < todayStart
})
if (paidOverdue.length) {
issues.push({ key: 'paid-overdue', title: 'Просрочена оплата (оценка)', count: paidOverdue.length, to: '/vps?health=paid-overdue' })
}
const bmAccounts = providerAccounts.filter((a) => {
const p = providerById.get(a.providerId)
return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet
})
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
const staleAccounts = bmAccounts.filter((a) => {
const t = lastOkSyncFinishedAt(a.id, syncLog)
if (t == null) return true
return now.getTime() - t > staleMs
})
if (staleAccounts.length) {
issues.push({
key: 'stale-sync',
title: `Нет успешного синка > ${STALE_SYNC_HOURS} ч`,
count: staleAccounts.length,
to: '/accounts?health=stale-sync',
hint: 'Проверьте API и журнал синхронизации',
})
}
const mismatchAccounts = providerAccounts.filter((a) => accountHasApiLedgerMismatch(a, balanceLedger))
if (mismatchAccounts.length) {
issues.push({
key: 'balance-mismatch',
title: 'Баланс API и ledger расходятся',
count: mismatchAccounts.length,
to: '/accounts?health=balance-mismatch',
hint: 'Считается только если в журнале «Баланс и списания» есть движения по аккаунту',
})
}
return issues
}
export function getStaleSyncAccountIds(
providerAccounts: ProviderAccount[],
providers: Provider[],
syncLog: SyncLogRow[] = [],
now = new Date(),
): string[] {
const providerById = new Map(providers.map((p) => [p.id, p]))
const bmAccounts = providerAccounts.filter((a) => {
const p = providerById.get(a.providerId)
return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet
})
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
return bmAccounts
.filter((a) => {
const t = lastOkSyncFinishedAt(a.id, syncLog)
if (t == null) return true
return now.getTime() - t > staleMs
})
.map((a) => a.id)
}
export function getBalanceMismatchAccountIds(
providerAccounts: ProviderAccount[],
balanceLedger: BalanceLedgerRow[],
): string[] {
return providerAccounts.filter((a) => accountHasApiLedgerMismatch(a, balanceLedger)).map((a) => a.id)
}
export function formatSyncSummaryLine(summary: SyncSummary | null | undefined): string {
if (!summary || typeof summary !== 'object') return ''
if (summary.error) return String(summary.error)
const parts: string[] = []
if (summary.added?.length) parts.push(`+${summary.added.length} VPS`)
if (summary.updated?.length) parts.push(`~${summary.updated.length} изм.`)
if (summary.paymentsAdded) parts.push(`+${summary.paymentsAdded} платежей`)
if (summary.tariffsOnly && summary.tariffsCount != null) parts.push(`тарифы: ${summary.tariffsCount}`)
if (parts.length) return parts.join(', ')
if (summary.vpsCount != null || summary.paymentsCount != null) {
return `синк: VPS ${summary.vpsCount ?? 0}, платежи ${summary.paymentsCount ?? 0}`
}
return ''
}
+13
View File
@@ -0,0 +1,13 @@
import type { HTMLInputProps } from '@/types/dom'
export const noBrowserSuggestProps: HTMLInputProps = Object.freeze({
autoComplete: 'off',
'data-lpignore': 'true',
'data-1p-ignore': 'true',
'data-bwignore': 'true',
'data-form-type': 'other',
} as HTMLInputProps)
export const passwordCredentialInputProps: HTMLInputProps = Object.freeze({
autoComplete: 'new-password',
} as HTMLInputProps)
+72
View File
@@ -0,0 +1,72 @@
import type { Vps, ProviderAccount, Payment, BalanceLedgerRow } from '@/types/entities'
function getAccountBalance(
accountId: string,
providerAccounts: ProviderAccount[],
balanceLedger: BalanceLedgerRow[],
): number {
const account = providerAccounts.find((a) => a.id === accountId)
if (account?.balance_api != null && Number.isFinite(Number(account.balance_api))) {
return Number(account.balance_api)
}
const ledgerRows = balanceLedger.filter((row) => row.providerAccountId === accountId)
const credits = ledgerRows.filter((r) => r.direction === 'credit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
const debits = ledgerRows.filter((r) => r.direction === 'debit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
return credits - debits
}
export interface PaidUntilContext {
vps: Vps[]
providerAccounts: ProviderAccount[]
payments: Payment[]
balanceLedger: BalanceLedgerRow[]
now?: Date
}
export function getPaidUntilDate(item: Vps, ctx: PaidUntilContext): Date | null {
const { vps, providerAccounts, payments, balanceLedger, now = new Date() } = ctx
if (item.status !== 'active') return null
const account = providerAccounts.find((a) => a.id === item.providerAccountId)
const tariffType = item.tariffType || (Number(item.dailyRate || 0) > 0 ? 'daily' : 'monthly')
const isDailyBilling = tariffType === 'daily' || account?.billingMode === 'daily'
const paidUntilFromApi = item.paidUntil
? (() => {
const d = new Date(item.paidUntil)
return Number.isNaN(d.getTime()) ? null : d
})()
: null
const isPaidUntilNextDay =
paidUntilFromApi != null &&
(() => {
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const diffMs = paidUntilFromApi.getTime() - today.getTime()
const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000))
return diffDays >= 0 && diffDays <= 2
})()
const shouldCalculateFromBalance = isDailyBilling || isPaidUntilNextDay
if (!shouldCalculateFromBalance && paidUntilFromApi) return paidUntilFromApi
const dailyRate = Number(item.dailyRate || 0)
const monthlyRate = Number(item.monthlyRate || 0)
const burnRate = tariffType === 'daily' ? dailyRate : monthlyRate / 30
if (!Number.isFinite(burnRate) || burnRate <= 0) return paidUntilFromApi
const accountBalance = getAccountBalance(item.providerAccountId, providerAccounts, balanceLedger)
const activeInAccount = vps.filter(
(v) => v.providerAccountId === item.providerAccountId && v.status === 'active',
).length
const allocatedBalance = activeInAccount > 0 ? Math.max(0, accountBalance) / activeInAccount : 0
const directPayments = payments
.filter((p) => p.vpsId === item.id && p.type === 'direct_vps_payment')
.reduce((acc, p) => acc + Number(p.amount || 0), 0)
const funds = directPayments + allocatedBalance
const coveredDays = Math.floor(funds / burnRate)
if (!Number.isFinite(coveredDays) || coveredDays <= 0) return paidUntilFromApi
const paidUntil = new Date(now)
paidUntil.setDate(paidUntil.getDate() + coveredDays)
return paidUntil
}
+11
View File
@@ -0,0 +1,11 @@
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
})
+25
View File
@@ -0,0 +1,25 @@
import { QueryClient } from '@tanstack/react-query'
import {
createRouter as tanstackCreateRouter,
rootRouteId,
} from '@tanstack/react-router'
import { routeTree } from '../routeTree.gen'
import { queryClient } from './queryClient'
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof createRouter>
}
}
export function createRouter(opts?: { context?: { queryClient: QueryClient } }) {
return tanstackCreateRouter({
routeTree,
context: opts?.context ?? { queryClient },
defaultPreload: 'intent',
scrollRestoration: true,
})
}
export { rootRouteId }
+92
View File
@@ -0,0 +1,92 @@
import { z } from 'zod'
export const vpsStatusSchema = z.enum(['active', 'paused', 'archived'])
export const tariffTypeSchema = z.enum(['daily', 'monthly'])
export const billingModeSchema = z.enum(['daily', 'monthly'])
export const paymentTypeSchema = z.enum([
'direct_vps_payment',
'provider_balance_topup',
'daily_debit',
'monthly_debit',
])
export const ledgerDirectionSchema = z.enum(['credit', 'debit'])
export const apiTypeSchema = z.enum(['billmanager', 'none'])
export const providerSchema = z.object({
id: z.string().min(1).optional(),
name: z.string().min(1, 'Название обязательно'),
website: z.string().url('Невалидный URL').or(z.literal('')).optional(),
apiType: apiTypeSchema,
apiBaseUrl: z.string().optional().default(''),
baseCurrency: z.string().min(1).default('RUB'),
usdRate: z.string().optional().default(''),
eurRate: z.string().optional().default(''),
supportPhone: z.string().optional().default(''),
supportUrl: z.string().optional().default(''),
notes: z.string().optional().default(''),
})
export const providerAccountSchema = z.object({
id: z.string().min(1).optional(),
providerId: z.string().min(1, 'Выберите хостера'),
name: z.string().min(1, 'Название обязательно'),
login: z.string().optional().default(''),
apiCredentials: z.string().optional().default(''),
billingMode: billingModeSchema.default('monthly'),
notes: z.string().optional().default(''),
})
export const vpsSchema = z.object({
id: z.string().min(1).optional(),
ip: z.string().min(1, 'IP обязателен'),
dns: z.string().optional().default(''),
providerId: z.string().min(1, 'Выберите хостера'),
providerAccountId: z.string().min(1, 'Выберите аккаунт'),
vcpu: z.coerce.number().int().min(0).default(1),
ramGb: z.coerce.number().min(0).default(1),
diskGb: z.coerce.number().min(0).default(10),
status: vpsStatusSchema.default('active'),
tariffType: tariffTypeSchema.default('monthly'),
currency: z.string().min(1, 'Валюта обязательна').default('RUB'),
monthlyRate: z.coerce.number().min(0).default(0),
dailyRate: z.coerce.number().min(0).default(0),
paidUntil: z.string().optional().default(''),
project: z.string().optional().default(''),
notes: z.string().optional().default(''),
})
export const paymentSchema = z.object({
id: z.string().min(1).optional(),
type: paymentTypeSchema,
date: z.string().min(1, 'Дата обязательна'),
amount: z.coerce.number().min(0, 'Сумма должна быть ≥ 0'),
currency: z.string().min(1).default('RUB'),
providerAccountId: z.string().min(1, 'Выберите аккаунт'),
note: z.string().optional().default(''),
})
export const balanceLedgerSchema = z.object({
providerAccountId: z.string().min(1, 'Выберите аккаунт'),
direction: ledgerDirectionSchema,
amount: z.coerce.number().min(0, 'Сумма должна быть ≥ 0'),
currency: z.string().min(1).default('RUB'),
date: z.string().min(1, 'Дата обязательна'),
note: z.string().optional().default(''),
})
export const settingsSchema = z.object({
id: z.string().optional(),
baseCurrency: z.string().min(1).default('RUB'),
ratesUrl: z.string().url('Невалидный URL').or(z.literal('')).optional(),
autoConvert: z.boolean().default(true),
syncEnabled: z.boolean().optional().default(true),
telegramChatId: z.string().optional().default(''),
telegramBotToken: z.string().optional().default(''),
})
export type ProviderFormValues = z.infer<typeof providerSchema>
export type ProviderAccountFormValues = z.infer<typeof providerAccountSchema>
export type VpsFormValues = z.infer<typeof vpsSchema>
export type PaymentFormValues = z.infer<typeof paymentSchema>
export type BalanceLedgerFormValues = z.infer<typeof balanceLedgerSchema>
export type SettingsFormValues = z.infer<typeof settingsSchema>
+24
View File
@@ -0,0 +1,24 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider } from '@tanstack/react-router'
import { Toaster } from '@cfdm/ui/components/sonner'
import '@cfdm/ui/globals.css'
import { queryClient } from '@/lib/queryClient'
import { createRouter } from '@/lib/router'
const router = createRouter({ context: { queryClient } })
const rootEl = document.getElementById('root')
if (!rootEl) throw new Error('Root element #root not found')
createRoot(rootEl).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<Toaster richColors position="top-right" />
</QueryClientProvider>
</StrictMode>,
)
+47
View File
@@ -0,0 +1,47 @@
import { queryClient } from '../lib/queryClient'
import { api } from '../lib/api-client'
export const snapshotKeys = {
all: ['snapshot'] as const,
}
export const snapshotQueryOptions = () => ({
queryKey: snapshotKeys.all,
queryFn: () => api.fetchData(),
staleTime: 30_000,
})
export const ratesKeys = {
all: ['rates'] as const,
}
export const ratesQueryOptions = (ratesUrl?: string) => ({
queryKey: ratesKeys.all,
queryFn: async () => {
if (!ratesUrl) return null
const proxyUrl = `/api/rates-proxy?url=${encodeURIComponent(ratesUrl)}`
try {
const res = await fetch(proxyUrl)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return await res.json()
} catch {
const direct = await fetch(ratesUrl)
if (!direct.ok) throw new Error(`HTTP ${direct.status}`)
return await direct.json()
}
},
enabled: Boolean(ratesUrl),
})
export const projectsKeys = {
suggest: (q: string) => ['projects', 'suggest', q] as const,
}
export const projectsSuggestQueryOptions = (q: string) => ({
queryKey: projectsKeys.suggest(q),
queryFn: () => api.fetchProjectSuggestions(q),
enabled: q.length >= 2,
})
export type SnapshotFromQuery = Awaited<ReturnType<typeof api.fetchData>>
export { queryClient }
+297
View File
@@ -0,0 +1,297 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AuthVpsRouteImport } from './routes/_auth/vps'
import { Route as AuthTariffsRouteImport } from './routes/_auth/tariffs'
import { Route as AuthSettingsRouteImport } from './routes/_auth/settings'
import { Route as AuthResourcesRouteImport } from './routes/_auth/resources'
import { Route as AuthReportsRouteImport } from './routes/_auth/reports'
import { Route as AuthProvidersRouteImport } from './routes/_auth/providers'
import { Route as AuthPaymentsRouteImport } from './routes/_auth/payments'
import { Route as AuthDashboardRouteImport } from './routes/_auth/dashboard'
import { Route as AuthBalanceRouteImport } from './routes/_auth/balance'
import { Route as AuthAccountsRouteImport } from './routes/_auth/accounts'
const AuthRoute = AuthRouteImport.update({
id: '/_auth',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const AuthVpsRoute = AuthVpsRouteImport.update({
id: '/vps',
path: '/vps',
getParentRoute: () => AuthRoute,
} as any)
const AuthTariffsRoute = AuthTariffsRouteImport.update({
id: '/tariffs',
path: '/tariffs',
getParentRoute: () => AuthRoute,
} as any)
const AuthSettingsRoute = AuthSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => AuthRoute,
} as any)
const AuthResourcesRoute = AuthResourcesRouteImport.update({
id: '/resources',
path: '/resources',
getParentRoute: () => AuthRoute,
} as any)
const AuthReportsRoute = AuthReportsRouteImport.update({
id: '/reports',
path: '/reports',
getParentRoute: () => AuthRoute,
} as any)
const AuthProvidersRoute = AuthProvidersRouteImport.update({
id: '/providers',
path: '/providers',
getParentRoute: () => AuthRoute,
} as any)
const AuthPaymentsRoute = AuthPaymentsRouteImport.update({
id: '/payments',
path: '/payments',
getParentRoute: () => AuthRoute,
} as any)
const AuthDashboardRoute = AuthDashboardRouteImport.update({
id: '/dashboard',
path: '/dashboard',
getParentRoute: () => AuthRoute,
} as any)
const AuthBalanceRoute = AuthBalanceRouteImport.update({
id: '/balance',
path: '/balance',
getParentRoute: () => AuthRoute,
} as any)
const AuthAccountsRoute = AuthAccountsRouteImport.update({
id: '/accounts',
path: '/accounts',
getParentRoute: () => AuthRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/accounts': typeof AuthAccountsRoute
'/balance': typeof AuthBalanceRoute
'/dashboard': typeof AuthDashboardRoute
'/payments': typeof AuthPaymentsRoute
'/providers': typeof AuthProvidersRoute
'/reports': typeof AuthReportsRoute
'/resources': typeof AuthResourcesRoute
'/settings': typeof AuthSettingsRoute
'/tariffs': typeof AuthTariffsRoute
'/vps': typeof AuthVpsRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/accounts': typeof AuthAccountsRoute
'/balance': typeof AuthBalanceRoute
'/dashboard': typeof AuthDashboardRoute
'/payments': typeof AuthPaymentsRoute
'/providers': typeof AuthProvidersRoute
'/reports': typeof AuthReportsRoute
'/resources': typeof AuthResourcesRoute
'/settings': typeof AuthSettingsRoute
'/tariffs': typeof AuthTariffsRoute
'/vps': typeof AuthVpsRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/_auth': typeof AuthRouteWithChildren
'/_auth/accounts': typeof AuthAccountsRoute
'/_auth/balance': typeof AuthBalanceRoute
'/_auth/dashboard': typeof AuthDashboardRoute
'/_auth/payments': typeof AuthPaymentsRoute
'/_auth/providers': typeof AuthProvidersRoute
'/_auth/reports': typeof AuthReportsRoute
'/_auth/resources': typeof AuthResourcesRoute
'/_auth/settings': typeof AuthSettingsRoute
'/_auth/tariffs': typeof AuthTariffsRoute
'/_auth/vps': typeof AuthVpsRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/accounts'
| '/balance'
| '/dashboard'
| '/payments'
| '/providers'
| '/reports'
| '/resources'
| '/settings'
| '/tariffs'
| '/vps'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/accounts'
| '/balance'
| '/dashboard'
| '/payments'
| '/providers'
| '/reports'
| '/resources'
| '/settings'
| '/tariffs'
| '/vps'
id:
| '__root__'
| '/'
| '/_auth'
| '/_auth/accounts'
| '/_auth/balance'
| '/_auth/dashboard'
| '/_auth/payments'
| '/_auth/providers'
| '/_auth/reports'
| '/_auth/resources'
| '/_auth/settings'
| '/_auth/tariffs'
| '/_auth/vps'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AuthRoute: typeof AuthRouteWithChildren
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/_auth': {
id: '/_auth'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/vps': {
id: '/_auth/vps'
path: '/vps'
fullPath: '/vps'
preLoaderRoute: typeof AuthVpsRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/tariffs': {
id: '/_auth/tariffs'
path: '/tariffs'
fullPath: '/tariffs'
preLoaderRoute: typeof AuthTariffsRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/settings': {
id: '/_auth/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof AuthSettingsRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/resources': {
id: '/_auth/resources'
path: '/resources'
fullPath: '/resources'
preLoaderRoute: typeof AuthResourcesRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/reports': {
id: '/_auth/reports'
path: '/reports'
fullPath: '/reports'
preLoaderRoute: typeof AuthReportsRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/providers': {
id: '/_auth/providers'
path: '/providers'
fullPath: '/providers'
preLoaderRoute: typeof AuthProvidersRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/payments': {
id: '/_auth/payments'
path: '/payments'
fullPath: '/payments'
preLoaderRoute: typeof AuthPaymentsRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/dashboard': {
id: '/_auth/dashboard'
path: '/dashboard'
fullPath: '/dashboard'
preLoaderRoute: typeof AuthDashboardRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/balance': {
id: '/_auth/balance'
path: '/balance'
fullPath: '/balance'
preLoaderRoute: typeof AuthBalanceRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/accounts': {
id: '/_auth/accounts'
path: '/accounts'
fullPath: '/accounts'
preLoaderRoute: typeof AuthAccountsRouteImport
parentRoute: typeof AuthRoute
}
}
}
interface AuthRouteChildren {
AuthAccountsRoute: typeof AuthAccountsRoute
AuthBalanceRoute: typeof AuthBalanceRoute
AuthDashboardRoute: typeof AuthDashboardRoute
AuthPaymentsRoute: typeof AuthPaymentsRoute
AuthProvidersRoute: typeof AuthProvidersRoute
AuthReportsRoute: typeof AuthReportsRoute
AuthResourcesRoute: typeof AuthResourcesRoute
AuthSettingsRoute: typeof AuthSettingsRoute
AuthTariffsRoute: typeof AuthTariffsRoute
AuthVpsRoute: typeof AuthVpsRoute
}
const AuthRouteChildren: AuthRouteChildren = {
AuthAccountsRoute: AuthAccountsRoute,
AuthBalanceRoute: AuthBalanceRoute,
AuthDashboardRoute: AuthDashboardRoute,
AuthPaymentsRoute: AuthPaymentsRoute,
AuthProvidersRoute: AuthProvidersRoute,
AuthReportsRoute: AuthReportsRoute,
AuthResourcesRoute: AuthResourcesRoute,
AuthSettingsRoute: AuthSettingsRoute,
AuthTariffsRoute: AuthTariffsRoute,
AuthVpsRoute: AuthVpsRoute,
}
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuthRoute: AuthRouteWithChildren,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
+18
View File
@@ -0,0 +1,18 @@
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
import { AppShell } from '@/components/layout/app-shell'
interface RouterContext {
queryClient: import('@tanstack/react-query').QueryClient
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent,
})
function RootComponent() {
return (
<AppShell>
<Outlet />
</AppShell>
)
}
+12
View File
@@ -0,0 +1,12 @@
import { Outlet, createFileRoute } from '@tanstack/react-router'
import { snapshotQueryOptions } from '@/queries/snapshot'
export const Route = createFileRoute('/_auth')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
component: AuthLayout,
})
function AuthLayout() {
return <Outlet />
}
+229
View File
@@ -0,0 +1,229 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { PlusIcon, PencilIcon, Trash2Icon, RefreshCwIcon } from 'lucide-react'
import { toast } from 'sonner'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { DataTableCard, type DataTableColumn } from '@/components/data-table-card'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { FormSheet } from '@/components/form-sheet'
import { FormField } from '@/components/form-field'
import { Input } from '@cfdm/ui/components/input'
import { Textarea } from '@cfdm/ui/components/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@cfdm/ui/components/select'
import { LoadingButton } from '@/components/loading-button'
import type { ProviderAccount, BillingMode } from '@/types/entities'
import { providerByIdMap, accountBillmanagerUiReady } from '@/lib/billmanager'
import { billingModeLabel, formatCurrency } from '@/lib/format'
export const Route = createFileRoute('/_auth/accounts')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
component: AccountsPage,
})
interface FormState {
id?: string
providerId: string
name: string
login: string
apiCredentials: string
billingMode: BillingMode
notes: string
}
const EMPTY: FormState = { providerId: '', name: '', login: '', apiCredentials: '', billingMode: 'monthly', notes: '' }
function AccountsPage() {
const queryClient = useQueryClient()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const [open, setOpen] = useState(false)
const [form, setForm] = useState<FormState>(EMPTY)
const saveMut = useMutation({
mutationFn: (r: FormState) => {
const { apiCredentials, ...rest } = r
const payload = apiCredentials ? { ...rest, apiCredentials } : rest
return r.id
? api.update<ProviderAccount>('providerAccounts', r.id, payload as unknown as Partial<ProviderAccount>)
: api.create('providerAccounts', payload as unknown as ProviderAccount)
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
toast.success('Аккаунт сохранён')
setOpen(false)
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
})
const delMut = useMutation({
mutationFn: (id: string) => api.remove<ProviderAccount>('providerAccounts', id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
toast.success('Аккаунт удалён')
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
})
const syncMut = useMutation({
mutationFn: (id: string) => api.syncAccount(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
toast.success('Синк запущен')
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка синка'),
})
const openCreate = () => { setForm({ ...EMPTY, providerId: snapshot?.providers[0]?.id ?? '' }); setOpen(true) }
const openEdit = (a: ProviderAccount) => {
setForm({
id: a.id, providerId: a.providerId, name: a.name, login: a.login ?? '',
apiCredentials: '', billingMode: a.billingMode ?? 'monthly', notes: a.notes ?? '',
})
setOpen(true)
}
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
const columns: DataTableColumn<ProviderAccount>[] = [
{
key: 'name',
header: 'Аккаунт',
cell: (a) => (
<div className="flex flex-col">
<span className="font-medium">{a.name}</span>
<span className="text-xs text-muted-foreground">{providerById.get(a.providerId)?.name ?? '—'}</span>
</div>
),
},
{ key: 'login', header: 'Логин', cell: (a) => <span className="text-muted-foreground">{a.login || '—'}</span> },
{
key: 'creds',
header: 'API-доступ',
cell: (a) => <Badge variant={a.apiCredentialsSet ? 'default' : 'outline'}>{a.apiCredentialsSet ? 'установлены' : 'нет'}</Badge>,
},
{
key: 'mode',
header: 'Биллинг',
cell: (a) => <span>{billingModeLabel(a.billingMode ?? 'monthly')}</span>,
},
{
key: 'balance',
header: 'Баланс (API)',
cell: (a) => {
const provider = providerById.get(a.providerId)
if (!accountBillmanagerUiReady(a, provider)) return <span className="text-muted-foreground"></span>
const cur = a.balance_currency || a.currency || provider?.baseCurrency || 'USD'
return <span className="tabular-nums">{formatCurrency(Number(a.balance_api ?? 0), cur)}</span>
},
},
{
key: 'actions',
header: '',
className: 'w-32 text-right',
cell: (a) => {
const provider = providerById.get(a.providerId)
const canSync = accountBillmanagerUiReady(a, provider)
return (
<div className="flex justify-end gap-1">
<LoadingButton
variant="outline"
size="sm"
loading={syncMut.isPending && syncMut.variables === a.id}
disabled={!canSync}
onClick={() => syncMut.mutate(a.id)}
>
<RefreshCwIcon data-icon="inline-start" />
Синк
</LoadingButton>
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(a)} aria-label="Редактировать">
<PencilIcon />
</Button>
<ConfirmDialog
trigger={<Button variant="ghost" size="icon-sm" aria-label="Удалить"><Trash2Icon /></Button>}
title="Удалить аккаунт?"
description={`«${a.name}» будет удалён.`}
destructive
confirmLabel="Удалить"
onConfirm={() => delMut.mutate(a.id)}
/>
</div>
)
},
},
]
return (
<PageShell>
<PageHeader
title="Аккаунты хостеров"
description="Аккаунты провайдеров с API-доступом"
actions={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить</Button>}
/>
<QueryState
data={snapshot}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<TableSkeleton />}
empty={snapshot?.providerAccounts.length === 0}
emptyTitle="Аккаунты не найдены"
emptyAction={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить аккаунт</Button>}
>
{(snap) => <DataTableCard columns={columns} data={snap.providerAccounts} rowKey={(a) => a.id} />}
</QueryState>
<FormSheet
open={open}
onOpenChange={setOpen}
trigger={null}
title={form.id ? 'Редактировать аккаунт' : 'Новый аккаунт'}
description="API-креды хранятся на сервере и используются для синка с BILLmanager"
onSubmit={() => saveMut.mutate(form)}
submitting={saveMut.isPending}
>
<FormField label="Хостер" htmlFor="acc-provider">
<Select value={form.providerId} onValueChange={(v) => setForm({ ...form, providerId: v ?? '' })}>
<SelectTrigger id="acc-provider"><SelectValue placeholder="Выберите хостера" /></SelectTrigger>
<SelectContent>
{snapshot?.providers.map((p) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
</SelectContent>
</Select>
</FormField>
<FormField label="Название" htmlFor="acc-name">
<Input id="acc-name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</FormField>
<FormField label="Логин" htmlFor="acc-login">
<Input id="acc-login" value={form.login} onChange={(e) => setForm({ ...form, login: e.target.value })} />
</FormField>
<FormField
label={form.id ? 'Новый API-пароль (необязательно)' : 'API-пароль (логин:пароль)'}
htmlFor="acc-creds"
description="Оставьте пустым при редактировании, чтобы сохранить существующий"
>
<Input id="acc-creds" type="password" value={form.apiCredentials} onChange={(e) => setForm({ ...form, apiCredentials: e.target.value })} />
</FormField>
<FormField label="Режим биллинга" htmlFor="acc-mode">
<Select value={form.billingMode} onValueChange={(v) => setForm({ ...form, billingMode: (v ?? 'monthly') as BillingMode })}>
<SelectTrigger id="acc-mode"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="monthly">{billingModeLabel('monthly')}</SelectItem>
<SelectItem value="daily">{billingModeLabel('daily')}</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField label="Заметки" htmlFor="acc-notes">
<Textarea id="acc-notes" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</FormField>
</FormSheet>
</PageShell>
)
}
+204
View File
@@ -0,0 +1,204 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { PlusIcon, Trash2Icon, ArrowDownUpIcon } from 'lucide-react'
import { toast } from 'sonner'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { DataTableCard, type DataTableColumn } from '@/components/data-table-card'
import { QueryState } from '@/components/query-state'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { SectionCards } from '@/components/section-cards'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { FormSheet } from '@/components/form-sheet'
import { FormField } from '@/components/form-field'
import { Input } from '@cfdm/ui/components/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@cfdm/ui/components/select'
import { Textarea } from '@cfdm/ui/components/textarea'
import type { BalanceLedgerRow, LedgerDirection } from '@/types/entities'
import { formatCurrency } from '@/lib/format'
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
export const Route = createFileRoute('/_auth/balance')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
component: BalancePage,
})
interface FormState {
providerAccountId: string
direction: LedgerDirection
amount: number
currency: string
date: string
note: string
}
const TODAY = new Date().toISOString().slice(0, 10)
const EMPTY: FormState = { providerAccountId: '', direction: 'credit', amount: 0, currency: 'RUB', date: TODAY, note: '' }
function BalancePage() {
const queryClient = useQueryClient()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const [open, setOpen] = useState(false)
const [form, setForm] = useState<FormState>(EMPTY)
const addMut = useMutation({
mutationFn: (r: FormState) => api.create('balanceLedger', r as unknown as BalanceLedgerRow),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
toast.success('Запись добавлена')
setOpen(false)
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
})
const delMut = useMutation({
mutationFn: (id: string) => api.remove<BalanceLedgerRow>('balanceLedger', id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
toast.success('Запись удалена')
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
})
const openCreate = () => { setForm({ ...EMPTY, providerAccountId: snapshot?.providerAccounts[0]?.id ?? '' }); setOpen(true) }
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
const columns: DataTableColumn<BalanceLedgerRow>[] = [
{ key: 'date', header: 'Дата', cell: (r) => <span className="tabular-nums">{r.date}</span> },
{
key: 'account',
header: 'Аккаунт',
cell: (r) => {
const acc = snapshot?.providerAccounts.find((a) => a.id === r.providerAccountId)
return acc ? accountSelectLabel(acc, providerById) : '—'
},
},
{
key: 'dir',
header: 'Движение',
cell: (r) => (
<Badge variant={r.direction === 'credit' ? 'default' : 'destructive'}>
<ArrowDownUpIcon data-icon="inline-start" />
{r.direction === 'credit' ? 'Приход' : 'Списание'}
</Badge>
),
},
{
key: 'amount',
header: 'Сумма',
cell: (r) => (
<span className={`tabular-nums ${r.direction === 'credit' ? '' : 'text-destructive'}`}>
{r.direction === 'credit' ? '+' : ''}{formatCurrency(Number(r.amount), r.currency ?? 'RUB')}
</span>
),
},
{ key: 'note', header: 'Заметка', cell: (r) => <span className="text-muted-foreground">{r.note || '—'}</span> },
{
key: 'actions',
header: '',
className: 'w-16 text-right',
cell: (r) => (
<ConfirmDialog
trigger={<Button variant="ghost" size="icon-sm" aria-label="Удалить"><Trash2Icon /></Button>}
title="Удалить запись?"
confirmLabel="Удалить"
destructive
onConfirm={() => delMut.mutate(r.id)}
/>
),
},
]
const rows = [...(snapshot?.balanceLedger ?? [])].sort((a, b) => b.date.localeCompare(a.date))
const totalCredit = rows.filter((r) => r.direction === 'credit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
const totalDebit = rows.filter((r) => r.direction === 'debit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
return (
<PageShell>
<PageHeader
title="Баланс и списания"
description="Журнал движений по аккаунтам"
actions={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить запись</Button>}
/>
<QueryState
data={snapshot}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<SectionCardsSkeleton count={3} />}
>
{(snap) => (
<>
<SectionCards
items={[
{ label: 'Всего приходов', value: formatCurrency(totalCredit, snap.settings[0]?.baseCurrency ?? 'RUB') },
{ label: 'Всего списаний', value: formatCurrency(totalDebit, snap.settings[0]?.baseCurrency ?? 'RUB') },
{ label: 'Чистый баланс (ledger)', value: formatCurrency(totalCredit - totalDebit, snap.settings[0]?.baseCurrency ?? 'RUB') },
]}
/>
<DataTableCard
columns={columns}
data={rows}
rowKey={(r) => r.id}
emptyTitle="Записей нет"
emptyAction={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить</Button>}
/>
</>
)}
</QueryState>
<FormSheet
open={open}
onOpenChange={setOpen}
trigger={null}
title="Новая запись"
onSubmit={() => addMut.mutate(form)}
submitting={addMut.isPending}
>
<FormField label="Аккаунт" htmlFor="bl-acc">
<Select value={form.providerAccountId} onValueChange={(v) => setForm({ ...form, providerAccountId: v ?? '' })}>
<SelectTrigger id="bl-acc"><SelectValue placeholder="Выберите аккаунт" /></SelectTrigger>
<SelectContent>
{snapshot?.providerAccounts.map((a) => (
<SelectItem key={a.id} value={a.id}>{accountSelectLabel(a, providerById)}</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField label="Движение" htmlFor="bl-dir">
<Select value={form.direction} onValueChange={(v) => setForm({ ...form, direction: (v ?? 'credit') as LedgerDirection })}>
<SelectTrigger id="bl-dir"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="credit">Приход</SelectItem>
<SelectItem value="debit">Списание</SelectItem>
</SelectContent>
</Select>
</FormField>
<div className="grid grid-cols-3 gap-3">
<FormField label="Дата" htmlFor="bl-date">
<Input id="bl-date" type="date" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} />
</FormField>
<FormField label="Сумма" htmlFor="bl-amount">
<Input id="bl-amount" type="number" step="0.01" value={form.amount} onChange={(e) => setForm({ ...form, amount: Number(e.target.value) })} />
</FormField>
<FormField label="Валюта" htmlFor="bl-cur">
<Input id="bl-cur" value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value })} />
</FormField>
</div>
<FormField label="Заметка" htmlFor="bl-note">
<Textarea id="bl-note" value={form.note} onChange={(e) => setForm({ ...form, note: e.target.value })} />
</FormField>
</FormSheet>
</PageShell>
)
}
+194
View File
@@ -0,0 +1,194 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ServerIcon, AlertTriangleIcon, WalletIcon, TrendingUpIcon } from 'lucide-react'
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { SectionCards } from '@/components/section-cards'
import { QueryState } from '@/components/query-state'
import { TableCard } from '@/components/table-card'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { EmptyState } from '@/components/empty-state'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@cfdm/ui/components/table'
import { computeInventoryHealth } from '@/lib/inventory-health'
import { formatInBaseCurrency, normalizeRatesPayload } from '@/lib/format'
import { vpsStatusLabel } from '@/lib/format'
export const Route = createFileRoute('/_auth/dashboard')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
component: DashboardPage,
})
function DashboardPage() {
const navigate = useNavigate()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const settings = snapshot?.settings?.[0]
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
return (
<PageShell>
<PageHeader title="Дашборд" description="Сводка по VPS, балансам и здоровью инвентаря" />
<QueryState
data={snapshot}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<SectionCardsSkeleton />}
>
{(snap) => {
const activeVps = snap.vps.filter((v) => v.status === 'active')
const monthlyTotal = activeVps.reduce((acc, v) => {
const monthly = Number(v.monthlyRate || 0)
const daily = Number(v.dailyRate || 0)
const burn = v.tariffType === 'daily' ? daily * 30 : monthly
return acc + (Number.isFinite(burn) ? burn : 0)
}, 0)
const issues = computeInventoryHealth(snap)
const totalBalance = snap.providerAccounts.reduce(
(acc, a) => acc + (Number(a.balance_api ?? 0) || 0),
0,
)
return (
<>
<SectionCards
items={[
{
label: 'Активные VPS',
value: activeVps.length,
icon: <ServerIcon className="size-4" />,
hint: `всего ${snap.vps.length}`,
},
{
label: 'Хостеры',
value: snap.providers.length,
icon: <WalletIcon className="size-4" />,
hint: `${snap.providerAccounts.length} аккаунтов`,
},
{
label: 'Расход/мес (оценка)',
value: formatInBaseCurrency(monthlyTotal, snap.vps[0]?.currency ?? 'USD', snap.settings, ratesData),
icon: <TrendingUpIcon className="size-4" />,
},
{
label: 'Баланс аккаунтов (API)',
value: formatInBaseCurrency(totalBalance, snap.settings[0]?.baseCurrency ?? 'RUB', snap.settings, ratesData),
icon: <WalletIcon className="size-4" />,
},
]}
/>
<TableCard
title="Здоровье инвентаря"
description="Подсказки: нет проекта, нет ставки, просрочка, устаревший синк, расхождения баланса"
actions={
issues.length > 0 ? (
<Badge variant="destructive">{issues.length}</Badge>
) : (
<Badge variant="secondary">всё ок</Badge>
)
}
>
{issues.length === 0 ? (
<div className="p-4">
<EmptyState
icon={<TrendingUpIcon className="size-8" />}
title="Проблем не найдено"
description="Все активные VPS имеют проект, ставку и актуальный синк"
/>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Проблема</TableHead>
<TableHead className="w-24 text-right">Кол-во</TableHead>
<TableHead className="w-32">Действие</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{issues.map((issue) => (
<TableRow key={issue.key}>
<TableCell>
<div className="flex items-center gap-2">
<AlertTriangleIcon className="size-4 text-destructive" />
<div className="flex flex-col">
<span>{issue.title}</span>
{issue.hint ? (
<span className="text-xs text-muted-foreground">{issue.hint}</span>
) : null}
</div>
</div>
</TableCell>
<TableCell className="text-right tabular-nums">{issue.count}</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
onClick={() => navigate({ to: issue.to })}
>
Открыть
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</TableCard>
<TableCard title="Последние VPS" description="Активные серверы">
<Table>
<TableHeader>
<TableRow>
<TableHead>IP / DNS</TableHead>
<TableHead>Проект</TableHead>
<TableHead>Статус</TableHead>
<TableHead className="text-right">Ставка/мес</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{activeVps.slice(0, 8).map((v) => (
<TableRow key={v.id}>
<TableCell className="font-medium">{v.ip || v.dns}</TableCell>
<TableCell className="text-muted-foreground">{v.project || '—'}</TableCell>
<TableCell>
<Badge variant={v.status === 'active' ? 'default' : 'secondary'}>
{vpsStatusLabel(v.status)}
</Badge>
</TableCell>
<TableCell className="text-right tabular-nums">
{formatInBaseCurrency(
v.tariffType === 'daily' ? Number(v.dailyRate || 0) * 30 : Number(v.monthlyRate || 0),
v.currency,
snap.settings,
ratesData,
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableCard>
</>
)
}}
</QueryState>
</PageShell>
)
}
+193
View File
@@ -0,0 +1,193 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { PlusIcon, PencilIcon, Trash2Icon } from 'lucide-react'
import { toast } from 'sonner'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Button } from '@cfdm/ui/components/button'
import { DataTableCard, type DataTableColumn } from '@/components/data-table-card'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { FormSheet } from '@/components/form-sheet'
import { FormField } from '@/components/form-field'
import { Input } from '@cfdm/ui/components/input'
import { Textarea } from '@cfdm/ui/components/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@cfdm/ui/components/select'
import type { Payment, PaymentType } from '@/types/entities'
import { paymentTypeLabel, formatCurrency } from '@/lib/format'
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
export const Route = createFileRoute('/_auth/payments')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
component: PaymentsPage,
})
interface FormState {
id?: string
type: PaymentType
date: string
amount: number
currency: string
providerAccountId: string
note: string
}
const TODAY = new Date().toISOString().slice(0, 10)
const EMPTY: FormState = { type: 'provider_balance_topup', date: TODAY, amount: 0, currency: 'RUB', providerAccountId: '', note: '' }
function PaymentsPage() {
const queryClient = useQueryClient()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const [open, setOpen] = useState(false)
const [form, setForm] = useState<FormState>(EMPTY)
const saveMut = useMutation({
mutationFn: (r: FormState) => r.id
? api.update<Payment>('payments', r.id, r as unknown as Partial<Payment>)
: api.create('payments', r as unknown as Payment),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
toast.success('Платёж сохранён')
setOpen(false)
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
})
const delMut = useMutation({
mutationFn: (id: string) => api.remove<Payment>('payments', id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
toast.success('Платёж удалён')
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
})
const openCreate = () => { setForm({ ...EMPTY, providerAccountId: snapshot?.providerAccounts[0]?.id ?? '' }); setOpen(true) }
const openEdit = (p: Payment) => {
setForm({
id: p.id, type: p.type, date: p.date, amount: p.amount, currency: p.currency,
providerAccountId: p.providerAccountId, note: p.note ?? '',
})
setOpen(true)
}
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
const columns: DataTableColumn<Payment>[] = [
{ key: 'date', header: 'Дата', cell: (p) => <span className="tabular-nums">{p.date}</span> },
{
key: 'account',
header: 'Аккаунт',
cell: (p) => {
const acc = snapshot?.providerAccounts.find((a) => a.id === p.providerAccountId)
return acc ? accountSelectLabel(acc, providerById) : '—'
},
},
{
key: 'type',
header: 'Тип',
cell: (p) => <span>{paymentTypeLabel(p.type)}</span>,
},
{
key: 'amount',
header: 'Сумма',
cell: (p) => <span className="tabular-nums">{formatCurrency(p.amount, p.currency)}</span>,
},
{ key: 'note', header: 'Заметка', cell: (p) => <span className="text-muted-foreground">{p.note || '—'}</span> },
{
key: 'actions',
header: '',
className: 'w-24 text-right',
cell: (p) => (
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(p)} aria-label="Редактировать">
<PencilIcon />
</Button>
<ConfirmDialog
trigger={<Button variant="ghost" size="icon-sm" aria-label="Удалить"><Trash2Icon /></Button>}
title="Удалить платёж?"
confirmLabel="Удалить"
destructive
onConfirm={() => delMut.mutate(p.id)}
/>
</div>
),
},
]
const sorted = [...(snapshot?.payments ?? [])].sort((a, b) => b.date.localeCompare(a.date))
return (
<PageShell>
<PageHeader
title="Платежи"
description="Пополнения балансов и прямые платежи за VPS"
actions={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить</Button>}
/>
<QueryState
data={snapshot}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<TableSkeleton />}
empty={snapshot?.payments.length === 0}
emptyTitle="Платежей нет"
emptyAction={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить платёж</Button>}
>
{() => <DataTableCard columns={columns} data={sorted} rowKey={(p) => p.id} />}
</QueryState>
<FormSheet
open={open}
onOpenChange={setOpen}
trigger={null}
title={form.id ? 'Редактировать платёж' : 'Новый платёж'}
onSubmit={() => saveMut.mutate(form)}
submitting={saveMut.isPending}
>
<FormField label="Тип" htmlFor="pay-type">
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: (v ?? 'provider_balance_topup') as PaymentType })}>
<SelectTrigger id="pay-type"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="provider_balance_topup">{paymentTypeLabel('provider_balance_topup')}</SelectItem>
<SelectItem value="direct_vps_payment">{paymentTypeLabel('direct_vps_payment')}</SelectItem>
<SelectItem value="daily_debit">{paymentTypeLabel('daily_debit')}</SelectItem>
<SelectItem value="monthly_debit">{paymentTypeLabel('monthly_debit')}</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField label="Аккаунт" htmlFor="pay-acc">
<Select value={form.providerAccountId} onValueChange={(v) => setForm({ ...form, providerAccountId: v ?? '' })}>
<SelectTrigger id="pay-acc"><SelectValue placeholder="Выберите аккаунт" /></SelectTrigger>
<SelectContent>
{snapshot?.providerAccounts.map((a) => (
<SelectItem key={a.id} value={a.id}>{accountSelectLabel(a, providerById)}</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<div className="grid grid-cols-3 gap-3">
<FormField label="Дата" htmlFor="pay-date">
<Input id="pay-date" type="date" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} />
</FormField>
<FormField label="Сумма" htmlFor="pay-amount">
<Input id="pay-amount" type="number" step="0.01" value={form.amount} onChange={(e) => setForm({ ...form, amount: Number(e.target.value) })} />
</FormField>
<FormField label="Валюта" htmlFor="pay-cur">
<Input id="pay-cur" value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value })} />
</FormField>
</div>
<FormField label="Заметка" htmlFor="pay-note">
<Textarea id="pay-note" value={form.note} onChange={(e) => setForm({ ...form, note: e.target.value })} />
</FormField>
</FormSheet>
</PageShell>
)
}
+187
View File
@@ -0,0 +1,187 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { PlusIcon, PencilIcon, Trash2Icon, BuildingIcon } from 'lucide-react'
import { toast } from 'sonner'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { DataTableCard, type DataTableColumn } from '@/components/data-table-card'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { FormSheet } from '@/components/form-sheet'
import { FormField } from '@/components/form-field'
import { Input } from '@cfdm/ui/components/input'
import { Textarea } from '@cfdm/ui/components/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@cfdm/ui/components/select'
import { faviconUrlFromWebsite } from '@/lib/format'
import type { Provider, ApiType } from '@/types/entities'
export const Route = createFileRoute('/_auth/providers')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
component: ProvidersPage,
})
interface FormState {
id?: string
name: string
website: string
apiType: ApiType
apiBaseUrl: string
baseCurrency: string
usdRate: string
eurRate: string
supportPhone: string
supportUrl: string
notes: string
}
const EMPTY: FormState = {
name: '', website: '', apiType: 'billmanager', apiBaseUrl: '', baseCurrency: 'RUB',
usdRate: '', eurRate: '', supportPhone: '', supportUrl: '', notes: '',
}
function ProvidersPage() {
const queryClient = useQueryClient()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const [open, setOpen] = useState(false)
const [form, setForm] = useState<FormState>(EMPTY)
const saveMut = useMutation({
mutationFn: (r: FormState) => (r.id ? api.update<Provider>('providers', r.id, r as unknown as Provider) : api.create('providers', r as unknown as Provider)),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
toast.success('Хостер сохранён')
setOpen(false)
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
})
const delMut = useMutation({
mutationFn: (id: string) => api.remove<Provider>('providers', id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
toast.success('Хостер удалён')
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
})
const openCreate = () => { setForm(EMPTY); setOpen(true) }
const openEdit = (p: Provider) => {
setForm({
id: p.id, name: p.name, website: p.website ?? '', apiType: p.apiType, apiBaseUrl: p.apiBaseUrl ?? '',
baseCurrency: p.baseCurrency ?? 'RUB', usdRate: String(p.usdRate ?? ''), eurRate: String(p.eurRate ?? ''),
supportPhone: p.supportPhone ?? '', supportUrl: p.supportUrl ?? '', notes: p.notes ?? '',
})
setOpen(true)
}
const columns: DataTableColumn<Provider>[] = [
{
key: 'name',
header: 'Хостер',
cell: (p) => (
<div className="flex items-center gap-2">
{p.website ? (
<img src={faviconUrlFromWebsite(p.website)} alt="" className="size-4 rounded-sm" />
) : (
<BuildingIcon className="size-4 text-muted-foreground" />
)}
<span className="font-medium">{p.name}</span>
</div>
),
},
{ key: 'api', header: 'API', cell: (p) => <Badge variant="outline">{p.apiType}</Badge> },
{ key: 'cur', header: 'Валюта', cell: (p) => <span className="tabular-nums">{p.baseCurrency ?? '—'}</span> },
{
key: 'actions',
header: '',
className: 'w-24 text-right',
cell: (p) => (
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(p)} aria-label="Редактировать">
<PencilIcon />
</Button>
<ConfirmDialog
trigger={<Button variant="ghost" size="icon-sm" aria-label="Удалить"><Trash2Icon /></Button>}
title="Удалить хостера?"
description={`«${p.name}» будет удалён. Аккаунты и VPS не затрагиваются, но потеряют привязку.`}
destructive
confirmLabel="Удалить"
onConfirm={() => delMut.mutate(p.id)}
/>
</div>
),
},
]
return (
<PageShell>
<PageHeader
title="Хостеры"
description="Провайдеры хостинга и параметры API"
actions={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить</Button>}
/>
<QueryState
data={snapshot}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<TableSkeleton />}
empty={snapshot?.providers.length === 0}
emptyTitle="Хостеры не найдены"
emptyAction={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить хостера</Button>}
>
{(snap) => <DataTableCard columns={columns} data={snap.providers} rowKey={(p) => p.id} />}
</QueryState>
<FormSheet
open={open}
onOpenChange={setOpen}
trigger={null}
title={form.id ? 'Редактировать хостера' : 'Новый хостер'}
onSubmit={() => saveMut.mutate(form)}
submitting={saveMut.isPending}
>
<FormField label="Название" htmlFor="pr-name">
<Input id="pr-name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</FormField>
<FormField label="Сайт" htmlFor="pr-site">
<Input id="pr-site" value={form.website} onChange={(e) => setForm({ ...form, website: e.target.value })} />
</FormField>
<FormField label="Тип API" htmlFor="pr-api">
<Select value={form.apiType} onValueChange={(v) => setForm({ ...form, apiType: (v ?? 'none') as ApiType })}>
<SelectTrigger id="pr-api"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="billmanager">BILLmanager</SelectItem>
<SelectItem value="none">Нет</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField label="API URL" htmlFor="pr-apiurl" description="Один URL на хостера для BILLmanager">
<Input id="pr-apiurl" value={form.apiBaseUrl} onChange={(e) => setForm({ ...form, apiBaseUrl: e.target.value })} />
</FormField>
<div className="grid grid-cols-3 gap-3">
<FormField label="Валюта" htmlFor="pr-cur">
<Input id="pr-cur" value={form.baseCurrency} onChange={(e) => setForm({ ...form, baseCurrency: e.target.value })} />
</FormField>
<FormField label="Курс USD" htmlFor="pr-usd">
<Input id="pr-usd" value={form.usdRate} onChange={(e) => setForm({ ...form, usdRate: e.target.value })} />
</FormField>
<FormField label="Курс EUR" htmlFor="pr-eur">
<Input id="pr-eur" value={form.eurRate} onChange={(e) => setForm({ ...form, eurRate: e.target.value })} />
</FormField>
</div>
<FormField label="Заметки" htmlFor="pr-notes">
<Textarea id="pr-notes" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</FormField>
</FormSheet>
</PageShell>
)
}
+82
View File
@@ -0,0 +1,82 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { DownloadIcon } from 'lucide-react'
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Button } from '@cfdm/ui/components/button'
import { QueryState } from '@/components/query-state'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { SectionCards } from '@/components/section-cards'
import { ChartsGrid, MonthlyExpenseChart, PaymentsPieChart, MonthlyTrendChart } from '@/components/domain/charts'
import { normalizeRatesPayload, formatCurrency, toCsv, downloadTextFile } from '@/lib/format'
export const Route = createFileRoute('/_auth/reports')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
component: ReportsPage,
})
function ReportsPage() {
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const settings = snapshot?.settings?.[0]
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
const exportCsv = () => {
if (!snapshot) return
const rows = snapshot.vps.map((v) => ({
ip: v.ip, project: v.project ?? '', status: v.status, vcpu: v.vcpu, ramGb: v.ramGb, diskGb: v.diskGb,
monthlyRate: v.monthlyRate ?? 0, currency: v.currency,
}))
downloadTextFile('vps-report.csv', toCsv(rows))
}
return (
<PageShell>
<PageHeader
title="Отчёты"
description="Расходы, платежи и динамика"
actions={
<Button variant="outline" onClick={exportCsv} disabled={!snapshot}>
<DownloadIcon data-icon="inline-start" />
Экспорт CSV
</Button>
}
/>
<QueryState
data={snapshot}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<SectionCardsSkeleton count={3} />}
>
{(snap) => {
const monthly = snap.vps.filter((v) => v.status === 'active').reduce((acc, v) => {
const burn = v.tariffType === 'daily' ? Number(v.dailyRate || 0) * 30 : Number(v.monthlyRate || 0)
return acc + burn
}, 0)
return (
<>
<SectionCards
items={[
{ label: 'Расход/мес (в валюте VPS)', value: formatCurrency(monthly, snap.vps[0]?.currency ?? 'RUB') },
{ label: 'Платежей всего', value: snap.payments.length },
{ label: 'Активных VPS', value: snap.vps.filter((v) => v.status === 'active').length },
]}
/>
<ChartsGrid>
<MonthlyExpenseChart vps={snap.vps} providers={snap.providers} settings={snap.settings} ratesData={ratesData} />
<PaymentsPieChart payments={snap.payments} settings={snap.settings} ratesData={ratesData} />
<MonthlyTrendChart payments={snap.payments} settings={snap.settings} ratesData={ratesData} className="lg:col-span-2" />
</ChartsGrid>
</>
)
}}
</QueryState>
</PageShell>
)
}
+105
View File
@@ -0,0 +1,105 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { CpuIcon, MemoryStickIcon, HardDriveIcon } from 'lucide-react'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { SectionCards } from '@/components/section-cards'
import {
ChartContainer,
ChartTooltipContent,
type ChartConfig,
} from '@cfdm/ui/components/chart'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { Tooltip as RechartsTooltip } from 'recharts'
export const Route = createFileRoute('/_auth/resources')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
component: ResourcesPage,
})
const RESOURCE_CONFIG: ChartConfig = {
vcpu: { label: 'vCPU', color: 'var(--chart-1)' },
ram: { label: 'RAM (GB)', color: 'var(--chart-2)' },
disk: { label: 'Disk (GB)', color: 'var(--chart-3)' },
}
function ResourcesPage() {
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
return (
<PageShell>
<PageHeader title="Ресурсы" description="Сводка по вычислительным ресурсам активных VPS" />
<QueryState
data={snapshot}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<SectionCardsSkeleton count={3} />}
>
{(snap) => {
const active = snap.vps.filter((v) => v.status === 'active')
const totals = active.reduce(
(acc, v) => ({
vcpu: acc.vcpu + Number(v.vcpu || 0),
ram: acc.ram + Number(v.ramGb || 0),
disk: acc.disk + Number(v.diskGb || 0),
}),
{ vcpu: 0, ram: 0, disk: 0 },
)
const byProvider = new Map<string, { name: string; vcpu: number; ram: number; disk: number }>()
for (const v of active) {
const provider = snap.providers.find((p) => p.id === v.providerId)
const name = provider?.name ?? '—'
const key = provider?.id ?? 'unknown'
const entry = byProvider.get(key) ?? { name, vcpu: 0, ram: 0, disk: 0 }
entry.vcpu += Number(v.vcpu || 0)
entry.ram += Number(v.ramGb || 0)
entry.disk += Number(v.diskGb || 0)
byProvider.set(key, entry)
}
const chartData = Array.from(byProvider.values())
return (
<>
<SectionCards
items={[
{ label: 'vCPU', value: totals.vcpu, icon: <CpuIcon className="size-4" /> },
{ label: 'RAM (GB)', value: totals.ram, icon: <MemoryStickIcon className="size-4" /> },
{ label: 'Disk (GB)', value: totals.disk, icon: <HardDriveIcon className="size-4" /> },
]}
/>
<Card>
<CardHeader>
<CardTitle>Ресурсы по хостерам</CardTitle>
<CardDescription>Только активные VPS</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={RESOURCE_CONFIG} className="h-80 w-full">
<BarChart data={chartData} 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 />} />
<Bar dataKey="vcpu" fill="var(--color-vcpu)" radius={4} />
<Bar dataKey="ram" fill="var(--color-ram)" radius={4} />
<Bar dataKey="disk" fill="var(--color-disk)" radius={4} />
</BarChart>
</ChartContainer>
</CardContent>
</Card>
</>
)
}}
</QueryState>
</PageShell>
)
}
+174
View File
@@ -0,0 +1,174 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import { LoadingButton } from '@/components/loading-button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@cfdm/ui/components/select'
import type { Settings } from '@/types/entities'
import { useState } from 'react'
export const Route = createFileRoute('/_auth/settings')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
component: SettingsPage,
})
const CURRENCIES = ['RUB', 'USD', 'EUR', 'UAH', 'KZT']
function SettingsPage() {
const queryClient = useQueryClient()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const current = snapshot?.settings?.[0]
const [form, setForm] = useState<Partial<Settings> | null>(null)
const draft = form ?? current ?? {}
const upsertMut = useMutation({
mutationFn: (patch: Partial<Settings>) => {
if (current?.id) return api.update<Settings>('settings', current.id, patch)
return api.create<Settings>('settings', {
id: 'settings-main',
baseCurrency: 'RUB',
ratesUrl: 'https://www.cbr-xml-daily.ru/latest.js',
autoConvert: true,
...patch,
} as Settings)
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
toast.success('Настройки сохранены')
setForm(null)
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
})
const telegramTestMut = useMutation({
mutationFn: () => api.sendTelegramTest(),
onSuccess: () => toast.success('Тестовое сообщение отправлено'),
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка отправки'),
})
return (
<PageShell>
<PageHeader title="Настройки" description="Базовая валюта, курсы, синк, Telegram" />
<QueryState
data={snapshot}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<SectionCardsSkeleton count={1} />}
>
{() => (
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Валюта и курсы</CardTitle>
<CardDescription>Отображение сумм и источник курсов</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Field>
<FieldLabel htmlFor="set-cur">Базовая валюта</FieldLabel>
<Select
value={draft.baseCurrency ?? 'RUB'}
onValueChange={(v) => setForm({ ...draft, baseCurrency: v ?? 'RUB' })}
>
<SelectTrigger id="set-cur">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CURRENCIES.map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="set-rates">URL курсов (JSON)</FieldLabel>
<Input
id="set-rates"
value={draft.ratesUrl ?? ''}
onChange={(e) => setForm({ ...draft, ratesUrl: e.target.value })}
placeholder="https://www.cbr-xml-daily.ru/latest.js"
/>
</Field>
<Field orientation="horizontal">
<FieldLabel htmlFor="set-auto">Автоконвертация</FieldLabel>
<Select
value={draft.autoConvert === false ? 'off' : 'on'}
onValueChange={(v) => setForm({ ...draft, autoConvert: (v ?? 'on') === 'on' })}
>
<SelectTrigger id="set-auto" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="on">Включена</SelectItem>
<SelectItem value="off">Выключена</SelectItem>
</SelectContent>
</Select>
</Field>
<LoadingButton
className="w-fit"
onClick={() => upsertMut.mutate(draft)}
loading={upsertMut.isPending}
disabled={!form}
>
Сохранить
</LoadingButton>
</FieldGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Telegram</CardTitle>
<CardDescription>Уведомления о здоровье инвентаря</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Field>
<FieldLabel htmlFor="set-tg-chat">Chat ID</FieldLabel>
<Input
id="set-tg-chat"
value={draft.telegramChatId ?? ''}
onChange={(e) => setForm({ ...draft, telegramChatId: e.target.value })}
placeholder="-1001234567890"
/>
</Field>
<Field>
<FieldLabel htmlFor="set-tg-token">Bot token</FieldLabel>
<Input
id="set-tg-token"
type="password"
value={draft.telegramBotToken ?? ''}
onChange={(e) => setForm({ ...draft, telegramBotToken: e.target.value })}
placeholder="123456:ABC-DEF..."
/>
</Field>
<div className="flex gap-2">
<LoadingButton onClick={() => upsertMut.mutate(draft)} loading={upsertMut.isPending} disabled={!form}>
Сохранить
</LoadingButton>
<LoadingButton variant="outline" onClick={() => telegramTestMut.mutate()} loading={telegramTestMut.isPending}>
Тест
</LoadingButton>
</div>
</FieldGroup>
</CardContent>
</Card>
</div>
)}
</QueryState>
</PageShell>
)
}
+78
View File
@@ -0,0 +1,78 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Badge } from '@cfdm/ui/components/badge'
import { DataTableCard, type DataTableColumn } from '@/components/data-table-card'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { EmptyState } from '@/components/empty-state'
import { ServerCogIcon } from 'lucide-react'
import type { ActiveTariff } from '@/types/entities'
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
import { formatCurrency } from '@/lib/format'
export const Route = createFileRoute('/_auth/tariffs')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
component: TariffsPage,
})
function TariffsPage() {
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
const columns: DataTableColumn<ActiveTariff>[] = [
{
key: 'name',
header: 'Тариф',
cell: (t) => <span className="font-medium">{t.name || `#${t.pricelistId ?? t.id}`}</span>,
},
{
key: 'account',
header: 'Аккаунт',
cell: (t) => {
const acc = snapshot?.providerAccounts.find((a) => a.id === t.providerAccountId)
return acc ? accountSelectLabel(acc, providerById) : '—'
},
},
{
key: 'specs',
header: 'Ресурсы',
cell: (t) => (
<span className="tabular-nums text-muted-foreground">
{t.vcpu ?? '—'} vCPU / {t.ramGb ?? '—'} GB / {t.diskGb ?? '—'} GB
</span>
),
},
{
key: 'price',
header: 'Цена/мес',
cell: (t) => <span className="tabular-nums">{formatCurrency(Number(t.monthlyRate ?? 0), t.currency ?? 'RUB')}</span>,
},
{ key: 'disk', header: 'Диск', cell: (t) => <Badge variant="outline">{t.diskType ?? '—'}</Badge> },
]
return (
<PageShell>
<PageHeader title="Активные тарифы" description="Тарифы, загруженные из BILLmanager vds.order" />
<QueryState
data={snapshot}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<TableSkeleton />}
empty={snapshot?.activeTariffs.length === 0}
emptyTitle="Тарифы не загружены"
emptyDescription="Выполните синхронизацию аккаунта BILLmanager, чтобы загрузить тарифы"
emptyAction={<EmptyState icon={<ServerCogIcon className="size-8" />} title="Нет тарифов" />}
>
{(snap) => <DataTableCard columns={columns} data={snap.activeTariffs} rowKey={(t) => t.id} />}
</QueryState>
</PageShell>
)
}
+346
View File
@@ -0,0 +1,346 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { PlusIcon, PencilIcon, Trash2Icon } from 'lucide-react'
import { toast } from 'sonner'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { vpsSchema, type VpsFormValues } from '@/lib/schemas'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { DataTableCard, type DataTableColumn } from '@/components/data-table-card'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { FormSheetRhf } from '@/components/form-sheet-rhf'
import { FormField } from '@/components/form-field'
import { Input } from '@cfdm/ui/components/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@cfdm/ui/components/select'
import { Textarea } from '@cfdm/ui/components/textarea'
import type { Vps } from '@/types/entities'
import { vpsStatusLabel, tariffTypeLabel, formatInBaseCurrency } from '@/lib/format'
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
export const Route = createFileRoute('/_auth/vps')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
component: VpsPage,
})
const EMPTY_FORM: VpsFormValues = {
ip: '', dns: '', providerId: '', providerAccountId: '',
vcpu: 1, ramGb: 1, diskGb: 10, status: 'active', tariffType: 'monthly',
currency: 'RUB', monthlyRate: 0, dailyRate: 0, paidUntil: '', project: '', notes: '',
}
function VpsPage() {
const queryClient = useQueryClient()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const [sheetOpen, setSheetOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [defaultValues, setDefaultValues] = useState<VpsFormValues>(EMPTY_FORM)
const createMutation = useMutation({
mutationFn: (record: VpsFormValues) => api.create('vps', record as unknown as Vps),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
toast.success('VPS создан')
setSheetOpen(false)
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка создания'),
})
const updateMutation = useMutation({
mutationFn: ({ id, patch }: { id: string; patch: Partial<Vps> }) =>
api.update<Vps>('vps', id, patch),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
toast.success('VPS обновлён')
setSheetOpen(false)
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка обновления'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.remove<Vps>('vps', id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
toast.success('VPS удалён')
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка удаления'),
})
const openCreate = () => {
setEditingId(null)
setDefaultValues({
...EMPTY_FORM,
providerId: snapshot?.providers[0]?.id ?? '',
providerAccountId: snapshot?.providerAccounts[0]?.id ?? '',
})
setSheetOpen(true)
}
const openEdit = (v: Vps) => {
setEditingId(v.id)
setDefaultValues({
id: v.id, ip: v.ip, dns: v.dns ?? '', providerId: v.providerId, providerAccountId: v.providerAccountId,
vcpu: v.vcpu, ramGb: v.ramGb, diskGb: v.diskGb, status: v.status, tariffType: v.tariffType,
currency: v.currency, monthlyRate: Number(v.monthlyRate ?? 0), dailyRate: Number(v.dailyRate ?? 0),
paidUntil: v.paidUntil ?? '', project: v.project ?? '', notes: v.notes ?? '',
})
setSheetOpen(true)
}
const submit = (values: VpsFormValues) => {
if (editingId) {
void updateMutation.mutate({ id: editingId, patch: values as unknown as Partial<Vps> })
} else {
void createMutation.mutate(values)
}
}
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
const columns: DataTableColumn<Vps>[] = [
{
key: 'ip',
header: 'IP / DNS',
cell: (v) => (
<div className="flex flex-col">
<span className="font-medium">{v.ip || '—'}</span>
{v.dns ? <span className="text-xs text-muted-foreground">{v.dns}</span> : null}
</div>
),
},
{
key: 'account',
header: 'Аккаунт',
cell: (v) => {
const acc = snapshot?.providerAccounts.find((a) => a.id === v.providerAccountId)
return acc ? accountSelectLabel(acc, providerById) : '—'
},
},
{ key: 'project', header: 'Проект', cell: (v) => <span className="text-muted-foreground">{v.project || '—'}</span> },
{
key: 'specs',
header: 'Ресурсы',
cell: (v) => (
<span className="tabular-nums text-muted-foreground">
{v.vcpu} vCPU / {v.ramGb} GB / {v.diskGb} GB
</span>
),
},
{
key: 'status',
header: 'Статус',
cell: (v) => (
<Badge variant={v.status === 'active' ? 'default' : v.status === 'archived' ? 'outline' : 'secondary'}>
{vpsStatusLabel(v.status)}
</Badge>
),
},
{
key: 'tariff',
header: 'Тариф',
cell: (v) => (
<span className="tabular-nums">
{formatInBaseCurrency(
v.tariffType === 'daily' ? Number(v.dailyRate || 0) * 30 : Number(v.monthlyRate || 0),
v.currency,
snapshot?.settings ?? [],
null,
)}
<span className="ml-1 text-xs text-muted-foreground">{tariffTypeLabel(v.tariffType)}</span>
</span>
),
},
{
key: 'actions',
header: '',
className: 'w-24 text-right',
cell: (v) => (
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(v)} aria-label="Редактировать">
<PencilIcon />
</Button>
<ConfirmDialog
trigger={
<Button variant="ghost" size="icon-sm" aria-label="Удалить">
<Trash2Icon />
</Button>
}
title="Удалить VPS?"
description={`IP ${v.ip} будет удалён безвозвратно.`}
destructive
confirmLabel="Удалить"
onConfirm={() => deleteMutation.mutate(v.id)}
/>
</div>
),
},
]
return (
<PageShell>
<PageHeader
title="VPS"
description="Виртуальные серверы"
actions={
<Button onClick={openCreate}>
<PlusIcon data-icon="inline-start" />
Добавить
</Button>
}
/>
<QueryState
data={snapshot}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<TableSkeleton />}
empty={snapshot?.vps.length === 0}
emptyTitle="VPS не найдены"
emptyDescription="Добавьте первый виртуальный сервер"
emptyAction={
<Button onClick={openCreate}>
<PlusIcon data-icon="inline-start" />
Добавить VPS
</Button>
}
>
{(snap) => (
<DataTableCard
columns={columns}
data={snap.vps}
rowKey={(v) => v.id}
emptyTitle="VPS не найдены"
/>
)}
</QueryState>
<FormSheetRhf
open={sheetOpen}
onOpenChange={setSheetOpen}
title={editingId ? 'Редактировать VPS' : 'Новый VPS'}
description="Заполните параметры сервера"
schema={vpsSchema as unknown as import('zod').ZodType<VpsFormValues>}
defaultValues={defaultValues}
onSubmit={submit}
submitting={createMutation.isPending || updateMutation.isPending}
>
{(form) => {
const { register, formState: { errors }, watch, setValue } = form
const providerId = watch('providerId')
return (
<>
<FormField label="IP" htmlFor="vps-ip" error={errors.ip?.message} invalid={!!errors.ip}>
<Input id="vps-ip" {...register('ip')} />
</FormField>
<FormField label="DNS" htmlFor="vps-dns">
<Input id="vps-dns" {...register('dns')} />
</FormField>
<FormField label="Хостер" htmlFor="vps-provider" error={errors.providerId?.message}>
<Select
value={providerId}
onValueChange={(v) => setValue('providerId', v ?? '', { shouldValidate: true })}
>
<SelectTrigger id="vps-provider">
<SelectValue placeholder="Выберите хостера" />
</SelectTrigger>
<SelectContent>
{snapshot?.providers.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField label="Аккаунт" htmlFor="vps-account" error={errors.providerAccountId?.message}>
<Select
value={watch('providerAccountId')}
onValueChange={(v) => setValue('providerAccountId', v ?? '', { shouldValidate: true })}
>
<SelectTrigger id="vps-account">
<SelectValue placeholder="Выберите аккаунт" />
</SelectTrigger>
<SelectContent>
{snapshot?.providerAccounts
.filter((a) => !providerId || a.providerId === providerId)
.map((a) => (
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField label="Проект" htmlFor="vps-project">
<Input id="vps-project" {...register('project')} />
</FormField>
<div className="grid grid-cols-3 gap-3">
<FormField label="vCPU" htmlFor="vps-vcpu" error={errors.vcpu?.message}>
<Input id="vps-vcpu" type="number" min={0} {...register('vcpu', { valueAsNumber: true })} />
</FormField>
<FormField label="RAM (GB)" htmlFor="vps-ram" error={errors.ramGb?.message}>
<Input id="vps-ram" type="number" min={0} {...register('ramGb', { valueAsNumber: true })} />
</FormField>
<FormField label="Disk (GB)" htmlFor="vps-disk" error={errors.diskGb?.message}>
<Input id="vps-disk" type="number" min={0} {...register('diskGb', { valueAsNumber: true })} />
</FormField>
</div>
<div className="grid grid-cols-2 gap-3">
<FormField label="Статус" htmlFor="vps-status">
<Select
value={watch('status')}
onValueChange={(v) => setValue('status', (v ?? 'active') as 'active' | 'paused' | 'archived')}
>
<SelectTrigger id="vps-status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">{vpsStatusLabel('active')}</SelectItem>
<SelectItem value="paused">{vpsStatusLabel('paused')}</SelectItem>
<SelectItem value="archived">{vpsStatusLabel('archived')}</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField label="Тип тарифа" htmlFor="vps-tariff">
<Select
value={watch('tariffType')}
onValueChange={(v) => setValue('tariffType', (v ?? 'monthly') as 'daily' | 'monthly')}
>
<SelectTrigger id="vps-tariff">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">{tariffTypeLabel('monthly')}</SelectItem>
<SelectItem value="daily">{tariffTypeLabel('daily')}</SelectItem>
</SelectContent>
</Select>
</FormField>
</div>
<div className="grid grid-cols-3 gap-3">
<FormField label="Валюта" htmlFor="vps-cur" error={errors.currency?.message}>
<Input id="vps-cur" {...register('currency')} />
</FormField>
<FormField label="Ставка/мес" htmlFor="vps-monthly">
<Input id="vps-monthly" type="number" min={0} {...register('monthlyRate', { valueAsNumber: true })} />
</FormField>
<FormField label="Ставка/день" htmlFor="vps-daily">
<Input id="vps-daily" type="number" min={0} {...register('dailyRate', { valueAsNumber: true })} />
</FormField>
</div>
<FormField label="Оплачено до" htmlFor="vps-paid">
<Input id="vps-paid" type="date" {...register('paidUntil')} />
</FormField>
<FormField label="Заметки" htmlFor="vps-notes">
<Textarea id="vps-notes" {...register('notes')} />
</FormField>
</>
)
}}
</FormSheetRhf>
</PageShell>
)
}
+7
View File
@@ -0,0 +1,7 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/')({
beforeLoad: () => {
throw redirect({ to: '/dashboard' })
},
})
+6
View File
@@ -0,0 +1,6 @@
export interface HTMLInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
'data-lpignore'?: string
'data-1p-ignore'?: string
'data-bwignore'?: string
'data-form-type'?: string
}
+158
View File
@@ -0,0 +1,158 @@
export type VpsStatus = 'active' | 'paused' | 'archived'
export type TariffType = 'daily' | 'monthly'
export type BillingMode = 'daily' | 'monthly'
export type PaymentType =
| 'direct_vps_payment'
| 'provider_balance_topup'
| 'daily_debit'
| 'monthly_debit'
export type LedgerDirection = 'credit' | 'debit'
export type ApiType = 'billmanager' | 'none'
export interface Provider {
id: string
name: string
website?: string
apiType: ApiType
apiBaseUrl?: string
baseCurrency?: string
usdRate?: string | number | null
eurRate?: string | number | null
supportPhone?: string
supportUrl?: string
notes?: string
}
export interface ProviderAccount {
id: string
providerId: string
name: string
login?: string
apiCredentialsSet?: boolean
billingMode?: BillingMode
balance_api?: number | null
balance_currency?: string
currency?: string
notes?: string
}
export interface Vps {
id: string
externalId?: string
ip: string
dns?: string
ipv6?: string
additionalIps?: string[]
providerId: string
providerAccountId: string
country?: string
city?: string
datacenter?: string
os?: string
vcpu: number
ramGb: number
diskGb: number
diskType?: string
virtualization?: string
bandwidthTb?: number
sshPort?: number
rootUser?: string
purpose?: string
environment?: 'prod' | 'dev' | 'staging'
project?: string
monitoringEnabled?: boolean
backupEnabled?: boolean
status: VpsStatus
tariffType: TariffType
currency: string
dailyRate: number | null
monthlyRate: number | null
createdAt: string
paidUntil?: string
notes?: string
}
export interface Payment {
id: string
externalId?: string
type: PaymentType
date: string
amount: number
currency: string
providerAccountId: string
vpsId?: string | null
note?: string
}
export interface BalanceLedgerRow {
id: string
providerAccountId: string
direction: LedgerDirection
amount: number
currency?: string
date: string
note?: string
}
export interface Settings {
id: string
baseCurrency: string
ratesUrl?: string
autoConvert?: boolean
syncEnabled?: boolean
telegramChatId?: string
telegramBotToken?: string
}
export interface ActiveTariff {
id: string
providerAccountId: string
pricelistId?: string
name?: string
vcpu?: number
ramGb?: number
diskGb?: number
diskType?: string
monthlyRate?: number
currency?: string
}
export interface SyncLogRow {
id: string
accountId: string
status: 'ok' | 'error' | 'running'
startedAt?: string
finishedAt?: string
summary?: Record<string, unknown> | null
error?: string
}
export interface SyncSummary {
added?: unknown[]
updated?: unknown[]
paymentsAdded?: number
tariffsOnly?: boolean
tariffsCount?: number
vpsCount?: number
paymentsCount?: number
error?: string
}
export interface RatesData {
base: string
rates: Record<string, number>
date?: string
}
export interface DataSnapshot {
vps: Vps[]
providers: Provider[]
providerAccounts: ProviderAccount[]
payments: Payment[]
balanceLedger: BalanceLedgerRow[]
settings: Settings[]
activeTariffs: ActiveTariff[]
tariffSyncOptions?: unknown[]
serverProjects?: unknown[]
syncLog?: SyncLogRow[]
}
+21
View File
@@ -0,0 +1,21 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@cfdm/ui/components/*": ["../../packages/ui/src/components/*"],
"@cfdm/ui/hooks/*": ["../../packages/ui/src/hooks/*"],
"@cfdm/ui/lib/utils": ["../../packages/ui/src/lib/utils.ts"],
"@cfdm/ui/globals.css": ["../../packages/ui/src/styles/globals.css"],
"@cfdm/shared": ["../../packages/shared/src/index.ts"],
"@cfdm/shared/contracts/*": ["../../packages/shared/src/contracts/*"],
"@cfdm/shared/types/*": ["../../packages/shared/src/types/*"],
"@cfdm/db": ["../../packages/db/src/index.ts"],
"@cfdm/db/schema": ["../../packages/db/src/schema/index.ts"],
"@cfdm/db/repositories/*": ["../../packages/db/src/repositories/*"]
},
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
+36
View File
@@ -0,0 +1,36 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { TanStackRouterVite as TanStackRouterPlugin } from '@tanstack/router-plugin/vite'
import path from 'node:path'
export default defineConfig({
plugins: [
TanStackRouterPlugin({ target: 'react', autoCodeSplitting: true }),
react(),
tailwindcss(),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@cfdm/ui/components': path.resolve(__dirname, '../../packages/ui/src/components'),
'@cfdm/ui/hooks': path.resolve(__dirname, '../../packages/ui/src/hooks'),
'@cfdm/ui/lib/utils': path.resolve(__dirname, '../../packages/ui/src/lib/utils.ts'),
'@cfdm/shared': path.resolve(__dirname, '../../packages/shared/src/index.ts'),
'@cfdm/db': path.resolve(__dirname, '../../packages/db/src/index.ts'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
test: {
environment: 'happy-dom',
globals: true,
},
})