Files
pulse-web/src/components/finance/FinanceAnalytics.jsx
Cosmo 72915aa6c4
All checks were successful
CI / ci (push) Successful in 40s
feat: add month switcher to Finance page - fix transactions not showing
2026-03-01 05:02:23 +00:00

232 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from "react"
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
PieChart, Pie, Cell, LineChart, Line,
} from "recharts"
import { financeApi } from "../../api/finance"
const COLORS = [
"#0D4F4F", "#F7B538", "#6366f1", "#22c55e", "#ef4444",
"#8b5cf6", "#0ea5e9", "#f97316", "#ec4899", "#14b8a6",
"#64748b", "#a855f7", "#78716c",
]
const fmt = (n) => Number(n).toLocaleString("ru-RU") + " ₽"
const MONTH_NAMES = [
"", "Янв", "Фев", "Мар", "Апр", "Май", "Июн",
"Июл", "Авг", "Сен", "Окт", "Ноя", "Дек",
]
export default function FinanceAnalytics({ month, year }) {
const [analytics, setAnalytics] = useState(null)
const [summary, setSummary] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true)
Promise.all([
financeApi.getAnalytics({ months: 6 }),
financeApi.getSummary({ month, year }),
])
.then(([a, s]) => {
setAnalytics(a)
setSummary(s)
})
.catch(console.error)
.finally(() => setLoading(false))
}, [month, year])
if (loading) {
return (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="card p-8 animate-pulse">
<div className="h-40 bg-gray-200 dark:bg-gray-800 rounded" />
</div>
))}
</div>
)
}
if (!analytics || !summary) return null
const expenseCategories = (summary.by_category || []).filter(
(c) => c.type === "expense"
)
const barData = expenseCategories.map((c) => ({
name: c.category_emoji + " " + c.category_name,
value: c.amount,
}))
const pieData = barData
const monthlyData = (analytics.monthly_trend || []).map((m) => ({
month: MONTH_NAMES[parseInt(m.month.slice(5))] || m.month,
income: m.income,
expense: m.expense,
}))
const comp = analytics.comparison_prev_month
const diffPct = Math.abs(Math.round(comp.diff_percent))
const isUp = comp.diff_percent > 0
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-3">
<div className="card p-4">
<p className="text-xs text-gray-500 dark:text-gray-400">
Всего расходов
</p>
<p className="text-xl font-bold text-gray-900 dark:text-white">
{fmt(summary.total_expense)}
</p>
<p
className={`text-xs mt-1 ${isUp ? "text-red-500" : "text-green-500"}`}
>
{isUp ? "↑" : "↓"} {diffPct}% vs пред. месяц
</p>
</div>
<div className="card p-4">
<p className="text-xs text-gray-500 dark:text-gray-400">
В среднем / день
</p>
<p className="text-xl font-bold text-gray-900 dark:text-white">
{fmt(Math.round(analytics.avg_daily_expense))}
</p>
</div>
</div>
{barData.length > 0 && (
<div className="card p-5">
<h3 className="font-display font-bold text-gray-900 dark:text-white mb-4">
Расходы по категориям
</h3>
<div className="h-64">
<ResponsiveContainer>
<BarChart data={barData} layout="vertical" margin={{ left: 10 }}>
<XAxis
type="number"
tick={{ fontSize: 10 }}
stroke="#94a3b8"
tickFormatter={(v) => v / 1000 + "к"}
/>
<YAxis
type="category"
dataKey="name"
tick={{ fontSize: 11 }}
stroke="#94a3b8"
width={120}
/>
<Tooltip formatter={(v) => fmt(v)} />
<Bar dataKey="value" radius={[0, 6, 6, 0]}>
{barData.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
)}
{pieData.length > 0 && (
<div className="card p-5">
<h3 className="font-display font-bold text-gray-900 dark:text-white mb-4">
Доля категорий
</h3>
<div className="flex items-start gap-4">
<div className="w-44 h-44 flex-shrink-0">
<ResponsiveContainer>
<PieChart>
<Pie
data={pieData}
innerRadius={45}
outerRadius={75}
dataKey="value"
stroke="none"
>
{pieData.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(v) => fmt(v)} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="space-y-1.5 pt-2">
{pieData.map((c, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<div
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: COLORS[i] }}
/>
<span className="text-gray-600 dark:text-gray-400 truncate max-w-[100px]">
{c.name}
</span>
<span className="ml-auto font-semibold text-gray-900 dark:text-white">
{Math.round(
(c.value / summary.total_expense) * 100
)}
%
</span>
</div>
))}
</div>
</div>
</div>
)}
{monthlyData.length > 0 && (
<div className="card p-5">
<h3 className="font-display font-bold text-gray-900 dark:text-white mb-4">
Тренд по месяцам
</h3>
<div className="h-48">
<ResponsiveContainer>
<LineChart data={monthlyData}>
<XAxis
dataKey="month"
tick={{ fontSize: 12 }}
stroke="#94a3b8"
/>
<YAxis
tick={{ fontSize: 10 }}
stroke="#94a3b8"
tickFormatter={(v) => v / 1000 + "к"}
/>
<Tooltip formatter={(v) => fmt(v)} />
<Line
type="monotone"
dataKey="income"
stroke="#22c55e"
strokeWidth={2}
name="Доходы"
dot={{ r: 3 }}
/>
<Line
type="monotone"
dataKey="expense"
stroke="#ef4444"
strokeWidth={2}
name="Расходы"
dot={{ r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div className="flex justify-center gap-6 mt-3">
<div className="flex items-center gap-2 text-xs">
<div className="w-3 h-0.5 bg-green-500" />
<span className="text-gray-500">Доходы</span>
</div>
<div className="flex items-center gap-2 text-xs">
<div className="w-3 h-0.5 bg-red-500" />
<span className="text-gray-500">Расходы</span>
</div>
</div>
</div>
)}
</div>
)
}