feat(web): доработка UX/UI — дашборд, навигация и data foundation
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Добавлены syncLog в snapshot, API статистики дашборда и маппинг цен тарифов; переработаны shell, главная страница, empty states и новые экраны журнала синка и проектов. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -20,6 +20,7 @@ import { projectsRoutes } from './routes/projects.js'
|
||||
import { backupRoutes } from './routes/backup.js'
|
||||
import { ratesProxyRoutes } from './routes/rates-proxy.js'
|
||||
import { migrateRoutes } from './routes/migrate.js'
|
||||
import { dashboardRoutes } from './routes/dashboard.js'
|
||||
import { startScheduler } from './services/scheduler.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
@@ -52,6 +53,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
await app.register(backupRoutes)
|
||||
await app.register(ratesProxyRoutes)
|
||||
await app.register(migrateRoutes)
|
||||
await app.register(dashboardRoutes)
|
||||
|
||||
const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist')
|
||||
if (existsSync(staticDir)) {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { computeDashboardStats } from '../services/dashboard-stats.js'
|
||||
|
||||
export const dashboardRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/api/dashboard/stats', async () => computeDashboardStats())
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { getSnapshot } from '@cfdm/db/repositories/snapshot'
|
||||
|
||||
const STALE_SYNC_HOURS = 48
|
||||
|
||||
function vpsBurnRate(v: {
|
||||
status: string
|
||||
tariffType: string
|
||||
dailyRate: number | null
|
||||
monthlyRate: number | null
|
||||
}): number {
|
||||
if (v.status !== 'active') return 0
|
||||
const monthly = Number(v.monthlyRate || 0)
|
||||
const daily = Number(v.dailyRate || 0)
|
||||
const burn = v.tariffType === 'daily' ? daily * 30 : monthly
|
||||
return Number.isFinite(burn) ? burn : 0
|
||||
}
|
||||
|
||||
function lastOkSyncAt(accountId: string, syncLog: { accountId: string; status: string | null; finishedAt: string | null }[]): number | null {
|
||||
let best: number | null = null
|
||||
for (const r of syncLog) {
|
||||
if (r.accountId !== accountId || r.status !== 'ok' || !r.finishedAt) continue
|
||||
const t = new Date(r.finishedAt).getTime()
|
||||
if (!Number.isNaN(t) && (best == null || t > best)) best = t
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
activeVpsCount: number
|
||||
totalVpsCount: number
|
||||
providerCount: number
|
||||
accountCount: number
|
||||
monthlyBurnEstimate: number
|
||||
totalBalanceApi: number
|
||||
minRunwayDays: number | null
|
||||
expiringWithin7Days: number
|
||||
issuesCount: number
|
||||
lastGlobalSyncAt: string | null
|
||||
staleSyncAccountCount: number
|
||||
lowBalanceAccountCount: number
|
||||
}
|
||||
|
||||
export function computeDashboardStats(): DashboardStats {
|
||||
const snap = getSnapshot()
|
||||
const now = new Date()
|
||||
const in7Days = new Date(now)
|
||||
in7Days.setDate(in7Days.getDate() + 7)
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
|
||||
const activeVps = snap.vps.filter((v) => v.status === 'active')
|
||||
const monthlyBurnEstimate = activeVps.reduce((acc, v) => acc + vpsBurnRate(v), 0)
|
||||
const totalBalanceApi = snap.providerAccounts.reduce(
|
||||
(acc, a) => acc + (Number(a.balanceApi ?? 0) || 0),
|
||||
0,
|
||||
)
|
||||
|
||||
const burnByAccount = new Map<string, number>()
|
||||
for (const v of activeVps) {
|
||||
const burn = vpsBurnRate(v)
|
||||
burnByAccount.set(v.providerAccountId, (burnByAccount.get(v.providerAccountId) ?? 0) + burn)
|
||||
}
|
||||
|
||||
let minRunwayDays: number | null = null
|
||||
for (const account of snap.providerAccounts) {
|
||||
const balance = Number(account.balanceApi ?? 0)
|
||||
const burn = burnByAccount.get(account.id) ?? 0
|
||||
if (balance <= 0 || burn <= 0) continue
|
||||
const days = Math.floor((balance / burn) * 30)
|
||||
if (minRunwayDays == null || days < minRunwayDays) minRunwayDays = days
|
||||
}
|
||||
|
||||
const expiringWithin7Days = activeVps.filter((v) => {
|
||||
if (!v.paidUntil) return false
|
||||
const d = new Date(v.paidUntil)
|
||||
if (Number.isNaN(d.getTime())) return false
|
||||
return d >= todayStart && d <= in7Days
|
||||
}).length
|
||||
|
||||
const providerById = new Map(snap.providers.map((p) => [p.id, p]))
|
||||
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
|
||||
const bmAccounts = snap.providerAccounts.filter((a) => {
|
||||
const p = providerById.get(a.providerId)
|
||||
return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet
|
||||
})
|
||||
const staleSyncAccountCount = bmAccounts.filter((a) => {
|
||||
const t = lastOkSyncAt(a.id, snap.syncLog)
|
||||
if (t == null) return true
|
||||
return now.getTime() - t > staleMs
|
||||
}).length
|
||||
|
||||
const lowBalanceAccountCount = snap.providerAccounts.filter((a) => {
|
||||
const threshold = Number(a.balanceAlertBelow ?? 0)
|
||||
if (!Number.isFinite(threshold) || threshold <= 0) return false
|
||||
const balance = Number(a.balanceApi ?? 0)
|
||||
return balance < threshold
|
||||
}).length
|
||||
|
||||
let issuesCount = 0
|
||||
if (activeVps.some((v) => !(v.project || '').trim())) issuesCount++
|
||||
if (
|
||||
activeVps.some((v) => {
|
||||
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
|
||||
})
|
||||
) {
|
||||
issuesCount++
|
||||
}
|
||||
if (expiringWithin7Days > 0) issuesCount++
|
||||
if (staleSyncAccountCount > 0) issuesCount++
|
||||
if (lowBalanceAccountCount > 0) issuesCount++
|
||||
|
||||
let lastGlobalSyncAt: string | null = null
|
||||
for (const row of snap.syncLog) {
|
||||
if (row.status === 'ok' && row.finishedAt) {
|
||||
if (!lastGlobalSyncAt || row.finishedAt > lastGlobalSyncAt) lastGlobalSyncAt = row.finishedAt
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeVpsCount: activeVps.length,
|
||||
totalVpsCount: snap.vps.length,
|
||||
providerCount: snap.providers.length,
|
||||
accountCount: snap.providerAccounts.length,
|
||||
monthlyBurnEstimate,
|
||||
totalBalanceApi,
|
||||
minRunwayDays,
|
||||
expiringWithin7Days,
|
||||
issuesCount,
|
||||
lastGlobalSyncAt,
|
||||
staleSyncAccountCount,
|
||||
lowBalanceAccountCount,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseTariffPrice } from '@cfdm/db/repositories/tariffs'
|
||||
|
||||
describe('parseTariffPrice', () => {
|
||||
it('parses amount and currency', () => {
|
||||
expect(parseTariffPrice('100.50 RUB')).toEqual({ monthlyRate: 100.5, currency: 'RUB' })
|
||||
expect(parseTariffPrice('12 USD')).toEqual({ monthlyRate: 12, currency: 'USD' })
|
||||
})
|
||||
|
||||
it('handles empty', () => {
|
||||
expect(parseTariffPrice('')).toEqual({ monthlyRate: null, currency: null })
|
||||
})
|
||||
})
|
||||
@@ -21,7 +21,7 @@ import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { EmptyState } from './empty-state'
|
||||
import type { DataTableColumn } from './data-table-card'
|
||||
import type { DataTableColumn } from './data-grid-types'
|
||||
|
||||
const PAGINATION_LABELS = {
|
||||
rowsPerPageLabel: 'Строк на странице',
|
||||
@@ -228,8 +228,8 @@ export function DataGridCard<TData extends object>({
|
||||
|
||||
return (
|
||||
<Card className={cn('ring-0 shadow-none', className)}>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 border-b border-border/50 pb-4">
|
||||
<div className="space-y-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 border-b border-border/50 pb-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
{title ? <CardTitle>{title}</CardTitle> : null}
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
export interface DataTableColumn<T> {
|
||||
key: string
|
||||
header: ReactNode
|
||||
cell: (row: T, index: number) => ReactNode
|
||||
icon?: LucideIcon
|
||||
sortable?: boolean
|
||||
sortValue?: (row: T) => string | number
|
||||
headerTitle?: string
|
||||
className?: string
|
||||
headerClassName?: string
|
||||
}
|
||||
@@ -1,87 +1,2 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@cfdm/ui/components/table'
|
||||
import { TableCard } from './table-card'
|
||||
import { EmptyState } from './empty-state'
|
||||
|
||||
export interface DataTableColumn<T> {
|
||||
key: string
|
||||
header: ReactNode
|
||||
cell: (row: T, index: number) => ReactNode
|
||||
icon?: LucideIcon
|
||||
sortable?: boolean
|
||||
sortValue?: (row: T) => string | number
|
||||
headerTitle?: string
|
||||
className?: string
|
||||
headerClassName?: string
|
||||
}
|
||||
|
||||
interface DataTableCardProps<T> {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
columns: DataTableColumn<T>[]
|
||||
data: T[]
|
||||
rowKey: (row: T, index: number) => string
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
emptyAction?: ReactNode
|
||||
onRowClick?: (row: T) => void
|
||||
}
|
||||
|
||||
export function DataTableCard<T>({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
columns,
|
||||
data,
|
||||
rowKey,
|
||||
emptyTitle = 'Нет записей',
|
||||
emptyDescription,
|
||||
emptyAction,
|
||||
onRowClick,
|
||||
}: DataTableCardProps<T>) {
|
||||
return (
|
||||
<TableCard title={title} description={description} actions={actions}>
|
||||
{data.length === 0 ? (
|
||||
<div className="p-4">
|
||||
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((col) => (
|
||||
<TableHead key={col.key} className={col.headerClassName}>
|
||||
{col.header}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((row, index) => (
|
||||
<TableRow
|
||||
key={rowKey(row, index)}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
className={onRowClick ? 'cursor-pointer' : undefined}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col.key} className={col.className}>
|
||||
{col.cell(row, index)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</TableCard>
|
||||
)
|
||||
}
|
||||
/** @deprecated Используйте DataGridCard. Тип колонок — data-grid-types. */
|
||||
export type { DataTableColumn } from './data-grid-types'
|
||||
|
||||
@@ -20,6 +20,11 @@ import type { ReactNode } from 'react'
|
||||
import type { Vps, Provider, Payment, Settings, RatesData } from '@/types/entities'
|
||||
import { convertCurrency, formatCurrency, monthKey, toIsoCurrency } from '@/lib/format'
|
||||
import { providerByIdMap } from '@/lib/billmanager'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
|
||||
function ChartEmpty({ message }: { message: string }) {
|
||||
return <EmptyState title={message} className="h-72 border-none" />
|
||||
}
|
||||
|
||||
const EXPENSE_CONFIG: ChartConfig = {
|
||||
expense: { label: 'Расход', color: 'var(--chart-1)' },
|
||||
@@ -28,18 +33,21 @@ const EXPENSE_CONFIG: ChartConfig = {
|
||||
export function MonthlyExpenseChart({
|
||||
vps,
|
||||
providers,
|
||||
providerAccounts,
|
||||
settings,
|
||||
ratesData,
|
||||
className,
|
||||
}: {
|
||||
vps: Vps[]
|
||||
providers: Provider[]
|
||||
providerAccounts?: { id: string; name: string; providerId: string }[]
|
||||
settings: Settings[]
|
||||
ratesData: RatesData | null
|
||||
className?: string
|
||||
}) {
|
||||
const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
|
||||
const providerById = providerByIdMap(providers)
|
||||
const accountById = new Map((providerAccounts ?? []).map((a) => [a.id, a]))
|
||||
|
||||
const monthlyByAccount = new Map<string, number>()
|
||||
for (const v of vps) {
|
||||
@@ -56,7 +64,7 @@ export function MonthlyExpenseChart({
|
||||
const data = Array.from(monthlyByAccount.entries())
|
||||
.map(([accountId, value]) => ({
|
||||
accountId,
|
||||
name: providerById.get(accountId)?.name ?? accountId,
|
||||
name: accountById.get(accountId)?.name ?? accountId,
|
||||
expense: Math.round(value),
|
||||
}))
|
||||
.sort((a, b) => b.expense - a.expense)
|
||||
@@ -69,6 +77,9 @@ export function MonthlyExpenseChart({
|
||||
<CardDescription>Топ-10 по monthly rate, в {baseCurrency}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.length === 0 ? (
|
||||
<ChartEmpty message="Нет данных для графика" />
|
||||
) : (
|
||||
<ChartContainer config={EXPENSE_CONFIG} className="h-72 w-full">
|
||||
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
@@ -78,6 +89,7 @@ export function MonthlyExpenseChart({
|
||||
<Bar dataKey="expense" fill="var(--color-expense)" radius={4} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -115,6 +127,9 @@ export function PaymentsPieChart({
|
||||
<CardDescription>Структура в {baseCurrency}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.length === 0 ? (
|
||||
<ChartEmpty message="Нет данных о платежах" />
|
||||
) : (
|
||||
<ChartContainer config={PAYMENTS_CONFIG} className="mx-auto h-72 w-full">
|
||||
<PieChart>
|
||||
<RechartsTooltip content={<ChartTooltipContent nameKey="type" formatter={(v) => formatCurrency(Number(v), baseCurrency)} />} />
|
||||
@@ -125,6 +140,7 @@ export function PaymentsPieChart({
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -163,6 +179,9 @@ export function MonthlyTrendChart({
|
||||
<CardDescription>Последние 12 месяцев, {baseCurrency}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.length === 0 ? (
|
||||
<ChartEmpty message="Нет данных за выбранный период" />
|
||||
) : (
|
||||
<ChartContainer config={trendConfig} className="h-72 w-full">
|
||||
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
@@ -172,6 +191,7 @@ export function MonthlyTrendChart({
|
||||
<Bar dataKey="amount" fill="var(--color-amount)" radius={4} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ export function EmptyState({ title, description, icon, action, className }: Empt
|
||||
)}
|
||||
>
|
||||
{icon ? <div className="text-muted-foreground">{icon}</div> : null}
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
ChartColumnBig,
|
||||
ChartBar,
|
||||
Settings,
|
||||
RefreshCwIcon,
|
||||
FolderKanbanIcon,
|
||||
HistoryIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
@@ -29,42 +32,108 @@ import {
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@cfdm/ui/components/breadcrumb'
|
||||
import { Separator } from '@cfdm/ui/components/separator'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { ModeToggle } from '@/components/mode-toggle'
|
||||
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
|
||||
import { formatRelativeSyncTime } from '@/lib/sync-format'
|
||||
|
||||
interface NavItem {
|
||||
to: string
|
||||
label: string
|
||||
icon: typeof LayoutDashboard
|
||||
badge?: number
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/dashboard', label: 'Дашборд', icon: LayoutDashboard },
|
||||
{ to: '/vps', label: 'VPS', icon: Server },
|
||||
{ to: '/tariffs', label: 'Активные тарифы', icon: ServerCog },
|
||||
{ to: '/providers', label: 'Хостеры', icon: Building2 },
|
||||
{ to: '/accounts', label: 'Аккаунты хостеров', icon: Wallet },
|
||||
{ to: '/payments', label: 'Платежи', icon: CreditCard },
|
||||
{ to: '/balance', label: 'Баланс и списания', icon: Coins },
|
||||
{ to: '/reports', label: 'Отчёты', icon: ChartColumnBig },
|
||||
{ to: '/resources', label: 'Ресурсы', icon: ChartBar },
|
||||
{ to: '/settings', label: 'Настройки', icon: Settings },
|
||||
interface NavGroup {
|
||||
label: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: 'Обзор',
|
||||
items: [{ to: '/dashboard', label: 'Дашборд', icon: LayoutDashboard }],
|
||||
},
|
||||
{
|
||||
label: 'Инфраструктура',
|
||||
items: [
|
||||
{ to: '/vps', label: 'VPS', icon: Server },
|
||||
{ to: '/tariffs', label: 'Активные тарифы', icon: ServerCog },
|
||||
{ to: '/providers', label: 'Хостеры', icon: Building2 },
|
||||
{ to: '/accounts', label: 'Аккаунты хостеров', icon: Wallet },
|
||||
{ to: '/projects', label: 'Проекты', icon: FolderKanbanIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Финансы',
|
||||
items: [
|
||||
{ to: '/payments', label: 'Платежи', icon: CreditCard },
|
||||
{ to: '/balance', label: 'Баланс и списания', icon: Coins },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Аналитика',
|
||||
items: [
|
||||
{ to: '/reports', label: 'Отчёты', icon: ChartColumnBig },
|
||||
{ to: '/resources', label: 'Ресурсы', icon: ChartBar },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Система',
|
||||
items: [
|
||||
{ to: '/sync-journal', label: 'Журнал синка', icon: HistoryIcon },
|
||||
{ to: '/settings', label: 'Настройки', icon: Settings },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const ALL_NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items)
|
||||
|
||||
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
|
||||
NAV_ITEMS.map((i) => [i.to, i.label]),
|
||||
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
|
||||
)
|
||||
|
||||
const PARENT_ROUTE: Record<string, string> = {
|
||||
'/vps': '/dashboard',
|
||||
'/tariffs': '/dashboard',
|
||||
'/providers': '/dashboard',
|
||||
'/accounts': '/dashboard',
|
||||
'/projects': '/dashboard',
|
||||
'/payments': '/dashboard',
|
||||
'/balance': '/dashboard',
|
||||
'/reports': '/dashboard',
|
||||
'/resources': '/dashboard',
|
||||
'/sync-journal': '/settings',
|
||||
}
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const activeItem = NAV_ITEMS.find((i) => pathname.startsWith(i.to)) ?? NAV_ITEMS[0]
|
||||
const activeItem = ALL_NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`)) ?? ALL_NAV_ITEMS[0]
|
||||
const parentTo = PARENT_ROUTE[activeItem.to]
|
||||
const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null
|
||||
|
||||
const { data: stats } = useQuery(dashboardStatsQueryOptions())
|
||||
|
||||
const navGroups: NavGroup[] = NAV_GROUPS.map((group) => ({
|
||||
...group,
|
||||
items: group.items.map((item) => {
|
||||
if (item.to === '/dashboard' && stats?.issuesCount) {
|
||||
return { ...item, badge: stats.issuesCount }
|
||||
}
|
||||
return item
|
||||
}),
|
||||
}))
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
@@ -88,44 +157,81 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Меню</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
|
||||
return (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
render={<Link to={item.to} />}
|
||||
isActive={isActive}
|
||||
tooltip={item.label}
|
||||
>
|
||||
<Icon />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
{navGroups.map((group) => (
|
||||
<SidebarGroup key={group.label}>
|
||||
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
|
||||
return (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
render={<Link to={item.to} />}
|
||||
isActive={isActive}
|
||||
tooltip={item.label}
|
||||
>
|
||||
<Icon />
|
||||
<span>{item.label}</span>
|
||||
{item.badge ? (
|
||||
<Badge variant="destructive" className="ml-auto size-5 justify-center p-0 text-xs">
|
||||
{item.badge}
|
||||
</Badge>
|
||||
) : null}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter />
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton render={<Link to="/settings" />} tooltip="Настройки">
|
||||
<Settings />
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
Синк: {formatRelativeSyncTime(stats?.lastGlobalSyncAt)}
|
||||
</span>
|
||||
{stats?.staleSyncAccountCount ? (
|
||||
<Badge variant="outline" className="ml-auto text-xs">
|
||||
<RefreshCwIcon className="size-3" />
|
||||
{stats.staleSyncAccountCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
<header className="sticky top-0 flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-supports">
|
||||
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{parentLabel && parentTo ? (
|
||||
<>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbLink render={<Link to={parentTo} />}>{parentLabel}</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
</>
|
||||
) : null}
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{ROUTE_LABELS[activeItem.to] ?? ''}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto">
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{stats?.issuesCount ? (
|
||||
<Badge variant="destructive" className="hidden sm:inline-flex">
|
||||
{stats.issuesCount} проблем
|
||||
</Badge>
|
||||
) : null}
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -9,7 +9,7 @@ interface PageHeaderProps {
|
||||
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
|
||||
@@ -7,13 +7,22 @@ export interface SectionCardItem {
|
||||
value: string | number | ReactElement
|
||||
hint?: ReactNode
|
||||
icon?: ReactNode
|
||||
variant?: 'default' | 'warning' | 'destructive'
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
|
||||
default: '',
|
||||
warning: 'border-amber-500/50',
|
||||
destructive: 'border-destructive/50',
|
||||
}
|
||||
|
||||
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
|
||||
return (
|
||||
<div className={cn('grid gap-4 sm:grid-cols-2 lg:grid-cols-4', className)}>
|
||||
{items.map((item, idx) => (
|
||||
<Card key={typeof item.label === 'string' ? item.label : idx} className="gap-0">
|
||||
<div className={cn('grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6', className)}>
|
||||
{items.map((item, idx) => {
|
||||
const clickable = Boolean(item.onClick)
|
||||
const content = (
|
||||
<CardContent className="flex flex-col gap-1 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">{item.label}</span>
|
||||
@@ -22,8 +31,29 @@ export function SectionCards({ items, className }: { items: SectionCardItem[]; c
|
||||
<span className="text-2xl font-semibold tabular-nums">{item.value}</span>
|
||||
{item.hint ? <span className="text-xs text-muted-foreground">{item.hint}</span> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
)
|
||||
return (
|
||||
<Card
|
||||
key={typeof item.label === 'string' ? item.label : idx}
|
||||
className={cn('gap-0', VARIANT_CLASS[item.variant ?? 'default'], clickable && 'cursor-pointer transition-colors hover:bg-muted/40')}
|
||||
onClick={item.onClick}
|
||||
role={clickable ? 'button' : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
onKeyDown={
|
||||
clickable
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
item.onClick?.()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -236,6 +236,10 @@ export function countActiveFilters(filters: VpsFiltersState): number {
|
||||
return n
|
||||
}
|
||||
|
||||
export function hasActiveVpsFilters(filters: VpsFiltersState): boolean {
|
||||
return countActiveFilters(filters) > 0 || filters.groupByProject || filters.tableCompact
|
||||
}
|
||||
|
||||
export interface VpsFilterPreset {
|
||||
name: string
|
||||
filters: VpsFiltersState
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Баланс API аккаунта (поддержка camelCase из API и snake_case в типах). */
|
||||
export function accountBalanceApi(account: {
|
||||
balance_api?: number | null
|
||||
balanceApi?: number | null
|
||||
}): number | null {
|
||||
const raw = account.balance_api ?? account.balanceApi
|
||||
if (raw == null) return null
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
export function accountBalanceCurrency(account: {
|
||||
balance_currency?: string
|
||||
balanceCurrency?: string
|
||||
currency?: string
|
||||
}): string {
|
||||
return (
|
||||
account.balance_currency ??
|
||||
account.balanceCurrency ??
|
||||
account.currency ??
|
||||
'RUB'
|
||||
)
|
||||
}
|
||||
@@ -139,6 +139,11 @@ export const api = {
|
||||
if (!res.ok) throw new ApiError(res.statusText || 'Ошибка восстановления', res.status)
|
||||
return res.json()
|
||||
},
|
||||
|
||||
fetchDashboardStats: () =>
|
||||
fetchApi<import('@/queries/dashboard').DashboardStats>('/api/dashboard/stats'),
|
||||
|
||||
fetchProjects: () => fetchApi<{ id: string; name: string }[]>('/api/projects'),
|
||||
}
|
||||
|
||||
export type {
|
||||
|
||||
@@ -31,16 +31,18 @@ function ledgerBalanceInCurrency(
|
||||
return credits - debits
|
||||
}
|
||||
|
||||
import { accountBalanceApi } from '@/lib/account'
|
||||
|
||||
export function accountHasApiLedgerMismatch(
|
||||
account: ProviderAccount,
|
||||
balanceLedger: BalanceLedgerRow[],
|
||||
): boolean {
|
||||
if (account.balance_api == null || !Number.isFinite(Number(account.balance_api))) return false
|
||||
const apiBalance = accountBalanceApi(account)
|
||||
if (apiBalance == null) 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
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export function formatRelativeSyncTime(iso: string | null | undefined): string {
|
||||
if (!iso) return 'нет данных'
|
||||
const t = new Date(iso).getTime()
|
||||
if (Number.isNaN(t)) return 'нет данных'
|
||||
const diffMs = Date.now() - t
|
||||
const mins = Math.floor(diffMs / 60_000)
|
||||
if (mins < 1) return 'только что'
|
||||
if (mins < 60) return `${mins} мин назад`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 48) return `${hours} ч назад`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days} дн назад`
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { api } from '@/lib/api-client'
|
||||
|
||||
export interface DashboardStats {
|
||||
activeVpsCount: number
|
||||
totalVpsCount: number
|
||||
providerCount: number
|
||||
accountCount: number
|
||||
monthlyBurnEstimate: number
|
||||
totalBalanceApi: number
|
||||
minRunwayDays: number | null
|
||||
expiringWithin7Days: number
|
||||
issuesCount: number
|
||||
lastGlobalSyncAt: string | null
|
||||
staleSyncAccountCount: number
|
||||
lowBalanceAccountCount: number
|
||||
}
|
||||
|
||||
export const dashboardKeys = {
|
||||
stats: ['dashboard', 'stats'] as const,
|
||||
}
|
||||
|
||||
export const dashboardStatsQueryOptions = () => ({
|
||||
queryKey: dashboardKeys.stats,
|
||||
queryFn: () => api.fetchDashboardStats(),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
@@ -13,10 +13,12 @@ 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 AuthSyncJournalRouteImport } from './routes/_auth/sync-journal'
|
||||
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 AuthProjectsRouteImport } from './routes/_auth/projects'
|
||||
import { Route as AuthPaymentsRouteImport } from './routes/_auth/payments'
|
||||
import { Route as AuthDashboardRouteImport } from './routes/_auth/dashboard'
|
||||
import { Route as AuthBalanceRouteImport } from './routes/_auth/balance'
|
||||
@@ -41,6 +43,11 @@ const AuthTariffsRoute = AuthTariffsRouteImport.update({
|
||||
path: '/tariffs',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthSyncJournalRoute = AuthSyncJournalRouteImport.update({
|
||||
id: '/sync-journal',
|
||||
path: '/sync-journal',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthSettingsRoute = AuthSettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
@@ -61,6 +68,11 @@ const AuthProvidersRoute = AuthProvidersRouteImport.update({
|
||||
path: '/providers',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthProjectsRoute = AuthProjectsRouteImport.update({
|
||||
id: '/projects',
|
||||
path: '/projects',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthPaymentsRoute = AuthPaymentsRouteImport.update({
|
||||
id: '/payments',
|
||||
path: '/payments',
|
||||
@@ -88,10 +100,12 @@ export interface FileRoutesByFullPath {
|
||||
'/balance': typeof AuthBalanceRoute
|
||||
'/dashboard': typeof AuthDashboardRoute
|
||||
'/payments': typeof AuthPaymentsRoute
|
||||
'/projects': typeof AuthProjectsRoute
|
||||
'/providers': typeof AuthProvidersRoute
|
||||
'/reports': typeof AuthReportsRoute
|
||||
'/resources': typeof AuthResourcesRoute
|
||||
'/settings': typeof AuthSettingsRoute
|
||||
'/sync-journal': typeof AuthSyncJournalRoute
|
||||
'/tariffs': typeof AuthTariffsRoute
|
||||
'/vps': typeof AuthVpsRoute
|
||||
}
|
||||
@@ -101,10 +115,12 @@ export interface FileRoutesByTo {
|
||||
'/balance': typeof AuthBalanceRoute
|
||||
'/dashboard': typeof AuthDashboardRoute
|
||||
'/payments': typeof AuthPaymentsRoute
|
||||
'/projects': typeof AuthProjectsRoute
|
||||
'/providers': typeof AuthProvidersRoute
|
||||
'/reports': typeof AuthReportsRoute
|
||||
'/resources': typeof AuthResourcesRoute
|
||||
'/settings': typeof AuthSettingsRoute
|
||||
'/sync-journal': typeof AuthSyncJournalRoute
|
||||
'/tariffs': typeof AuthTariffsRoute
|
||||
'/vps': typeof AuthVpsRoute
|
||||
}
|
||||
@@ -116,10 +132,12 @@ export interface FileRoutesById {
|
||||
'/_auth/balance': typeof AuthBalanceRoute
|
||||
'/_auth/dashboard': typeof AuthDashboardRoute
|
||||
'/_auth/payments': typeof AuthPaymentsRoute
|
||||
'/_auth/projects': typeof AuthProjectsRoute
|
||||
'/_auth/providers': typeof AuthProvidersRoute
|
||||
'/_auth/reports': typeof AuthReportsRoute
|
||||
'/_auth/resources': typeof AuthResourcesRoute
|
||||
'/_auth/settings': typeof AuthSettingsRoute
|
||||
'/_auth/sync-journal': typeof AuthSyncJournalRoute
|
||||
'/_auth/tariffs': typeof AuthTariffsRoute
|
||||
'/_auth/vps': typeof AuthVpsRoute
|
||||
}
|
||||
@@ -131,10 +149,12 @@ export interface FileRouteTypes {
|
||||
| '/balance'
|
||||
| '/dashboard'
|
||||
| '/payments'
|
||||
| '/projects'
|
||||
| '/providers'
|
||||
| '/reports'
|
||||
| '/resources'
|
||||
| '/settings'
|
||||
| '/sync-journal'
|
||||
| '/tariffs'
|
||||
| '/vps'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
@@ -144,10 +164,12 @@ export interface FileRouteTypes {
|
||||
| '/balance'
|
||||
| '/dashboard'
|
||||
| '/payments'
|
||||
| '/projects'
|
||||
| '/providers'
|
||||
| '/reports'
|
||||
| '/resources'
|
||||
| '/settings'
|
||||
| '/sync-journal'
|
||||
| '/tariffs'
|
||||
| '/vps'
|
||||
id:
|
||||
@@ -158,10 +180,12 @@ export interface FileRouteTypes {
|
||||
| '/_auth/balance'
|
||||
| '/_auth/dashboard'
|
||||
| '/_auth/payments'
|
||||
| '/_auth/projects'
|
||||
| '/_auth/providers'
|
||||
| '/_auth/reports'
|
||||
| '/_auth/resources'
|
||||
| '/_auth/settings'
|
||||
| '/_auth/sync-journal'
|
||||
| '/_auth/tariffs'
|
||||
| '/_auth/vps'
|
||||
fileRoutesById: FileRoutesById
|
||||
@@ -201,6 +225,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthTariffsRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/sync-journal': {
|
||||
id: '/_auth/sync-journal'
|
||||
path: '/sync-journal'
|
||||
fullPath: '/sync-journal'
|
||||
preLoaderRoute: typeof AuthSyncJournalRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/settings': {
|
||||
id: '/_auth/settings'
|
||||
path: '/settings'
|
||||
@@ -229,6 +260,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthProvidersRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/projects': {
|
||||
id: '/_auth/projects'
|
||||
path: '/projects'
|
||||
fullPath: '/projects'
|
||||
preLoaderRoute: typeof AuthProjectsRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/payments': {
|
||||
id: '/_auth/payments'
|
||||
path: '/payments'
|
||||
@@ -265,10 +303,12 @@ interface AuthRouteChildren {
|
||||
AuthBalanceRoute: typeof AuthBalanceRoute
|
||||
AuthDashboardRoute: typeof AuthDashboardRoute
|
||||
AuthPaymentsRoute: typeof AuthPaymentsRoute
|
||||
AuthProjectsRoute: typeof AuthProjectsRoute
|
||||
AuthProvidersRoute: typeof AuthProvidersRoute
|
||||
AuthReportsRoute: typeof AuthReportsRoute
|
||||
AuthResourcesRoute: typeof AuthResourcesRoute
|
||||
AuthSettingsRoute: typeof AuthSettingsRoute
|
||||
AuthSyncJournalRoute: typeof AuthSyncJournalRoute
|
||||
AuthTariffsRoute: typeof AuthTariffsRoute
|
||||
AuthVpsRoute: typeof AuthVpsRoute
|
||||
}
|
||||
@@ -278,10 +318,12 @@ const AuthRouteChildren: AuthRouteChildren = {
|
||||
AuthBalanceRoute: AuthBalanceRoute,
|
||||
AuthDashboardRoute: AuthDashboardRoute,
|
||||
AuthPaymentsRoute: AuthPaymentsRoute,
|
||||
AuthProjectsRoute: AuthProjectsRoute,
|
||||
AuthProvidersRoute: AuthProvidersRoute,
|
||||
AuthReportsRoute: AuthReportsRoute,
|
||||
AuthResourcesRoute: AuthResourcesRoute,
|
||||
AuthSettingsRoute: AuthSettingsRoute,
|
||||
AuthSyncJournalRoute: AuthSyncJournalRoute,
|
||||
AuthTariffsRoute: AuthTariffsRoute,
|
||||
AuthVpsRoute: AuthVpsRoute,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { PlusIcon, PencilIcon, Trash2Icon, RefreshCwIcon, UserRoundIcon, KeyRoundIcon, PlugIcon, ReceiptIcon, WalletIcon } from 'lucide-react'
|
||||
import { PlusIcon, PencilIcon, Trash2Icon, RefreshCwIcon, UserRoundIcon, KeyRoundIcon, PlugIcon, ReceiptIcon, WalletIcon, MoreHorizontalIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
@@ -11,7 +11,7 @@ import { PageHeader } from '@/components/page-header'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||
import type { DataTableColumn } from '@/components/data-table-card'
|
||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
@@ -21,7 +21,14 @@ import { FormField } from '@/components/form-field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { Textarea } from '@cfdm/ui/components/textarea'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
import { accountBalanceApi, accountBalanceCurrency } from '@/lib/account'
|
||||
|
||||
import type { ProviderAccount, BillingMode } from '@/types/entities'
|
||||
import { providerByIdMap, accountBillmanagerUiReady } from '@/lib/billmanager'
|
||||
@@ -40,10 +47,19 @@ interface FormState {
|
||||
login: string
|
||||
apiCredentials: string
|
||||
billingMode: BillingMode
|
||||
balanceAlertBelow: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
const EMPTY: FormState = { providerId: '', name: '', login: '', apiCredentials: '', billingMode: 'monthly', notes: '' }
|
||||
const EMPTY: FormState = {
|
||||
providerId: '',
|
||||
name: '',
|
||||
login: '',
|
||||
apiCredentials: '',
|
||||
billingMode: 'monthly',
|
||||
balanceAlertBelow: '',
|
||||
notes: '',
|
||||
}
|
||||
|
||||
function AccountsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
@@ -53,8 +69,14 @@ function AccountsPage() {
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (r: FormState) => {
|
||||
const { apiCredentials, ...rest } = r
|
||||
const payload = apiCredentials ? { ...rest, apiCredentials } : rest
|
||||
const { apiCredentials, balanceAlertBelow, ...rest } = r
|
||||
const alertRaw = balanceAlertBelow.trim()
|
||||
const alertNum = alertRaw ? Number(alertRaw) : null
|
||||
const base = {
|
||||
...rest,
|
||||
balanceAlertBelow: Number.isFinite(alertNum) ? alertNum : null,
|
||||
}
|
||||
const payload = apiCredentials ? { ...base, apiCredentials } : base
|
||||
return r.id
|
||||
? api.update<ProviderAccount>('providerAccounts', r.id, payload as unknown as Partial<ProviderAccount>)
|
||||
: api.create('providerAccounts', payload as unknown as ProviderAccount)
|
||||
@@ -85,9 +107,16 @@ function AccountsPage() {
|
||||
|
||||
const openCreate = () => { setForm({ ...EMPTY, providerId: snapshot?.providers[0]?.id ?? '' }); setOpen(true) }
|
||||
const openEdit = (a: ProviderAccount) => {
|
||||
const ext = a as ProviderAccount & { balanceAlertBelow?: number | null }
|
||||
setForm({
|
||||
id: a.id, providerId: a.providerId, name: a.name, login: a.login ?? '',
|
||||
apiCredentials: '', billingMode: a.billingMode ?? 'monthly', notes: a.notes ?? '',
|
||||
id: a.id,
|
||||
providerId: a.providerId,
|
||||
name: a.name,
|
||||
login: a.login ?? '',
|
||||
apiCredentials: '',
|
||||
billingMode: a.billingMode ?? 'monthly',
|
||||
balanceAlertBelow: ext.balanceAlertBelow != null ? String(ext.balanceAlertBelow) : '',
|
||||
notes: a.notes ?? '',
|
||||
})
|
||||
setOpen(true)
|
||||
}
|
||||
@@ -125,12 +154,16 @@ function AccountsPage() {
|
||||
icon: WalletIcon,
|
||||
headerClassName: 'text-right',
|
||||
className: 'text-right',
|
||||
sortValue: (a) => Number(a.balance_api ?? 0),
|
||||
sortValue: (a) => accountBalanceApi(a) ?? 0,
|
||||
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 font-medium">{formatCurrency(Number(a.balance_api ?? 0), cur)}</span>
|
||||
const cur = accountBalanceCurrency(a)
|
||||
const ext = a as ProviderAccount & { enoughmoneyto?: string }
|
||||
return dataGridCellStack(
|
||||
formatCurrency(accountBalanceApi(a) ?? 0, cur),
|
||||
ext.enoughmoneyto ? `до ${ext.enoughmoneyto}` : undefined,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -142,28 +175,43 @@ function AccountsPage() {
|
||||
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 className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" aria-label="Действия">
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
disabled={!canSync || (syncMut.isPending && syncMut.variables === a.id)}
|
||||
onClick={() => syncMut.mutate(a.id)}
|
||||
>
|
||||
<RefreshCwIcon />
|
||||
Синхронизировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openEdit(a)}>
|
||||
<PencilIcon />
|
||||
Редактировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<DropdownMenuItem variant="destructive" onSelect={(e) => e.preventDefault()}>
|
||||
<Trash2Icon />
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
title="Удалить аккаунт?"
|
||||
description={`«${a.name}» будет удалён.`}
|
||||
destructive
|
||||
confirmLabel="Удалить"
|
||||
onConfirm={() => delMut.mutate(a.id)}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@@ -233,6 +281,16 @@ function AccountsPage() {
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Порог низкого баланса" htmlFor="acc-alert" description="Уведомление на дашборде, если баланс API ниже">
|
||||
<Input
|
||||
id="acc-alert"
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.balanceAlertBelow}
|
||||
onChange={(e) => setForm({ ...form, balanceAlertBelow: e.target.value })}
|
||||
placeholder="Не задан"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Заметки" htmlFor="acc-notes">
|
||||
<Textarea id="acc-notes" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||
</FormField>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { PageHeader } from '@/components/page-header'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||
import type { DataTableColumn } from '@/components/data-table-card'
|
||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
ServerIcon,
|
||||
@@ -9,45 +9,99 @@ import {
|
||||
ExternalLinkIcon,
|
||||
GlobeIcon,
|
||||
FolderKanbanIcon,
|
||||
CircleDotIcon,
|
||||
CoinsIcon,
|
||||
ClockIcon,
|
||||
RefreshCwIcon,
|
||||
BarChart3Icon,
|
||||
DownloadIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
||||
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
|
||||
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 { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||
import type { DataTableColumn } from '@/components/data-table-card'
|
||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
|
||||
import { computeInventoryHealth } from '@/lib/inventory-health'
|
||||
import { computeInventoryHealth, getStaleSyncAccountIds } from '@/lib/inventory-health'
|
||||
import { formatInBaseCurrency, normalizeRatesPayload, vpsStatusLabel } from '@/lib/format'
|
||||
import { accountBalanceApi } from '@/lib/account'
|
||||
import { MonthlyTrendChart, MonthlyExpenseChart } from '@/components/domain/charts'
|
||||
|
||||
import type { Vps } from '@/types/entities'
|
||||
import type { Vps, ProviderAccount, Provider, SyncLogRow } from '@/types/entities'
|
||||
|
||||
export const Route = createFileRoute('/_auth/dashboard')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||
Promise.all([
|
||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||
queryClient.ensureQueryData(dashboardStatsQueryOptions()),
|
||||
]),
|
||||
component: DashboardPage,
|
||||
})
|
||||
|
||||
type InventoryIssue = { key: string; title: string; count: number; to: string; hint?: string }
|
||||
|
||||
interface AtRiskAccount {
|
||||
id: string
|
||||
name: string
|
||||
reason: string
|
||||
severity: 'warning' | 'destructive'
|
||||
}
|
||||
|
||||
function buildAtRiskAccounts(
|
||||
accounts: ProviderAccount[],
|
||||
providers: Provider[],
|
||||
syncLog: SyncLogRow[] = [],
|
||||
): AtRiskAccount[] {
|
||||
const staleIds = new Set(getStaleSyncAccountIds(accounts, providers, syncLog))
|
||||
const rows: AtRiskAccount[] = []
|
||||
for (const a of accounts) {
|
||||
const ext = a as ProviderAccount & { balanceAlertBelow?: number | null }
|
||||
const threshold = Number(ext.balanceAlertBelow ?? 0)
|
||||
const balance = accountBalanceApi(a)
|
||||
if (Number.isFinite(threshold) && threshold > 0 && balance != null && balance < threshold) {
|
||||
rows.push({ id: a.id, name: a.name, reason: 'Низкий баланс', severity: 'destructive' })
|
||||
} else if (staleIds.has(a.id)) {
|
||||
rows.push({ id: a.id, name: a.name, reason: 'Устаревший синк', severity: 'warning' })
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function DashboardPage() {
|
||||
const navigate = useNavigate()
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
const { data: stats } = useQuery(dashboardStatsQueryOptions())
|
||||
const settings = snapshot?.settings?.[0]
|
||||
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
|
||||
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader title="Дашборд" description="Сводка по VPS, балансам и здоровью инвентаря" />
|
||||
<PageHeader
|
||||
title="Дашборд"
|
||||
description="Сводка по VPS, балансам и здоровью инвентаря"
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" render={<Link to="/reports" />}>
|
||||
<BarChart3Icon data-icon="inline-start" />
|
||||
Отчёты
|
||||
</Button>
|
||||
<Button variant="outline" render={<Link to="/accounts" />}>
|
||||
<RefreshCwIcon data-icon="inline-start" />
|
||||
Синхронизация
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<QueryState
|
||||
data={snapshot}
|
||||
@@ -55,21 +109,18 @@ function DashboardPage() {
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
skeleton={<SectionCardsSkeleton />}
|
||||
skeleton={
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionCardsSkeleton count={6} />
|
||||
<TableSkeleton />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(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,
|
||||
)
|
||||
const issues = computeInventoryHealth({ ...snap, syncLog: snap.syncLog ?? [] })
|
||||
const atRisk = buildAtRiskAccounts(snap.providerAccounts, snap.providers, snap.syncLog ?? [])
|
||||
const baseCur = snap.settings[0]?.baseCurrency ?? 'RUB'
|
||||
|
||||
const issueColumns: DataTableColumn<InventoryIssue>[] = [
|
||||
{
|
||||
@@ -121,12 +172,7 @@ function DashboardPage() {
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Статус',
|
||||
icon: CircleDotIcon,
|
||||
cell: (v) => (
|
||||
<Badge variant={v.status === 'active' ? 'default' : 'secondary'}>
|
||||
{vpsStatusLabel(v.status)}
|
||||
</Badge>
|
||||
),
|
||||
cell: (v) => <StatusBadge status={v.status} label={vpsStatusLabel(v.status)} />,
|
||||
},
|
||||
{
|
||||
key: 'rate',
|
||||
@@ -135,15 +181,11 @@ function DashboardPage() {
|
||||
headerClassName: 'text-right',
|
||||
className: 'text-right',
|
||||
sortValue: (v) =>
|
||||
v.tariffType === 'daily'
|
||||
? Number(v.dailyRate || 0) * 30
|
||||
: Number(v.monthlyRate || 0),
|
||||
v.tariffType === 'daily' ? Number(v.dailyRate || 0) * 30 : Number(v.monthlyRate || 0),
|
||||
cell: (v) => (
|
||||
<span className="tabular-nums font-medium">
|
||||
{formatInBaseCurrency(
|
||||
v.tariffType === 'daily'
|
||||
? Number(v.dailyRate || 0) * 30
|
||||
: Number(v.monthlyRate || 0),
|
||||
v.tariffType === 'daily' ? Number(v.dailyRate || 0) * 30 : Number(v.monthlyRate || 0),
|
||||
v.currency,
|
||||
snap.settings,
|
||||
ratesData,
|
||||
@@ -153,61 +195,193 @@ function DashboardPage() {
|
||||
},
|
||||
]
|
||||
|
||||
const riskColumns: DataTableColumn<AtRiskAccount>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Аккаунт',
|
||||
cell: (row) => <span className="font-medium">{row.name}</span>,
|
||||
},
|
||||
{
|
||||
key: 'reason',
|
||||
header: 'Причина',
|
||||
cell: (row) => (
|
||||
<StatusBadge
|
||||
status={row.severity === 'destructive' ? 'error' : 'stale'}
|
||||
label={row.reason}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
header: '',
|
||||
sortable: false,
|
||||
cell: () => (
|
||||
<Button variant="outline" size="sm" onClick={() => navigate({ to: '/accounts' })}>
|
||||
Открыть
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<SectionCards
|
||||
items={[
|
||||
{
|
||||
label: 'Активные VPS',
|
||||
value: activeVps.length,
|
||||
value: stats?.activeVpsCount ?? activeVps.length,
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
hint: `всего ${snap.vps.length}`,
|
||||
hint: `всего ${stats?.totalVpsCount ?? snap.vps.length}`,
|
||||
onClick: () => navigate({ to: '/vps' }),
|
||||
},
|
||||
{
|
||||
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),
|
||||
label: 'Расход/мес',
|
||||
value: formatInBaseCurrency(
|
||||
stats?.monthlyBurnEstimate ?? 0,
|
||||
baseCur,
|
||||
snap.settings,
|
||||
ratesData,
|
||||
),
|
||||
icon: <TrendingUpIcon className="size-4" />,
|
||||
onClick: () => navigate({ to: '/reports' }),
|
||||
},
|
||||
{
|
||||
label: 'Баланс аккаунтов (API)',
|
||||
value: formatInBaseCurrency(totalBalance, snap.settings[0]?.baseCurrency ?? 'RUB', snap.settings, ratesData),
|
||||
label: 'Баланс API',
|
||||
value: formatInBaseCurrency(
|
||||
stats?.totalBalanceApi ?? 0,
|
||||
baseCur,
|
||||
snap.settings,
|
||||
ratesData,
|
||||
),
|
||||
icon: <WalletIcon className="size-4" />,
|
||||
onClick: () => navigate({ to: '/accounts' }),
|
||||
},
|
||||
{
|
||||
label: 'Runway (мин.)',
|
||||
value: stats?.minRunwayDays != null ? `${stats.minRunwayDays} дн` : '—',
|
||||
icon: <ClockIcon className="size-4" />,
|
||||
variant: stats?.minRunwayDays != null && stats.minRunwayDays < 14 ? 'warning' : 'default',
|
||||
onClick: () => navigate({ to: '/accounts' }),
|
||||
},
|
||||
{
|
||||
label: 'Истекает 7 дн',
|
||||
value: stats?.expiringWithin7Days ?? 0,
|
||||
icon: <AlertTriangleIcon className="size-4" />,
|
||||
variant: (stats?.expiringWithin7Days ?? 0) > 0 ? 'warning' : 'default',
|
||||
onClick: () => navigate({ to: '/vps', search: { health: 'paid-overdue' } }),
|
||||
},
|
||||
{
|
||||
label: 'Проблемы',
|
||||
value: stats?.issuesCount ?? issues.length,
|
||||
icon: <HashIcon className="size-4" />,
|
||||
variant: (stats?.issuesCount ?? issues.length) > 0 ? 'destructive' : 'default',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<DataGridCard
|
||||
title="Здоровье инвентаря"
|
||||
description="Подсказки: нет проекта, нет ставки, просрочка, устаревший синк, расхождения баланса"
|
||||
actions={
|
||||
issues.length > 0 ? (
|
||||
<Badge variant="destructive">{issues.length}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">всё ок</Badge>
|
||||
)
|
||||
}
|
||||
columns={columnDefFromDataTable(issueColumns)}
|
||||
data={issues}
|
||||
rowId={(i) => i.key}
|
||||
emptyTitle="Проблем не найдено"
|
||||
emptyDescription="Все активные VPS имеют проект, ставку и актуальный синк"
|
||||
/>
|
||||
{issues.length > 0 ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangleIcon />
|
||||
<AlertTitle>Требует внимания</AlertTitle>
|
||||
<AlertDescription>
|
||||
Обнаружено {issues.length} категорий проблем в инвентаре. Проверьте вкладку «Проблемы».
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<DataGridCard
|
||||
title="Последние VPS"
|
||||
description="Активные серверы"
|
||||
columns={columnDefFromDataTable(vpsColumns)}
|
||||
data={activeVps.slice(0, 8)}
|
||||
rowId={(v) => v.id}
|
||||
pagination={false}
|
||||
/>
|
||||
</>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<MonthlyTrendChart
|
||||
payments={snap.payments}
|
||||
settings={snap.settings}
|
||||
ratesData={ratesData}
|
||||
className="h-full"
|
||||
/>
|
||||
<MonthlyExpenseChart
|
||||
vps={snap.vps}
|
||||
providers={snap.providers}
|
||||
providerAccounts={snap.providerAccounts}
|
||||
settings={snap.settings}
|
||||
ratesData={ratesData}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="issues">
|
||||
<TabsList>
|
||||
<TabsTrigger value="issues">Проблемы ({issues.length})</TabsTrigger>
|
||||
<TabsTrigger value="recent">Последние VPS</TabsTrigger>
|
||||
<TabsTrigger value="risk">Аккаунты ({atRisk.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="issues" className="mt-4">
|
||||
<DataGridCard
|
||||
title="Здоровье инвентаря"
|
||||
description="Нет проекта, ставки, просрочка, устаревший синк, расхождения баланса"
|
||||
columns={columnDefFromDataTable(issueColumns)}
|
||||
data={issues}
|
||||
rowId={(i) => i.key}
|
||||
emptyTitle="Проблем не найдено"
|
||||
emptyDescription="Все активные VPS имеют проект, ставку и актуальный синк"
|
||||
pagination={false}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="recent" className="mt-4">
|
||||
<DataGridCard
|
||||
title="Последние VPS"
|
||||
description="Активные серверы"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" render={<Link to="/vps" />}>
|
||||
Все VPS
|
||||
</Button>
|
||||
}
|
||||
columns={columnDefFromDataTable(vpsColumns)}
|
||||
data={activeVps.slice(0, 8)}
|
||||
rowId={(v) => v.id}
|
||||
pagination={false}
|
||||
onRowClick={(v) => navigate({ to: '/vps', search: { edit: v.id } })}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="risk" className="mt-4">
|
||||
<DataGridCard
|
||||
title="Аккаунты под риском"
|
||||
description="Низкий баланс или устаревший синк BILLmanager"
|
||||
columns={columnDefFromDataTable(riskColumns)}
|
||||
data={atRisk}
|
||||
rowId={(r) => r.id}
|
||||
emptyTitle="Рисков нет"
|
||||
emptyDescription="Балансы и синхронизация в норме"
|
||||
pagination={false}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" render={<Link to="/resources" />}>
|
||||
Ресурсы
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const csv = ['ip,status,project,currency,monthlyRate']
|
||||
for (const v of activeVps) {
|
||||
csv.push(
|
||||
[v.ip, v.status, v.project ?? '', v.currency, v.monthlyRate ?? ''].join(','),
|
||||
)
|
||||
}
|
||||
const blob = new Blob([csv.join('\n')], { type: 'text/csv' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'vps-export.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}}
|
||||
>
|
||||
<DownloadIcon data-icon="inline-start" />
|
||||
Экспорт CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</QueryState>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||
import type { DataTableColumn } from '@/components/data-table-card'
|
||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { PlusIcon, FolderKanbanIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
import { ApiError } from '@/lib/api-client'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { FormField } from '@/components/form-field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
|
||||
interface ProjectRow {
|
||||
id: string
|
||||
name: string
|
||||
vpsCount: number
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/projects')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||
component: ProjectsPage,
|
||||
})
|
||||
|
||||
function ProjectsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (projectName: string) =>
|
||||
fetch(`${import.meta.env.VITE_API_URL ?? ''}/api/projects`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: projectName }),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) throw new ApiError(await res.text(), res.status)
|
||||
return res.json()
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||
toast.success('Проект создан')
|
||||
setOpen(false)
|
||||
setName('')
|
||||
},
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
||||
})
|
||||
|
||||
const rows: ProjectRow[] = (snapshot?.serverProjects ?? []).map((p) => {
|
||||
const row = p as { id: string; name: string }
|
||||
const vpsCount = (snapshot?.vps ?? []).filter((v) => v.project === row.name).length
|
||||
return { id: row.id, name: row.name, vpsCount }
|
||||
})
|
||||
|
||||
const columns: DataTableColumn<ProjectRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Проект',
|
||||
icon: FolderKanbanIcon,
|
||||
cell: (row) => <span className="font-medium">{row.name}</span>,
|
||||
},
|
||||
{
|
||||
key: 'vps',
|
||||
header: 'VPS',
|
||||
headerClassName: 'text-right',
|
||||
className: 'text-right tabular-nums',
|
||||
sortValue: (row) => row.vpsCount,
|
||||
cell: (row) => row.vpsCount,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Проекты"
|
||||
description="Группировка VPS по проектам"
|
||||
actions={
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
Добавить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<QueryState
|
||||
data={snapshot}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
skeleton={<TableSkeleton />}
|
||||
empty={rows.length === 0}
|
||||
emptyTitle="Проектов нет"
|
||||
emptyDescription="Создайте проект или назначьте его при редактировании VPS"
|
||||
emptyAction={
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
Создать проект
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{() => (
|
||||
<DataGridCard
|
||||
columns={columnDefFromDataTable(columns)}
|
||||
data={rows}
|
||||
rowId={(r) => r.id}
|
||||
dense
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
|
||||
<FormSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
trigger={null}
|
||||
title="Новый проект"
|
||||
description="Имя будет доступно в автодополнении на форме VPS"
|
||||
onSubmit={() => createMut.mutate(name.trim())}
|
||||
submitting={createMut.isPending}
|
||||
submitDisabled={!name.trim()}
|
||||
>
|
||||
<FormField label="Название" htmlFor="project-name">
|
||||
<Input id="project-name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</FormField>
|
||||
</FormSheet>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { PageHeader } from '@/components/page-header'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||
import type { DataTableColumn } from '@/components/data-table-card'
|
||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
||||
import { dataGridCellWithIcon } from '@/components/data-grid-cells'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
|
||||
@@ -69,7 +69,13 @@ function ReportsPage() {
|
||||
]}
|
||||
/>
|
||||
<ChartsGrid>
|
||||
<MonthlyExpenseChart vps={snap.vps} providers={snap.providers} settings={snap.settings} ratesData={ratesData} />
|
||||
<MonthlyExpenseChart
|
||||
vps={snap.vps}
|
||||
providers={snap.providers}
|
||||
providerAccounts={snap.providerAccounts}
|
||||
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>
|
||||
|
||||
@@ -16,6 +16,33 @@ import { SelectField } from '@/components/select-field'
|
||||
|
||||
import type { Settings } from '@/types/entities'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { DownloadIcon, UploadIcon } from 'lucide-react'
|
||||
|
||||
function boolSelect(
|
||||
draft: Partial<Settings>,
|
||||
setForm: (v: Partial<Settings>) => void,
|
||||
key: keyof Settings,
|
||||
id: string,
|
||||
label: string,
|
||||
) {
|
||||
const val = draft[key] === false ? 'off' : 'on'
|
||||
return (
|
||||
<Field orientation="horizontal">
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
<SelectField
|
||||
triggerId={id}
|
||||
triggerClassName="w-32"
|
||||
value={val}
|
||||
onValueChange={(v) => setForm({ ...draft, [key]: (v ?? 'on') === 'on' })}
|
||||
options={[
|
||||
{ value: 'on', label: 'Вкл' },
|
||||
{ value: 'off', label: 'Выкл' },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/settings')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
@@ -157,6 +184,132 @@ function SettingsPage() {
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Синхронизация</CardTitle>
|
||||
<CardDescription>Автосинк BILLmanager и интервалы</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
{boolSelect(draft, (v) => setForm(v), 'syncEnabled', 'set-sync', 'Автосинк')}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="set-sync-int">Интервал синка (мин)</FieldLabel>
|
||||
<Input
|
||||
id="set-sync-int"
|
||||
type="number"
|
||||
min={15}
|
||||
value={draft.syncIntervalMinutes ?? 60}
|
||||
onChange={(e) =>
|
||||
setForm({ ...draft, syncIntervalMinutes: Number(e.target.value) || 60 })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="set-tariff-int">Интервал тарифов (мин)</FieldLabel>
|
||||
<Input
|
||||
id="set-tariff-int"
|
||||
type="number"
|
||||
min={60}
|
||||
value={draft.syncTariffsIntervalMinutes ?? 1440}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...draft,
|
||||
syncTariffsIntervalMinutes: Number(e.target.value) || 1440,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
{boolSelect(draft, (v) => setForm(v), 'notifyLowBalanceEnabled', 'set-notify-bal', 'Низкий баланс')}
|
||||
{boolSelect(draft, (v) => setForm(v), 'notifySyncDigestEnabled', 'set-notify-sync', 'Дайджест синка')}
|
||||
{boolSelect(draft, (v) => setForm(v), 'notifyPaymentExpiryEnabled', 'set-notify-pay', 'Истечение оплаты')}
|
||||
{boolSelect(draft, (v) => setForm(v), 'notifyNewTariffsEnabled', 'set-notify-tar', 'Новые тарифы')}
|
||||
<LoadingButton
|
||||
className="w-fit"
|
||||
onClick={() => upsertMut.mutate(draft)}
|
||||
loading={upsertMut.isPending}
|
||||
disabled={!form}
|
||||
>
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Резервное копирование</CardTitle>
|
||||
<CardDescription>Экспорт и импорт базы данных</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const blob = await api.downloadBackupJson()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `vps-tracker-backup-${new Date().toISOString().slice(0, 10)}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('JSON выгружен')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : 'Ошибка выгрузки')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DownloadIcon data-icon="inline-start" />
|
||||
JSON
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const blob = await api.downloadBackupDatabase()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `vps-tracker-${new Date().toISOString().slice(0, 10)}.db`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('База выгружена')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : 'Ошибка выгрузки')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DownloadIcon data-icon="inline-start" />
|
||||
SQLite
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'application/json,.json'
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
const text = await file.text()
|
||||
await api.importBackupJson(JSON.parse(text))
|
||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||
toast.success('Импорт JSON выполнен')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта')
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}}
|
||||
>
|
||||
<UploadIcon data-icon="inline-start" />
|
||||
Импорт JSON
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { HistoryIcon, UserRoundIcon, CheckCircle2Icon, XCircleIcon, LoaderIcon } from 'lucide-react'
|
||||
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||
import { formatSyncSummaryLine } from '@/lib/inventory-health'
|
||||
import { formatRelativeSyncTime } from '@/lib/sync-format'
|
||||
|
||||
import type { SyncLogRow } from '@/types/entities'
|
||||
|
||||
export const Route = createFileRoute('/_auth/sync-journal')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||
component: SyncJournalPage,
|
||||
})
|
||||
|
||||
function statusIcon(status: SyncLogRow['status']) {
|
||||
if (status === 'ok') return <CheckCircle2Icon className="size-4 text-primary" />
|
||||
if (status === 'error') return <XCircleIcon className="size-4 text-destructive" />
|
||||
return <LoaderIcon className="size-4 animate-spin text-muted-foreground" />
|
||||
}
|
||||
|
||||
function SyncJournalPage() {
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
|
||||
const columns: DataTableColumn<SyncLogRow>[] = [
|
||||
{
|
||||
key: 'started',
|
||||
header: 'Запуск',
|
||||
icon: HistoryIcon,
|
||||
sortValue: (r) => r.startedAt ?? '',
|
||||
cell: (r) => dataGridCellStack(
|
||||
r.startedAt ? new Date(r.startedAt).toLocaleString('ru-RU') : '—',
|
||||
r.finishedAt ? `завершён ${formatRelativeSyncTime(r.finishedAt)}` : undefined,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'account',
|
||||
header: 'Аккаунт',
|
||||
icon: UserRoundIcon,
|
||||
cell: (r) => {
|
||||
const acc = snapshot?.providerAccounts.find((a) => a.id === r.accountId)
|
||||
return acc?.name ?? r.accountId
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Статус',
|
||||
cell: (r) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{statusIcon(r.status)}
|
||||
<StatusBadge status={r.status} label={r.status === 'ok' ? 'OK' : r.status === 'error' ? 'Ошибка' : 'Выполняется'} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'summary',
|
||||
header: 'Итог',
|
||||
cell: (r) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{r.error || formatSyncSummaryLine(r.summary as never) || '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Журнал синка"
|
||||
description="История синхронизаций BILLmanager по аккаунтам"
|
||||
actions={
|
||||
<Link to="/accounts" className="text-sm text-muted-foreground underline-offset-4 hover:underline">
|
||||
Управление аккаунтами
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<QueryState
|
||||
data={snapshot}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
skeleton={<TableSkeleton />}
|
||||
empty={!snapshot?.syncLog?.length}
|
||||
emptyTitle="Записей синка нет"
|
||||
emptyDescription="Запустите синхронизацию на странице аккаунтов"
|
||||
>
|
||||
{(snap) => (
|
||||
<DataGridCard
|
||||
columns={columnDefFromDataTable(columns)}
|
||||
data={snap.syncLog ?? []}
|
||||
rowId={(r) => r.id}
|
||||
dense
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
@@ -6,12 +6,12 @@ import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||
import type { DataTableColumn } from '@/components/data-table-card'
|
||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { ServerCogIcon, ServerIcon, UserRoundIcon, CpuIcon, CoinsIcon, HardDriveIcon } from 'lucide-react'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { ServerIcon, UserRoundIcon, CpuIcon, CoinsIcon, HardDriveIcon } from 'lucide-react'
|
||||
|
||||
import type { ActiveTariff } from '@/types/entities'
|
||||
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
||||
@@ -90,7 +90,9 @@ function TariffsPage() {
|
||||
empty={snapshot?.activeTariffs.length === 0}
|
||||
emptyTitle="Тарифы не загружены"
|
||||
emptyDescription="Выполните синхронизацию аккаунта BILLmanager, чтобы загрузить тарифы"
|
||||
emptyAction={<EmptyState icon={<ServerCogIcon className="size-8" />} title="Нет тарифов" />}
|
||||
emptyAction={
|
||||
<Button render={<Link to="/accounts" />}>Перейти к аккаунтам</Button>
|
||||
}
|
||||
>
|
||||
{(snap) => <DataGridCard columns={columnDefFromDataTable(columns)} data={snap.activeTariffs} rowId={(t) => t.id} />}
|
||||
</QueryState>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { Controller } from 'react-hook-form'
|
||||
import { PlusIcon, PencilIcon, Trash2Icon, GlobeIcon, UserRoundIcon, FolderKanbanIcon, CpuIcon, CircleDotIcon, CreditCardIcon, MapPinIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
@@ -13,10 +13,11 @@ import { PageHeader } from '@/components/page-header'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||
import type { DataTableColumn } from '@/components/data-table-card'
|
||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
||||
import { dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells'
|
||||
import { CountryFlag } from '@/components/country-flag'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { FormSheetRhf } from '@/components/form-sheet-rhf'
|
||||
@@ -36,6 +37,7 @@ import { FormDatePicker } from '@/components/form-date-picker'
|
||||
import {
|
||||
applyVpsFilters,
|
||||
buildDefaultVpsFilters,
|
||||
hasActiveVpsFilters,
|
||||
type VpsFiltersState,
|
||||
} from '@/components/vps-filters'
|
||||
import { VpsFiltersToolbar } from '@/components/vps-filters-toolbar'
|
||||
@@ -44,8 +46,17 @@ import type { Vps } from '@/types/entities'
|
||||
import { vpsStatusLabel, tariffTypeLabel } from '@/lib/format'
|
||||
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
||||
import { COUNTRIES, COUNTRY_BY_NAME_RU, buildCityOptions, cityMatchesCountry, resolveCountryForCityFromRows } from '@cfdm/shared/geo'
|
||||
import { getPaidUntilDate } from '@/lib/paid-until'
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
const vpsSearchSchema = z.object({
|
||||
health: z.string().optional(),
|
||||
edit: z.string().optional(),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute('/_auth/vps')({
|
||||
validateSearch: (search) => vpsSearchSchema.parse(search),
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||
component: VpsPage,
|
||||
@@ -60,12 +71,49 @@ const EMPTY_FORM: VpsFormValues = {
|
||||
|
||||
function VpsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const { health, edit } = Route.useSearch()
|
||||
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 [filters, setFilters] = useState<VpsFiltersState>(buildDefaultVpsFilters())
|
||||
|
||||
useEffect(() => {
|
||||
if (!health) return
|
||||
setFilters((prev) => ({ ...prev, status: prev.status.length ? prev.status : ['active'] }))
|
||||
}, [health])
|
||||
|
||||
useEffect(() => {
|
||||
if (!edit || !snapshot) return
|
||||
const row = snapshot.vps.find((v) => v.id === edit)
|
||||
if (row) {
|
||||
setEditingId(row.id)
|
||||
setDefaultValues({
|
||||
ip: row.ip,
|
||||
dns: row.dns ?? '',
|
||||
providerId: row.providerId,
|
||||
providerAccountId: row.providerAccountId,
|
||||
country: row.country ?? '',
|
||||
city: row.city ?? '',
|
||||
datacenter: row.datacenter ?? '',
|
||||
vcpu: row.vcpu,
|
||||
ramGb: row.ramGb,
|
||||
diskGb: row.diskGb,
|
||||
status: row.status,
|
||||
tariffType: row.tariffType,
|
||||
currency: row.currency,
|
||||
monthlyRate: Number(row.monthlyRate || 0),
|
||||
dailyRate: Number(row.dailyRate || 0),
|
||||
paidUntil: row.paidUntil ?? '',
|
||||
project: row.project ?? '',
|
||||
notes: row.notes ?? '',
|
||||
})
|
||||
setSheetOpen(true)
|
||||
void navigate({ to: '/vps', search: { edit: undefined }, replace: true })
|
||||
}
|
||||
}, [edit, snapshot, navigate])
|
||||
|
||||
const settings = snapshot?.settings[0]
|
||||
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
|
||||
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
|
||||
@@ -143,10 +191,38 @@ function VpsPage() {
|
||||
return [...names].sort((a, b) => a.localeCompare(b, 'ru'))
|
||||
}, [snapshot])
|
||||
|
||||
const filteredVps = useMemo(
|
||||
() => applyVpsFilters(snapshot?.vps ?? [], filters),
|
||||
[snapshot?.vps, filters],
|
||||
)
|
||||
const filteredVps = useMemo(() => {
|
||||
let rows = applyVpsFilters(snapshot?.vps ?? [], filters)
|
||||
if (!health || !snapshot) return rows
|
||||
const now = new Date()
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const ctx = {
|
||||
vps: snapshot.vps,
|
||||
providerAccounts: snapshot.providerAccounts,
|
||||
payments: snapshot.payments,
|
||||
balanceLedger: snapshot.balanceLedger,
|
||||
now,
|
||||
}
|
||||
if (health === 'no-project') {
|
||||
rows = rows.filter((v) => v.status === 'active' && !(v.project || '').trim())
|
||||
} else if (health === 'no-rate') {
|
||||
rows = rows.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
|
||||
})
|
||||
} else if (health === 'paid-overdue') {
|
||||
rows = rows.filter((v) => {
|
||||
if (v.status !== 'active') return false
|
||||
const d = getPaidUntilDate(v, ctx)
|
||||
return d != null && d < todayStart
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}, [snapshot, filters, health])
|
||||
|
||||
const countryOptions = useMemo(() => {
|
||||
const names = new Set<string>()
|
||||
@@ -335,7 +411,37 @@ function VpsPage() {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{(snap) => (
|
||||
{(snap) => {
|
||||
const filtersActive = hasActiveVpsFilters(filters)
|
||||
const zeroResults = snap.vps.length > 0 && filteredVps.length === 0 && filtersActive
|
||||
|
||||
if (zeroResults) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<VpsFiltersToolbar
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
providers={snap.providers}
|
||||
providerAccounts={snap.providerAccounts}
|
||||
vps={snap.vps}
|
||||
projectNameOptions={projectNameOptions}
|
||||
countryOptions={countryOptions}
|
||||
cityOptions={cityOptions}
|
||||
/>
|
||||
<EmptyState
|
||||
title="Ничего не найдено"
|
||||
description="По текущим фильтрам VPS не найдены"
|
||||
action={
|
||||
<Button variant="outline" onClick={() => setFilters(buildDefaultVpsFilters())}>
|
||||
Сбросить фильтры
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<VpsFiltersToolbar
|
||||
filters={filters}
|
||||
@@ -361,7 +467,8 @@ function VpsPage() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
}}
|
||||
</QueryState>
|
||||
|
||||
<FormSheetRhf
|
||||
|
||||
@@ -31,8 +31,13 @@ export interface ProviderAccount {
|
||||
apiCredentialsSet?: boolean
|
||||
billingMode?: BillingMode
|
||||
balance_api?: number | null
|
||||
balanceApi?: number | null
|
||||
balance_currency?: string
|
||||
balanceCurrency?: string
|
||||
currency?: string
|
||||
balanceAlertBelow?: number | null
|
||||
enoughmoneyto?: string
|
||||
balanceUpdatedAt?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
@@ -100,6 +105,12 @@ export interface Settings {
|
||||
ratesUrl?: string
|
||||
autoConvert?: boolean
|
||||
syncEnabled?: boolean
|
||||
syncIntervalMinutes?: number
|
||||
syncTariffsIntervalMinutes?: number
|
||||
notifyLowBalanceEnabled?: boolean
|
||||
notifySyncDigestEnabled?: boolean
|
||||
notifyPaymentExpiryEnabled?: boolean
|
||||
notifyNewTariffsEnabled?: boolean
|
||||
telegramChatId?: string
|
||||
telegramBotToken?: string
|
||||
}
|
||||
@@ -158,5 +169,5 @@ export interface DataSnapshot {
|
||||
activeTariffs: ActiveTariff[]
|
||||
tariffSyncOptions?: unknown[]
|
||||
serverProjects?: unknown[]
|
||||
syncLog?: SyncLogRow[]
|
||||
syncLog: SyncLogRow[]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { balanceLedgerRepository } from './balance-ledger.js'
|
||||
import { settingsRepository } from './settings.js'
|
||||
import { activeTariffsRepository, tariffSyncOptionsRepository } from './tariffs.js'
|
||||
import { projectsRepository } from './projects.js'
|
||||
import { syncLogRepository } from './sync-log.js'
|
||||
|
||||
export interface Snapshot {
|
||||
vps: ReturnType<typeof vpsRepository.list>
|
||||
@@ -17,6 +18,7 @@ export interface Snapshot {
|
||||
settings: ReturnType<typeof settingsRepository.list>
|
||||
activeTariffs: ReturnType<typeof activeTariffsRepository.list>
|
||||
tariffSyncOptions: ReturnType<typeof tariffSyncOptionsRepository.list>
|
||||
syncLog: ReturnType<typeof syncLogRepository.listRecent>
|
||||
}
|
||||
|
||||
export function getSnapshot(): Snapshot {
|
||||
@@ -30,6 +32,7 @@ export function getSnapshot(): Snapshot {
|
||||
settings: settingsRepository.list(),
|
||||
activeTariffs: activeTariffsRepository.list(),
|
||||
tariffSyncOptions: tariffSyncOptionsRepository.list(),
|
||||
syncLog: syncLogRepository.listRecent(50),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,4 +46,5 @@ export {
|
||||
activeTariffsRepository,
|
||||
tariffSyncOptionsRepository,
|
||||
projectsRepository,
|
||||
syncLogRepository,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { desc } from 'drizzle-orm'
|
||||
import { getDb, schema } from '../index.js'
|
||||
|
||||
export interface SyncLogDto {
|
||||
id: string
|
||||
accountId: string
|
||||
status: 'ok' | 'error' | 'running' | string | null
|
||||
startedAt: string
|
||||
finishedAt: string | null
|
||||
vpsCount: number | null
|
||||
paymentsCount: number | null
|
||||
error: string | null
|
||||
summary: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
function toDto(row: typeof schema.syncLog.$inferSelect): SyncLogDto {
|
||||
let summary: Record<string, unknown> | null = null
|
||||
if (row.summary) {
|
||||
try {
|
||||
const parsed = JSON.parse(row.summary) as unknown
|
||||
summary = parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null
|
||||
} catch {
|
||||
summary = null
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
accountId: row.accountId,
|
||||
status: row.status,
|
||||
startedAt: row.startedAt,
|
||||
finishedAt: row.finishedAt,
|
||||
vpsCount: row.vpsCount,
|
||||
paymentsCount: row.paymentsCount,
|
||||
error: row.error,
|
||||
summary,
|
||||
}
|
||||
}
|
||||
|
||||
export const syncLogRepository = {
|
||||
listRecent(limit = 50): SyncLogDto[] {
|
||||
const rows = getDb()
|
||||
.select()
|
||||
.from(schema.syncLog)
|
||||
.orderBy(desc(schema.syncLog.startedAt))
|
||||
.limit(limit)
|
||||
.all()
|
||||
return rows.map(toDto)
|
||||
},
|
||||
}
|
||||
@@ -3,17 +3,40 @@ import { getDb, schema } from '../index.js'
|
||||
|
||||
type Row = typeof schema.activeTariffs.$inferSelect
|
||||
|
||||
export type ActiveTariffDto = Omit<Row, 'orderAvailable' | 'ramGb'> & {
|
||||
export type ActiveTariffDto = Omit<Row, 'orderAvailable' | 'ramGb' | 'price'> & {
|
||||
orderAvailable: boolean
|
||||
ramGb: number
|
||||
monthlyRate: number | null
|
||||
currency: string | null
|
||||
}
|
||||
|
||||
/** Парсит строку цены BILLmanager: «100.50 RUB», «€12», «12 USD». */
|
||||
export function parseTariffPrice(price: string | null | undefined): {
|
||||
monthlyRate: number | null
|
||||
currency: string | null
|
||||
} {
|
||||
const raw = String(price ?? '').trim()
|
||||
if (!raw) return { monthlyRate: null, currency: null }
|
||||
const match = raw.match(/([\d.,]+)\s*([A-Za-z]{3})?/)
|
||||
if (!match) return { monthlyRate: null, currency: null }
|
||||
const monthlyRate = Number.parseFloat(match[1].replace(',', '.'))
|
||||
const currency = match[2]?.toUpperCase() ?? null
|
||||
return {
|
||||
monthlyRate: Number.isFinite(monthlyRate) ? monthlyRate : null,
|
||||
currency,
|
||||
}
|
||||
}
|
||||
|
||||
function toDto(row: Row | undefined): ActiveTariffDto | undefined {
|
||||
if (!row) return undefined
|
||||
const { monthlyRate, currency } = parseTariffPrice(row.price)
|
||||
const { price: _price, ...rest } = row
|
||||
return {
|
||||
...row,
|
||||
...rest,
|
||||
orderAvailable: Boolean(row.orderAvailable),
|
||||
ramGb: row.ramGb != null ? Number(row.ramGb) : 0,
|
||||
monthlyRate,
|
||||
currency,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-action"
|
||||
className={cn("absolute top-2 right-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||
Reference in New Issue
Block a user