diff --git a/src/components/finance/FinanceAnalytics.jsx b/src/components/finance/FinanceAnalytics.jsx index 1dc597d..3383c5f 100644 --- a/src/components/finance/FinanceAnalytics.jsx +++ b/src/components/finance/FinanceAnalytics.jsx @@ -1,32 +1,83 @@ import { useState, useEffect } from "react" import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, - PieChart, Pie, Cell, LineChart, Line, + PieChart, Pie, Cell, LineChart, Line, LabelList, } from "recharts" import { financeApi } from "../../api/finance" -const COLORS = [ - "#0D4F4F", "#F7B538", "#6366f1", "#22c55e", "#ef4444", - "#8b5cf6", "#0ea5e9", "#f97316", "#ec4899", "#14b8a6", - "#64748b", "#a855f7", "#78716c", +const CATEGORY_COLORS = { + "Жильё": "#ef4444", + "Еда": "#f97316", + "Транспорт": "#6366f1", + "Одежда": "#8b5cf6", + "Здоровье": "#22c55e", + "Развлечения": "#ec4899", + "Связь / Подписки": "#0ea5e9", + "Путешествия": "#14b8a6", + "Подарки": "#a855f7", + "Бытовое": "#64748b", + "Маркетплейсы": "#F7B538", + "Накопления": "#0D4F4F", + "Другое": "#78716c", + "Красота": "#f472b6", +} + +const COLORS_FALLBACK = [ + "#6366f1", "#22c55e", "#ef4444", "#f97316", "#8b5cf6", + "#0ea5e9", "#ec4899", "#14b8a6", "#64748b", "#a855f7", ] +const getColor = (name, i) => { + const clean = name.replace(/^[\p{Emoji}\s]+/u, "").trim() + return CATEGORY_COLORS[clean] || COLORS_FALLBACK[i % COLORS_FALLBACK.length] +} + const fmt = (n) => Number(n).toLocaleString("ru-RU") + " ₽" +const fmtShort = (n) => { + if (n >= 1000) return Math.round(n / 1000).toLocaleString("ru-RU") + "к" + return n.toLocaleString("ru-RU") +} const MONTH_NAMES = [ "", "Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек", ] +const SAVINGS_NAMES = ["накопления"] + +const CustomTooltip = ({ active, payload, label }) => { + if (!active || !payload?.length) return null + return ( +
+ {payload.map((p, i) => ( +
+ {p.name || label}: + {fmt(p.value)} +
+ ))} +
+ ) +} + +const BarLabel = ({ x, y, width, height, value }) => { + if (!value) return null + return ( + + {fmtShort(value)} + + ) +} + export default function FinanceAnalytics({ month, year }) { const [analytics, setAnalytics] = useState(null) const [summary, setSummary] = useState(null) const [loading, setLoading] = useState(true) + const [showSavings, setShowSavings] = useState(false) useEffect(() => { setLoading(true) Promise.all([ - financeApi.getAnalytics({ months: 6 }), + financeApi.getAnalytics({ months: 6, month, year }), financeApi.getSummary({ month, year }), ]) .then(([a, s]) => { @@ -51,15 +102,33 @@ export default function FinanceAnalytics({ month, year }) { if (!analytics || !summary) return null - const expenseCategories = (summary.by_category || []).filter( + const allExpenseCategories = (summary.by_category || []).filter( (c) => c.type === "expense" ) - const barData = expenseCategories.map((c) => ({ - name: c.category_emoji + " " + c.category_name, - value: c.amount, - })) + const isSavings = (c) => + SAVINGS_NAMES.includes(c.category_name?.toLowerCase?.()) + + const expenseCategories = showSavings + ? allExpenseCategories + : allExpenseCategories.filter((c) => !isSavings(c)) + + const hasSavings = allExpenseCategories.some(isSavings) + + const barData = expenseCategories + .sort((a, b) => b.amount - a.amount) + .map((c, i) => ({ + name: (c.category_emoji || "") + " " + c.category_name, + shortName: c.category_name?.length > 10 + ? c.category_name.slice(0, 10) + "…" + : c.category_name, + value: c.amount, + fill: getColor(c.category_name, i), + })) + const pieData = barData + const filteredTotal = expenseCategories.reduce((s, c) => s + c.amount, 0) + const monthlyData = (analytics.monthly_trend || []).map((m) => ({ month: MONTH_NAMES[parseInt(m.month.slice(5))] || m.month, income: m.income, @@ -67,8 +136,10 @@ export default function FinanceAnalytics({ month, year }) { })) const comp = analytics.comparison_prev_month - const diffPct = Math.abs(Math.round(comp.diff_percent)) - const isUp = comp.diff_percent > 0 + const diffPct = Math.abs(Math.round(comp?.diff_percent || 0)) + const isUp = (comp?.diff_percent || 0) > 0 + + const barChartHeight = Math.max(400, barData.length * 44) return (
@@ -98,30 +169,41 @@ export default function FinanceAnalytics({ month, year }) { {barData.length > 0 && (
-

- Расходы по категориям -

-
+
+

+ Расходы по категориям +

+ {hasSavings && ( + + )} +
+
- + v / 1000 + "к"} + tickFormatter={fmtShort} /> - fmt(v)} /> - - {barData.map((_, i) => ( - + } /> + + {barData.map((entry, i) => ( + ))} + } /> @@ -145,29 +227,28 @@ export default function FinanceAnalytics({ month, year }) { dataKey="value" stroke="none" > - {pieData.map((_, i) => ( - + {pieData.map((entry, i) => ( + ))} - fmt(v)} /> + } />
-
+
{pieData.map((c, i) => (
- + {c.name} - + {Math.round( - (c.value / summary.total_expense) * 100 - )} - % + (c.value / filteredTotal) * 100 + )}%
))} @@ -192,9 +273,9 @@ export default function FinanceAnalytics({ month, year }) { v / 1000 + "к"} + tickFormatter={fmtShort} /> - fmt(v)} /> + } />