feat: Модуль Финансы + Трекер + CI/CD #1

Merged
daniil merged 15 commits from dev into main 2026-03-01 05:14:59 +00:00
8 changed files with 936 additions and 1 deletions
Showing only changes of commit 0ec0eede76 - Show all commits

View File

@@ -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() {
</ProtectedRoute>
}
/>
<Route
path="/finance"
element={
<ProtectedRoute>
<Finance />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={

47
src/api/finance.js Normal file
View File

@@ -0,0 +1,47 @@
import client from './client'
export const financeApi = {
// Categories
listCategories: async () => {
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
},
}

View File

@@ -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: "Настройки" },
]

View File

@@ -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 (
<div
className="fixed inset-0 bg-black/50 flex items-end justify-center z-50"
onClick={onClose}
>
<div
className="bg-white dark:bg-gray-900 rounded-t-3xl w-full max-w-lg p-6 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="w-12 h-1 bg-gray-300 dark:bg-gray-700 rounded-full mx-auto mb-4" />
<h2 className="text-lg font-display font-bold text-gray-900 dark:text-white mb-4">
Новая запись
</h2>
<div className="space-y-5">
{/* Type toggle */}
<div className="flex bg-gray-100 dark:bg-gray-800 rounded-xl p-1">
{[
["expense", "Расход"],
["income", "Доход"],
].map(([k, l]) => (
<button
key={k}
onClick={() => {
setType(k)
setCategoryId(null)
}}
className={`flex-1 py-2.5 rounded-lg text-sm font-semibold transition ${
type === k
? k === "expense"
? "bg-red-500 text-white"
: "bg-green-500 text-white"
: "text-gray-500"
}`}
>
{l}
</button>
))}
</div>
{/* Quick templates */}
{type === "expense" && (
<div>
<p className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">
Быстрые шаблоны
</p>
<div className="flex flex-wrap gap-2">
{quickTemplates.map((t, i) => (
<button
key={i}
onClick={() => applyTemplate(t)}
className="px-3 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-300 hover:bg-primary-100 dark:hover:bg-primary-900 transition"
>
{t.description}
</button>
))}
</div>
</div>
)}
{/* Amount */}
<div>
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase block mb-1">
Сумма
</label>
<div className="relative">
<input
type="number"
value={amount}
onChange={(e) => 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"
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-lg text-gray-400">
</span>
</div>
</div>
{/* Description */}
<div>
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase block mb-1">
Описание
</label>
<input
value={description}
onChange={(e) => 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="Что купили?"
/>
</div>
{/* Category */}
<div>
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase block mb-1">
Категория
</label>
<div className="grid grid-cols-2 gap-2">
{cats.map((c) => (
<button
key={c.id}
onClick={() => setCategoryId(c.id)}
className={`px-3 py-2.5 rounded-xl text-sm text-left font-medium transition ${
categoryId === c.id
? "bg-primary-500 text-white ring-2 ring-primary-300"
: "bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300"
}`}
>
{c.emoji} {c.name}
</button>
))}
</div>
</div>
{/* Date */}
<div>
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase block mb-1">
Дата
</label>
<input
type="date"
value={date}
onChange={(e) => 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"
/>
</div>
{/* Submit */}
<button
onClick={handleSubmit}
disabled={saving || !amount || !categoryId}
className="w-full py-3.5 bg-primary-500 hover:bg-primary-600 disabled:opacity-50 text-white rounded-xl font-semibold text-base transition shadow-lg"
>
{saving
? "Сохраняю..."
: `Добавить ${type === "expense" ? "расход" : "доход"}`}
</button>
</div>
</div>
</div>
)
}

View File

@@ -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 (
<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">
{/* Summary cards */}
<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>
{/* Bar chart */}
{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>
)}
{/* Donut chart */}
{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>
)}
{/* Monthly trend */}
{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>
)
}

View File

@@ -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 (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="card p-6 animate-pulse">
<div className="h-8 bg-gray-200 dark:bg-gray-800 rounded w-1/2" />
</div>
))}
</div>
)
}
if (!summary || (summary.total_income === 0 && summary.total_expense === 0)) {
return (
<div className="card p-12 text-center">
<span className="text-5xl block mb-4">📊</span>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
Нет данных
</h3>
<p className="text-gray-500 dark:text-gray-400">
Добавьте первую транзакцию
</p>
</div>
)
}
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 (
<div className="space-y-6">
{/* Balance Card */}
<div className="card p-6 bg-gradient-to-br from-primary-950 to-primary-800 text-white">
<p className="text-sm opacity-70">Баланс за месяц</p>
<p className="text-3xl font-bold mt-1">{fmt(summary.balance)}</p>
<div className="flex gap-6 mt-4">
<div>
<p className="text-xs opacity-60">Доходы</p>
<p className="text-lg font-semibold text-green-300">
+{fmt(summary.total_income)}
</p>
</div>
<div>
<p className="text-xs opacity-60">Расходы</p>
<p className="text-lg font-semibold text-red-300">
-{fmt(summary.total_expense)}
</p>
</div>
</div>
</div>
{/* Top Categories */}
{expenseCategories.length > 0 && (
<div className="card p-5">
<h3 className="font-display font-bold text-gray-900 dark:text-white mb-4">
Топ расходов
</h3>
<div className="space-y-3">
{expenseCategories.slice(0, 5).map((c, i) => (
<div key={i} className="flex items-center gap-3">
<span className="text-xl w-8 text-center">
{c.category_emoji}
</span>
<div className="flex-1">
<div className="flex justify-between mb-1">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{c.category_name}
</span>
<span className="text-sm font-semibold text-gray-900 dark:text-white">
{fmt(c.amount)}
</span>
</div>
<div className="h-2 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full"
style={{
width: Math.round(c.percentage) + "%",
backgroundColor: COLORS[i % COLORS.length],
}}
/>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Donut Chart */}
{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-center gap-4">
<div className="w-40 h-40">
<ResponsiveContainer>
<PieChart>
<Pie
data={pieData}
innerRadius={40}
outerRadius={70}
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="flex-1 space-y-1">
{pieData.slice(0, 6).map((c, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<div
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: COLORS[i] }}
/>
<span className="text-gray-600 dark:text-gray-400 truncate">
{c.name}
</span>
<span className="ml-auto font-medium text-gray-900 dark:text-white">
{Math.round(
(c.value / summary.total_expense) * 100
)}
%
</span>
</div>
))}
</div>
</div>
</div>
)}
{/* Daily Line Chart */}
{dailyData.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={dailyData}>
<XAxis dataKey="day" tick={{ fontSize: 12 }} stroke="#94a3b8" />
<YAxis
tick={{ fontSize: 10 }}
stroke="#94a3b8"
tickFormatter={(v) => v / 1000 + "к"}
/>
<Tooltip formatter={(v) => fmt(v)} />
<Line
type="monotone"
dataKey="amount"
stroke="#0D4F4F"
strokeWidth={2}
dot={{ r: 4, fill: "#0D4F4F" }}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
)}
</div>
)
}

View File

@@ -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 (
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="card p-4 animate-pulse">
<div className="h-5 bg-gray-200 dark:bg-gray-800 rounded w-3/4" />
</div>
))}
</div>
)
}
return (
<div className="space-y-4">
{/* Search */}
<input
className="w-full px-4 py-2.5 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm text-gray-900 dark:text-white placeholder-gray-400 outline-none"
placeholder="Поиск по описанию..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{/* Type filter */}
<div className="flex gap-2">
{[
["all", "Все"],
["income", "Доходы"],
["expense", "Расходы"],
].map(([k, l]) => (
<button
key={k}
onClick={() => setFilter(k)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
filter === k
? "bg-primary-500 text-white"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
}`}
>
{l}
</button>
))}
</div>
{/* Category filter */}
<div className="flex gap-2 overflow-x-auto pb-1">
<button
onClick={() => setCatFilter(null)}
className={`px-3 py-1 rounded-lg text-xs font-medium whitespace-nowrap transition ${
!catFilter
? "bg-accent-500 text-white"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
}`}
>
Все
</button>
{categories.map((c) => (
<button
key={c.id}
onClick={() => setCatFilter(c.id)}
className={`px-3 py-1 rounded-lg text-xs font-medium whitespace-nowrap transition ${
catFilter === c.id
? "bg-accent-500 text-white"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
}`}
>
{c.emoji} {c.name}
</button>
))}
</div>
{/* Transaction groups */}
{Object.keys(grouped).length === 0 ? (
<div className="card p-12 text-center">
<span className="text-4xl block mb-3">🔍</span>
<p className="text-gray-500 dark:text-gray-400">Ничего не найдено</p>
</div>
) : (
Object.entries(grouped).map(([date, txs]) => (
<div key={date}>
<p className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">
{formatDate(date)}
</p>
<div className="card divide-y divide-gray-100 dark:divide-gray-800">
{txs.map((t) => (
<div
key={t.id}
className="px-4 py-3 flex items-center gap-3"
onClick={() => handleDelete(t.id)}
>
<span className="text-xl">{t.category_emoji}</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{t.description || t.category_name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t.category_emoji} {t.category_name}
</p>
</div>
<span
className={`text-sm font-bold ${
t.type === "income" ? "text-green-500" : "text-red-500"
}`}
>
{t.type === "income" ? "+" : "-"}
{fmt(t.amount)}
</span>
</div>
))}
</div>
</div>
))
)}
</div>
)
}

75
src/pages/Finance.jsx Normal file
View File

@@ -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 (
<div className="min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24">
<header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 sticky top-0 z-10">
<div className="max-w-lg mx-auto px-4 py-4 flex items-center justify-between">
<div>
<h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">
💰 Финансы
</h1>
</div>
<button
onClick={() => setShowAdd(true)}
className="w-10 h-10 rounded-xl bg-primary-500 text-white flex items-center justify-center text-xl shadow-lg hover:bg-primary-600 transition"
>
+
</button>
</div>
<div className="max-w-lg mx-auto px-4 pb-3 flex gap-2">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => setActiveTab(t.key)}
className={`flex-1 py-2 rounded-xl text-sm font-semibold transition ${
activeTab === t.key
? "bg-primary-500 text-white"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
}`}
>
{t.icon} {t.label}
</button>
))}
</div>
</header>
<div className="max-w-lg mx-auto px-4 py-6">
{activeTab === "dashboard" && <FinanceDashboard key={refreshKey} />}
{activeTab === "transactions" && (
<TransactionList key={refreshKey} onAdd={() => setShowAdd(true)} />
)}
{activeTab === "analytics" && <FinanceAnalytics key={refreshKey} />}
</div>
{showAdd && (
<AddTransactionModal
onClose={() => setShowAdd(false)}
onSaved={() => {
setShowAdd(false)
refresh()
}}
/>
)}
<Navigation />
</div>
)
}