diff --git a/src/App.jsx b/src/App.jsx index 3a2c923..5f817ee 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -12,6 +12,7 @@ import ResetPassword from "./pages/ResetPassword" import ForgotPassword from "./pages/ForgotPassword" import Stats from "./pages/Stats" import Settings from "./pages/Settings" +import Finance from "./pages/Finance" function ProtectedRoute({ children }) { const { isAuthenticated, isLoading } = useAuthStore() @@ -124,6 +125,14 @@ export default function App() { } /> + + + + } + /> { + const res = await client.get('finance/categories') + return res.data + }, + createCategory: async (data) => { + const res = await client.post('finance/categories', data) + return res.data + }, + updateCategory: async (id, data) => { + const res = await client.put(`finance/categories/${id}`, data) + return res.data + }, + deleteCategory: async (id) => { + await client.delete(`finance/categories/${id}`) + }, + + // Transactions + listTransactions: async (params = {}) => { + const res = await client.get('finance/transactions', { params }) + return res.data + }, + createTransaction: async (data) => { + const res = await client.post('finance/transactions', data) + return res.data + }, + updateTransaction: async (id, data) => { + const res = await client.put(`finance/transactions/${id}`, data) + return res.data + }, + deleteTransaction: async (id) => { + await client.delete(`finance/transactions/${id}`) + }, + + // Summary & Analytics + getSummary: async (params = {}) => { + const res = await client.get('finance/summary', { params }) + return res.data + }, + getAnalytics: async (params = {}) => { + const res = await client.get('finance/analytics', { params }) + return res.data + }, +} diff --git a/src/components/Navigation.jsx b/src/components/Navigation.jsx index 0a5f869..fa24bea 100644 --- a/src/components/Navigation.jsx +++ b/src/components/Navigation.jsx @@ -1,5 +1,5 @@ import { NavLink } from "react-router-dom" -import { Home, ListChecks, CheckSquare, BarChart3, PiggyBank, Settings } from "lucide-react" +import { Home, ListChecks, CheckSquare, BarChart3, PiggyBank, Wallet, Settings } from "lucide-react" import clsx from "clsx" export default function Navigation() { @@ -9,6 +9,7 @@ export default function Navigation() { { to: "/tasks", icon: CheckSquare, label: "Задачи" }, { to: "/stats", icon: BarChart3, label: "Статистика" }, { to: "/savings", icon: PiggyBank, label: "Накопления" }, + { to: "/finance", icon: Wallet, label: "Финансы" }, { to: "/settings", icon: Settings, label: "Настройки" }, ] diff --git a/src/components/finance/AddTransactionModal.jsx b/src/components/finance/AddTransactionModal.jsx new file mode 100644 index 0000000..6ac3751 --- /dev/null +++ b/src/components/finance/AddTransactionModal.jsx @@ -0,0 +1,197 @@ +import { useState, useEffect } from "react" +import { financeApi } from "../../api/finance" + +const quickTemplates = [ + { description: "Продукты", categoryName: "Еда", amount: 2000 }, + { description: "Такси", categoryName: "Транспорт", amount: 400 }, + { description: "Кофе", categoryName: "Еда", amount: 350 }, + { description: "Обед", categoryName: "Еда", amount: 600 }, + { description: "Метро", categoryName: "Транспорт", amount: 57 }, +] + +export default function AddTransactionModal({ onClose, onSaved }) { + const [type, setType] = useState("expense") + const [categories, setCategories] = useState([]) + const [categoryId, setCategoryId] = useState(null) + const [amount, setAmount] = useState("") + const [description, setDescription] = useState("") + const [date, setDate] = useState(new Date().toISOString().slice(0, 10)) + const [saving, setSaving] = useState(false) + + useEffect(() => { + financeApi.listCategories().then(setCategories).catch(console.error) + }, []) + + const cats = categories.filter((c) => c.type === type) + + const applyTemplate = (t) => { + setDescription(t.description) + setAmount(String(t.amount)) + const found = categories.find( + (c) => c.name === t.categoryName && c.type === "expense" + ) + if (found) setCategoryId(found.id) + } + + const handleSubmit = async () => { + if (!amount || !categoryId) return + setSaving(true) + try { + await financeApi.createTransaction({ + type, + category_id: categoryId, + amount: parseFloat(amount), + description, + date, + }) + onSaved() + } catch (e) { + console.error(e) + alert("Ошибка при сохранении") + } finally { + setSaving(false) + } + } + + return ( +
+
e.stopPropagation()} + > +
+

+ Новая запись +

+ +
+ {/* Type toggle */} +
+ {[ + ["expense", "Расход"], + ["income", "Доход"], + ].map(([k, l]) => ( + + ))} +
+ + {/* Quick templates */} + {type === "expense" && ( +
+

+ Быстрые шаблоны +

+
+ {quickTemplates.map((t, i) => ( + + ))} +
+
+ )} + + {/* Amount */} +
+ +
+ setAmount(e.target.value)} + className="w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-2xl font-bold text-gray-900 dark:text-white outline-none" + placeholder="0" + /> + + ₽ + +
+
+ + {/* Description */} +
+ + setDescription(e.target.value)} + className="w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm text-gray-900 dark:text-white outline-none" + placeholder="Что купили?" + /> +
+ + {/* Category */} +
+ +
+ {cats.map((c) => ( + + ))} +
+
+ + {/* Date */} +
+ + setDate(e.target.value)} + className="w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm text-gray-900 dark:text-white outline-none" + /> +
+ + {/* Submit */} + +
+
+
+ ) +} diff --git a/src/components/finance/FinanceAnalytics.jsx b/src/components/finance/FinanceAnalytics.jsx new file mode 100644 index 0000000..8f4c14c --- /dev/null +++ b/src/components/finance/FinanceAnalytics.jsx @@ -0,0 +1,235 @@ +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() { + const [analytics, setAnalytics] = useState(null) + const [summary, setSummary] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const now = new Date() + Promise.all([ + financeApi.getAnalytics({ months: 6 }), + financeApi.getSummary({ month: now.getMonth() + 1, year: now.getFullYear() }), + ]) + .then(([a, s]) => { + setAnalytics(a) + setSummary(s) + }) + .catch(console.error) + .finally(() => setLoading(false)) + }, []) + + if (loading) { + return ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+ ))} +
+ ) + } + + 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 ( +
+ {/* Summary cards */} +
+
+

+ Всего расходов +

+

+ {fmt(summary.total_expense)} +

+

+ {isUp ? "↑" : "↓"} {diffPct}% vs пред. месяц +

+
+
+

+ В среднем / день +

+

+ {fmt(Math.round(analytics.avg_daily_expense))} +

+
+
+ + {/* Bar chart */} + {barData.length > 0 && ( +
+

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

+
+ + + v / 1000 + "к"} + /> + + fmt(v)} /> + + {barData.map((_, i) => ( + + ))} + + + +
+
+ )} + + {/* Donut chart */} + {pieData.length > 0 && ( +
+

+ Доля категорий +

+
+
+ + + + {pieData.map((_, i) => ( + + ))} + + fmt(v)} /> + + +
+
+ {pieData.map((c, i) => ( +
+
+ + {c.name} + + + {Math.round( + (c.value / summary.total_expense) * 100 + )} + % + +
+ ))} +
+
+
+ )} + + {/* Monthly trend */} + {monthlyData.length > 0 && ( +
+

+ Тренд по месяцам +

+
+ + + + v / 1000 + "к"} + /> + fmt(v)} /> + + + + +
+
+
+
+ Доходы +
+
+
+ Расходы +
+
+
+ )} +
+ ) +} diff --git a/src/components/finance/FinanceDashboard.jsx b/src/components/finance/FinanceDashboard.jsx new file mode 100644 index 0000000..8b711be --- /dev/null +++ b/src/components/finance/FinanceDashboard.jsx @@ -0,0 +1,201 @@ +import { useState, useEffect } from "react" +import { + PieChart, Pie, Cell, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, +} 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") + " ₽" + +export default function FinanceDashboard() { + const [summary, setSummary] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const now = new Date() + financeApi + .getSummary({ month: now.getMonth() + 1, year: now.getFullYear() }) + .then(setSummary) + .catch(console.error) + .finally(() => setLoading(false)) + }, []) + + if (loading) { + return ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+ ))} +
+ ) + } + + if (!summary || (summary.total_income === 0 && summary.total_expense === 0)) { + return ( +
+ 📊 +

+ Нет данных +

+

+ Добавьте первую транзакцию +

+
+ ) + } + + const expenseCategories = summary.by_category.filter((c) => c.type === "expense") + const pieData = expenseCategories.map((c) => ({ + name: c.category_emoji + " " + c.category_name, + value: c.amount, + })) + const dailyData = summary.daily.map((d) => ({ + day: d.date.slice(8, 10), + amount: d.amount, + })) + + return ( +
+ {/* Balance Card */} +
+

Баланс за месяц

+

{fmt(summary.balance)}

+
+
+

Доходы

+

+ +{fmt(summary.total_income)} +

+
+
+

Расходы

+

+ -{fmt(summary.total_expense)} +

+
+
+
+ + {/* Top Categories */} + {expenseCategories.length > 0 && ( +
+

+ Топ расходов +

+
+ {expenseCategories.slice(0, 5).map((c, i) => ( +
+ + {c.category_emoji} + +
+
+ + {c.category_name} + + + {fmt(c.amount)} + +
+
+
+
+
+
+ ))} +
+
+ )} + + {/* Donut Chart */} + {pieData.length > 0 && ( +
+

+ По категориям +

+
+
+ + + + {pieData.map((_, i) => ( + + ))} + + fmt(v)} /> + + +
+
+ {pieData.slice(0, 6).map((c, i) => ( +
+
+ + {c.name} + + + {Math.round( + (c.value / summary.total_expense) * 100 + )} + % + +
+ ))} +
+
+
+ )} + + {/* Daily Line Chart */} + {dailyData.length > 0 && ( +
+

+ Расходы по дням +

+
+ + + + v / 1000 + "к"} + /> + fmt(v)} /> + + + +
+
+ )} +
+ ) +} diff --git a/src/components/finance/TransactionList.jsx b/src/components/finance/TransactionList.jsx new file mode 100644 index 0000000..b2f90be --- /dev/null +++ b/src/components/finance/TransactionList.jsx @@ -0,0 +1,170 @@ +import { useState, useEffect } from "react" +import { financeApi } from "../../api/finance" + +const fmt = (n) => Number(n).toLocaleString("ru-RU") + " ₽" + +const formatDate = (d) => { + const dt = new Date(d) + return dt.toLocaleDateString("ru-RU", { day: "numeric", month: "long" }) +} + +export default function TransactionList({ onAdd }) { + const [transactions, setTransactions] = useState([]) + const [categories, setCategories] = useState([]) + const [loading, setLoading] = useState(true) + const [filter, setFilter] = useState("all") + const [catFilter, setCatFilter] = useState(null) + const [search, setSearch] = useState("") + + useEffect(() => { + Promise.all([ + financeApi.listCategories(), + financeApi.listTransactions({ + month: new Date().getMonth() + 1, + year: new Date().getFullYear(), + limit: 100, + }), + ]) + .then(([cats, txs]) => { + setCategories(cats || []) + setTransactions(txs || []) + }) + .catch(console.error) + .finally(() => setLoading(false)) + }, []) + + const filtered = transactions.filter((t) => { + if (filter !== "all" && t.type !== filter) return false + if (catFilter && t.category_id !== catFilter) return false + if (search && !t.description.toLowerCase().includes(search.toLowerCase())) + return false + return true + }) + + const grouped = filtered.reduce((acc, t) => { + const d = t.date.slice(0, 10) + ;(acc[d] = acc[d] || []).push(t) + return acc + }, {}) + + const handleDelete = async (id) => { + if (!confirm("Удалить транзакцию?")) return + await financeApi.deleteTransaction(id) + setTransactions((txs) => txs.filter((t) => t.id !== id)) + } + + if (loading) { + return ( +
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+ ))} +
+ ) + } + + return ( +
+ {/* Search */} + setSearch(e.target.value)} + /> + + {/* Type filter */} +
+ {[ + ["all", "Все"], + ["income", "Доходы"], + ["expense", "Расходы"], + ].map(([k, l]) => ( + + ))} +
+ + {/* Category filter */} +
+ + {categories.map((c) => ( + + ))} +
+ + {/* Transaction groups */} + {Object.keys(grouped).length === 0 ? ( +
+ 🔍 +

Ничего не найдено

+
+ ) : ( + Object.entries(grouped).map(([date, txs]) => ( +
+

+ {formatDate(date)} +

+
+ {txs.map((t) => ( +
handleDelete(t.id)} + > + {t.category_emoji} +
+

+ {t.description || t.category_name} +

+

+ {t.category_emoji} {t.category_name} +

+
+ + {t.type === "income" ? "+" : "-"} + {fmt(t.amount)} + +
+ ))} +
+
+ )) + )} +
+ ) +} diff --git a/src/pages/Finance.jsx b/src/pages/Finance.jsx new file mode 100644 index 0000000..8d849d3 --- /dev/null +++ b/src/pages/Finance.jsx @@ -0,0 +1,75 @@ +import { useState } from "react" +import Navigation from "../components/Navigation" +import FinanceDashboard from "../components/finance/FinanceDashboard" +import TransactionList from "../components/finance/TransactionList" +import FinanceAnalytics from "../components/finance/FinanceAnalytics" +import AddTransactionModal from "../components/finance/AddTransactionModal" + +const tabs = [ + { key: "dashboard", label: "Обзор", icon: "📊" }, + { key: "transactions", label: "Транзакции", icon: "📋" }, + { key: "analytics", label: "Аналитика", icon: "📈" }, +] + +export default function Finance() { + const [activeTab, setActiveTab] = useState("dashboard") + const [showAdd, setShowAdd] = useState(false) + const [refreshKey, setRefreshKey] = useState(0) + + const refresh = () => setRefreshKey((k) => k + 1) + + return ( +
+
+
+
+

+ 💰 Финансы +

+
+ +
+
+ {tabs.map((t) => ( + + ))} +
+
+ +
+ {activeTab === "dashboard" && } + {activeTab === "transactions" && ( + setShowAdd(true)} /> + )} + {activeTab === "analytics" && } +
+ + {showAdd && ( + setShowAdd(false)} + onSaved={() => { + setShowAdd(false) + refresh() + }} + /> + )} + + +
+ ) +}