Учитывать списания из журнала баланса и не-пополнения; при отсутствии истории показывать оценку по активным VPS. Год по умолчанию — текущий. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -30,11 +30,13 @@ import { useMemo, useState } from 'react'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import {
|
||||
aggregatePaymentsByMonthYear,
|
||||
aggregateDashboardExpensesByMonthYear,
|
||||
availablePaymentYears,
|
||||
availableExpenseYears,
|
||||
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, BalanceLedgerRow } from '@/types/entities'
|
||||
import {
|
||||
canonicalPaymentType,
|
||||
convertCurrency,
|
||||
@@ -216,9 +218,10 @@ function DashboardMonthlyBarChart({
|
||||
}) {
|
||||
const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
|
||||
const years = useMemo(() => availablePaymentYears(payments), [payments])
|
||||
const [year, setYear] = useState(() => years[0] ?? new Date().getFullYear())
|
||||
const currentYear = new Date().getFullYear()
|
||||
const [year, setYear] = useState(currentYear)
|
||||
|
||||
const effectiveYear = years.includes(year) ? year : (years[0] ?? year)
|
||||
const effectiveYear = years.includes(year) ? year : currentYear
|
||||
|
||||
const data = useMemo(
|
||||
() => aggregatePaymentsByMonthYear(payments, effectiveYear, settings, ratesData, paymentFilter),
|
||||
@@ -302,20 +305,106 @@ export function DashboardPaymentsChart(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardExpensesChart(props: {
|
||||
export function DashboardExpensesChart({
|
||||
payments,
|
||||
balanceLedger,
|
||||
vps,
|
||||
providers,
|
||||
settings,
|
||||
ratesData,
|
||||
className,
|
||||
}: {
|
||||
payments: Payment[]
|
||||
balanceLedger: BalanceLedgerRow[]
|
||||
vps: Vps[]
|
||||
providers: Provider[]
|
||||
settings: Settings[]
|
||||
ratesData: RatesData | null
|
||||
className?: string
|
||||
}) {
|
||||
const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
|
||||
const years = useMemo(
|
||||
() => availableExpenseYears(payments, balanceLedger),
|
||||
[payments, balanceLedger],
|
||||
)
|
||||
const currentYear = new Date().getFullYear()
|
||||
const [year, setYear] = useState(currentYear)
|
||||
|
||||
const effectiveYear = years.includes(year) ? year : currentYear
|
||||
|
||||
const { rows: data, mode } = useMemo(
|
||||
() =>
|
||||
aggregateDashboardExpensesByMonthYear(
|
||||
payments,
|
||||
balanceLedger,
|
||||
vps,
|
||||
providers,
|
||||
effectiveYear,
|
||||
settings,
|
||||
ratesData,
|
||||
),
|
||||
[payments, balanceLedger, vps, providers, effectiveYear, settings, ratesData],
|
||||
)
|
||||
|
||||
const chartConfig: ChartConfig = useMemo(
|
||||
() => ({
|
||||
amount: { label: 'Расходы', color: 'var(--chart-1)' },
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
const hasData = data.some((row) => row.amount > 0)
|
||||
const description =
|
||||
mode === 'estimate' ? 'Оценка по активным VPS за текущий год' : 'Последние 12 мес'
|
||||
|
||||
return (
|
||||
<DashboardMonthlyBarChart
|
||||
{...props}
|
||||
title="Расходы"
|
||||
chartColor="var(--chart-1)"
|
||||
paymentFilter="expense"
|
||||
ariaLabel="График расходов по месяцам"
|
||||
/>
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Расходы</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="График расходов по месяцам">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
aggregateDashboardExpensesByMonthYear,
|
||||
aggregatePaymentsByMonthYear,
|
||||
isExpensePayment,
|
||||
} from './chart-analytics'
|
||||
import type { Payment, Settings, Vps } from '@/types/entities'
|
||||
|
||||
const settings: Settings[] = [{ id: 's1', baseCurrency: 'RUB' }]
|
||||
|
||||
const activeVps: Vps = {
|
||||
id: 'v1',
|
||||
ip: '1.2.3.4',
|
||||
providerId: 'pr1',
|
||||
providerAccountId: 'a1',
|
||||
vcpu: 1,
|
||||
ramGb: 1,
|
||||
diskGb: 10,
|
||||
status: 'active',
|
||||
tariffType: 'monthly',
|
||||
currency: 'RUB',
|
||||
dailyRate: null,
|
||||
monthlyRate: 1500,
|
||||
createdAt: '2026-01-01',
|
||||
}
|
||||
|
||||
describe('chart-analytics', () => {
|
||||
it('treats topups as income, not expense', () => {
|
||||
expect(isExpensePayment('provider_balance_topup')).toBe(false)
|
||||
expect(isExpensePayment('direct_vps_payment')).toBe(true)
|
||||
})
|
||||
|
||||
it('aggregates topups only in payments chart', () => {
|
||||
const payments: Payment[] = [
|
||||
{
|
||||
id: 'p1',
|
||||
type: 'provider_balance_topup',
|
||||
date: '2026-03-15',
|
||||
amount: 1000,
|
||||
currency: 'RUB',
|
||||
providerAccountId: 'a1',
|
||||
vpsId: null,
|
||||
note: '',
|
||||
},
|
||||
]
|
||||
|
||||
const all = aggregatePaymentsByMonthYear(payments, 2026, settings, null, 'all')
|
||||
const expenseOnly = aggregatePaymentsByMonthYear(payments, 2026, settings, null, 'expense')
|
||||
|
||||
expect(all[2]?.amount).toBe(1000)
|
||||
expect(expenseOnly[2]?.amount).toBe(0)
|
||||
})
|
||||
|
||||
it('falls back to VPS burn estimate when no expense records exist', () => {
|
||||
const payments: Payment[] = [
|
||||
{
|
||||
id: 'p1',
|
||||
type: 'provider_balance_topup',
|
||||
date: '2026-03-15',
|
||||
amount: 1000,
|
||||
currency: 'RUB',
|
||||
providerAccountId: 'a1',
|
||||
vpsId: null,
|
||||
note: '',
|
||||
},
|
||||
]
|
||||
|
||||
const { rows, mode } = aggregateDashboardExpensesByMonthYear(
|
||||
payments,
|
||||
[],
|
||||
[activeVps],
|
||||
[],
|
||||
2026,
|
||||
settings,
|
||||
null,
|
||||
)
|
||||
|
||||
expect(mode).toBe('estimate')
|
||||
expect(rows[2]?.amount).toBe(1500)
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,21 @@
|
||||
import type { Payment, Settings, RatesData } from '@/types/entities'
|
||||
import { canonicalPaymentType, convertCurrency, monthKey, toIsoCurrency } from '@/lib/format'
|
||||
import type {
|
||||
Payment,
|
||||
Settings,
|
||||
RatesData,
|
||||
Vps,
|
||||
Provider,
|
||||
BalanceLedgerRow,
|
||||
} from '@/types/entities'
|
||||
import {
|
||||
canonicalPaymentType,
|
||||
convertCurrency,
|
||||
convertVpsMonthlyBurnToBase,
|
||||
monthKey,
|
||||
toIsoCurrency,
|
||||
} from '@/lib/format'
|
||||
import { providerByIdMap } from '@/lib/billmanager'
|
||||
|
||||
export const EXPENSE_PAYMENT_TYPES = new Set([
|
||||
'direct_vps_payment',
|
||||
'daily_debit',
|
||||
'monthly_debit',
|
||||
])
|
||||
const INCOME_PAYMENT_TYPES = new Set(['provider_balance_topup'])
|
||||
|
||||
const MONTH_SHORT_RU = [
|
||||
'янв',
|
||||
@@ -24,38 +34,35 @@ const MONTH_SHORT_RU = [
|
||||
|
||||
export type PaymentChartFilter = 'all' | 'expense'
|
||||
|
||||
export type DashboardExpenseChartMode = 'actual' | 'estimate'
|
||||
|
||||
export function formatMonthShortRu(monthIndex: number): string {
|
||||
return MONTH_SHORT_RU[monthIndex] ?? ''
|
||||
}
|
||||
|
||||
export function isExpensePayment(type: string): boolean {
|
||||
return EXPENSE_PAYMENT_TYPES.has(canonicalPaymentType(type))
|
||||
return !INCOME_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)
|
||||
function yearFromDateString(dateString: string): number | null {
|
||||
const key = monthKey(dateString)
|
||||
if (!key) return null
|
||||
const year = Number(key.slice(0, 4))
|
||||
return Number.isFinite(year) ? year : null
|
||||
}
|
||||
|
||||
export function aggregatePaymentsByMonthYear(
|
||||
function monthBucketsFromPayments(
|
||||
payments: Payment[],
|
||||
year: number,
|
||||
settings: Settings[],
|
||||
ratesData: RatesData | null,
|
||||
filter: PaymentChartFilter = 'all',
|
||||
): { month: string; amount: number }[] {
|
||||
includePayment: (type: string) => boolean,
|
||||
): 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
|
||||
if (!includePayment(p.type)) continue
|
||||
const date = new Date(p.date)
|
||||
if (Number.isNaN(date.getTime()) || date.getFullYear() !== year) continue
|
||||
const converted = convertCurrency(
|
||||
@@ -67,8 +74,147 @@ export function aggregatePaymentsByMonthYear(
|
||||
byMonth[date.getMonth()]! += converted
|
||||
}
|
||||
|
||||
return byMonth.map((amount, index) => ({
|
||||
return byMonth
|
||||
}
|
||||
|
||||
function monthBucketsFromLedgerDebits(
|
||||
balanceLedger: BalanceLedgerRow[],
|
||||
year: number,
|
||||
settings: Settings[],
|
||||
ratesData: RatesData | null,
|
||||
): number[] {
|
||||
const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
|
||||
const byMonth = Array.from({ length: 12 }, () => 0)
|
||||
|
||||
for (const row of balanceLedger) {
|
||||
if (row.direction !== 'debit') continue
|
||||
const date = new Date(row.date)
|
||||
if (Number.isNaN(date.getTime()) || date.getFullYear() !== year) continue
|
||||
const converted = convertCurrency(
|
||||
Number(row.amount),
|
||||
toIsoCurrency(row.currency ?? baseCurrency),
|
||||
baseCurrency,
|
||||
ratesData,
|
||||
)
|
||||
byMonth[date.getMonth()]! += converted
|
||||
}
|
||||
|
||||
return byMonth
|
||||
}
|
||||
|
||||
function mergeMonthBuckets(...sources: number[][]): number[] {
|
||||
return Array.from({ length: 12 }, (_, index) =>
|
||||
sources.reduce((sum, buckets) => sum + (buckets[index] ?? 0), 0),
|
||||
)
|
||||
}
|
||||
|
||||
function bucketsToRows(buckets: number[]): { month: string; amount: number }[] {
|
||||
return buckets.map((amount, index) => ({
|
||||
month: formatMonthShortRu(index),
|
||||
amount: Math.round(amount),
|
||||
}))
|
||||
}
|
||||
|
||||
export function availablePaymentYears(payments: Payment[]): number[] {
|
||||
const years = new Set<number>()
|
||||
for (const p of payments) {
|
||||
const year = yearFromDateString(p.date)
|
||||
if (year != null) years.add(year)
|
||||
}
|
||||
years.add(new Date().getFullYear())
|
||||
return Array.from(years).sort((a, b) => b - a)
|
||||
}
|
||||
|
||||
export function availableExpenseYears(
|
||||
payments: Payment[],
|
||||
balanceLedger: BalanceLedgerRow[],
|
||||
): number[] {
|
||||
const years = new Set<number>()
|
||||
|
||||
for (const p of payments) {
|
||||
if (!isExpensePayment(p.type)) continue
|
||||
const year = yearFromDateString(p.date)
|
||||
if (year != null) years.add(year)
|
||||
}
|
||||
|
||||
for (const row of balanceLedger) {
|
||||
if (row.direction !== 'debit') continue
|
||||
const year = yearFromDateString(row.date)
|
||||
if (year != null) 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 includePayment = filter === 'expense' ? isExpensePayment : () => true
|
||||
return bucketsToRows(
|
||||
monthBucketsFromPayments(payments, year, settings, ratesData, includePayment),
|
||||
)
|
||||
}
|
||||
|
||||
export function aggregateEstimatedVpsBurnByMonthYear(
|
||||
vps: Vps[],
|
||||
providers: Provider[],
|
||||
year: number,
|
||||
settings: Settings[],
|
||||
ratesData: RatesData | null,
|
||||
): { month: string; amount: number }[] {
|
||||
const providerById = providerByIdMap(providers)
|
||||
const monthlyBurn = vps
|
||||
.filter((item) => item.status === 'active')
|
||||
.reduce(
|
||||
(sum, item) =>
|
||||
sum +
|
||||
convertVpsMonthlyBurnToBase(item, providerById.get(item.providerId), settings, ratesData),
|
||||
0,
|
||||
)
|
||||
|
||||
const now = new Date()
|
||||
const rounded = Math.round(monthlyBurn)
|
||||
|
||||
return Array.from({ length: 12 }, (_, index) => ({
|
||||
month: formatMonthShortRu(index),
|
||||
amount: year === now.getFullYear() && index <= now.getMonth() ? rounded : 0,
|
||||
}))
|
||||
}
|
||||
|
||||
export function aggregateDashboardExpensesByMonthYear(
|
||||
payments: Payment[],
|
||||
balanceLedger: BalanceLedgerRow[],
|
||||
vps: Vps[],
|
||||
providers: Provider[],
|
||||
year: number,
|
||||
settings: Settings[],
|
||||
ratesData: RatesData | null,
|
||||
): { rows: { month: string; amount: number }[]; mode: DashboardExpenseChartMode } {
|
||||
const combined = mergeMonthBuckets(
|
||||
monthBucketsFromPayments(payments, year, settings, ratesData, isExpensePayment),
|
||||
monthBucketsFromLedgerDebits(balanceLedger, year, settings, ratesData),
|
||||
)
|
||||
const rows = bucketsToRows(combined)
|
||||
|
||||
if (rows.some((row) => row.amount > 0)) {
|
||||
return { rows, mode: 'actual' }
|
||||
}
|
||||
|
||||
const estimated = aggregateEstimatedVpsBurnByMonthYear(
|
||||
vps,
|
||||
providers,
|
||||
year,
|
||||
settings,
|
||||
ratesData,
|
||||
)
|
||||
if (estimated.some((row) => row.amount > 0)) {
|
||||
return { rows: estimated, mode: 'estimate' }
|
||||
}
|
||||
|
||||
return { rows, mode: 'actual' }
|
||||
}
|
||||
|
||||
@@ -298,6 +298,9 @@ function DashboardPage() {
|
||||
/>
|
||||
<DashboardExpensesChart
|
||||
payments={snap.payments}
|
||||
balanceLedger={snap.balanceLedger ?? []}
|
||||
vps={snap.vps}
|
||||
providers={snap.providers}
|
||||
settings={snap.settings}
|
||||
ratesData={ratesData}
|
||||
className="h-full"
|
||||
|
||||
Reference in New Issue
Block a user