Обновление приложения

This commit is contained in:
2026-04-28 22:37:19 +03:00
parent 7d00cf062e
commit 52594b5dc6
99 changed files with 5736 additions and 1052 deletions

12
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Finance App</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

12
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,12 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/javascript;
}

23
frontend/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "finance-app-web",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1",
"recharts": "^2.12.7",
"react-datepicker": "^6.9.0",
"date-fns": "^3.6.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.0",
"vite": "^5.2.12"
}
}

49
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,49 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { AuthProvider, useAuth } from "./contexts/AuthContext";
import Layout from "./components/Layout";
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
import Accounts from "./pages/Accounts";
import Operations from "./pages/Operations";
import Budgets from "./pages/Budgets";
import ExchangeRates from "./pages/ExchangeRates";
import Locations from "./pages/Locations";
import CalendarPage from "./pages/Calendar";
import Admin from "./pages/Admin";
function PrivateRoute({ children, adminOnly = false }) {
const { user, loading } = useAuth();
if (loading) return <div className="loader">Загрузка…</div>;
if (!user) return <Navigate to="/login" replace />;
if (adminOnly && user.role?.name !== "admin") return <Navigate to="/" replace />;
return children;
}
function AppRoutes() {
const { user } = useAuth();
return (
<Routes>
<Route path="/login" element={user ? <Navigate to="/" /> : <Login />} />
<Route path="/" element={<PrivateRoute><Layout /></PrivateRoute>}>
<Route index element={<Dashboard />} />
<Route path="accounts" element={<Accounts />} />
<Route path="operations" element={<Operations />} />
<Route path="budgets" element={<Budgets />} />
<Route path="exchange-rates" element={<ExchangeRates />} />
<Route path="locations" element={<Locations />} />
<Route path="calendar" element={<CalendarPage />} />
<Route path="admin" element={<PrivateRoute adminOnly><Admin /></PrivateRoute>} />
</Route>
</Routes>
);
}
export default function App() {
return (
<AuthProvider>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</AuthProvider>
);
}

101
frontend/src/api/client.js Normal file
View File

@@ -0,0 +1,101 @@
import axios from "axios";
const api = axios.create({
baseURL: "/api/v1",
headers: { "Content-Type": "application/json" },
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem("access_token");
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
api.interceptors.response.use(
(r) => r,
async (error) => {
const original = error.config;
if (error.response?.status === 401 && !original._retry) {
original._retry = true;
const refresh = localStorage.getItem("refresh_token");
if (refresh) {
try {
const { data } = await axios.post("/api/v1/auth/refresh", null, {
params: { refresh_token: refresh },
});
localStorage.setItem("access_token", data.access_token);
localStorage.setItem("refresh_token", data.refresh_token);
original.headers.Authorization = `Bearer ${data.access_token}`;
return api(original);
} catch {
localStorage.clear();
window.location.href = "/login";
}
}
}
return Promise.reject(error);
}
);
// ── Auth ────────────────────────────────────────────────
export const login = (username, password) =>
api.post("/auth/login/json", { username, password });
export const register = (data) => api.post("/auth/register", data);
export const getMe = () => api.get("/users/me");
// ── Users (admin) ────────────────────────────────────────
export const getUsers = () => api.get("/users");
export const updateUser = (id, data) => api.patch(`/users/${id}`, data);
export const deleteUser = (id) => api.delete(`/users/${id}`);
// ── Currencies ───────────────────────────────────────────
export const getCurrencies = () => api.get("/currencies");
export const createCurrency = (d) => api.post("/currencies", d);
export const updateCurrency = (id, d) => api.patch(`/currencies/${id}`, d);
export const deleteCurrency = (id) => api.delete(`/currencies/${id}`);
// ── Exchange rates ────────────────────────────────────────
export const getExchangeRates = (params) => api.get("/exchange-rates", { params });
export const createExchangeRate = (d) => api.post("/exchange-rates", d);
export const deleteExchangeRate = (id) => api.delete(`/exchange-rates/${id}`);
// ── Accounts ──────────────────────────────────────────────
export const getAccountTypes = () => api.get("/accounts/types");
export const getAccounts = () => api.get("/accounts");
export const createAccount = (d) => api.post("/accounts", d);
export const updateAccount = (id, d) => api.patch(`/accounts/${id}`, d);
export const deleteAccount = (id) => api.delete(`/accounts/${id}`);
// ── Categories ────────────────────────────────────────────
export const getCategories = () => api.get("/categories");
export const createCategory = (d) => api.post("/categories", d);
export const updateCategory = (id, d) => api.patch(`/categories/${id}`, d);
export const deleteCategory = (id) => api.delete(`/categories/${id}`);
// ── Budgets ───────────────────────────────────────────────
export const getBudgets = () => api.get("/budgets");
export const createBudget = (d) => api.post("/budgets", d);
export const updateBudget = (id, d) => api.patch(`/budgets/${id}`, d);
export const deleteBudget = (id) => api.delete(`/budgets/${id}`);
// ── Locations ─────────────────────────────────────────────
export const getLocations = () => api.get("/locations");
export const createLocation = (d) => api.post("/locations", d);
export const updateLocation = (id, d) => api.patch(`/locations/${id}`, d);
export const deleteLocation = (id) => api.delete(`/locations/${id}`);
// ── Operations ────────────────────────────────────────────
export const getOperationTypes = () => api.get("/operations/types");
export const getOperations = (params) => api.get("/operations", { params });
export const createOperation = (d) => api.post("/operations", d);
export const createTransfer = (d) => api.post("/operations/transfer", d);
export const updateOperation = (id, d) => api.patch(`/operations/${id}`, d);
export const deleteOperation = (id) => api.delete(`/operations/${id}`);
// ── Calendar ──────────────────────────────────────────────
export const getCalendarEvents = (params) => api.get("/calendar", { params });
export const createCalendarEvent = (d) => api.post("/calendar", d);
export const updateCalendarEvent = (id, d) => api.patch(`/calendar/${id}`, d);
export const deleteCalendarEvent = (id) => api.delete(`/calendar/${id}`);
export default api;

View File

@@ -0,0 +1,156 @@
/* ── Reset & tokens ──────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f6fa;
--surface: #ffffff;
--sidebar: #1e2a3a;
--sidebar-hover: #2d3f55;
--primary: #3b82f6;
--primary-dark: #2563eb;
--success: #22c55e;
--danger: #ef4444;
--warning: #f59e0b;
--text: #1e293b;
--muted: #64748b;
--border: #e2e8f0;
--radius: 8px;
--shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.04);
}
body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
a { color: inherit; text-decoration: none; }
button { cursor: pointer; font-family: inherit; }
/* ── Layout ─────────────────────────────────────── */
.app-shell { display: flex; height: 100vh; overflow: hidden; }
.sidebar {
width: 240px; flex-shrink: 0;
background: var(--sidebar); color: #cbd5e1;
display: flex; flex-direction: column;
overflow-y: auto;
}
.sidebar-logo {
padding: 24px 20px 16px;
font-size: 1.1rem; font-weight: 700; color: #fff;
border-bottom: 1px solid rgba(255,255,255,.08);
letter-spacing: .5px;
}
.sidebar-logo span { color: var(--primary); }
.sidebar nav { padding: 12px 8px; flex: 1; }
.nav-link {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px; border-radius: var(--radius);
font-size: .9rem; transition: background .15s, color .15s;
margin-bottom: 2px;
}
.nav-link:hover, .nav-link.active { background: var(--sidebar-hover); color: #fff; }
.nav-link.active { font-weight: 600; }
.nav-icon { width: 18px; text-align: center; font-size: 1rem; }
.sidebar-footer { padding: 16px 12px; border-top: 1px solid rgba(255,255,255,.08); }
.sidebar-user { font-size: .8rem; color: #94a3b8; margin-bottom: 8px; }
.main-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.topbar {
height: 56px; background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex; align-items: center; padding: 0 24px;
gap: 12px;
}
.topbar-title { font-size: 1.05rem; font-weight: 600; flex: 1; }
.page-content { flex: 1; overflow-y: auto; padding: 24px; }
/* ── Cards / Surfaces ───────────────────────────── */
.card {
background: var(--surface); border-radius: var(--radius);
box-shadow: var(--shadow); padding: 20px;
}
.card-title { font-size: .75rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 6px; }
.card-value { font-size: 1.6rem; font-weight: 700; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
/* ── Buttons ────────────────────────────────────── */
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 16px; border-radius: var(--radius);
border: none; font-size: .875rem; font-weight: 500;
transition: background .15s, opacity .15s;
}
.btn:disabled { opacity: .5; pointer-events: none; }
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover { background: var(--primary-dark); }
.btn-danger { background: var(--danger); color: #fff; }
.btn-danger:hover { opacity: .85; }
.btn-ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); }
.btn-ghost:hover { background: var(--bg); }
.btn-sm { padding: 5px 10px; font-size: .8rem; }
.btn-icon { padding: 6px; border-radius: 6px; background: transparent; border: 1px solid var(--border); color: var(--muted); }
.btn-icon:hover { background: var(--bg); color: var(--text); }
/* ── Forms ──────────────────────────────────────── */
.form-group { margin-bottom: 16px; }
.form-label { display: block; font-size: .85rem; font-weight: 500; margin-bottom: 6px; }
.form-control {
width: 100%; padding: 9px 12px;
border: 1px solid var(--border); border-radius: var(--radius);
font-size: .9rem; background: var(--surface); color: var(--text);
transition: border-color .15s;
}
.form-control:focus { outline: none; border-color: var(--primary); }
select.form-control { appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748b' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; padding-right: 32px; }
/* ── Table ──────────────────────────────────────── */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: .875rem; }
th { text-align: left; padding: 10px 12px; font-size: .75rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; border-bottom: 2px solid var(--border); }
td { padding: 12px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--bg); }
/* ── Modal ──────────────────────────────────────── */
.modal-backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,.4);
display: flex; align-items: center; justify-content: center; z-index: 100;
}
.modal {
background: var(--surface); border-radius: 12px;
width: 100%; max-width: 480px; max-height: 90vh;
overflow-y: auto; padding: 28px;
box-shadow: 0 20px 60px rgba(0,0,0,.2);
}
.modal-title { font-size: 1.1rem; font-weight: 700; margin-bottom: 20px; }
.modal-footer { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
/* ── Badges ─────────────────────────────────────── */
.badge { display: inline-block; padding: 2px 8px; border-radius: 20px; font-size: .75rem; font-weight: 600; }
.badge-income { background: #dcfce7; color: #166534; }
.badge-expense { background: #fee2e2; color: #991b1b; }
.badge-transfer{ background: #dbeafe; color: #1e40af; }
.badge-active { background: #dcfce7; color: #166534; }
.badge-inactive{ background: #f1f5f9; color: var(--muted); }
/* ── Progress bar ───────────────────────────────── */
.progress { height: 8px; background: var(--border); border-radius: 4px; overflow: hidden; }
.progress-bar { height: 100%; border-radius: 4px; background: var(--primary); transition: width .3s; }
.progress-bar.over { background: var(--danger); }
/* ── Misc ───────────────────────────────────────── */
.loader { display: flex; align-items: center; justify-content: center; height: 100vh; font-size: 1.1rem; color: var(--muted); }
.empty { text-align: center; padding: 48px; color: var(--muted); }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
.page-header h1 { font-size: 1.3rem; font-weight: 700; }
.text-success { color: var(--success); }
.text-danger { color: var(--danger); }
.text-muted { color: var(--muted); }
.flex-gap { display: flex; gap: 8px; }
.mt-16 { margin-top: 16px; }
.login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg); }
.login-card { width: 100%; max-width: 400px; padding: 40px 36px; }
.login-logo { text-align: center; margin-bottom: 28px; font-size: 1.4rem; font-weight: 800; }
.login-logo span { color: var(--primary); }
.error-msg { color: var(--danger); font-size: .85rem; margin-top: 8px; }
.color-dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }

View File

@@ -0,0 +1,80 @@
import { Outlet, NavLink, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
const NAV = [
{ to: "/", icon: "📊", label: "Дашборд" },
{ to: "/accounts", icon: "🏦", label: "Счета" },
{ to: "/operations", icon: "💸", label: "Операции" },
{ to: "/budgets", icon: "🎯", label: "Бюджеты" },
{ to: "/exchange-rates",icon: "💱", label: "Курсы валют" },
{ to: "/locations", icon: "📍", label: "Местоположения" },
{ to: "/calendar", icon: "📅", label: "Календарь" },
];
const PAGE_TITLES = {
"/": "Дашборд",
"/accounts": "Счета",
"/operations": "Операции",
"/budgets": "Бюджеты",
"/exchange-rates": "Курсы валют",
"/locations": "Местоположения",
"/calendar": "Календарь событий",
"/admin": "Управление пользователями",
};
export default function Layout() {
const { user, logout, isAdmin } = useAuth();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate("/login");
};
const currentTitle = PAGE_TITLES[window.location.pathname] || "Finance App";
return (
<div className="app-shell">
<aside className="sidebar">
<div className="sidebar-logo">Finance<span>App</span></div>
<nav>
{NAV.map(({ to, icon, label }) => (
<NavLink
key={to}
to={to}
end={to === "/"}
className={({ isActive }) => `nav-link${isActive ? " active" : ""}`}
>
<span className="nav-icon">{icon}</span>
{label}
</NavLink>
))}
{isAdmin && (
<NavLink
to="/admin"
className={({ isActive }) => `nav-link${isActive ? " active" : ""}`}
>
<span className="nav-icon">👥</span>Пользователи
</NavLink>
)}
</nav>
<div className="sidebar-footer">
<div className="sidebar-user">{user?.username} · {user?.role?.name}</div>
<button className="btn btn-ghost btn-sm" style={{ width: "100%" }} onClick={handleLogout}>
Выйти
</button>
</div>
</aside>
<div className="main-area">
<header className="topbar">
<span className="topbar-title">{currentTitle}</span>
<span className="text-muted" style={{ fontSize: ".85rem" }}>{user?.email}</span>
</header>
<main className="page-content">
<Outlet />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { createContext, useContext, useState, useEffect, useCallback } from "react";
import { login as apiLogin, getMe } from "../api/client";
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const loadUser = useCallback(async () => {
const token = localStorage.getItem("access_token");
if (!token) { setLoading(false); return; }
try {
const { data } = await getMe();
setUser(data);
} catch {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadUser(); }, [loadUser]);
const login = async (username, password) => {
const { data } = await apiLogin(username, password);
localStorage.setItem("access_token", data.access_token);
localStorage.setItem("refresh_token", data.refresh_token);
await loadUser();
};
const logout = () => {
localStorage.clear();
setUser(null);
};
const isAdmin = user?.role?.name === "admin";
const isAccountant = ["admin", "accountant"].includes(user?.role?.name);
return (
<AuthContext.Provider value={{ user, loading, login, logout, isAdmin, isAccountant }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);

10
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "./assets/styles.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,137 @@
import { useEffect, useState } from "react";
import { getAccounts, getAccountTypes, getCurrencies, createAccount, updateAccount, deleteAccount } from "../api/client";
const empty = { name: "", account_type_id: "", currency_id: "", initial_balance: "0", credit_limit: "", description: "", color: "#3b82f6" };
export default function Accounts() {
const [accounts, setAccounts] = useState([]);
const [types, setTypes] = useState([]);
const [currencies, setCurrencies] = useState([]);
const [modal, setModal] = useState(null); // null | "create" | account obj
const [form, setForm] = useState(empty);
const [error, setError] = useState("");
const load = () => getAccounts().then(r => setAccounts(r.data));
useEffect(() => {
load();
getAccountTypes().then(r => setTypes(r.data));
getCurrencies().then(r => setCurrencies(r.data.filter(c => c.is_active)));
}, []);
const openCreate = () => { setForm(empty); setError(""); setModal("create"); };
const openEdit = (a) => { setForm({ name: a.name, account_type_id: a.account_type_id, currency_id: a.currency_id, initial_balance: a.initial_balance, credit_limit: a.credit_limit || "", description: a.description || "", color: a.color || "#3b82f6", is_active: a.is_active }); setError(""); setModal(a); };
const close = () => setModal(null);
const save = async (e) => {
e.preventDefault(); setError("");
try {
const payload = { ...form, initial_balance: parseFloat(form.initial_balance) || 0, credit_limit: form.credit_limit ? parseFloat(form.credit_limit) : null, account_type_id: parseInt(form.account_type_id), currency_id: parseInt(form.currency_id) };
if (modal === "create") await createAccount(payload);
else await updateAccount(modal.id, { name: form.name, is_active: form.is_active ?? true, credit_limit: payload.credit_limit, description: form.description, color: form.color });
await load(); close();
} catch (err) { setError(err.response?.data?.detail || "Ошибка"); }
};
const remove = async (id) => {
if (!confirm("Удалить счёт?")) return;
await deleteAccount(id); load();
};
const fmt = (v) => parseFloat(v).toLocaleString("ru-RU", { minimumFractionDigits: 2 });
return (
<div>
<div className="page-header">
<h1>Счета</h1>
<button className="btn btn-primary" onClick={openCreate}>+ Добавить счёт</button>
</div>
<div className="card">
{accounts.length === 0
? <div className="empty">Счетов пока нет. Создайте первый!</div>
: <div className="table-wrap">
<table>
<thead>
<tr><th>Название</th><th>Тип</th><th>Валюта</th><th>Баланс</th><th>Лимит</th><th>Статус</th><th></th></tr>
</thead>
<tbody>
{accounts.map(a => (
<tr key={a.id}>
<td><span className="color-dot" style={{ background: a.color || "#94a3b8", marginRight: 8 }} /><b>{a.name}</b></td>
<td>{a.account_type?.name}</td>
<td>{a.currency?.code} {a.currency?.symbol}</td>
<td style={{ fontWeight: 600, color: parseFloat(a.balance) >= 0 ? "var(--success)" : "var(--danger)" }}>
{fmt(a.balance)}
</td>
<td>{a.credit_limit ? fmt(a.credit_limit) : "—"}</td>
<td><span className={`badge badge-${a.is_active ? "active" : "inactive"}`}>{a.is_active ? "Активен" : "Закрыт"}</span></td>
<td>
<div className="flex-gap">
<button className="btn-icon" onClick={() => openEdit(a)}>✏️</button>
<button className="btn-icon" onClick={() => remove(a.id)}>🗑️</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
}
</div>
{modal && (
<div className="modal-backdrop" onClick={close}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-title">{modal === "create" ? "Новый счёт" : "Редактировать счёт"}</div>
<form onSubmit={save}>
<div className="form-group">
<label className="form-label">Название</label>
<input className="form-control" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} required />
</div>
{modal === "create" && (
<>
<div className="form-group">
<label className="form-label">Тип счёта</label>
<select className="form-control" value={form.account_type_id} onChange={e => setForm({ ...form, account_type_id: e.target.value })} required>
<option value="">— выберите —</option>
{types.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Валюта</label>
<select className="form-control" value={form.currency_id} onChange={e => setForm({ ...form, currency_id: e.target.value })} required>
<option value="">— выберите —</option>
{currencies.map(c => <option key={c.id} value={c.id}>{c.code} — {c.name}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Начальный баланс</label>
<input type="number" step="0.01" className="form-control" value={form.initial_balance} onChange={e => setForm({ ...form, initial_balance: e.target.value })} />
</div>
</>
)}
<div className="form-group">
<label className="form-label">Кредитный лимит</label>
<input type="number" step="0.01" className="form-control" value={form.credit_limit} onChange={e => setForm({ ...form, credit_limit: e.target.value })} placeholder="Не задан" />
</div>
<div className="form-group">
<label className="form-label">Описание</label>
<input className="form-control" value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} />
</div>
<div className="form-group">
<label className="form-label">Цвет</label>
<input type="color" className="form-control" style={{ height: 40 }} value={form.color} onChange={e => setForm({ ...form, color: e.target.value })} />
</div>
{error && <p className="error-msg">{error}</p>}
<div className="modal-footer">
<button type="button" className="btn btn-ghost" onClick={close}>Отмена</button>
<button type="submit" className="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,165 @@
import { useEffect, useState } from "react";
import { getUsers, updateUser, deleteUser, getCurrencies, createCurrency, updateCurrency, deleteCurrency } from "../api/client";
import { useAuth } from "../contexts/AuthContext";
import { format } from "date-fns";
export default function Admin() {
const { user: me } = useAuth();
const [users, setUsers] = useState([]);
const [currencies, setCurrencies] = useState([]);
const [tab, setTab] = useState("users");
const [editUser, setEditUser] = useState(null);
const [currModal, setCurrModal] = useState(null);
const [currForm, setCurrForm] = useState({ code: "", name: "", symbol: "", is_active: true });
const [error, setError] = useState("");
const loadUsers = () => getUsers().then(r => setUsers(r.data));
const loadCurr = () => getCurrencies().then(r => setCurrencies(r.data));
useEffect(() => { loadUsers(); loadCurr(); }, []);
const toggleActive = async (u) => {
if (u.id === me.id) return;
await updateUser(u.id, { is_active: !u.is_active });
loadUsers();
};
const changeRole = async (u, role_id) => {
await updateUser(u.id, { role_id: parseInt(role_id) });
loadUsers();
};
const removeUser = async (id) => {
if (id === me.id || !confirm("Удалить пользователя?")) return;
await deleteUser(id); loadUsers();
};
const saveCurr = async (e) => {
e.preventDefault(); setError("");
try {
if (!currModal) await createCurrency(currForm);
else await updateCurrency(currModal.id, { name: currForm.name, symbol: currForm.symbol, is_active: currForm.is_active });
await loadCurr(); setCurrModal(undefined); setCurrForm({ code: "", name: "", symbol: "", is_active: true });
} catch (err) { setError(err.response?.data?.detail || "Ошибка"); }
};
const deleteCurr = async (id) => { if (!confirm("Удалить валюту?")) return; await deleteCurrency(id); loadCurr(); };
const ROLES = [
{ id: 1, name: "viewer", label: "Наблюдатель" },
{ id: 2, name: "user", label: "Пользователь" },
{ id: 3, name: "accountant", label: "Бухгалтер" },
{ id: 4, name: "admin", label: "Администратор" },
];
return (
<div>
<div className="page-header"><h1>Управление системой</h1></div>
<div className="flex-gap" style={{ marginBottom: 16 }}>
<button className={`btn ${tab === "users" ? "btn-primary" : "btn-ghost"}`} onClick={() => setTab("users")}>👥 Пользователи</button>
<button className={`btn ${tab === "currencies" ? "btn-primary" : "btn-ghost"}`} onClick={() => setTab("currencies")}>💱 Валюты</button>
</div>
{tab === "users" && (
<div className="card">
<div className="table-wrap">
<table>
<thead><tr><th>ID</th><th>Логин</th><th>Email</th><th>Роль</th><th>Статус</th><th>Создан</th><th></th></tr></thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
<td style={{ color: "var(--muted)", fontSize: ".8rem" }}>{u.id}</td>
<td><b>{u.username}</b>{u.id === me.id && <span style={{ marginLeft: 6, fontSize: ".75rem", color: "var(--primary)" }}>(Вы)</span>}</td>
<td>{u.email}</td>
<td>
<select className="form-control" style={{ width: 150, padding: "4px 8px" }} value={u.role_id} onChange={e => changeRole(u, e.target.value)} disabled={u.id === me.id}>
{ROLES.map(r => <option key={r.id} value={r.id}>{r.label}</option>)}
</select>
</td>
<td>
<button className={`badge badge-${u.is_active ? "active" : "inactive"}`} style={{ border: "none", cursor: u.id === me.id ? "default" : "pointer" }} onClick={() => toggleActive(u)}>
{u.is_active ? "Активен" : "Заблокирован"}
</button>
</td>
<td style={{ fontSize: ".8rem", color: "var(--muted)" }}>{u.created_at ? format(new Date(u.created_at), "dd.MM.yyyy") : "—"}</td>
<td>
{u.id !== me.id && (
<button className="btn-icon" onClick={() => removeUser(u.id)}>🗑️</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{tab === "currencies" && (
<div>
<div style={{ textAlign: "right", marginBottom: 12 }}>
<button className="btn btn-primary" onClick={() => { setCurrForm({ code: "", name: "", symbol: "", is_active: true }); setCurrModal(null); }}>+ Валюта</button>
</div>
<div className="card">
<div className="table-wrap">
<table>
<thead><tr><th>Код</th><th>Название</th><th>Символ</th><th>Статус</th><th></th></tr></thead>
<tbody>
{currencies.map(c => (
<tr key={c.id}>
<td><b>{c.code}</b></td>
<td>{c.name}</td>
<td style={{ fontSize: "1.1rem" }}>{c.symbol}</td>
<td><span className={`badge badge-${c.is_active ? "active" : "inactive"}`}>{c.is_active ? "Активна" : "Отключена"}</span></td>
<td>
<div className="flex-gap">
<button className="btn-icon" onClick={() => { setCurrForm({ code: c.code, name: c.name, symbol: c.symbol || "", is_active: c.is_active }); setCurrModal(c); }}>✏️</button>
<button className="btn-icon" onClick={() => deleteCurr(c.id)}>🗑️</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{currModal !== undefined && (
<div className="modal-backdrop" onClick={() => setCurrModal(undefined)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-title">{!currModal ? "Новая валюта" : "Редактировать валюту"}</div>
<form onSubmit={saveCurr}>
{!currModal && (
<div className="form-group">
<label className="form-label">Код ISO 4217 (3 буквы)</label>
<input className="form-control" maxLength={3} value={currForm.code} onChange={e => setCurrForm({ ...currForm, code: e.target.value.toUpperCase() })} required />
</div>
)}
<div className="form-group">
<label className="form-label">Название</label>
<input className="form-control" value={currForm.name} onChange={e => setCurrForm({ ...currForm, name: e.target.value })} required />
</div>
<div className="form-group">
<label className="form-label">Символ</label>
<input className="form-control" value={currForm.symbol} onChange={e => setCurrForm({ ...currForm, symbol: e.target.value })} />
</div>
{currModal && (
<div className="form-group">
<label className="form-label"><input type="checkbox" checked={currForm.is_active} onChange={e => setCurrForm({ ...currForm, is_active: e.target.checked })} style={{ marginRight: 8 }} />Активна</label>
</div>
)}
{error && <p className="error-msg">{error}</p>}
<div className="modal-footer">
<button type="button" className="btn btn-ghost" onClick={() => setCurrModal(undefined)}>Отмена</button>
<button type="submit" className="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,155 @@
import { useEffect, useState } from "react";
import { getBudgets, getCategories, getCurrencies, createBudget, updateBudget, deleteBudget } from "../api/client";
import { format } from "date-fns";
const empty = { name: "", category_id: "", amount: "", currency_id: "", period_type: "monthly", start_date: format(new Date(), "yyyy-MM-dd"), end_date: "", is_active: true };
export default function Budgets() {
const [budgets, setBudgets] = useState([]);
const [categories, setCategories] = useState([]);
const [currencies, setCurrencies] = useState([]);
const [modal, setModal] = useState(null);
const [form, setForm] = useState(empty);
const [error, setError] = useState("");
const load = () => getBudgets().then(r => setBudgets(r.data));
useEffect(() => {
load();
getCategories().then(r => setCategories(r.data.filter(c => c.type === "expense")));
getCurrencies().then(r => setCurrencies(r.data.filter(c => c.is_active)));
}, []);
const close = () => { setModal(null); setError(""); };
const openEdit = (b) => {
setForm({ name: b.name, category_id: b.category_id, amount: b.amount, currency_id: b.currency_id, period_type: b.period_type, start_date: b.start_date, end_date: b.end_date || "", is_active: b.is_active });
setModal(b);
};
const save = async (e) => {
e.preventDefault(); setError("");
try {
const payload = { ...form, category_id: parseInt(form.category_id), amount: parseFloat(form.amount), currency_id: parseInt(form.currency_id), end_date: form.end_date || null };
if (!modal) await createBudget(payload);
else await updateBudget(modal.id, { name: form.name, amount: payload.amount, period_type: form.period_type, end_date: payload.end_date, is_active: form.is_active });
await load(); close();
} catch (err) { setError(err.response?.data?.detail || "Ошибка"); }
};
const remove = async (id) => { if (!confirm("Удалить бюджет?")) return; await deleteBudget(id); load(); };
const fmt = (v) => parseFloat(v || 0).toLocaleString("ru-RU", { minimumFractionDigits: 2 });
const periodLabel = { monthly: "Ежемесячно", weekly: "Еженедельно", yearly: "Ежегодно", custom: "Произвольный" };
return (
<div>
<div className="page-header">
<h1>Бюджеты</h1>
<button className="btn btn-primary" onClick={() => { setForm(empty); setModal(false); }}>+ Бюджет</button>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{budgets.length === 0
? <div className="card"><div className="empty">Бюджеты не настроены</div></div>
: budgets.map(b => {
const spent = parseFloat(b.spent || 0);
const limit = parseFloat(b.amount);
const pct = limit > 0 ? Math.min((spent / limit) * 100, 100) : 0;
const over = spent > limit;
return (
<div key={b.id} className="card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 12 }}>
<div>
<div style={{ fontWeight: 600 }}>{b.name}</div>
<div style={{ fontSize: ".8rem", color: "var(--muted)" }}>
{b.category?.name} · {periodLabel[b.period_type]} · с {b.start_date}{b.end_date ? ` по ${b.end_date}` : ""}
</div>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<span className={`badge badge-${b.is_active ? "active" : "inactive"}`}>{b.is_active ? "Активен" : "Неактивен"}</span>
<button className="btn-icon" onClick={() => openEdit(b)}>✏️</button>
<button className="btn-icon" onClick={() => remove(b.id)}>🗑️</button>
</div>
</div>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: ".85rem", marginBottom: 6 }}>
<span>Потрачено: <b style={{ color: over ? "var(--danger)" : "inherit" }}>{fmt(spent)}</b></span>
<span>Лимит: <b>{fmt(limit)} {b.currency?.symbol}</b></span>
<span style={{ color: over ? "var(--danger)" : "var(--success)" }}>{over ? `Перерасход ${fmt(spent - limit)}` : `Остаток ${fmt(limit - spent)}`}</span>
</div>
<div className="progress">
<div className={`progress-bar${over ? " over" : ""}`} style={{ width: `${pct}%` }} />
</div>
</div>
);
})
}
</div>
{modal !== null && (
<div className="modal-backdrop" onClick={close}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-title">{!modal ? "Новый бюджет" : "Редактировать бюджет"}</div>
<form onSubmit={save}>
<div className="form-group">
<label className="form-label">Название</label>
<input className="form-control" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} required />
</div>
{!modal && (
<>
<div className="form-group">
<label className="form-label">Категория расхода</label>
<select className="form-control" value={form.category_id} onChange={e => setForm({ ...form, category_id: e.target.value })} required>
<option value="">— выберите —</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Валюта</label>
<select className="form-control" value={form.currency_id} onChange={e => setForm({ ...form, currency_id: e.target.value })} required>
<option value="">— выберите —</option>
{currencies.map(c => <option key={c.id} value={c.id}>{c.code} {c.symbol}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Дата начала</label>
<input type="date" className="form-control" value={form.start_date} onChange={e => setForm({ ...form, start_date: e.target.value })} required />
</div>
</>
)}
<div className="form-group">
<label className="form-label">Лимит</label>
<input type="number" step="0.01" min="0.01" className="form-control" value={form.amount} onChange={e => setForm({ ...form, amount: e.target.value })} required />
</div>
<div className="form-group">
<label className="form-label">Период</label>
<select className="form-control" value={form.period_type} onChange={e => setForm({ ...form, period_type: e.target.value })}>
<option value="monthly">Ежемесячно</option>
<option value="weekly">Еженедельно</option>
<option value="yearly">Ежегодно</option>
<option value="custom">Произвольный</option>
</select>
</div>
{form.period_type === "custom" && (
<div className="form-group">
<label className="form-label">Дата окончания</label>
<input type="date" className="form-control" value={form.end_date} onChange={e => setForm({ ...form, end_date: e.target.value })} />
</div>
)}
{modal && (
<div className="form-group">
<label className="form-label"><input type="checkbox" checked={form.is_active} onChange={e => setForm({ ...form, is_active: e.target.checked })} /> Активен</label>
</div>
)}
{error && <p className="error-msg">{error}</p>}
<div className="modal-footer">
<button type="button" className="btn btn-ghost" onClick={close}>Отмена</button>
<button type="submit" className="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,215 @@
import { useEffect, useState } from "react";
import { getCalendarEvents, getCategories, getCurrencies, createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from "../api/client";
import { format, startOfMonth, endOfMonth, addMonths, subMonths, eachDayOfInterval, isSameDay, parseISO } from "date-fns";
import { ru } from "date-fns/locale";
const empty = { title: "", description: "", event_date: format(new Date(), "yyyy-MM-dd"), event_time: "", is_recurring: false, recurrence_pattern: "", recurrence_end_date: "", category_id: "", amount: "", currency_id: "", reminder_days: "", is_completed: false };
export default function CalendarPage() {
const [events, setEvents] = useState([]);
const [categories, setCategories] = useState([]);
const [currencies, setCurrencies] = useState([]);
const [currentMonth, setCurrentMonth] = useState(new Date());
const [selectedDay, setSelectedDay] = useState(null);
const [modal, setModal] = useState(null);
const [form, setForm] = useState(empty);
const [error, setError] = useState("");
const load = () => {
const from = format(startOfMonth(currentMonth), "yyyy-MM-dd");
const to = format(endOfMonth(currentMonth), "yyyy-MM-dd");
getCalendarEvents({ date_from: from, date_to: to, limit: 500 }).then(r => setEvents(r.data));
};
useEffect(() => { load(); }, [currentMonth]);
useEffect(() => {
getCategories().then(r => setCategories(r.data));
getCurrencies().then(r => setCurrencies(r.data.filter(c => c.is_active)));
}, []);
const days = eachDayOfInterval({ start: startOfMonth(currentMonth), end: endOfMonth(currentMonth) });
const dayEvents = (day) => events.filter(e => e.event_date === format(day, "yyyy-MM-dd"));
const close = () => { setModal(null); setError(""); };
const openCreate = (day) => {
setForm({ ...empty, event_date: format(day, "yyyy-MM-dd") });
setModal("create");
};
const openEdit = (ev) => {
setForm({ title: ev.title, description: ev.description || "", event_date: ev.event_date, event_time: ev.event_time || "", is_recurring: ev.is_recurring, recurrence_pattern: ev.recurrence_pattern || "", recurrence_end_date: ev.recurrence_end_date || "", category_id: ev.category_id || "", amount: ev.amount || "", currency_id: ev.currency_id || "", reminder_days: ev.reminder_days || "", is_completed: ev.is_completed });
setModal(ev);
};
const save = async (e) => {
e.preventDefault(); setError("");
try {
const payload = { ...form, category_id: form.category_id ? parseInt(form.category_id) : null, currency_id: form.currency_id ? parseInt(form.currency_id) : null, amount: form.amount ? parseFloat(form.amount) : null, reminder_days: form.reminder_days ? parseInt(form.reminder_days) : null, event_time: form.event_time || null, recurrence_pattern: form.recurrence_pattern || null, recurrence_end_date: form.recurrence_end_date || null, description: form.description || null };
if (modal === "create") await createCalendarEvent(payload);
else await updateCalendarEvent(modal.id, payload);
await load(); close();
} catch (err) { setError(err.response?.data?.detail || "Ошибка"); }
};
const remove = async (id) => { if (!confirm("Удалить событие?")) return; await deleteCalendarEvent(id); load(); };
const toggle = async (ev) => { await updateCalendarEvent(ev.id, { is_completed: !ev.is_completed }); load(); };
const selectedDayEvents = selectedDay ? dayEvents(selectedDay) : [];
return (
<div>
<div className="page-header">
<h1>Календарь событий</h1>
<div className="flex-gap">
<button className="btn btn-ghost" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>← {format(subMonths(currentMonth, 1), "LLLL", { locale: ru })}</button>
<span style={{ fontWeight: 600, minWidth: 140, textAlign: "center" }}>{format(currentMonth, "LLLL yyyy", { locale: ru })}</span>
<button className="btn btn-ghost" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>{format(addMonths(currentMonth, 1), "LLLL", { locale: ru })} →</button>
</div>
</div>
<div className="grid-2" style={{ gap: 16 }}>
{/* Calendar grid */}
<div className="card">
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: 2, marginBottom: 8 }}>
{["Пн","Вт","Ср","Чт","Пт","Сб","Вс"].map(d => <div key={d} style={{ textAlign: "center", fontSize: ".75rem", fontWeight: 600, color: "var(--muted)", padding: "4px 0" }}>{d}</div>)}
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: 3 }}>
{/* Leading empty cells */}
{Array.from({ length: (days[0].getDay() + 6) % 7 }).map((_, i) => <div key={`e${i}`} />)}
{days.map(day => {
const de = dayEvents(day);
const isSelected = selectedDay && isSameDay(day, selectedDay);
const isToday = isSameDay(day, new Date());
return (
<div key={day.toISOString()} onClick={() => setSelectedDay(day)}
style={{ padding: "6px 4px", borderRadius: 6, cursor: "pointer", textAlign: "center", background: isSelected ? "var(--primary)" : isToday ? "#dbeafe" : "transparent", color: isSelected ? "#fff" : "inherit", border: isToday && !isSelected ? "1px solid var(--primary)" : "1px solid transparent", minHeight: 48, position: "relative" }}>
<div style={{ fontSize: ".85rem", fontWeight: isToday ? 700 : 400 }}>{format(day, "d")}</div>
{de.length > 0 && (
<div style={{ display: "flex", justifyContent: "center", gap: 2, flexWrap: "wrap", marginTop: 2 }}>
{de.slice(0, 3).map(ev => (
<div key={ev.id} style={{ width: 6, height: 6, borderRadius: "50%", background: ev.is_completed ? "var(--success)" : "var(--warning)" }} />
))}
{de.length > 3 && <div style={{ fontSize: ".6rem", color: isSelected ? "#fff" : "var(--muted)" }}>+{de.length - 3}</div>}
</div>
)}
</div>
);
})}
</div>
</div>
{/* Day events panel */}
<div className="card">
{!selectedDay
? <div className="empty">Выберите день</div>
: <>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
<div style={{ fontWeight: 600 }}>{format(selectedDay, "d MMMM yyyy", { locale: ru })}</div>
<button className="btn btn-primary btn-sm" onClick={() => openCreate(selectedDay)}>+ Событие</button>
</div>
{selectedDayEvents.length === 0
? <div className="empty" style={{ padding: 24 }}>Событий нет</div>
: selectedDayEvents.map(ev => (
<div key={ev.id} style={{ padding: "10px 0", borderBottom: "1px solid var(--border)", display: "flex", gap: 10, alignItems: "flex-start" }}>
<button onClick={() => toggle(ev)} style={{ background: "none", border: "none", fontSize: "1.1rem", cursor: "pointer", flexShrink: 0, marginTop: 2 }}>{ev.is_completed ? "✅" : "⏳"}</button>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, textDecoration: ev.is_completed ? "line-through" : "none", color: ev.is_completed ? "var(--muted)" : "inherit" }}>{ev.title}</div>
{ev.event_time && <div style={{ fontSize: ".8rem", color: "var(--muted)" }}>🕐 {ev.event_time}</div>}
{ev.amount && <div style={{ fontSize: ".85rem" }}>💰 {parseFloat(ev.amount).toLocaleString("ru-RU")} {currencies.find(c => c.id === ev.currency_id)?.symbol}</div>}
{ev.description && <div style={{ fontSize: ".8rem", color: "var(--muted)" }}>{ev.description}</div>}
{ev.is_recurring && <span className="badge" style={{ background: "#dbeafe", color: "#1e40af", marginTop: 4 }}>🔁 {ev.recurrence_pattern}</span>}
</div>
<div className="flex-gap">
<button className="btn-icon" onClick={() => openEdit(ev)}>✏️</button>
<button className="btn-icon" onClick={() => remove(ev.id)}>🗑️</button>
</div>
</div>
))
}
</>
}
</div>
</div>
{modal && (
<div className="modal-backdrop" onClick={close}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-title">{modal === "create" ? "Новое событие" : "Редактировать событие"}</div>
<form onSubmit={save}>
<div className="form-group">
<label className="form-label">Название</label>
<input className="form-control" value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} required />
</div>
<div className="grid-2" style={{ gap: 12 }}>
<div className="form-group">
<label className="form-label">Дата</label>
<input type="date" className="form-control" value={form.event_date} onChange={e => setForm({ ...form, event_date: e.target.value })} required />
</div>
<div className="form-group">
<label className="form-label">Время</label>
<input type="time" className="form-control" value={form.event_time} onChange={e => setForm({ ...form, event_time: e.target.value })} />
</div>
</div>
<div className="form-group">
<label className="form-label">Сумма</label>
<div style={{ display: "flex", gap: 8 }}>
<input type="number" step="0.01" className="form-control" value={form.amount} onChange={e => setForm({ ...form, amount: e.target.value })} placeholder="Не задана" />
<select className="form-control" style={{ width: 100 }} value={form.currency_id} onChange={e => setForm({ ...form, currency_id: e.target.value })}>
<option value="">—</option>
{currencies.map(c => <option key={c.id} value={c.id}>{c.code}</option>)}
</select>
</div>
</div>
<div className="form-group">
<label className="form-label">Категория</label>
<select className="form-control" value={form.category_id} onChange={e => setForm({ ...form, category_id: e.target.value })}>
<option value="">— без категории —</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Напоминание (за N дней)</label>
<input type="number" min="0" className="form-control" value={form.reminder_days} onChange={e => setForm({ ...form, reminder_days: e.target.value })} />
</div>
<div className="form-group">
<label className="form-label"><input type="checkbox" checked={form.is_recurring} onChange={e => setForm({ ...form, is_recurring: e.target.checked })} style={{ marginRight: 8 }} />Повторяющееся</label>
</div>
{form.is_recurring && (
<div className="grid-2" style={{ gap: 12 }}>
<div className="form-group">
<label className="form-label">Периодичность</label>
<select className="form-control" value={form.recurrence_pattern} onChange={e => setForm({ ...form, recurrence_pattern: e.target.value })}>
<option value="">— выберите —</option>
<option value="daily">Ежедневно</option>
<option value="weekly">Еженедельно</option>
<option value="monthly">Ежемесячно</option>
<option value="yearly">Ежегодно</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Конец повторений</label>
<input type="date" className="form-control" value={form.recurrence_end_date} onChange={e => setForm({ ...form, recurrence_end_date: e.target.value })} />
</div>
</div>
)}
<div className="form-group">
<label className="form-label">Описание</label>
<textarea className="form-control" rows={2} value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} />
</div>
<div className="form-group">
<label className="form-label"><input type="checkbox" checked={form.is_completed} onChange={e => setForm({ ...form, is_completed: e.target.checked })} style={{ marginRight: 8 }} />Выполнено</label>
</div>
{error && <p className="error-msg">{error}</p>}
<div className="modal-footer">
<button type="button" className="btn btn-ghost" onClick={close}>Отмена</button>
<button type="submit" className="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,184 @@
import { useEffect, useState } from "react";
import { getAccounts, getOperations, getBudgets } from "../api/client";
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
PieChart, Pie, Cell, Legend, ResponsiveContainer,
} from "recharts";
import { format, subDays, startOfMonth, endOfMonth } from "date-fns";
import { ru } from "date-fns/locale";
const COLORS = ["#3b82f6","#22c55e","#f59e0b","#ef4444","#8b5cf6","#06b6d4"];
export default function Dashboard() {
const [accounts, setAccounts] = useState([]);
const [operations, setOperations] = useState([]);
const [budgets, setBudgets] = useState([]);
useEffect(() => {
const from = startOfMonth(new Date()).toISOString();
const to = endOfMonth(new Date()).toISOString();
Promise.all([
getAccounts(),
getOperations({ date_from: from, date_to: to, limit: 500 }),
getBudgets(),
]).then(([a, o, b]) => {
setAccounts(a.data);
setOperations(o.data);
setBudgets(b.data);
});
}, []);
const totalBalance = accounts.reduce((s, a) => s + parseFloat(a.balance), 0);
const income = operations.filter(o => o.operation_type?.name === "income")
.reduce((s, o) => s + parseFloat(o.amount), 0);
const expense = operations.filter(o => o.operation_type?.name === "expense")
.reduce((s, o) => s + parseFloat(o.amount), 0);
// Daily chart data (last 30 days)
const days = Array.from({ length: 30 }, (_, i) => {
const day = subDays(new Date(), 29 - i);
const label = format(day, "d MMM", { locale: ru });
const dayStr = format(day, "yyyy-MM-dd");
const dayOps = operations.filter(o => o.date?.startsWith(dayStr));
return {
label,
Доход: dayOps.filter(o => o.operation_type?.name === "income").reduce((s, o) => s + parseFloat(o.amount), 0),
Расход: dayOps.filter(o => o.operation_type?.name === "expense").reduce((s, o) => s + parseFloat(o.amount), 0),
};
});
// Expense by category for pie
const byCategory = {};
operations.filter(o => o.operation_type?.name === "expense").forEach(o => {
const name = o.category?.name || "Без категории";
byCategory[name] = (byCategory[name] || 0) + parseFloat(o.amount);
});
const pieData = Object.entries(byCategory)
.map(([name, value]) => ({ name, value: Math.round(value * 100) / 100 }))
.sort((a, b) => b.value - a.value)
.slice(0, 6);
const fmt = (v) => v.toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return (
<div>
{/* KPI cards */}
<div className="grid-4" style={{ marginBottom: 20 }}>
<div className="card">
<div className="card-title">Общий баланс</div>
<div className="card-value" style={{ fontSize: "1.4rem" }}>{fmt(totalBalance)} ₽</div>
<div className="text-muted" style={{ fontSize: ".8rem", marginTop: 4 }}>{accounts.length} счётов</div>
</div>
<div className="card">
<div className="card-title">Доходы (месяц)</div>
<div className="card-value text-success">{fmt(income)} ₽</div>
</div>
<div className="card">
<div className="card-title">Расходы (месяц)</div>
<div className="card-value text-danger">{fmt(expense)} ₽</div>
</div>
<div className="card">
<div className="card-title">Бюджеты активных</div>
<div className="card-value">{budgets.filter(b => b.is_active).length}</div>
<div className="text-muted" style={{ fontSize: ".8rem", marginTop: 4 }}>
Всего: {budgets.length}
</div>
</div>
</div>
<div className="grid-2" style={{ marginBottom: 20 }}>
{/* Area chart */}
<div className="card">
<div style={{ fontWeight: 600, marginBottom: 12 }}>Доходы и расходы (30 дней)</div>
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={days} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="gIncome" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.25}/>
<stop offset="95%" stopColor="#22c55e" stopOpacity={0}/>
</linearGradient>
<linearGradient id="gExpense" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.25}/>
<stop offset="95%" stopColor="#ef4444" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="label" tick={{ fontSize: 11 }} interval={6} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip formatter={(v) => fmt(v)} />
<Area type="monotone" dataKey="Доход" stroke="#22c55e" fill="url(#gIncome)" strokeWidth={2} />
<Area type="monotone" dataKey="Расход" stroke="#ef4444" fill="url(#gExpense)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
</div>
{/* Pie chart */}
<div className="card">
<div style={{ fontWeight: 600, marginBottom: 12 }}>Расходы по категориям</div>
{pieData.length === 0
? <div className="empty">Нет данных за месяц</div>
: <ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie data={pieData} cx="50%" cy="50%" outerRadius={80} dataKey="value" label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`} labelLine={false} fontSize={11}>
{pieData.map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} />)}
</Pie>
<Legend />
<Tooltip formatter={(v) => fmt(v)} />
</PieChart>
</ResponsiveContainer>
}
</div>
</div>
{/* Accounts */}
<div className="card" style={{ marginBottom: 20 }}>
<div style={{ fontWeight: 600, marginBottom: 12 }}>Мои счета</div>
{accounts.length === 0
? <div className="empty">Счета не созданы</div>
: <div className="table-wrap">
<table>
<thead><tr><th>Счёт</th><th>Тип</th><th>Валюта</th><th>Баланс</th><th>Статус</th></tr></thead>
<tbody>
{accounts.map(a => (
<tr key={a.id}>
<td><span className="color-dot" style={{ background: a.color || "#94a3b8", marginRight: 8 }} />{a.name}</td>
<td>{a.account_type?.name}</td>
<td>{a.currency?.code}</td>
<td style={{ fontWeight: 600 }}>{fmt(parseFloat(a.balance))} {a.currency?.symbol}</td>
<td><span className={`badge badge-${a.is_active ? "active" : "inactive"}`}>{a.is_active ? "Активен" : "Закрыт"}</span></td>
</tr>
))}
</tbody>
</table>
</div>
}
</div>
{/* Budgets */}
{budgets.filter(b => b.is_active).length > 0 && (
<div className="card">
<div style={{ fontWeight: 600, marginBottom: 12 }}>Бюджеты</div>
{budgets.filter(b => b.is_active).map(b => {
const spent = parseFloat(b.spent || 0);
const limit = parseFloat(b.amount);
const pct = Math.min((spent / limit) * 100, 100);
const over = spent > limit;
return (
<div key={b.id} style={{ marginBottom: 16 }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 4, fontSize: ".85rem" }}>
<span>{b.name} ({b.category?.name})</span>
<span style={{ color: over ? "var(--danger)" : "var(--text)" }}>
{fmt(spent)} / {fmt(limit)} {b.currency?.symbol}
</span>
</div>
<div className="progress">
<div className={`progress-bar${over ? " over" : ""}`} style={{ width: `${pct}%` }} />
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { useEffect, useState } from "react";
import { getExchangeRates, getCurrencies, createExchangeRate, deleteExchangeRate } from "../api/client";
import { format } from "date-fns";
const empty = { currency_from_id: "", currency_to_id: "", rate: "", rate_date: format(new Date(), "yyyy-MM-dd"), source: "" };
export default function ExchangeRates() {
const [rates, setRates] = useState([]);
const [currencies, setCurrencies] = useState([]);
const [modal, setModal] = useState(false);
const [form, setForm] = useState(empty);
const [error, setError] = useState("");
const load = () => getExchangeRates({ limit: 200 }).then(r => setRates(r.data));
useEffect(() => {
load();
getCurrencies().then(r => setCurrencies(r.data.filter(c => c.is_active)));
}, []);
const save = async (e) => {
e.preventDefault(); setError("");
try {
await createExchangeRate({ currency_from_id: parseInt(form.currency_from_id), currency_to_id: parseInt(form.currency_to_id), rate: parseFloat(form.rate), rate_date: form.rate_date, source: form.source || null });
await load(); setModal(false);
} catch (err) { setError(err.response?.data?.detail || "Ошибка"); }
};
const remove = async (id) => { if (!confirm("Удалить курс?")) return; await deleteExchangeRate(id); load(); };
return (
<div>
<div className="page-header">
<h1>Курсы валют</h1>
<button className="btn btn-primary" onClick={() => { setForm(empty); setModal(true); }}>+ Добавить курс</button>
</div>
<div className="card">
{rates.length === 0
? <div className="empty">Курсов не задано</div>
: <div className="table-wrap">
<table>
<thead><tr><th>Дата</th><th>Из</th><th>В</th><th>Курс</th><th>Источник</th><th></th></tr></thead>
<tbody>
{rates.map(r => (
<tr key={r.id}>
<td>{r.rate_date}</td>
<td><b>{r.currency_from?.code}</b> {r.currency_from?.name}</td>
<td><b>{r.currency_to?.code}</b> {r.currency_to?.name}</td>
<td style={{ fontWeight: 600 }}>{parseFloat(r.rate).toFixed(4)}</td>
<td style={{ color: "var(--muted)", fontSize: ".85rem" }}>{r.source || "—"}</td>
<td><button className="btn-icon" onClick={() => remove(r.id)}>🗑️</button></td>
</tr>
))}
</tbody>
</table>
</div>
}
</div>
{modal && (
<div className="modal-backdrop" onClick={() => setModal(false)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-title">Добавить курс валюты</div>
<form onSubmit={save}>
<div className="form-group">
<label className="form-label">Из валюты</label>
<select className="form-control" value={form.currency_from_id} onChange={e => setForm({ ...form, currency_from_id: e.target.value })} required>
<option value="">— выберите —</option>
{currencies.map(c => <option key={c.id} value={c.id}>{c.code} — {c.name}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">В валюту</label>
<select className="form-control" value={form.currency_to_id} onChange={e => setForm({ ...form, currency_to_id: e.target.value })} required>
<option value="">— выберите —</option>
{currencies.map(c => <option key={c.id} value={c.id}>{c.code} — {c.name}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Курс (1 ед. исходной → кол-во целевой)</label>
<input type="number" step="0.000001" min="0.000001" className="form-control" value={form.rate} onChange={e => setForm({ ...form, rate: e.target.value })} required />
</div>
<div className="form-group">
<label className="form-label">Дата курса</label>
<input type="date" className="form-control" value={form.rate_date} onChange={e => setForm({ ...form, rate_date: e.target.value })} required />
</div>
<div className="form-group">
<label className="form-label">Источник</label>
<input className="form-control" value={form.source} onChange={e => setForm({ ...form, source: e.target.value })} placeholder="ЦБ РФ, CBR, ручной ввод…" />
</div>
{error && <p className="error-msg">{error}</p>}
<div className="modal-footer">
<button type="button" className="btn btn-ghost" onClick={() => setModal(false)}>Отмена</button>
<button type="submit" className="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,119 @@
import { useEffect, useState } from "react";
import { getLocations, createLocation, updateLocation, deleteLocation } from "../api/client";
const empty = { name: "", type: "store", address: "", city: "", country: "Россия", parent_id: "" };
const TYPES = ["store","bank","atm","online","restaurant","other"];
const TYPE_LABELS = { store: "Магазин", bank: "Банк", atm: "Банкомат", online: "Онлайн", restaurant: "Ресторан", other: "Другое" };
const TYPE_ICONS = { store: "🛒", bank: "🏦", atm: "💳", online: "🌐", restaurant: "🍽️", other: "📍" };
export default function Locations() {
const [locations, setLocations] = useState([]);
const [modal, setModal] = useState(null);
const [form, setForm] = useState(empty);
const [error, setError] = useState("");
const load = () => getLocations().then(r => setLocations(r.data));
useEffect(() => { load(); }, []);
const close = () => { setModal(null); setError(""); };
const openEdit = (l) => {
setForm({ name: l.name, type: l.type || "other", address: l.address || "", city: l.city || "", country: l.country || "", parent_id: l.parent_id || "" });
setModal(l);
};
const save = async (e) => {
e.preventDefault(); setError("");
try {
const payload = { ...form, parent_id: form.parent_id ? parseInt(form.parent_id) : null };
if (modal === "create") await createLocation(payload);
else await updateLocation(modal.id, payload);
await load(); close();
} catch (err) { setError(err.response?.data?.detail || "Ошибка"); }
};
const remove = async (id) => { if (!confirm("Удалить местоположение?")) return; await deleteLocation(id); load(); };
return (
<div>
<div className="page-header">
<h1>Местоположения</h1>
<button className="btn btn-primary" onClick={() => { setForm(empty); setModal("create"); }}>+ Добавить</button>
</div>
<div className="card">
{locations.length === 0
? <div className="empty">Местоположений нет</div>
: <div className="table-wrap">
<table>
<thead><tr><th>Название</th><th>Тип</th><th>Город</th><th>Страна</th><th>Адрес</th><th></th></tr></thead>
<tbody>
{locations.map(l => (
<tr key={l.id}>
<td><span style={{ marginRight: 6 }}>{TYPE_ICONS[l.type] || "📍"}</span><b>{l.name}</b></td>
<td>{TYPE_LABELS[l.type] || l.type}</td>
<td>{l.city || "—"}</td>
<td>{l.country || "—"}</td>
<td style={{ fontSize: ".85rem", color: "var(--muted)" }}>{l.address || "—"}</td>
<td>
{l.user_id && (
<div className="flex-gap">
<button className="btn-icon" onClick={() => openEdit(l)}>✏️</button>
<button className="btn-icon" onClick={() => remove(l.id)}>🗑️</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
}
</div>
{modal && (
<div className="modal-backdrop" onClick={close}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-title">{modal === "create" ? "Новое местоположение" : "Редактировать"}</div>
<form onSubmit={save}>
<div className="form-group">
<label className="form-label">Название</label>
<input className="form-control" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} required />
</div>
<div className="form-group">
<label className="form-label">Тип</label>
<select className="form-control" value={form.type} onChange={e => setForm({ ...form, type: e.target.value })}>
{TYPES.map(t => <option key={t} value={t}>{TYPE_LABELS[t]}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Город</label>
<input className="form-control" value={form.city} onChange={e => setForm({ ...form, city: e.target.value })} />
</div>
<div className="form-group">
<label className="form-label">Страна</label>
<input className="form-control" value={form.country} onChange={e => setForm({ ...form, country: e.target.value })} />
</div>
<div className="form-group">
<label className="form-label">Адрес</label>
<input className="form-control" value={form.address} onChange={e => setForm({ ...form, address: e.target.value })} />
</div>
<div className="form-group">
<label className="form-label">Родительское местоположение</label>
<select className="form-control" value={form.parent_id} onChange={e => setForm({ ...form, parent_id: e.target.value })}>
<option value="">— нет —</option>
{locations.filter(l => !modal || l.id !== modal.id).map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</div>
{error && <p className="error-msg">{error}</p>}
<div className="modal-footer">
<button type="button" className="btn btn-ghost" onClick={close}>Отмена</button>
<button type="submit" className="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
export default function Login() {
const { login } = useAuth();
const navigate = useNavigate();
const [form, setForm] = useState({ username: "", password: "" });
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handle = async (e) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await login(form.username, form.password);
navigate("/");
} catch (err) {
setError(err.response?.data?.detail || "Неверный логин или пароль");
} finally {
setLoading(false);
}
};
return (
<div className="login-wrap">
<div className="card login-card">
<div className="login-logo">Finance<span>App</span></div>
<form onSubmit={handle}>
<div className="form-group">
<label className="form-label">Логин или Email</label>
<input
className="form-control"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
required autoFocus
/>
</div>
<div className="form-group">
<label className="form-label">Пароль</label>
<input
type="password" className="form-control"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
required
/>
</div>
{error && <p className="error-msg">{error}</p>}
<button type="submit" className="btn btn-primary" style={{ width: "100%", marginTop: 8 }} disabled={loading}>
{loading ? "Вход…" : "Войти"}
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,253 @@
import { useEffect, useState } from "react";
import {
getOperations, getOperationTypes, getAccounts, getCategories, getLocations,
getCurrencies, createOperation, createTransfer, updateOperation, deleteOperation,
} from "../api/client";
import { format } from "date-fns";
const emptyOp = { account_id: "", category_id: "", operation_type_id: "", amount: "", currency_id: "", date: format(new Date(), "yyyy-MM-dd'T'HH:mm"), description: "", location_id: "", is_planned: false };
const emptyTr = { from_account_id: "", to_account_id: "", amount: "", currency_id: "", amount_converted: "", exchange_rate: "", date: format(new Date(), "yyyy-MM-dd'T'HH:mm"), description: "" };
export default function Operations() {
const [ops, setOps] = useState([]);
const [opTypes, setOpTypes] = useState([]);
const [accounts, setAccounts] = useState([]);
const [categories, setCategories] = useState([]);
const [locations, setLocations] = useState([]);
const [currencies, setCurrencies] = useState([]);
const [modal, setModal] = useState(null); // null | "op" | "transfer" | op_obj
const [form, setForm] = useState(emptyOp);
const [trForm, setTrForm] = useState(emptyTr);
const [filters, setFilters] = useState({ account_id: "", operation_type_id: "", is_planned: "" });
const [error, setError] = useState("");
const load = () => getOperations({ ...Object.fromEntries(Object.entries(filters).filter(([, v]) => v !== "")), limit: 200 }).then(r => setOps(r.data));
useEffect(() => {
Promise.all([getOperationTypes(), getAccounts(), getCategories(), getLocations(), getCurrencies()])
.then(([ot, ac, ca, lo, cu]) => {
setOpTypes(ot.data); setAccounts(ac.data); setCategories(ca.data); setLocations(lo.data); setCurrencies(cu.data.filter(c => c.is_active));
});
}, []);
useEffect(() => { load(); }, [filters]);
const close = () => { setModal(null); setError(""); };
const typeName = (id) => opTypes.find(t => t.id === id)?.name || "";
const saveOp = async (e) => {
e.preventDefault(); setError("");
try {
const payload = { ...form, account_id: parseInt(form.account_id), operation_type_id: parseInt(form.operation_type_id), amount: parseFloat(form.amount), currency_id: parseInt(form.currency_id), category_id: form.category_id ? parseInt(form.category_id) : null, location_id: form.location_id ? parseInt(form.location_id) : null };
if (modal === "op") await createOperation(payload);
else await updateOperation(modal.id, { amount: payload.amount, category_id: payload.category_id, date: payload.date, description: payload.description, location_id: payload.location_id });
await load(); close();
} catch (err) { setError(err.response?.data?.detail || "Ошибка"); }
};
const saveTr = async (e) => {
e.preventDefault(); setError("");
try {
const payload = { from_account_id: parseInt(trForm.from_account_id), to_account_id: parseInt(trForm.to_account_id), amount: parseFloat(trForm.amount), currency_id: parseInt(trForm.currency_id), amount_converted: trForm.amount_converted ? parseFloat(trForm.amount_converted) : null, exchange_rate: trForm.exchange_rate ? parseFloat(trForm.exchange_rate) : null, date: trForm.date, description: trForm.description || null };
await createTransfer(payload); await load(); close();
} catch (err) { setError(err.response?.data?.detail || "Ошибка"); }
};
const remove = async (id) => { if (!confirm("Удалить операцию?")) return; await deleteOperation(id); load(); };
const fmt = (v) => parseFloat(v).toLocaleString("ru-RU", { minimumFractionDigits: 2 });
const typeColor = { income: "badge-income", expense: "badge-expense", transfer: "badge-transfer" };
const typeLabel = { income: "Доход", expense: "Расход", transfer: "Перевод" };
return (
<div>
<div className="page-header">
<h1>Операции</h1>
<div className="flex-gap">
<button className="btn btn-ghost" onClick={() => { setTrForm(emptyTr); setModal("transfer"); }}>⇄ Перевод</button>
<button className="btn btn-primary" onClick={() => { setForm(emptyOp); setModal("op"); }}>+ Операция</button>
</div>
</div>
{/* Filters */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<select className="form-control" style={{ width: 200 }} value={filters.account_id} onChange={e => setFilters({ ...filters, account_id: e.target.value })}>
<option value="">Все счета</option>
{accounts.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
<select className="form-control" style={{ width: 180 }} value={filters.operation_type_id} onChange={e => setFilters({ ...filters, operation_type_id: e.target.value })}>
<option value="">Все типы</option>
{opTypes.map(t => <option key={t.id} value={t.id}>{typeLabel[t.name] || t.name}</option>)}
</select>
<select className="form-control" style={{ width: 160 }} value={filters.is_planned} onChange={e => setFilters({ ...filters, is_planned: e.target.value })}>
<option value="">Все статусы</option>
<option value="false">Выполненные</option>
<option value="true">Запланированные</option>
</select>
</div>
</div>
<div className="card">
{ops.length === 0
? <div className="empty">Операций нет</div>
: <div className="table-wrap">
<table>
<thead><tr><th>Дата</th><th>Счёт</th><th>Тип</th><th>Категория</th><th>Сумма</th><th>Описание</th><th>Статус</th><th></th></tr></thead>
<tbody>
{ops.map(o => (
<tr key={o.id}>
<td style={{ fontSize: ".8rem", whiteSpace: "nowrap" }}>{o.date ? format(new Date(o.date), "dd.MM.yy HH:mm") : "—"}</td>
<td>{accounts.find(a => a.id === o.account_id)?.name || o.account_id}</td>
<td><span className={`badge ${typeColor[typeName(o.operation_type_id)] || ""}`}>{typeLabel[typeName(o.operation_type_id)] || "—"}</span></td>
<td>{o.category?.name || "—"}</td>
<td style={{ fontWeight: 600, color: typeName(o.operation_type_id) === "income" ? "var(--success)" : typeName(o.operation_type_id) === "expense" ? "var(--danger)" : "inherit" }}>
{typeName(o.operation_type_id) === "expense" ? "−" : "+"}{fmt(o.amount)} {o.currency?.symbol}
</td>
<td style={{ fontSize: ".85rem", color: "var(--muted)" }}>{o.description || "—"}</td>
<td><span className={`badge badge-${o.is_planned ? "inactive" : "active"}`}>{o.is_planned ? "План" : "Факт"}</span></td>
<td>
<div className="flex-gap">
<button className="btn-icon" onClick={() => { setForm({ account_id: o.account_id, category_id: o.category_id || "", operation_type_id: o.operation_type_id, amount: o.amount, currency_id: o.currency_id, date: o.date?.slice(0, 16) || "", description: o.description || "", location_id: o.location_id || "", is_planned: o.is_planned }); setModal(o); }}>✏️</button>
<button className="btn-icon" onClick={() => remove(o.id)}>🗑️</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
}
</div>
{/* Operation modal */}
{(modal === "op" || (modal && modal.id)) && modal !== "transfer" && (
<div className="modal-backdrop" onClick={close}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-title">{modal === "op" ? "Новая операция" : "Редактировать операцию"}</div>
<form onSubmit={saveOp}>
{modal === "op" && (
<>
<div className="form-group">
<label className="form-label">Счёт</label>
<select className="form-control" value={form.account_id} onChange={e => setForm({ ...form, account_id: e.target.value })} required>
<option value="">— выберите —</option>
{accounts.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Тип операции</label>
<select className="form-control" value={form.operation_type_id} onChange={e => setForm({ ...form, operation_type_id: e.target.value })} required>
<option value="">— выберите —</option>
{opTypes.filter(t => t.name !== "transfer").map(t => <option key={t.id} value={t.id}>{typeLabel[t.name] || t.name}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Валюта</label>
<select className="form-control" value={form.currency_id} onChange={e => setForm({ ...form, currency_id: e.target.value })} required>
<option value="">— выберите —</option>
{currencies.map(c => <option key={c.id} value={c.id}>{c.code}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Запланировано</label>
<input type="checkbox" checked={form.is_planned} onChange={e => setForm({ ...form, is_planned: e.target.checked })} />
</div>
</>
)}
<div className="form-group">
<label className="form-label">Сумма</label>
<input type="number" step="0.01" min="0.01" className="form-control" value={form.amount} onChange={e => setForm({ ...form, amount: e.target.value })} required />
</div>
<div className="form-group">
<label className="form-label">Категория</label>
<select className="form-control" value={form.category_id} onChange={e => setForm({ ...form, category_id: e.target.value })}>
<option value="">— без категории —</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Дата</label>
<input type="datetime-local" className="form-control" value={form.date} onChange={e => setForm({ ...form, date: e.target.value })} required />
</div>
<div className="form-group">
<label className="form-label">Местоположение</label>
<select className="form-control" value={form.location_id} onChange={e => setForm({ ...form, location_id: e.target.value })}>
<option value="">— не указано —</option>
{locations.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Описание</label>
<input className="form-control" value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} />
</div>
{error && <p className="error-msg">{error}</p>}
<div className="modal-footer">
<button type="button" className="btn btn-ghost" onClick={close}>Отмена</button>
<button type="submit" className="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
)}
{/* Transfer modal */}
{modal === "transfer" && (
<div className="modal-backdrop" onClick={close}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-title">⇄ Перевод между счетами</div>
<form onSubmit={saveTr}>
<div className="form-group">
<label className="form-label">Со счёта</label>
<select className="form-control" value={trForm.from_account_id} onChange={e => setTrForm({ ...trForm, from_account_id: e.target.value })} required>
<option value="">— выберите —</option>
{accounts.map(a => <option key={a.id} value={a.id}>{a.name} ({a.currency?.code})</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">На счёт</label>
<select className="form-control" value={trForm.to_account_id} onChange={e => setTrForm({ ...trForm, to_account_id: e.target.value })} required>
<option value="">— выберите —</option>
{accounts.map(a => <option key={a.id} value={a.id}>{a.name} ({a.currency?.code})</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Сумма</label>
<input type="number" step="0.01" min="0.01" className="form-control" value={trForm.amount} onChange={e => setTrForm({ ...trForm, amount: e.target.value })} required />
</div>
<div className="form-group">
<label className="form-label">Валюта списания</label>
<select className="form-control" value={trForm.currency_id} onChange={e => setTrForm({ ...trForm, currency_id: e.target.value })} required>
<option value="">— выберите —</option>
{currencies.map(c => <option key={c.id} value={c.id}>{c.code}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Сумма зачисления (если иная валюта)</label>
<input type="number" step="0.01" className="form-control" value={trForm.amount_converted} onChange={e => setTrForm({ ...trForm, amount_converted: e.target.value })} placeholder="Оставьте пустым если та же валюта" />
</div>
<div className="form-group">
<label className="form-label">Курс конвертации</label>
<input type="number" step="0.000001" className="form-control" value={trForm.exchange_rate} onChange={e => setTrForm({ ...trForm, exchange_rate: e.target.value })} />
</div>
<div className="form-group">
<label className="form-label">Дата</label>
<input type="datetime-local" className="form-control" value={trForm.date} onChange={e => setTrForm({ ...trForm, date: e.target.value })} required />
</div>
<div className="form-group">
<label className="form-label">Описание</label>
<input className="form-control" value={trForm.description} onChange={e => setTrForm({ ...trForm, description: e.target.value })} />
</div>
{error && <p className="error-msg">{error}</p>}
<div className="modal-footer">
<button type="button" className="btn btn-ghost" onClick={close}>Отмена</button>
<button type="submit" className="btn btn-primary">Перевести</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

12
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": { target: "http://localhost:8000", changeOrigin: true },
},
},
});