diff --git a/apps/web/src/components/domain/charts.tsx b/apps/web/src/components/domain/charts.tsx index ae66395..d2c21ac 100644 --- a/apps/web/src/components/domain/charts.tsx +++ b/apps/web/src/components/domain/charts.tsx @@ -16,9 +16,23 @@ import { ChartLegendContent, type ChartConfig, } from '@cfdm/ui/components/chart' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card' +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@cfdm/ui/components/card' import type { ReactNode } from 'react' -import { useMemo } from 'react' +import { useMemo, useState } from 'react' + +import { SelectField } from '@/components/select-field' +import { + aggregatePaymentsByMonthYear, + availablePaymentYears, + type PaymentChartFilter, +} from '@/lib/chart-analytics' import type { Vps, Provider, Payment, Settings, RatesData, ServerProject } from '@/types/entities' import { @@ -179,6 +193,132 @@ export function PaymentsPieChart({ ) } +function DashboardMonthlyBarChart({ + payments, + settings, + ratesData, + title, + description = 'Последние 12 мес', + chartColor, + paymentFilter, + className, + ariaLabel, +}: { + payments: Payment[] + settings: Settings[] + ratesData: RatesData | null + title: string + description?: string + chartColor: string + paymentFilter: PaymentChartFilter + className?: string + ariaLabel: string +}) { + const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase() + const years = useMemo(() => availablePaymentYears(payments), [payments]) + const [year, setYear] = useState(() => years[0] ?? new Date().getFullYear()) + + const effectiveYear = years.includes(year) ? year : (years[0] ?? year) + + const data = useMemo( + () => aggregatePaymentsByMonthYear(payments, effectiveYear, settings, ratesData, paymentFilter), + [payments, effectiveYear, settings, ratesData, paymentFilter], + ) + + const chartConfig: ChartConfig = useMemo( + () => ({ + amount: { label: title, color: chartColor }, + }), + [title, chartColor], + ) + + const hasData = data.some((row) => row.amount > 0) + + return ( + + + {title} + {description} + +
+ + { + if (v) setYear(Number(v)) + }} + options={years.map((y) => ({ value: String(y), label: String(y) }))} + /> +
+
+
+ + {!hasData ? ( + + ) : ( + + + + + + formatCurrency(Number(v), baseCurrency)} /> + } + /> + + + + )} + +
+ ) +} + +export function DashboardPaymentsChart(props: { + payments: Payment[] + settings: Settings[] + ratesData: RatesData | null + className?: string +}) { + return ( + + ) +} + +export function DashboardExpensesChart(props: { + payments: Payment[] + settings: Settings[] + ratesData: RatesData | null + className?: string +}) { + return ( + + ) +} + export function MonthlyTrendChart({ payments, settings, diff --git a/apps/web/src/components/domain/dashboard-inventory-alert.tsx b/apps/web/src/components/domain/dashboard-inventory-alert.tsx new file mode 100644 index 0000000..931e763 --- /dev/null +++ b/apps/web/src/components/domain/dashboard-inventory-alert.tsx @@ -0,0 +1,27 @@ +import { AlertTriangleIcon } from 'lucide-react' +import { Alert, AlertAction, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert' +import { Button } from '@cfdm/ui/components/button' + +interface DashboardInventoryAlertProps { + issuesCount: number + onGoToIssues: () => void +} + +export function DashboardInventoryAlert({ issuesCount, onGoToIssues }: DashboardInventoryAlertProps) { + if (issuesCount <= 0) return null + + return ( + + + Требуется внимание! + + Обнаружено {issuesCount} категорий проблем в инвентаре. Проверьте вкладку «Проблемы». + + + + + + ) +} diff --git a/apps/web/src/components/section-cards.tsx b/apps/web/src/components/section-cards.tsx index 291a029..9cedc93 100644 --- a/apps/web/src/components/section-cards.tsx +++ b/apps/web/src/components/section-cards.tsx @@ -26,9 +26,16 @@ function sectionGridClass(count: number): string { if (count === 3) return 'sm:grid-cols-2 lg:grid-cols-3' if (count === 4) return 'sm:grid-cols-2 lg:grid-cols-4' if (count === 5) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5' + if (count === 6) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6' return 'sm:grid-cols-2 lg:grid-cols-3' } +const VALUE_VARIANT_CLASS: Record, string> = { + default: '', + warning: 'text-warning-foreground', + destructive: 'text-destructive', +} + export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) { return (
@@ -51,7 +58,14 @@ export function SectionCards({ items, className }: { items: SectionCardItem[]; c {item.badge ? {item.badge} : null}
- {item.value} + + {item.value} + {item.hint ? ( typeof item.hint === 'string' ? ( · {item.hint} diff --git a/apps/web/src/lib/chart-analytics.ts b/apps/web/src/lib/chart-analytics.ts new file mode 100644 index 0000000..d026b41 --- /dev/null +++ b/apps/web/src/lib/chart-analytics.ts @@ -0,0 +1,74 @@ +import type { Payment, Settings, RatesData } from '@/types/entities' +import { canonicalPaymentType, convertCurrency, monthKey, toIsoCurrency } from '@/lib/format' + +export const EXPENSE_PAYMENT_TYPES = new Set([ + 'direct_vps_payment', + 'daily_debit', + 'monthly_debit', +]) + +const MONTH_SHORT_RU = [ + 'янв', + 'фев', + 'мар', + 'апр', + 'май', + 'июн', + 'июл', + 'авг', + 'сен', + 'окт', + 'ноя', + 'дек', +] as const + +export type PaymentChartFilter = 'all' | 'expense' + +export function formatMonthShortRu(monthIndex: number): string { + return MONTH_SHORT_RU[monthIndex] ?? '' +} + +export function isExpensePayment(type: string): boolean { + return EXPENSE_PAYMENT_TYPES.has(canonicalPaymentType(type)) +} + +export function availablePaymentYears(payments: Payment[]): number[] { + const years = new Set() + for (const p of payments) { + const key = monthKey(p.date) + if (!key) continue + const year = Number(key.slice(0, 4)) + if (Number.isFinite(year)) years.add(year) + } + years.add(new Date().getFullYear()) + return Array.from(years).sort((a, b) => b - a) +} + +export function aggregatePaymentsByMonthYear( + payments: Payment[], + year: number, + settings: Settings[], + ratesData: RatesData | null, + filter: PaymentChartFilter = 'all', +): { month: string; amount: number }[] { + const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase() + const byMonth = Array.from({ length: 12 }, () => 0) + + for (const p of payments) { + if (filter === 'expense' && !isExpensePayment(p.type)) continue + const date = new Date(p.date) + if (Number.isNaN(date.getTime()) || date.getFullYear() !== year) continue + const converted = convertCurrency( + Number(p.amount), + toIsoCurrency(p.currency), + baseCurrency, + ratesData, + ) + byMonth[date.getMonth()]! += converted + } + + return byMonth.map((amount, index) => ({ + month: formatMonthShortRu(index), + amount: Math.round(amount), + })) +} diff --git a/apps/web/src/routes/_auth/dashboard.tsx b/apps/web/src/routes/_auth/dashboard.tsx index 2156c9b..fa783de 100644 --- a/apps/web/src/routes/_auth/dashboard.tsx +++ b/apps/web/src/routes/_auth/dashboard.tsx @@ -1,5 +1,6 @@ import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' +import { useRef, useState } from 'react' import { ServerIcon, AlertTriangleIcon, @@ -11,8 +12,6 @@ import { FolderKanbanIcon, CoinsIcon, ClockIcon, - RefreshCwIcon, - BarChart3Icon, DownloadIcon, } from 'lucide-react' @@ -28,7 +27,6 @@ import { dataGridCellStack } from '@/components/data-grid-cells' 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 { cn } from '@cfdm/ui/lib/utils' @@ -37,7 +35,12 @@ import { computeInventoryHealth } from '@/lib/inventory-health' import { buildAtRiskAccounts, type AtRiskAccount } from '@/lib/account-health' import { formatInBaseCurrency, normalizeRatesPayload, vpsStatusLabel } from '@/lib/format' import { exportActiveVpsCsv } from '@/lib/export-csv' -import { MonthlyTrendChart, MonthlyExpenseChart } from '@/components/domain/charts' +import { + ChartsGrid, + DashboardPaymentsChart, + DashboardExpensesChart, +} from '@/components/domain/charts' +import { DashboardInventoryAlert } from '@/components/domain/dashboard-inventory-alert' import type { Vps } from '@/types/entities' @@ -57,6 +60,8 @@ type InventoryIssue = { key: string; title: string; count: number; to: string; h function DashboardPage() { const navigate = useNavigate() + const tabsRef = useRef(null) + const [activeTab, setActiveTab] = useState('issues') const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions()) const { data: stats, isLoading: statsLoading } = useQuery(dashboardStatsQueryOptions()) const settings = snapshot?.settings?.[0] @@ -68,18 +73,6 @@ function DashboardPage() { - - -
- } /> { + setActiveTab('issues') + tabsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + return (
+ + {statsLoading ? ( ) : ( @@ -211,13 +216,12 @@ function DashboardPage() { items={[ { label: 'Активные VPS', - value: stats?.activeVpsCount ?? activeVps.length, + value: `${activeCount} из ${totalCount}`, icon: , - hint: `всего ${stats?.totalVpsCount ?? snap.vps.length}`, onClick: () => navigate({ to: '/vps' }), }, { - label: 'Расход/мес', + label: 'Расход в месяц', value: formatInBaseCurrency( stats?.monthlyBurnEstimate ?? 0, baseCur, @@ -239,7 +243,7 @@ function DashboardPage() { onClick: () => navigate({ to: '/accounts' }), }, { - label: 'Runway (мин.)', + label: 'Runway', value: stats?.minRunwayDays != null ? `${stats.minRunwayDays} дн` : '—', icon: , variant: stats?.minRunwayDays != null && stats.minRunwayDays < 14 ? 'warning' : 'default', @@ -253,55 +257,59 @@ function DashboardPage() { }, { label: 'Истекает 7 дн', - value: stats?.expiringWithin7Days ?? 0, + value: + expiringCount > 0 ? ( + + {expiringCount} + + + ) : ( + expiringCount + ), icon: , - variant: (stats?.expiringWithin7Days ?? 0) > 0 ? 'warning' : 'default', - badge: - (stats?.expiringWithin7Days ?? 0) > 0 ? ( - - внимание - - ) : undefined, + variant: expiringCount > 0 ? 'warning' : 'default', onClick: () => navigate({ to: '/vps', search: { health: 'expiring-soon' } }), }, { label: 'Проблемы', - value: issues.length, + value: + issuesCount > 0 ? ( + + {issuesCount} + + + ) : ( + issuesCount + ), icon: , - variant: issues.length > 0 ? 'destructive' : 'default', + variant: issuesCount > 0 ? 'destructive' : 'default', + onClick: handleGoToIssues, }, ]} /> )} - {issues.length > 0 ? ( - - - Требует внимания - - Обнаружено {issues.length} категорий проблем в инвентаре. Проверьте вкладку «Проблемы». - - - ) : null} - -
- + - -
+ - +
+ Проблемы @@ -360,6 +368,7 @@ function DashboardPage() { /> +