Привести layout к эталону: alert с CTA, 6 KPI в ряд, помесячные графики платежей и расходов с фильтром года. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -16,9 +16,23 @@ import {
|
|||||||
ChartLegendContent,
|
ChartLegendContent,
|
||||||
type ChartConfig,
|
type ChartConfig,
|
||||||
} from '@cfdm/ui/components/chart'
|
} 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 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 type { Vps, Provider, Payment, Settings, RatesData, ServerProject } from '@/types/entities'
|
||||||
import {
|
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 (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
<CardDescription>{description}</CardDescription>
|
||||||
|
<CardAction>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<SelectField
|
||||||
|
size="sm"
|
||||||
|
triggerClassName="w-[130px]"
|
||||||
|
aria-label="Группировка"
|
||||||
|
value="month"
|
||||||
|
options={[{ value: 'month', label: 'По месяцам' }]}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
size="sm"
|
||||||
|
triggerClassName="w-[100px]"
|
||||||
|
aria-label="Год"
|
||||||
|
value={String(effectiveYear)}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v) setYear(Number(v))
|
||||||
|
}}
|
||||||
|
options={years.map((y) => ({ value: String(y), label: String(y) }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!hasData ? (
|
||||||
|
<ChartEmpty message="Нет данных за выбранный период" />
|
||||||
|
) : (
|
||||||
|
<ChartContainer config={chartConfig} className="h-72 w-full" aria-label={ariaLabel}>
|
||||||
|
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||||
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} />
|
||||||
|
<YAxis tickLine={false} axisLine={false} width={48} />
|
||||||
|
<RechartsTooltip
|
||||||
|
cursor={false}
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent formatter={(v) => formatCurrency(Number(v), baseCurrency)} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="amount" fill="var(--color-amount)" radius={4} />
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardPaymentsChart(props: {
|
||||||
|
payments: Payment[]
|
||||||
|
settings: Settings[]
|
||||||
|
ratesData: RatesData | null
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DashboardMonthlyBarChart
|
||||||
|
{...props}
|
||||||
|
title="Платежи"
|
||||||
|
chartColor="var(--chart-3)"
|
||||||
|
paymentFilter="all"
|
||||||
|
ariaLabel="График платежей по месяцам"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardExpensesChart(props: {
|
||||||
|
payments: Payment[]
|
||||||
|
settings: Settings[]
|
||||||
|
ratesData: RatesData | null
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DashboardMonthlyBarChart
|
||||||
|
{...props}
|
||||||
|
title="Расходы"
|
||||||
|
chartColor="var(--chart-1)"
|
||||||
|
paymentFilter="expense"
|
||||||
|
ariaLabel="График расходов по месяцам"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function MonthlyTrendChart({
|
export function MonthlyTrendChart({
|
||||||
payments,
|
payments,
|
||||||
settings,
|
settings,
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangleIcon />
|
||||||
|
<AlertTitle>Требуется внимание!</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Обнаружено {issuesCount} категорий проблем в инвентаре. Проверьте вкладку «Проблемы».
|
||||||
|
</AlertDescription>
|
||||||
|
<AlertAction>
|
||||||
|
<Button variant="destructive" size="sm" onClick={onGoToIssues}>
|
||||||
|
К проблемам
|
||||||
|
</Button>
|
||||||
|
</AlertAction>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -26,9 +26,16 @@ function sectionGridClass(count: number): string {
|
|||||||
if (count === 3) return 'sm:grid-cols-2 lg:grid-cols-3'
|
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 === 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 === 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'
|
return 'sm:grid-cols-2 lg:grid-cols-3'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALUE_VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
|
||||||
|
default: '',
|
||||||
|
warning: 'text-warning-foreground',
|
||||||
|
destructive: 'text-destructive',
|
||||||
|
}
|
||||||
|
|
||||||
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
|
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('grid gap-3', sectionGridClass(items.length), className)}>
|
<div className={cn('grid gap-3', sectionGridClass(items.length), className)}>
|
||||||
@@ -51,7 +58,14 @@ export function SectionCards({ items, className }: { items: SectionCardItem[]; c
|
|||||||
{item.badge ? <span className="shrink-0">{item.badge}</span> : null}
|
{item.badge ? <span className="shrink-0">{item.badge}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 items-baseline gap-1.5">
|
<div className="flex min-w-0 items-baseline gap-1.5">
|
||||||
<span className="text-lg font-semibold tabular-nums">{item.value}</span>
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 text-lg font-semibold tabular-nums',
|
||||||
|
VALUE_VARIANT_CLASS[item.variant ?? 'default'],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
{item.hint ? (
|
{item.hint ? (
|
||||||
typeof item.hint === 'string' ? (
|
typeof item.hint === 'string' ? (
|
||||||
<TruncatedText className="text-xs text-muted-foreground">· {item.hint}</TruncatedText>
|
<TruncatedText className="text-xs text-muted-foreground">· {item.hint}</TruncatedText>
|
||||||
|
|||||||
@@ -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<number>()
|
||||||
|
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),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
AlertTriangleIcon,
|
AlertTriangleIcon,
|
||||||
@@ -11,8 +12,6 @@ import {
|
|||||||
FolderKanbanIcon,
|
FolderKanbanIcon,
|
||||||
CoinsIcon,
|
CoinsIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
RefreshCwIcon,
|
|
||||||
BarChart3Icon,
|
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
@@ -28,7 +27,6 @@ import { dataGridCellStack } from '@/components/data-grid-cells'
|
|||||||
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
|
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { Badge } from '@cfdm/ui/components/badge'
|
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
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 { buildAtRiskAccounts, type AtRiskAccount } from '@/lib/account-health'
|
||||||
import { formatInBaseCurrency, normalizeRatesPayload, vpsStatusLabel } from '@/lib/format'
|
import { formatInBaseCurrency, normalizeRatesPayload, vpsStatusLabel } from '@/lib/format'
|
||||||
import { exportActiveVpsCsv } from '@/lib/export-csv'
|
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'
|
import type { Vps } from '@/types/entities'
|
||||||
|
|
||||||
@@ -57,6 +60,8 @@ type InventoryIssue = { key: string; title: string; count: number; to: string; h
|
|||||||
|
|
||||||
function DashboardPage() {
|
function DashboardPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const tabsRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [activeTab, setActiveTab] = useState('issues')
|
||||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||||
const { data: stats, isLoading: statsLoading } = useQuery(dashboardStatsQueryOptions())
|
const { data: stats, isLoading: statsLoading } = useQuery(dashboardStatsQueryOptions())
|
||||||
const settings = snapshot?.settings?.[0]
|
const settings = snapshot?.settings?.[0]
|
||||||
@@ -68,18 +73,6 @@ function DashboardPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Дашборд"
|
title="Дашборд"
|
||||||
description="Сводка по VPS, балансам и здоровью инвентаря"
|
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
|
<QueryState
|
||||||
@@ -202,8 +195,20 @@ function DashboardPage() {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const activeCount = stats?.activeVpsCount ?? activeVps.length
|
||||||
|
const totalCount = stats?.totalVpsCount ?? snap.vps.length
|
||||||
|
const expiringCount = stats?.expiringWithin7Days ?? 0
|
||||||
|
const issuesCount = issues.length
|
||||||
|
|
||||||
|
const handleGoToIssues = () => {
|
||||||
|
setActiveTab('issues')
|
||||||
|
tabsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 md:gap-6">
|
<div className="flex flex-col gap-4 md:gap-6">
|
||||||
|
<DashboardInventoryAlert issuesCount={issuesCount} onGoToIssues={handleGoToIssues} />
|
||||||
|
|
||||||
{statsLoading ? (
|
{statsLoading ? (
|
||||||
<SectionCardsSkeleton count={6} />
|
<SectionCardsSkeleton count={6} />
|
||||||
) : (
|
) : (
|
||||||
@@ -211,13 +216,12 @@ function DashboardPage() {
|
|||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
label: 'Активные VPS',
|
label: 'Активные VPS',
|
||||||
value: stats?.activeVpsCount ?? activeVps.length,
|
value: `${activeCount} из ${totalCount}`,
|
||||||
icon: <ServerIcon className="size-4" />,
|
icon: <ServerIcon className="size-4" />,
|
||||||
hint: `всего ${stats?.totalVpsCount ?? snap.vps.length}`,
|
|
||||||
onClick: () => navigate({ to: '/vps' }),
|
onClick: () => navigate({ to: '/vps' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Расход/мес',
|
label: 'Расход в месяц',
|
||||||
value: formatInBaseCurrency(
|
value: formatInBaseCurrency(
|
||||||
stats?.monthlyBurnEstimate ?? 0,
|
stats?.monthlyBurnEstimate ?? 0,
|
||||||
baseCur,
|
baseCur,
|
||||||
@@ -239,7 +243,7 @@ function DashboardPage() {
|
|||||||
onClick: () => navigate({ to: '/accounts' }),
|
onClick: () => navigate({ to: '/accounts' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Runway (мин.)',
|
label: 'Runway',
|
||||||
value: stats?.minRunwayDays != null ? `${stats.minRunwayDays} дн` : '—',
|
value: stats?.minRunwayDays != null ? `${stats.minRunwayDays} дн` : '—',
|
||||||
icon: <ClockIcon className="size-4" />,
|
icon: <ClockIcon className="size-4" />,
|
||||||
variant: stats?.minRunwayDays != null && stats.minRunwayDays < 14 ? 'warning' : 'default',
|
variant: stats?.minRunwayDays != null && stats.minRunwayDays < 14 ? 'warning' : 'default',
|
||||||
@@ -253,55 +257,59 @@ function DashboardPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Истекает 7 дн',
|
label: 'Истекает 7 дн',
|
||||||
value: stats?.expiringWithin7Days ?? 0,
|
value:
|
||||||
|
expiringCount > 0 ? (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{expiringCount}
|
||||||
|
<AlertTriangleIcon className="size-4" />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
expiringCount
|
||||||
|
),
|
||||||
icon: <AlertTriangleIcon className="size-4" />,
|
icon: <AlertTriangleIcon className="size-4" />,
|
||||||
variant: (stats?.expiringWithin7Days ?? 0) > 0 ? 'warning' : 'default',
|
variant: expiringCount > 0 ? 'warning' : 'default',
|
||||||
badge:
|
|
||||||
(stats?.expiringWithin7Days ?? 0) > 0 ? (
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
внимание
|
|
||||||
</Badge>
|
|
||||||
) : undefined,
|
|
||||||
onClick: () => navigate({ to: '/vps', search: { health: 'expiring-soon' } }),
|
onClick: () => navigate({ to: '/vps', search: { health: 'expiring-soon' } }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Проблемы',
|
label: 'Проблемы',
|
||||||
value: issues.length,
|
value:
|
||||||
|
issuesCount > 0 ? (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{issuesCount}
|
||||||
|
<AlertTriangleIcon className="size-4" />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
issuesCount
|
||||||
|
),
|
||||||
icon: <HashIcon className="size-4" />,
|
icon: <HashIcon className="size-4" />,
|
||||||
variant: issues.length > 0 ? 'destructive' : 'default',
|
variant: issuesCount > 0 ? 'destructive' : 'default',
|
||||||
|
onClick: handleGoToIssues,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{issues.length > 0 ? (
|
<ChartsGrid>
|
||||||
<Alert variant="destructive">
|
<DashboardPaymentsChart
|
||||||
<AlertTriangleIcon />
|
|
||||||
<AlertTitle>Требует внимания</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Обнаружено {issues.length} категорий проблем в инвентаре. Проверьте вкладку «Проблемы».
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
|
||||||
<MonthlyTrendChart
|
|
||||||
payments={snap.payments}
|
payments={snap.payments}
|
||||||
settings={snap.settings}
|
settings={snap.settings}
|
||||||
ratesData={ratesData}
|
ratesData={ratesData}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
/>
|
/>
|
||||||
<MonthlyExpenseChart
|
<DashboardExpensesChart
|
||||||
vps={snap.vps}
|
payments={snap.payments}
|
||||||
providers={snap.providers}
|
|
||||||
providerAccounts={snap.providerAccounts}
|
|
||||||
settings={snap.settings}
|
settings={snap.settings}
|
||||||
ratesData={ratesData}
|
ratesData={ratesData}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</ChartsGrid>
|
||||||
|
|
||||||
<Tabs defaultValue="issues" className="flex w-full flex-col gap-4">
|
<div ref={tabsRef}>
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={setActiveTab}
|
||||||
|
className="flex w-full flex-col gap-4"
|
||||||
|
>
|
||||||
<TabsList variant="line" className="mb-0 h-auto w-fit gap-1 border-b border-border p-0">
|
<TabsList variant="line" className="mb-0 h-auto w-fit gap-1 border-b border-border p-0">
|
||||||
<TabsTrigger value="issues" className={cn(DASHBOARD_TAB_TRIGGER_CLASS, 'gap-2')}>
|
<TabsTrigger value="issues" className={cn(DASHBOARD_TAB_TRIGGER_CLASS, 'gap-2')}>
|
||||||
Проблемы
|
Проблемы
|
||||||
@@ -360,6 +368,7 @@ function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="outline" size="sm" render={<Link to="/resources" />}>
|
<Button variant="outline" size="sm" render={<Link to="/resources" />}>
|
||||||
|
|||||||
Reference in New Issue
Block a user