diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d62618d..4543f99 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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)) { diff --git a/apps/api/src/routes/dashboard.ts b/apps/api/src/routes/dashboard.ts new file mode 100644 index 0000000..a46834a --- /dev/null +++ b/apps/api/src/routes/dashboard.ts @@ -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()) +} diff --git a/apps/api/src/services/dashboard-stats.ts b/apps/api/src/services/dashboard-stats.ts new file mode 100644 index 0000000..650f754 --- /dev/null +++ b/apps/api/src/services/dashboard-stats.ts @@ -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() + 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, + } +} diff --git a/apps/api/src/services/tariffs.test.ts b/apps/api/src/services/tariffs.test.ts new file mode 100644 index 0000000..b129b60 --- /dev/null +++ b/apps/api/src/services/tariffs.test.ts @@ -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 }) + }) +}) diff --git a/apps/web/src/components/data-grid-card.tsx b/apps/web/src/components/data-grid-card.tsx index 6bb943f..add10e0 100644 --- a/apps/web/src/components/data-grid-card.tsx +++ b/apps/web/src/components/data-grid-card.tsx @@ -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({ return ( - -
+ +
{title ? {title} : null} {description ?

{description}

: null}
diff --git a/apps/web/src/components/data-grid-types.ts b/apps/web/src/components/data-grid-types.ts new file mode 100644 index 0000000..196ce96 --- /dev/null +++ b/apps/web/src/components/data-grid-types.ts @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react' +import type { LucideIcon } from 'lucide-react' + +export interface DataTableColumn { + 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 +} diff --git a/apps/web/src/components/data-table-card.tsx b/apps/web/src/components/data-table-card.tsx index 3a6462b..8987823 100644 --- a/apps/web/src/components/data-table-card.tsx +++ b/apps/web/src/components/data-table-card.tsx @@ -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 { - 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 { - title?: ReactNode - description?: ReactNode - actions?: ReactNode - columns: DataTableColumn[] - data: T[] - rowKey: (row: T, index: number) => string - emptyTitle?: string - emptyDescription?: string - emptyAction?: ReactNode - onRowClick?: (row: T) => void -} - -export function DataTableCard({ - title, - description, - actions, - columns, - data, - rowKey, - emptyTitle = 'Нет записей', - emptyDescription, - emptyAction, - onRowClick, -}: DataTableCardProps) { - return ( - - {data.length === 0 ? ( -
- -
- ) : ( - - - - {columns.map((col) => ( - - {col.header} - - ))} - - - - {data.map((row, index) => ( - onRowClick(row) : undefined} - className={onRowClick ? 'cursor-pointer' : undefined} - > - {columns.map((col) => ( - - {col.cell(row, index)} - - ))} - - ))} - -
- )} -
- ) -} +/** @deprecated Используйте DataGridCard. Тип колонок — data-grid-types. */ +export type { DataTableColumn } from './data-grid-types' diff --git a/apps/web/src/components/domain/charts.tsx b/apps/web/src/components/domain/charts.tsx index f461cf2..7016b97 100644 --- a/apps/web/src/components/domain/charts.tsx +++ b/apps/web/src/components/domain/charts.tsx @@ -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 +} 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() 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({ Топ-10 по monthly rate, в {baseCurrency}
+ {data.length === 0 ? ( + + ) : ( @@ -78,6 +89,7 @@ export function MonthlyExpenseChart({ + )} ) @@ -115,6 +127,9 @@ export function PaymentsPieChart({ Структура в {baseCurrency} + {data.length === 0 ? ( + + ) : ( formatCurrency(Number(v), baseCurrency)} />} /> @@ -125,6 +140,7 @@ export function PaymentsPieChart({ + )} ) @@ -163,6 +179,9 @@ export function MonthlyTrendChart({ Последние 12 месяцев, {baseCurrency} + {data.length === 0 ? ( + + ) : ( @@ -172,6 +191,7 @@ export function MonthlyTrendChart({ + )} ) diff --git a/apps/web/src/components/empty-state.tsx b/apps/web/src/components/empty-state.tsx index db20195..fe1763e 100644 --- a/apps/web/src/components/empty-state.tsx +++ b/apps/web/src/components/empty-state.tsx @@ -18,7 +18,7 @@ export function EmptyState({ title, description, icon, action, className }: Empt )} > {icon ?
{icon}
: null} -
+

{title}

{description ?

{description}

: null}
diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index 430d540..89caf22 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -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 = Object.fromEntries( - NAV_ITEMS.map((i) => [i.to, i.label]), + ALL_NAV_ITEMS.map((i) => [i.to, i.label]), ) +const PARENT_ROUTE: Record = { + '/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 ( @@ -88,44 +157,81 @@ export function AppShell({ children }: { children: ReactNode }) { - - Меню - - - {NAV_ITEMS.map((item) => { - const Icon = item.icon - const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`) - return ( - - } - isActive={isActive} - tooltip={item.label} - > - - {item.label} - - - ) - })} - - - + {navGroups.map((group) => ( + + {group.label} + + + {group.items.map((item) => { + const Icon = item.icon + const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`) + return ( + + } + isActive={isActive} + tooltip={item.label} + > + + {item.label} + {item.badge ? ( + + {item.badge} + + ) : null} + + + ) + })} + + + + ))} - + + + + } tooltip="Настройки"> + + + Синк: {formatRelativeSyncTime(stats?.lastGlobalSyncAt)} + + {stats?.staleSyncAccountCount ? ( + + + {stats.staleSyncAccountCount} + + ) : null} + + + + -
+
+ {parentLabel && parentTo ? ( + <> + + }>{parentLabel} + + + + ) : null} {ROUTE_LABELS[activeItem.to] ?? ''} -
+
+ {stats?.issuesCount ? ( + + {stats.issuesCount} проблем + + ) : null}
diff --git a/apps/web/src/components/page-header.tsx b/apps/web/src/components/page-header.tsx index 782a8f7..77ea88d 100644 --- a/apps/web/src/components/page-header.tsx +++ b/apps/web/src/components/page-header.tsx @@ -9,7 +9,7 @@ interface PageHeaderProps { export function PageHeader({ title, description, actions }: PageHeaderProps) { return (
-
+

{title}

{description ?

{description}

: null}
diff --git a/apps/web/src/components/section-cards.tsx b/apps/web/src/components/section-cards.tsx index 57a6d4e..57f2e82 100644 --- a/apps/web/src/components/section-cards.tsx +++ b/apps/web/src/components/section-cards.tsx @@ -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, string> = { + default: '', + warning: 'border-amber-500/50', + destructive: 'border-destructive/50', } export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) { return ( -
- {items.map((item, idx) => ( - +
+ {items.map((item, idx) => { + const clickable = Boolean(item.onClick) + const content = (
{item.label} @@ -22,8 +31,29 @@ export function SectionCards({ items, className }: { items: SectionCardItem[]; c {item.value} {item.hint ? {item.hint} : null} - - ))} + ) + return ( + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + item.onClick?.() + } + } + : undefined + } + > + {content} + + ) + })}
) } diff --git a/apps/web/src/components/vps-filters.tsx b/apps/web/src/components/vps-filters.tsx index 3ed6e52..08f01df 100644 --- a/apps/web/src/components/vps-filters.tsx +++ b/apps/web/src/components/vps-filters.tsx @@ -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 diff --git a/apps/web/src/lib/account.ts b/apps/web/src/lib/account.ts new file mode 100644 index 0000000..7b559b1 --- /dev/null +++ b/apps/web/src/lib/account.ts @@ -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' + ) +} diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index f82d6bf..a809a74 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -139,6 +139,11 @@ export const api = { if (!res.ok) throw new ApiError(res.statusText || 'Ошибка восстановления', res.status) return res.json() }, + + fetchDashboardStats: () => + fetchApi('/api/dashboard/stats'), + + fetchProjects: () => fetchApi<{ id: string; name: string }[]>('/api/projects'), } export type { diff --git a/apps/web/src/lib/inventory-health.ts b/apps/web/src/lib/inventory-health.ts index 3efff1e..fe3372f 100644 --- a/apps/web/src/lib/inventory-health.ts +++ b/apps/web/src/lib/inventory-health.ts @@ -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 diff --git a/apps/web/src/lib/sync-format.ts b/apps/web/src/lib/sync-format.ts new file mode 100644 index 0000000..e9fe7b3 --- /dev/null +++ b/apps/web/src/lib/sync-format.ts @@ -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} дн назад` +} diff --git a/apps/web/src/queries/dashboard.ts b/apps/web/src/queries/dashboard.ts new file mode 100644 index 0000000..f6305df --- /dev/null +++ b/apps/web/src/queries/dashboard.ts @@ -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, +}) diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 029e4fe..daff2fe 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -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, } diff --git a/apps/web/src/routes/_auth/accounts.tsx b/apps/web/src/routes/_auth/accounts.tsx index 5a8302e..624b9fd 100644 --- a/apps/web/src/routes/_auth/accounts.tsx +++ b/apps/web/src/routes/_auth/accounts.tsx @@ -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('providerAccounts', r.id, payload as unknown as Partial) : 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 - const cur = a.balance_currency || a.currency || provider?.baseCurrency || 'USD' - return {formatCurrency(Number(a.balance_api ?? 0), cur)} + 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 ( -
- syncMut.mutate(a.id)} - > - - Синк - - - } - title="Удалить аккаунт?" - description={`«${a.name}» будет удалён.`} - destructive - confirmLabel="Удалить" - onConfirm={() => delMut.mutate(a.id)} - /> +
+ + + + + } + /> + + syncMut.mutate(a.id)} + > + + Синхронизировать + + openEdit(a)}> + + Редактировать + + + e.preventDefault()}> + + Удалить + + } + title="Удалить аккаунт?" + description={`«${a.name}» будет удалён.`} + destructive + confirmLabel="Удалить" + onConfirm={() => delMut.mutate(a.id)} + /> + +
) }, @@ -233,6 +281,16 @@ function AccountsPage() { ]} /> + + setForm({ ...form, balanceAlertBelow: e.target.value })} + placeholder="Не задан" + /> +