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