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

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

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(git -C c:/Data/git/finance_app status --short)"
]
}
}

View File

@@ -1,59 +0,0 @@
[*]
cpp_indent_braces=false
cpp_indent_multi_line_relative_to=innermost_parenthesis
cpp_indent_within_parentheses=indent
cpp_indent_preserve_within_parentheses=false
cpp_indent_case_labels=false
cpp_indent_case_contents=true
cpp_indent_case_contents_when_block=false
cpp_indent_lambda_braces_when_parameter=true
cpp_indent_goto_labels=one_left
cpp_indent_preprocessor=leftmost_column
cpp_indent_access_specifiers=false
cpp_indent_namespace_contents=true
cpp_indent_preserve_comments=false
cpp_new_line_before_open_brace_namespace=ignore
cpp_new_line_before_open_brace_type=ignore
cpp_new_line_before_open_brace_function=ignore
cpp_new_line_before_open_brace_block=ignore
cpp_new_line_before_open_brace_lambda=ignore
cpp_new_line_scope_braces_on_separate_lines=false
cpp_new_line_close_brace_same_line_empty_type=false
cpp_new_line_close_brace_same_line_empty_function=false
cpp_new_line_before_catch=true
cpp_new_line_before_else=true
cpp_new_line_before_while_in_do_while=false
cpp_space_before_function_open_parenthesis=remove
cpp_space_within_parameter_list_parentheses=false
cpp_space_between_empty_parameter_list_parentheses=false
cpp_space_after_keywords_in_control_flow_statements=true
cpp_space_within_control_flow_statement_parentheses=false
cpp_space_before_lambda_open_parenthesis=false
cpp_space_within_cast_parentheses=false
cpp_space_after_cast_close_parenthesis=false
cpp_space_within_expression_parentheses=false
cpp_space_before_block_open_brace=true
cpp_space_between_empty_braces=false
cpp_space_before_initializer_list_open_brace=false
cpp_space_within_initializer_list_braces=true
cpp_space_preserve_in_initializer_list=true
cpp_space_before_open_square_bracket=false
cpp_space_within_square_brackets=false
cpp_space_before_empty_square_brackets=false
cpp_space_between_empty_square_brackets=false
cpp_space_group_square_brackets=true
cpp_space_within_lambda_brackets=false
cpp_space_between_empty_lambda_brackets=false
cpp_space_before_comma=false
cpp_space_after_comma=true
cpp_space_remove_around_member_operators=true
cpp_space_before_inheritance_colon=true
cpp_space_before_constructor_colon=true
cpp_space_remove_before_semicolon=true
cpp_space_after_semicolon=false
cpp_space_remove_around_unary_operator=true
cpp_space_around_binary_operator=insert
cpp_space_around_assignment_operator=insert
cpp_space_pointer_reference_alignment=left
cpp_space_around_ternary_operator=insert
cpp_wrap_preserve_blocks=one_liners

2
.env
View File

@@ -1,2 +0,0 @@
DATABASE_URL=postgresql://finance_user:DY4xPf7SQWEwcXDfcfkg@192.168.1.150:9332/finance_db
SECRET_KEY=ZOEJZWEecjKUlNTvn8EX

10
.env.example Normal file
View File

@@ -0,0 +1,10 @@
# Root-level docker-compose env
POSTGRES_DB=financedb
POSTGRES_USER=financeuser
POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD
SECRET_KEY=CHANGE_ME_32_BYTES_HEX
ALLOWED_ORIGINS=https://your-domain.com
# Admin initial password (used by seed.py once)
ADMIN_PASSWORD=CHANGE_ME_ADMIN_PASSWORD

32
.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
# Env files
.env
backend/.env
android/.env
# Python
__pycache__/
*.pyc
.venv/
*.egg-info/
# Node
node_modules/
frontend/node_modules/
android/node_modules/
# Builds
frontend/dist/
android/dist/
android/.expo/
android/android/
android/ios/
# DB
*.sqlite3
# SSL certs (never commit private keys)
nginx/certs/
# IDE
.vscode/
.idea/

148
README.md Normal file
View File

@@ -0,0 +1,148 @@
# Finance App — Система финансового учёта
Приложение для управления личными финансами по принципу **1С:Деньги**.
Работает через **веб-браузер** и **Android** (React Native + Expo).
---
## Технологический стек
| Компонент | Технология |
|---|---|
| База данных | PostgreSQL 16 |
| Backend API | FastAPI + SQLAlchemy 2.0 + Alembic |
| Веб-фронтенд | React 18 + Vite + Recharts |
| Android | React Native + Expo |
| Аутентификация | JWT (access + refresh) + bcrypt |
| Реверс-прокси | Nginx (HTTPS + rate-limiting) |
| Развёртывание | Docker + docker-compose |
---
## Структура проекта
```
finance_app/
├── backend/ FastAPI-сервер
│ ├── app/
│ │ ├── models/ SQLAlchemy-модели (13 таблиц)
│ │ ├── schemas/ Pydantic-схемы
│ │ ├── auth/ JWT, RBAC
│ │ └── api/v1/ REST-роуты
│ ├── alembic/ Миграции БД
│ ├── seed.py Начальные данные
│ └── Dockerfile
├── frontend/ React-приложение
│ └── src/
│ ├── pages/ Dashboard, Accounts, Operations, Budgets,
│ │ ExchangeRates, Locations, Calendar, Admin
│ └── api/ HTTP-клиент (axios)
├── android/ React Native (Expo)
│ └── src/
│ ├── screens/ 5 экранов
│ └── navigation/ Bottom tabs
├── nginx/ nginx.conf с HTTPS
├── docs/
│ └── ER_diagram.md Mermaid ER-диаграмма
└── docker-compose.yml
```
---
## Быстрый старт (Docker)
### 1. Скопировать конфиг
```bash
cp backend/.env.example backend/.env
# Заполнить POSTGRES_PASSWORD, SECRET_KEY, ALLOWED_ORIGINS
```
### 2. Сгенерировать SSL-сертификат (dev)
```bash
mkdir -p nginx/certs
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout nginx/certs/server.key \
-out nginx/certs/server.crt \
-subj "/CN=localhost"
```
### 3. Запустить
```bash
docker-compose up --build
```
Сервисы:
- **Веб-приложение**: https://localhost
- **API**: https://localhost/api/v1
- **Swagger Docs** (только DEBUG=true): http://localhost:8000/docs
---
## Разработка без Docker
### Backend
```bash
cd backend
python -m venv .venv && .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env # настроить DATABASE_URL
alembic upgrade head
python seed.py
uvicorn app.main:app --reload
```
### Frontend
```bash
cd frontend
npm install
npm run dev # http://localhost:5173
```
### Android
```bash
cd android
npm install
npx expo start # открыть Expo Go на телефоне или запустить эмулятор
```
> В `android/src/api/client.ts` поменяйте `BASE_URL` на адрес вашего сервера.
---
## Учётные данные по умолчанию
| Роль | Логин | Пароль (из `ADMIN_PASSWORD` или по умолчанию) |
|---|---|---|
| admin | `admin` | `Admin1234!` |
⚠️ Смените пароль после первого входа!
---
## Функциональность
- **Валюты** — справочник (RUB, USD, EUR, GBP, CNY, BTC)
- **Курсы валют** — ввод вручную по датам, конвертация при операциях
- **Счета** — наличные, банк, кредитка, вклад, инвест., крипто. Хранение баланса
- **Категории** — двухуровневая иерархия (системные + пользовательские)
- **Бюджеты** — лимиты по категориям (месяц/неделя/год), прогресс-бар, перерасход
- **Местоположения** — магазины, банки, банкоматы с иерархией
- **Операции** — доходы, расходы, переводы между счетами (автобаланс)
- **Связи операций** — transfer, split, correction
- **Календарь событий** — разовые и повторяющиеся события с напоминанием
- **Дашборд** — KPI, Area-chart (30 дней), Pie по категориям, статус бюджетов
- **Управление пользователями** (admin) — роли, блокировка, управление валютами
---
## ER-диаграмма
Смотрите [docs/ER_diagram.md](docs/ER_diagram.md) — диаграмма в формате Mermaid.
---
## API-документация
При `DEBUG=true` доступны:
- Swagger UI: `/docs`
- ReDoc: `/redoc`

Binary file not shown.

16
android/App.tsx Normal file
View File

@@ -0,0 +1,16 @@
import React from "react";
import { StatusBar } from "expo-status-bar";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { AuthProvider } from "./src/contexts/AuthContext";
import AppNavigator from "./src/navigation/AppNavigator";
export default function App() {
return (
<SafeAreaProvider>
<AuthProvider>
<StatusBar style="light" />
<AppNavigator />
</AuthProvider>
</SafeAreaProvider>
);
}

25
android/app.json Normal file
View File

@@ -0,0 +1,25 @@
{
"expo": {
"name": "FinanceApp",
"slug": "finance-app",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#1e2a3a"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#1e2a3a"
},
"package": "com.financeapp",
"permissions": ["INTERNET"]
},
"plugins": ["expo-router", "expo-secure-store"],
"scheme": "financeapp"
}
}

31
android/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "finance-app-android",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios"
},
"dependencies": {
"expo": "~51.0.0",
"expo-router": "~3.5.0",
"expo-secure-store": "~13.0.0",
"expo-status-bar": "~1.12.1",
"react": "18.2.0",
"react-native": "0.74.1",
"react-native-safe-area-context": "4.10.1",
"react-native-screens": "3.31.1",
"@react-navigation/native": "^6.1.17",
"@react-navigation/bottom-tabs": "^6.5.20",
"@react-navigation/stack": "^6.3.29",
"axios": "^1.7.2",
"react-native-vector-icons": "^10.1.0",
"date-fns": "^3.6.0"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"@types/react": "~18.2.79",
"typescript": "~5.3.3"
}
}

83
android/src/api/client.ts Normal file
View File

@@ -0,0 +1,83 @@
import axios from "axios";
import * as SecureStore from "expo-secure-store";
// Change to your server address
const BASE_URL = process.env.EXPO_PUBLIC_API_URL || "https://your-server.com/api/v1";
const api = axios.create({
baseURL: BASE_URL,
headers: { "Content-Type": "application/json" },
timeout: 15000,
});
api.interceptors.request.use(async (config) => {
const token = await SecureStore.getItemAsync("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 = await SecureStore.getItemAsync("refresh_token");
if (refresh) {
try {
const { data } = await axios.post(`${BASE_URL}/auth/refresh`, null, {
params: { refresh_token: refresh },
});
await SecureStore.setItemAsync("access_token", data.access_token);
await SecureStore.setItemAsync("refresh_token", data.refresh_token);
original.headers.Authorization = `Bearer ${data.access_token}`;
return api(original);
} catch {
await SecureStore.deleteItemAsync("access_token");
await SecureStore.deleteItemAsync("refresh_token");
}
}
}
return Promise.reject(error);
}
);
// ── Auth ────────────────────────────────────────────────
export const login = (username: string, password: string) =>
api.post("/auth/login/json", { username, password });
export const getMe = () => api.get("/users/me");
// ── Accounts ─────────────────────────────────────────────
export const getAccountTypes = () => api.get("/accounts/types");
export const getAccounts = () => api.get("/accounts");
export const createAccount = (d: any) => api.post("/accounts", d);
export const updateAccount = (id: number, d: any) => api.patch(`/accounts/${id}`, d);
export const deleteAccount = (id: number) => api.delete(`/accounts/${id}`);
// ── Operations ────────────────────────────────────────────
export const getOperationTypes = () => api.get("/operations/types");
export const getOperations = (params?: any) => api.get("/operations", { params });
export const createOperation = (d: any) => api.post("/operations", d);
export const createTransfer = (d: any) => api.post("/operations/transfer", d);
export const deleteOperation = (id: number) => api.delete(`/operations/${id}`);
// ── Budgets ───────────────────────────────────────────────
export const getBudgets = () => api.get("/budgets");
export const createBudget = (d: any) => api.post("/budgets", d);
// ── Categories ────────────────────────────────────────────
export const getCategories = () => api.get("/categories");
// ── Currencies ────────────────────────────────────────────
export const getCurrencies = () => api.get("/currencies");
export const getExchangeRates = (params?: any) => api.get("/exchange-rates", { params });
// ── Locations ─────────────────────────────────────────────
export const getLocations = () => api.get("/locations");
// ── Calendar ──────────────────────────────────────────────
export const getCalendarEvents = (params?: any) => api.get("/calendar", { params });
export const createCalendarEvent = (d: any) => api.post("/calendar", d);
export const updateCalendarEvent = (id: number, d: any) => api.patch(`/calendar/${id}`, d);
export default api;

View File

@@ -0,0 +1,50 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from "react";
import * as SecureStore from "expo-secure-store";
import { login as apiLogin, getMe } from "../api/client";
interface User { id: number; username: string; email: string; role: { name: string }; }
interface AuthCtx { user: User | null; loading: boolean; login(u: string, p: string): Promise<void>; logout(): void; isAdmin: boolean; }
const AuthContext = createContext<AuthCtx | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const loadUser = useCallback(async () => {
const token = await SecureStore.getItemAsync("access_token");
if (!token) { setLoading(false); return; }
try {
const { data } = await getMe();
setUser(data);
} catch {
await SecureStore.deleteItemAsync("access_token");
await SecureStore.deleteItemAsync("refresh_token");
} finally { setLoading(false); }
}, []);
useEffect(() => { loadUser(); }, []);
const login = async (username: string, password: string) => {
const { data } = await apiLogin(username, password);
await SecureStore.setItemAsync("access_token", data.access_token);
await SecureStore.setItemAsync("refresh_token", data.refresh_token);
await loadUser();
};
const logout = async () => {
await SecureStore.deleteItemAsync("access_token");
await SecureStore.deleteItemAsync("refresh_token");
setUser(null);
};
const isAdmin = user?.role?.name === "admin";
return (
<AuthContext.Provider value={{ user, loading, login, logout, isAdmin }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext)!;

View File

@@ -0,0 +1,70 @@
import React from "react";
import { NavigationContainer } from "@react-navigation/native";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { createStackNavigator } from "@react-navigation/stack";
import { Text, ActivityIndicator, View } from "react-native";
import { useAuth } from "../contexts/AuthContext";
import LoginScreen from "../screens/LoginScreen";
import DashboardScreen from "../screens/DashboardScreen";
import AccountsScreen from "../screens/AccountsScreen";
import OperationsScreen from "../screens/OperationsScreen";
import BudgetsScreen from "../screens/BudgetsScreen";
import CalendarScreen from "../screens/CalendarScreen";
const Tab = createBottomTabNavigator();
const Stack = createStackNavigator();
const THEME = {
primary: "#3b82f6",
surface: "#ffffff",
bg: "#f4f6fa",
text: "#1e293b",
muted: "#64748b",
};
function TabNavigator() {
const tabIcon = (emoji: string) => ({ focused }: any) => (
<Text style={{ fontSize: focused ? 22 : 18 }}>{emoji}</Text>
);
return (
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarActiveTintColor: THEME.primary,
tabBarInactiveTintColor: THEME.muted,
tabBarStyle: { backgroundColor: THEME.surface, borderTopColor: "#e2e8f0", paddingBottom: 4 },
tabBarLabelStyle: { fontSize: 11, fontWeight: "600" },
}}
>
<Tab.Screen name="Дашборд" component={DashboardScreen} options={{ tabBarIcon: tabIcon("📊") }} />
<Tab.Screen name="Счета" component={AccountsScreen} options={{ tabBarIcon: tabIcon("🏦") }} />
<Tab.Screen name="Операции" component={OperationsScreen} options={{ tabBarIcon: tabIcon("💸") }} />
<Tab.Screen name="Бюджеты" component={BudgetsScreen} options={{ tabBarIcon: tabIcon("🎯") }} />
<Tab.Screen name="Календарь" component={CalendarScreen} options={{ tabBarIcon: tabIcon("📅") }} />
</Tab.Navigator>
);
}
export default function AppNavigator() {
const { user, loading } = useAuth();
if (loading) {
return (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: THEME.bg }}>
<ActivityIndicator size="large" color={THEME.primary} />
</View>
);
}
return (
<NavigationContainer>
<Stack.Navigator screenOptions={{ headerShown: false }}>
{user
? <Stack.Screen name="Main" component={TabNavigator} />
: <Stack.Screen name="Login" component={LoginScreen} />
}
</Stack.Navigator>
</NavigationContainer>
);
}

View File

@@ -0,0 +1,150 @@
import React, { useEffect, useState, useCallback } from "react";
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, TextInput, Modal, Alert, RefreshControl } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { getAccounts, getAccountTypes, getCurrencies, createAccount, deleteAccount } from "../api/client";
const C = { primary: "#3b82f6", success: "#22c55e", danger: "#ef4444", muted: "#64748b", bg: "#f4f6fa", surface: "#fff", border: "#e2e8f0" };
export default function AccountsScreen() {
const [accounts, setAccounts] = useState<any[]>([]);
const [types, setTypes] = useState<any[]>([]);
const [currencies, setCurrencies] = useState<any[]>([]);
const [modal, setModal] = useState(false);
const [form, setForm] = useState({ name: "", account_type_id: "", currency_id: "", initial_balance: "0", color: "#3b82f6" });
const [refreshing, setRefreshing] = useState(false);
const load = useCallback(async () => {
const [a, t, c] = await Promise.all([getAccounts(), getAccountTypes(), getCurrencies()]);
setAccounts(a.data); setTypes(t.data); setCurrencies(c.data.filter((c: any) => c.is_active));
}, []);
useEffect(() => { load(); }, []);
const onRefresh = async () => { setRefreshing(true); await load(); setRefreshing(false); };
const save = async () => {
if (!form.name || !form.account_type_id || !form.currency_id) {
Alert.alert("Ошибка", "Заполните все обязательные поля"); return;
}
try {
await createAccount({ name: form.name, account_type_id: parseInt(form.account_type_id), currency_id: parseInt(form.currency_id), initial_balance: parseFloat(form.initial_balance) || 0, color: form.color });
await load(); setModal(false);
} catch (e: any) { Alert.alert("Ошибка", e.response?.data?.detail || "Не удалось создать счёт"); }
};
const remove = (id: number) => {
Alert.alert("Удалить счёт?", "Все операции по счёту останутся.", [
{ text: "Отмена" },
{ text: "Удалить", style: "destructive", onPress: async () => { await deleteAccount(id); load(); } },
]);
};
const fmt = (v: number) => v.toLocaleString("ru-RU", { minimumFractionDigits: 2 });
return (
<SafeAreaView style={{ flex: 1, backgroundColor: C.bg }}>
<View style={s.header}>
<Text style={s.title}>Счета</Text>
<TouchableOpacity style={s.addBtn} onPress={() => setModal(true)}>
<Text style={s.addBtnText}>+ Добавить</Text>
</TouchableOpacity>
</View>
<ScrollView contentContainerStyle={{ padding: 16 }} refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
{accounts.length === 0
? <Text style={s.empty}>Счетов нет. Нажмите «+ Добавить»</Text>
: accounts.map((a: any) => (
<View key={a.id} style={s.card}>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<View style={[s.dot, { backgroundColor: a.color || "#94a3b8" }]} />
<View style={{ flex: 1 }}>
<Text style={s.cardTitle}>{a.name}</Text>
<Text style={s.cardSub}>{a.account_type?.name} · {a.currency?.code}</Text>
</View>
<View style={{ alignItems: "flex-end" }}>
<Text style={[s.balance, { color: parseFloat(a.balance) >= 0 ? C.success : C.danger }]}>
{fmt(parseFloat(a.balance))} {a.currency?.symbol}
</Text>
<Text style={s.cardSub}>{a.is_active ? "Активен" : "Закрыт"}</Text>
</View>
</View>
<View style={s.cardActions}>
<TouchableOpacity style={s.deleteBtn} onPress={() => remove(a.id)}>
<Text style={s.deleteText}>🗑️ Удалить</Text>
</TouchableOpacity>
</View>
</View>
))
}
</ScrollView>
<Modal visible={modal} animationType="slide" presentationStyle="pageSheet">
<SafeAreaView style={{ flex: 1, backgroundColor: C.bg }}>
<View style={s.modalHeader}>
<Text style={s.modalTitle}>Новый счёт</Text>
<TouchableOpacity onPress={() => setModal(false)}><Text style={{ color: C.danger, fontSize: 16 }}>✕</Text></TouchableOpacity>
</View>
<ScrollView contentContainerStyle={{ padding: 20 }}>
<Text style={s.label}>Название *</Text>
<TextInput style={s.input} value={form.name} onChangeText={v => setForm({ ...form, name: v })} placeholder="Например: Наличные" />
<Text style={s.label}>Тип счёта *</Text>
{types.map((t: any) => (
<TouchableOpacity key={t.id} onPress={() => setForm({ ...form, account_type_id: String(t.id) })}
style={[s.radioItem, form.account_type_id === String(t.id) && s.radioSelected]}>
<Text style={form.account_type_id === String(t.id) ? s.radioTextSelected : s.radioText}>{t.name}</Text>
</TouchableOpacity>
))}
<Text style={[s.label, { marginTop: 12 }]}>Валюта *</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
{currencies.map((c: any) => (
<TouchableOpacity key={c.id} onPress={() => setForm({ ...form, currency_id: String(c.id) })}
style={[s.currencyChip, form.currency_id === String(c.id) && s.currencyChipSelected]}>
<Text style={form.currency_id === String(c.id) ? { color: "#fff", fontWeight: "700" } : { color: C.muted }}>{c.code}</Text>
</TouchableOpacity>
))}
</ScrollView>
<Text style={[s.label, { marginTop: 12 }]}>Начальный баланс</Text>
<TextInput style={s.input} value={form.initial_balance} onChangeText={v => setForm({ ...form, initial_balance: v })} keyboardType="decimal-pad" />
<TouchableOpacity style={s.saveBtn} onPress={save}>
<Text style={s.saveBtnText}>Создать счёт</Text>
</TouchableOpacity>
</ScrollView>
</SafeAreaView>
</Modal>
</SafeAreaView>
);
}
const s = StyleSheet.create({
header: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", padding: 16, backgroundColor: "#1e2a3a" },
title: { color: "#fff", fontSize: 20, fontWeight: "800" },
addBtn: { backgroundColor: C.primary, paddingVertical: 7, paddingHorizontal: 14, borderRadius: 8 },
addBtnText: { color: "#fff", fontWeight: "700", fontSize: 13 },
empty: { textAlign: "center", color: C.muted, padding: 40, fontSize: 15 },
card: { backgroundColor: C.surface, borderRadius: 12, padding: 16, marginBottom: 10, shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.06, shadowRadius: 4, elevation: 2 },
dot: { width: 12, height: 12, borderRadius: 6, marginRight: 12 },
cardTitle: { fontSize: 15, fontWeight: "700", color: "#1e293b" },
cardSub: { fontSize: 12, color: C.muted, marginTop: 2 },
balance: { fontSize: 16, fontWeight: "800" },
cardActions: { flexDirection: "row", justifyContent: "flex-end", marginTop: 10, paddingTop: 10, borderTopWidth: 1, borderTopColor: C.border },
deleteBtn: { paddingVertical: 4, paddingHorizontal: 12 },
deleteText: { color: C.danger, fontSize: 13 },
modalHeader: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", padding: 20, borderBottomWidth: 1, borderBottomColor: C.border },
modalTitle: { fontSize: 18, fontWeight: "800", color: "#1e293b" },
label: { fontSize: 13, fontWeight: "600", color: "#1e293b", marginBottom: 6 },
input: { borderWidth: 1, borderColor: C.border, borderRadius: 8, padding: 12, fontSize: 15, marginBottom: 14, backgroundColor: C.surface, color: "#1e293b" },
radioItem: { padding: 12, borderRadius: 8, borderWidth: 1, borderColor: C.border, marginBottom: 6, backgroundColor: C.surface },
radioSelected: { borderColor: C.primary, backgroundColor: "#dbeafe" },
radioText: { color: "#1e293b" },
radioTextSelected: { color: C.primary, fontWeight: "700" },
currencyChip: { paddingVertical: 8, paddingHorizontal: 16, borderRadius: 20, borderWidth: 1, borderColor: C.border, marginRight: 8, backgroundColor: C.surface },
currencyChipSelected: { backgroundColor: C.primary, borderColor: C.primary },
saveBtn: { backgroundColor: C.primary, borderRadius: 12, padding: 16, alignItems: "center", marginTop: 24 },
saveBtnText: { color: "#fff", fontWeight: "800", fontSize: 16 },
});
const C_primary = "#3b82f6";

View File

@@ -0,0 +1,94 @@
import React, { useEffect, useState, useCallback } from "react";
import { View, Text, ScrollView, StyleSheet, RefreshControl } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { getBudgets } from "../api/client";
const C = { primary: "#3b82f6", success: "#22c55e", danger: "#ef4444", muted: "#64748b", bg: "#f4f6fa", surface: "#fff", border: "#e2e8f0" };
const PERIOD: Record<string, string> = { monthly: "Ежемесячно", weekly: "Еженедельно", yearly: "Ежегодно", custom: "Произвольный" };
export default function BudgetsScreen() {
const [budgets, setBudgets] = useState<any[]>([]);
const [refreshing, setRefreshing] = useState(false);
const load = useCallback(async () => {
const { data } = await getBudgets();
setBudgets(data);
}, []);
useEffect(() => { load(); }, []);
const onRefresh = async () => { setRefreshing(true); await load(); setRefreshing(false); };
const fmt = (v: number) => v.toLocaleString("ru-RU", { minimumFractionDigits: 2 });
const active = budgets.filter((b: any) => b.is_active);
const inactive = budgets.filter((b: any) => !b.is_active);
const BudgetCard = ({ b }: { b: any }) => {
const spent = parseFloat(b.spent || 0);
const limit = parseFloat(b.amount);
const pct = limit > 0 ? Math.min(spent / limit, 1) : 0;
const over = spent > limit;
return (
<View style={s.card}>
<View style={{ flexDirection: "row", justifyContent: "space-between", marginBottom: 4 }}>
<Text style={s.cardTitle}>{b.name}</Text>
<View style={[s.badge, { backgroundColor: b.is_active ? "#dcfce7" : "#f1f5f9" }]}>
<Text style={{ color: b.is_active ? "#166534" : C.muted, fontSize: 11, fontWeight: "700" }}>{b.is_active ? "Активен" : "Неактивен"}</Text>
</View>
</View>
<Text style={s.cardSub}>{b.category?.name} · {PERIOD[b.period_type] || b.period_type}</Text>
<View style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 10, marginBottom: 6 }}>
<Text style={{ fontSize: 13, color: C.muted }}>Потрачено: <Text style={{ fontWeight: "700", color: over ? C.danger : "#1e293b" }}>{fmt(spent)}</Text></Text>
<Text style={{ fontSize: 13, color: C.muted }}>Лимит: <Text style={{ fontWeight: "700", color: "#1e293b" }}>{fmt(limit)} {b.currency?.symbol}</Text></Text>
</View>
<View style={s.progressBg}>
<View style={[s.progressFill, { width: `${pct * 100}%`, backgroundColor: over ? C.danger : C.primary }]} />
</View>
{over
? <Text style={{ color: C.danger, fontSize: 12, marginTop: 6, fontWeight: "600" }}>Перерасход: {fmt(spent - limit)}</Text>
: <Text style={{ color: C.success, fontSize: 12, marginTop: 6 }}>Остаток: {fmt(limit - spent)}</Text>
}
</View>
);
};
return (
<SafeAreaView style={{ flex: 1, backgroundColor: C.bg }}>
<View style={s.header}>
<Text style={s.title}>Бюджеты</Text>
</View>
<ScrollView contentContainerStyle={{ padding: 16 }} refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
{budgets.length === 0
? <Text style={s.empty}>Бюджеты не настроены. Добавьте их в веб-версии.</Text>
: <>
{active.length > 0 && (
<>
<Text style={s.sectionTitle}>Активные</Text>
{active.map((b: any) => <BudgetCard key={b.id} b={b} />)}
</>
)}
{inactive.length > 0 && (
<>
<Text style={s.sectionTitle}>Неактивные</Text>
{inactive.map((b: any) => <BudgetCard key={b.id} b={b} />)}
</>
)}
</>
}
</ScrollView>
</SafeAreaView>
);
}
const s = StyleSheet.create({
header: { padding: 16, backgroundColor: "#1e2a3a" },
title: { color: "#fff", fontSize: 20, fontWeight: "800" },
empty: { textAlign: "center", color: "#64748b", padding: 40, fontSize: 15 },
sectionTitle: { fontSize: 15, fontWeight: "700", color: "#1e293b", marginTop: 12, marginBottom: 8 },
card: { backgroundColor: "#fff", borderRadius: 12, padding: 16, marginBottom: 10, shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.06, shadowRadius: 4, elevation: 2 },
cardTitle: { fontSize: 15, fontWeight: "700", color: "#1e293b" },
cardSub: { fontSize: 12, color: "#64748b", marginTop: 2 },
badge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 12 },
progressBg: { height: 10, backgroundColor: "#e2e8f0", borderRadius: 5, overflow: "hidden" },
progressFill: { height: 10, borderRadius: 5 },
});

View File

@@ -0,0 +1,118 @@
import React, { useEffect, useState, useCallback } from "react";
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, RefreshControl } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { getCalendarEvents, updateCalendarEvent } from "../api/client";
import { format, startOfMonth, endOfMonth, addMonths, subMonths } from "date-fns";
import { ru } from "date-fns/locale";
const C = { primary: "#3b82f6", success: "#22c55e", warning: "#f59e0b", muted: "#64748b", bg: "#f4f6fa", surface: "#fff", border: "#e2e8f0", danger: "#ef4444" };
export default function CalendarScreen() {
const [events, setEvents] = useState<any[]>([]);
const [currentMonth, setCurrentMonth] = useState(new Date());
const [refreshing, setRefreshing] = useState(false);
const load = useCallback(async () => {
const from = format(startOfMonth(currentMonth), "yyyy-MM-dd");
const to = format(endOfMonth(currentMonth), "yyyy-MM-dd");
const { data } = await getCalendarEvents({ date_from: from, date_to: to, limit: 500 });
setEvents(data);
}, [currentMonth]);
useEffect(() => { load(); }, [currentMonth]);
const onRefresh = async () => { setRefreshing(true); await load(); setRefreshing(false); };
const toggle = async (ev: any) => {
await updateCalendarEvent(ev.id, { is_completed: !ev.is_completed });
load();
};
// Group events by date
const grouped: Record<string, any[]> = {};
events.forEach((ev: any) => {
if (!grouped[ev.event_date]) grouped[ev.event_date] = [];
grouped[ev.event_date].push(ev);
});
const sortedDates = Object.keys(grouped).sort();
const pending = events.filter((e: any) => !e.is_completed).length;
const completed = events.filter((e: any) => e.is_completed).length;
return (
<SafeAreaView style={{ flex: 1, backgroundColor: C.bg }}>
<View style={s.header}>
<TouchableOpacity onPress={() => setCurrentMonth(subMonths(currentMonth, 1))}>
<Text style={s.navArrow}>‹</Text>
</TouchableOpacity>
<View>
<Text style={s.title}>Календарь</Text>
<Text style={s.subtitle}>{format(currentMonth, "LLLL yyyy", { locale: ru })}</Text>
</View>
<TouchableOpacity onPress={() => setCurrentMonth(addMonths(currentMonth, 1))}>
<Text style={s.navArrow}>›</Text>
</TouchableOpacity>
</View>
{/* Stats */}
<View style={s.statsRow}>
<View style={[s.statCard, { flex: 1, marginRight: 8 }]}>
<Text style={s.statNum}>{pending}</Text>
<Text style={s.statLabel}>Ожидается</Text>
</View>
<View style={[s.statCard, { flex: 1 }]}>
<Text style={[s.statNum, { color: C.success }]}>{completed}</Text>
<Text style={s.statLabel}>Выполнено</Text>
</View>
</View>
<ScrollView contentContainerStyle={{ padding: 16 }} refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
{sortedDates.length === 0
? <Text style={s.empty}>Событий в этом месяце нет</Text>
: sortedDates.map(date => (
<View key={date} style={{ marginBottom: 16 }}>
<Text style={s.dateHeader}>{format(new Date(date), "d MMMM, EEEE", { locale: ru })}</Text>
{grouped[date].map((ev: any) => (
<View key={ev.id} style={[s.eventCard, ev.is_completed && s.eventCompleted]}>
<TouchableOpacity onPress={() => toggle(ev)} style={s.checkBtn}>
<Text style={{ fontSize: 22 }}>{ev.is_completed ? "✅" : "⏳"}</Text>
</TouchableOpacity>
<View style={{ flex: 1 }}>
<Text style={[s.eventTitle, ev.is_completed && s.strikethrough]}>{ev.title}</Text>
<View style={{ flexDirection: "row", gap: 8, flexWrap: "wrap", marginTop: 4 }}>
{ev.event_time && <Text style={s.tag}>🕐 {ev.event_time.slice(0, 5)}</Text>}
{ev.amount && <Text style={s.tag}>💰 {parseFloat(ev.amount).toLocaleString("ru-RU")}</Text>}
{ev.category?.name && <Text style={s.tag}>{ev.category.name}</Text>}
{ev.is_recurring && <Text style={[s.tag, { color: C.primary }]}>🔁 {ev.recurrence_pattern}</Text>}
{ev.reminder_days && <Text style={s.tag}>🔔 за {ev.reminder_days} дн.</Text>}
</View>
{ev.description && <Text style={s.eventDesc}>{ev.description}</Text>}
</View>
</View>
))}
</View>
))
}
</ScrollView>
</SafeAreaView>
);
}
const s = StyleSheet.create({
header: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", padding: 16, backgroundColor: "#1e2a3a" },
navArrow: { color: "#cbd5e1", fontSize: 28, fontWeight: "300", paddingHorizontal: 8 },
title: { color: "#fff", fontSize: 20, fontWeight: "800", textAlign: "center" },
subtitle: { color: "#94a3b8", fontSize: 13, textAlign: "center", marginTop: 2 },
statsRow: { flexDirection: "row", padding: 12, gap: 0 },
statCard: { backgroundColor: "#fff", borderRadius: 10, padding: 12, alignItems: "center", shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 3, elevation: 1 },
statNum: { fontSize: 24, fontWeight: "800", color: "#1e293b" },
statLabel: { fontSize: 12, color: "#64748b", marginTop: 2 },
empty: { textAlign: "center", color: "#64748b", padding: 40, fontSize: 15 },
dateHeader: { fontSize: 14, fontWeight: "700", color: "#64748b", textTransform: "capitalize", marginBottom: 8 },
eventCard: { backgroundColor: "#fff", borderRadius: 12, padding: 14, marginBottom: 8, flexDirection: "row", alignItems: "flex-start", shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.06, shadowRadius: 4, elevation: 2 },
eventCompleted: { opacity: 0.6, backgroundColor: "#f8fafc" },
checkBtn: { marginRight: 12, paddingTop: 2 },
eventTitle: { fontSize: 15, fontWeight: "700", color: "#1e293b" },
strikethrough: { textDecorationLine: "line-through", color: "#94a3b8" },
tag: { fontSize: 11, color: "#64748b", backgroundColor: "#f1f5f9", paddingHorizontal: 8, paddingVertical: 3, borderRadius: 10 },
eventDesc: { fontSize: 12, color: "#94a3b8", marginTop: 6, fontStyle: "italic" },
});

View File

@@ -0,0 +1,155 @@
import React, { useEffect, useState, useCallback } from "react";
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, RefreshControl } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { getAccounts, getOperations, getBudgets } from "../api/client";
import { useAuth } from "../contexts/AuthContext";
import { startOfMonth, endOfMonth, format } from "date-fns";
const C = { primary: "#3b82f6", success: "#22c55e", danger: "#ef4444", muted: "#64748b", bg: "#f4f6fa", surface: "#fff", border: "#e2e8f0" };
const fmt = (v: number) => v.toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
export default function DashboardScreen() {
const { user, logout } = useAuth();
const [accounts, setAccounts] = useState<any[]>([]);
const [operations, setOperations] = useState<any[]>([]);
const [budgets, setBudgets] = useState<any[]>([]);
const [refreshing, setRefreshing] = useState(false);
const load = useCallback(async () => {
const from = startOfMonth(new Date()).toISOString();
const to = endOfMonth(new Date()).toISOString();
const [a, o, b] = await Promise.all([
getAccounts(), getOperations({ date_from: from, date_to: to, limit: 500 }), getBudgets(),
]);
setAccounts(a.data); setOperations(o.data); setBudgets(b.data);
}, []);
useEffect(() => { load(); }, []);
const onRefresh = async () => { setRefreshing(true); await load(); setRefreshing(false); };
const totalBalance = accounts.reduce((s: number, a: any) => s + parseFloat(a.balance), 0);
const income = operations.filter((o: any) => o.operation_type?.name === "income").reduce((s: number, o: any) => s + parseFloat(o.amount), 0);
const expense = operations.filter((o: any) => o.operation_type?.name === "expense").reduce((s: number, o: any) => s + parseFloat(o.amount), 0);
return (
<SafeAreaView style={{ flex: 1, backgroundColor: C.bg }}>
<View style={s.header}>
<View>
<Text style={s.headerTitle}>Дашборд</Text>
<Text style={s.headerSub}>{user?.username}</Text>
</View>
<TouchableOpacity onPress={logout} style={s.logoutBtn}>
<Text style={s.logoutText}>Выйти</Text>
</TouchableOpacity>
</View>
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16 }} refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
{/* KPI Cards */}
<View style={s.kpiRow}>
<View style={[s.kpiCard, { flex: 1 }]}>
<Text style={s.kpiLabel}>Общий баланс</Text>
<Text style={[s.kpiValue, { fontSize: 18 }]}>{fmt(totalBalance)} ₽</Text>
<Text style={s.kpiSub}>{accounts.length} счётов</Text>
</View>
</View>
<View style={s.kpiRow}>
<View style={[s.kpiCard, { flex: 1, marginRight: 8 }]}>
<Text style={s.kpiLabel}>Доходы (мес.)</Text>
<Text style={[s.kpiValue, { color: C.success }]}>{fmt(income)}</Text>
</View>
<View style={[s.kpiCard, { flex: 1 }]}>
<Text style={s.kpiLabel}>Расходы (мес.)</Text>
<Text style={[s.kpiValue, { color: C.danger }]}>{fmt(expense)}</Text>
</View>
</View>
{/* Accounts */}
<Text style={s.sectionTitle}>Мои счета</Text>
{accounts.length === 0
? <Text style={s.empty}>Счетов нет</Text>
: accounts.map((a: any) => (
<View key={a.id} style={s.listItem}>
<View style={[s.colorDot, { backgroundColor: a.color || "#94a3b8" }]} />
<View style={{ flex: 1 }}>
<Text style={s.itemTitle}>{a.name}</Text>
<Text style={s.itemSub}>{a.account_type?.name} · {a.currency?.code}</Text>
</View>
<Text style={[s.itemAmount, { color: parseFloat(a.balance) >= 0 ? C.success : C.danger }]}>
{fmt(parseFloat(a.balance))} {a.currency?.symbol}
</Text>
</View>
))
}
{/* Active budgets */}
{budgets.filter((b: any) => b.is_active).length > 0 && (
<>
<Text style={s.sectionTitle}>Бюджеты</Text>
{budgets.filter((b: any) => b.is_active).map((b: any) => {
const spent = parseFloat(b.spent || 0);
const limit = parseFloat(b.amount);
const pct = limit > 0 ? Math.min(spent / limit, 1) : 0;
const over = spent > limit;
return (
<View key={b.id} style={s.card}>
<View style={{ flexDirection: "row", justifyContent: "space-between", marginBottom: 6 }}>
<Text style={{ fontWeight: "600", color: "#1e293b" }}>{b.name}</Text>
<Text style={{ color: over ? C.danger : C.muted, fontSize: 13 }}>{fmt(spent)} / {fmt(limit)}</Text>
</View>
<View style={s.progressBg}>
<View style={[s.progressFill, { width: `${pct * 100}%`, backgroundColor: over ? C.danger : C.primary }]} />
</View>
{over && <Text style={{ color: C.danger, fontSize: 12, marginTop: 4 }}>Перерасход: {fmt(spent - limit)}</Text>}
</View>
);
})}
</>
)}
{/* Recent operations */}
<Text style={s.sectionTitle}>Последние операции</Text>
{operations.slice(0, 10).map((o: any) => {
const isIncome = o.operation_type?.name === "income";
const isExpense = o.operation_type?.name === "expense";
return (
<View key={o.id} style={s.listItem}>
<Text style={{ fontSize: 20, marginRight: 12 }}>{isIncome ? "📈" : isExpense ? "📉" : "🔄"}</Text>
<View style={{ flex: 1 }}>
<Text style={s.itemTitle}>{o.category?.name || o.description || "—"}</Text>
<Text style={s.itemSub}>{o.date ? format(new Date(o.date), "dd.MM.yy HH:mm") : ""}</Text>
</View>
<Text style={[s.itemAmount, { color: isIncome ? C.success : isExpense ? C.danger : C.muted }]}>
{isIncome ? "+" : isExpense ? "−" : ""}{fmt(parseFloat(o.amount))} {o.currency?.symbol}
</Text>
</View>
);
})}
</ScrollView>
</SafeAreaView>
);
}
const s = StyleSheet.create({
header: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", padding: 16, backgroundColor: "#1e2a3a" },
headerTitle: { color: "#fff", fontSize: 20, fontWeight: "800" },
headerSub: { color: "#94a3b8", fontSize: 13, marginTop: 2 },
logoutBtn: { paddingVertical: 6, paddingHorizontal: 14, borderRadius: 8, borderWidth: 1, borderColor: "rgba(255,255,255,0.3)" },
logoutText: { color: "#cbd5e1", fontSize: 13, fontWeight: "600" },
kpiRow: { flexDirection: "row", marginBottom: 10 },
kpiCard: { backgroundColor: "#fff", borderRadius: 12, padding: 16, shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.06, shadowRadius: 4, elevation: 2 },
kpiLabel: { fontSize: 11, fontWeight: "600", color: "#64748b", textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 4 },
kpiValue: { fontSize: 22, fontWeight: "800", color: "#1e293b" },
kpiSub: { fontSize: 12, color: "#64748b", marginTop: 2 },
sectionTitle: { fontSize: 15, fontWeight: "700", color: "#1e293b", marginTop: 20, marginBottom: 10 },
empty: { color: "#64748b", textAlign: "center", padding: 20 },
listItem: { flexDirection: "row", alignItems: "center", backgroundColor: "#fff", borderRadius: 10, padding: 14, marginBottom: 8, shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.04, shadowRadius: 3, elevation: 1 },
colorDot: { width: 10, height: 10, borderRadius: 5, marginRight: 12 },
itemTitle: { fontSize: 14, fontWeight: "600", color: "#1e293b" },
itemSub: { fontSize: 12, color: "#64748b", marginTop: 2 },
itemAmount: { fontWeight: "700", fontSize: 14 },
card: { backgroundColor: "#fff", borderRadius: 12, padding: 14, marginBottom: 10, shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.06, shadowRadius: 4, elevation: 2 },
progressBg: { height: 8, backgroundColor: "#e2e8f0", borderRadius: 4 },
progressFill: { height: 8, borderRadius: 4 },
});

View File

@@ -0,0 +1,54 @@
import React, { useState } from "react";
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, KeyboardAvoidingView, Platform, ActivityIndicator } from "react-native";
import { useAuth } from "../contexts/AuthContext";
export default function LoginScreen() {
const { login } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const handle = async () => {
if (!username || !password) return;
setLoading(true);
try {
await login(username, password);
} catch (err: any) {
Alert.alert("Ошибка", err.response?.data?.detail || "Неверный логин или пароль");
} finally {
setLoading(false);
}
};
return (
<KeyboardAvoidingView style={s.container} behavior={Platform.OS === "ios" ? "padding" : undefined}>
<View style={s.card}>
<Text style={s.logo}>Finance<Text style={s.accent}>App</Text></Text>
<Text style={s.subtitle}>Управление личными финансами</Text>
<Text style={s.label}>Логин или Email</Text>
<TextInput style={s.input} value={username} onChangeText={setUsername} autoCapitalize="none" autoCorrect={false} returnKeyType="next" />
<Text style={s.label}>Пароль</Text>
<TextInput style={s.input} value={password} onChangeText={setPassword} secureTextEntry returnKeyType="done" onSubmitEditing={handle} />
<TouchableOpacity style={[s.btn, loading && s.btnDisabled]} onPress={handle} disabled={loading}>
{loading ? <ActivityIndicator color="#fff" /> : <Text style={s.btnText}>Войти</Text>}
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: "#f4f6fa", alignItems: "center", justifyContent: "center", padding: 20 },
card: { width: "100%", maxWidth: 400, backgroundColor: "#fff", borderRadius: 16, padding: 32, shadowColor: "#000", shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.08, shadowRadius: 8, elevation: 4 },
logo: { fontSize: 28, fontWeight: "800", textAlign: "center", color: "#1e293b", marginBottom: 4 },
accent: { color: "#3b82f6" },
subtitle: { textAlign: "center", color: "#64748b", marginBottom: 28, fontSize: 14 },
label: { fontSize: 14, fontWeight: "600", color: "#1e293b", marginBottom: 6 },
input: { borderWidth: 1, borderColor: "#e2e8f0", borderRadius: 8, padding: 12, fontSize: 15, marginBottom: 16, color: "#1e293b" },
btn: { backgroundColor: "#3b82f6", borderRadius: 10, padding: 14, alignItems: "center", marginTop: 4 },
btnDisabled: { opacity: 0.6 },
btnText: { color: "#fff", fontWeight: "700", fontSize: 16 },
});

View File

@@ -0,0 +1,171 @@
import React, { useEffect, useState, useCallback } from "react";
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, TextInput, Modal, Alert, RefreshControl } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { getOperations, getOperationTypes, getAccounts, getCategories, getCurrencies, createOperation, deleteOperation } from "../api/client";
import { format } from "date-fns";
const C = { primary: "#3b82f6", success: "#22c55e", danger: "#ef4444", warning: "#f59e0b", muted: "#64748b", bg: "#f4f6fa", surface: "#fff", border: "#e2e8f0" };
const TYPE_LABELS: Record<string, string> = { income: "Доход", expense: "Расход", transfer: "Перевод" };
export default function OperationsScreen() {
const [ops, setOps] = useState<any[]>([]);
const [opTypes, setOpTypes] = useState<any[]>([]);
const [accounts, setAccounts] = useState<any[]>([]);
const [categories, setCategories] = useState<any[]>([]);
const [currencies, setCurrencies] = useState<any[]>([]);
const [modal, setModal] = useState(false);
const [form, setForm] = useState({ account_id: "", operation_type_id: "", amount: "", currency_id: "", category_id: "", description: "", date: format(new Date(), "yyyy-MM-dd'T'HH:mm") });
const [refreshing, setRefreshing] = useState(false);
const load = useCallback(async () => {
const [o, ot, a, c, cu] = await Promise.all([
getOperations({ limit: 100 }), getOperationTypes(), getAccounts(), getCategories(), getCurrencies()
]);
setOps(o.data); setOpTypes(ot.data); setAccounts(a.data); setCategories(c.data); setCurrencies(cu.data.filter((c: any) => c.is_active));
}, []);
useEffect(() => { load(); }, []);
const onRefresh = async () => { setRefreshing(true); await load(); setRefreshing(false); };
const save = async () => {
if (!form.account_id || !form.operation_type_id || !form.amount || !form.currency_id) {
Alert.alert("Ошибка", "Заполните счёт, тип, сумму и валюту"); return;
}
try {
await createOperation({ 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, description: form.description || null, date: form.date });
await load(); setModal(false);
} catch (e: any) { Alert.alert("Ошибка", e.response?.data?.detail || "Не удалось создать операцию"); }
};
const remove = (id: number) => {
Alert.alert("Удалить операцию?", "", [
{ text: "Отмена" },
{ text: "Удалить", style: "destructive", onPress: async () => { await deleteOperation(id); load(); } },
]);
};
const typeName = (id: number) => opTypes.find((t: any) => t.id === id)?.name || "";
const fmt = (v: number) => v.toLocaleString("ru-RU", { minimumFractionDigits: 2 });
return (
<SafeAreaView style={{ flex: 1, backgroundColor: C.bg }}>
<View style={s.header}>
<Text style={s.title}>Операции</Text>
<TouchableOpacity style={s.addBtn} onPress={() => setModal(true)}>
<Text style={s.addBtnText}>+ Добавить</Text>
</TouchableOpacity>
</View>
<ScrollView contentContainerStyle={{ padding: 16 }} refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
{ops.length === 0
? <Text style={s.empty}>Операций нет</Text>
: ops.map((o: any) => {
const tn = typeName(o.operation_type_id);
const color = tn === "income" ? C.success : tn === "expense" ? C.danger : C.warning;
const sign = tn === "income" ? "+" : tn === "expense" ? "−" : "↔";
return (
<View key={o.id} style={s.card}>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Text style={{ fontSize: 24, marginRight: 12 }}>{tn === "income" ? "📈" : tn === "expense" ? "📉" : "🔄"}</Text>
<View style={{ flex: 1 }}>
<Text style={s.cardTitle}>{o.category?.name || o.description || TYPE_LABELS[tn] || "—"}</Text>
<Text style={s.cardSub}>{o.date ? format(new Date(o.date), "dd.MM.yy HH:mm") : "—"} · {accounts.find((a: any) => a.id === o.account_id)?.name}</Text>
{o.description && o.category?.name && <Text style={[s.cardSub, { fontStyle: "italic" }]}>{o.description}</Text>}
</View>
<View style={{ alignItems: "flex-end" }}>
<Text style={[s.amount, { color }]}>{sign}{fmt(parseFloat(o.amount))} {o.currency?.symbol}</Text>
<Text style={[s.cardSub, { color: o.is_planned ? C.warning : C.success }]}>{o.is_planned ? "Запланировано" : "Выполнено"}</Text>
</View>
</View>
<TouchableOpacity onPress={() => remove(o.id)} style={s.deleteRow}>
<Text style={{ color: C.danger, fontSize: 12 }}>🗑️ Удалить</Text>
</TouchableOpacity>
</View>
);
})
}
</ScrollView>
<Modal visible={modal} animationType="slide" presentationStyle="pageSheet">
<SafeAreaView style={{ flex: 1, backgroundColor: C.bg }}>
<View style={s.modalHeader}>
<Text style={s.modalTitle}>Новая операция</Text>
<TouchableOpacity onPress={() => setModal(false)}><Text style={{ color: C.danger, fontSize: 16 }}>✕</Text></TouchableOpacity>
</View>
<ScrollView contentContainerStyle={{ padding: 20 }}>
<Text style={s.label}>Тип операции *</Text>
{opTypes.filter((t: any) => t.name !== "transfer").map((t: any) => (
<TouchableOpacity key={t.id} onPress={() => setForm({ ...form, operation_type_id: String(t.id) })}
style={[s.radioItem, form.operation_type_id === String(t.id) && s.radioSelected]}>
<Text style={form.operation_type_id === String(t.id) ? s.radioTextSel : s.radioText}>{TYPE_LABELS[t.name] || t.name}</Text>
</TouchableOpacity>
))}
<Text style={[s.label, { marginTop: 12 }]}>Счёт *</Text>
{accounts.map((a: any) => (
<TouchableOpacity key={a.id} onPress={() => setForm({ ...form, account_id: String(a.id) })}
style={[s.radioItem, form.account_id === String(a.id) && s.radioSelected]}>
<Text style={form.account_id === String(a.id) ? s.radioTextSel : s.radioText}>{a.name} ({a.currency?.code})</Text>
</TouchableOpacity>
))}
<Text style={[s.label, { marginTop: 12 }]}>Сумма *</Text>
<TextInput style={s.input} value={form.amount} onChangeText={v => setForm({ ...form, amount: v })} keyboardType="decimal-pad" placeholder="0.00" />
<Text style={s.label}>Валюта *</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ marginBottom: 14 }}>
{currencies.map((c: any) => (
<TouchableOpacity key={c.id} onPress={() => setForm({ ...form, currency_id: String(c.id) })}
style={[s.chip, form.currency_id === String(c.id) && s.chipSel]}>
<Text style={form.currency_id === String(c.id) ? { color: "#fff", fontWeight: "700" } : { color: C.muted }}>{c.code}</Text>
</TouchableOpacity>
))}
</ScrollView>
<Text style={s.label}>Категория</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ marginBottom: 14 }}>
{[{ id: "", name: "Без категории" }, ...categories].map((c: any) => (
<TouchableOpacity key={c.id} onPress={() => setForm({ ...form, category_id: String(c.id) })}
style={[s.chip, form.category_id === String(c.id) && s.chipSel]}>
<Text style={form.category_id === String(c.id) ? { color: "#fff", fontSize: 12 } : { color: C.muted, fontSize: 12 }}>{c.name}</Text>
</TouchableOpacity>
))}
</ScrollView>
<Text style={s.label}>Описание</Text>
<TextInput style={s.input} value={form.description} onChangeText={v => setForm({ ...form, description: v })} placeholder="Необязательно" />
<TouchableOpacity style={s.saveBtn} onPress={save}>
<Text style={s.saveBtnText}>Сохранить</Text>
</TouchableOpacity>
</ScrollView>
</SafeAreaView>
</Modal>
</SafeAreaView>
);
}
const s = StyleSheet.create({
header: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", padding: 16, backgroundColor: "#1e2a3a" },
title: { color: "#fff", fontSize: 20, fontWeight: "800" },
addBtn: { backgroundColor: "#3b82f6", paddingVertical: 7, paddingHorizontal: 14, borderRadius: 8 },
addBtnText: { color: "#fff", fontWeight: "700", fontSize: 13 },
empty: { textAlign: "center", color: "#64748b", padding: 40, fontSize: 15 },
card: { backgroundColor: "#fff", borderRadius: 12, padding: 14, marginBottom: 10, shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.06, shadowRadius: 4, elevation: 2 },
cardTitle: { fontSize: 14, fontWeight: "700", color: "#1e293b" },
cardSub: { fontSize: 12, color: "#64748b", marginTop: 2 },
amount: { fontSize: 15, fontWeight: "800" },
deleteRow: { flexDirection: "row", justifyContent: "flex-end", paddingTop: 8, marginTop: 8, borderTopWidth: 1, borderTopColor: "#e2e8f0" },
modalHeader: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", padding: 20, borderBottomWidth: 1, borderBottomColor: "#e2e8f0" },
modalTitle: { fontSize: 18, fontWeight: "800", color: "#1e293b" },
label: { fontSize: 13, fontWeight: "600", color: "#1e293b", marginBottom: 6 },
input: { borderWidth: 1, borderColor: "#e2e8f0", borderRadius: 8, padding: 12, fontSize: 15, marginBottom: 14, backgroundColor: "#fff", color: "#1e293b" },
radioItem: { padding: 12, borderRadius: 8, borderWidth: 1, borderColor: "#e2e8f0", marginBottom: 6, backgroundColor: "#fff" },
radioSelected: { borderColor: "#3b82f6", backgroundColor: "#dbeafe" },
radioText: { color: "#1e293b" },
radioTextSel: { color: "#3b82f6", fontWeight: "700" },
chip: { paddingVertical: 7, paddingHorizontal: 14, borderRadius: 20, borderWidth: 1, borderColor: "#e2e8f0", marginRight: 8, backgroundColor: "#fff" },
chipSel: { backgroundColor: "#3b82f6", borderColor: "#3b82f6" },
saveBtn: { backgroundColor: "#3b82f6", borderRadius: 12, padding: 16, alignItems: "center", marginTop: 24 },
saveBtnText: { color: "#fff", fontWeight: "800", fontSize: 16 },
});

164
app.py
View File

@@ -1,164 +0,0 @@
import os
from flask import Flask, render_template, request, redirect, url_for, flash, session
from flask_sqlalchemy import SQLAlchemy
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime, date
from dotenv import load_dotenv
from functools import wraps
load_dotenv()
app = Flask(__name__)
app.secret_key = os.getenv('SECRET_KEY', 'supersecretkey')
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'postgresql://user:password@localhost/finance_db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
# Модель пользователя
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password = db.Column(db.String(200), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def set_password(self, password):
self.password = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password, password)
# Модель транзакции (обновлена с привязкой к пользователю)
class Transaction(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
type = db.Column(db.String(10), nullable=False)
amount = db.Column(db.Float, nullable=False)
category = db.Column(db.String(50), nullable=False)
date = db.Column(db.Date, nullable=False, default=date.today())
description = db.Column(db.Text)
user = db.relationship('User', backref=db.backref('transactions', lazy=True))
# Декоратор для проверки аутентификации
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
flash('Пожалуйста, войдите в систему для доступа к этой странице', 'warning')
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated_function
# Регистрация пользователя
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form['username']
email = request.form['email']
password = request.form['password']
confirm_password = request.form['confirm_password']
# Проверки
if password != confirm_password:
flash('Пароли не совпадают', 'danger')
return redirect(url_for('register'))
if User.query.filter_by(username=username).first():
flash('Это имя пользователя уже занято', 'danger')
return redirect(url_for('register'))
if User.query.filter_by(email=email).first():
flash('Этот email уже используется', 'danger')
return redirect(url_for('register'))
# Создание пользователя
new_user = User(username=username, email=email)
new_user.set_password(password)
db.session.add(new_user)
db.session.commit()
flash('Регистрация успешна! Теперь вы можете войти', 'success')
return redirect(url_for('login'))
return render_template('register.html')
# Вход в систему
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
remember = 'remember' in request.form
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
session['user_id'] = user.id
session['username'] = user.username
flash('Вы успешно вошли в систему!', 'success')
return redirect(url_for('index'))
flash('Неверное имя пользователя или пароль', 'danger')
return render_template('login.html')
# Выход из системы
@app.route('/logout')
@login_required
def logout():
session.pop('user_id', None)
session.pop('username', None)
flash('Вы успешно вышли из системы', 'info')
return redirect(url_for('login'))
# Главная страница (требует аутентификации)
@app.route('/')
@login_required
def index():
user_id = session['user_id']
transactions = Transaction.query.filter_by(user_id=user_id).order_by(Transaction.date.desc()).all()
total_income = sum(t.amount for t in transactions if t.type == 'income')
total_expense = sum(t.amount for t in transactions if t.type == 'expense')
balance = total_income - total_expense
categories = {}
for t in transactions:
if t.category not in categories:
categories[t.category] = {'income': 0, 'expense': 0}
categories[t.category][t.type] += t.amount
return render_template('index.html',
transactions=transactions,
total_income=total_income,
total_expense=total_expense,
balance=balance,
categories=categories,
current_date=date.today(),
username=session['username'])
# Остальные роуты (add, edit, delete) аналогично защищаем @login_required
# и добавляем user_id при создании/редактировании транзакций
@app.route('/add', methods=['GET', 'POST'])
@login_required
def add_transaction():
if request.method == 'POST':
user_id = session['user_id']
# ... остальной код ...
new_transaction = Transaction(
user_id=user_id, # Добавляем привязку к пользователю
# ... остальные поля ...
)
# ... сохранение ...
return render_template('add.html', current_date=date.today())
# Аналогично для edit и delete добавляем проверку, что пользователь редактирует свою транзакцию
if __name__ == '__main__':
with app.app_context():
db.create_all()
app.run(debug=True)

18
backend/.env.example Normal file
View File

@@ -0,0 +1,18 @@
# Copy to .env and fill in all values — never commit .env
# PostgreSQL
POSTGRES_DB=financedb
POSTGRES_USER=financeuser
POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD
# Full DSN used by SQLAlchemy (set automatically in docker-compose, or set manually)
DATABASE_URL=postgresql://financeuser:CHANGE_ME@localhost:5432/financedb
# JWT — generate with: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=CHANGE_ME_32_BYTES_HEX
# CORS — comma-separated origins allowed to call the API
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
# App
DEBUG=false

15
backend/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Run migrations then seed then start server
CMD ["sh", "-c", "alembic upgrade head && python seed.py && uvicorn app.main:app --host 0.0.0.0 --port 8000"]

38
backend/alembic.ini Normal file
View File

@@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

44
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,44 @@
import os
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Import all models so Alembic sees them
from app.database import Base
import app.models # noqa: F401
target_metadata = Base.metadata
DATABASE_URL = os.getenv("DATABASE_URL", config.get_main_option("sqlalchemy.url", ""))
if DATABASE_URL:
config.set_main_option("sqlalchemy.url", DATABASE_URL)
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"})
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,211 @@
"""Initial schema
Revision ID: 001
Revises:
Create Date: 2026-04-28
"""
from alembic import op
import sqlalchemy as sa
revision = "001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"roles",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("name", sa.String(50), unique=True, nullable=False),
sa.Column("description", sa.Text),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_table(
"users",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("username", sa.String(100), unique=True, nullable=False),
sa.Column("email", sa.String(255), unique=True, nullable=False),
sa.Column("password_hash", sa.String(255), nullable=False),
sa.Column("role_id", sa.Integer, sa.ForeignKey("roles.id"), nullable=False),
sa.Column("is_active", sa.Boolean, default=True, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_users_username", "users", ["username"])
op.create_index("ix_users_email", "users", ["email"])
op.create_table(
"currencies",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("code", sa.String(3), unique=True, nullable=False),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("symbol", sa.String(10)),
sa.Column("is_active", sa.Boolean, default=True, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_table(
"exchange_rates",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("currency_from_id", sa.Integer, sa.ForeignKey("currencies.id"), nullable=False),
sa.Column("currency_to_id", sa.Integer, sa.ForeignKey("currencies.id"), nullable=False),
sa.Column("rate", sa.Numeric(20, 6), nullable=False),
sa.Column("rate_date", sa.Date, nullable=False),
sa.Column("source", sa.String(100)),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.UniqueConstraint("currency_from_id", "currency_to_id", "rate_date", name="uq_exchange_rate_day"),
)
op.create_table(
"locations",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("type", sa.String(50)),
sa.Column("address", sa.Text),
sa.Column("city", sa.String(100)),
sa.Column("country", sa.String(100)),
sa.Column("latitude", sa.Numeric(9, 6)),
sa.Column("longitude", sa.Numeric(9, 6)),
sa.Column("parent_id", sa.Integer, sa.ForeignKey("locations.id"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_table(
"account_types",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("name", sa.String(100), unique=True, nullable=False),
sa.Column("icon", sa.String(50)),
sa.Column("description", sa.Text),
)
op.create_table(
"accounts",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("account_type_id", sa.Integer, sa.ForeignKey("account_types.id"), nullable=False),
sa.Column("currency_id", sa.Integer, sa.ForeignKey("currencies.id"), nullable=False),
sa.Column("balance", sa.Numeric(20, 2), default=0, nullable=False),
sa.Column("initial_balance", sa.Numeric(20, 2), default=0, nullable=False),
sa.Column("is_active", sa.Boolean, default=True, nullable=False),
sa.Column("credit_limit", sa.Numeric(20, 2)),
sa.Column("description", sa.Text),
sa.Column("icon", sa.String(50)),
sa.Column("color", sa.String(7)),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_accounts_user_id", "accounts", ["user_id"])
op.create_table(
"categories",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("type", sa.String(20), nullable=False),
sa.Column("parent_id", sa.Integer, sa.ForeignKey("categories.id"), nullable=True),
sa.Column("icon", sa.String(50)),
sa.Column("color", sa.String(7)),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_categories_user_id", "categories", ["user_id"])
op.create_table(
"budgets",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False),
sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id"), nullable=False),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("amount", sa.Numeric(20, 2), nullable=False),
sa.Column("currency_id", sa.Integer, sa.ForeignKey("currencies.id"), nullable=False),
sa.Column("period_type", sa.String(20), nullable=False),
sa.Column("start_date", sa.Date, nullable=False),
sa.Column("end_date", sa.Date),
sa.Column("is_active", sa.Boolean, default=True, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_budgets_user_id", "budgets", ["user_id"])
op.create_table(
"operation_types",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("name", sa.String(50), unique=True, nullable=False),
sa.Column("description", sa.Text),
)
op.create_table(
"operations",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False),
sa.Column("account_id", sa.Integer, sa.ForeignKey("accounts.id"), nullable=False),
sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id"), nullable=True),
sa.Column("operation_type_id", sa.Integer, sa.ForeignKey("operation_types.id"), nullable=False),
sa.Column("amount", sa.Numeric(20, 2), nullable=False),
sa.Column("currency_id", sa.Integer, sa.ForeignKey("currencies.id"), nullable=False),
sa.Column("amount_converted", sa.Numeric(20, 2)),
sa.Column("exchange_rate", sa.Numeric(20, 6)),
sa.Column("date", sa.DateTime(timezone=True), nullable=False),
sa.Column("description", sa.Text),
sa.Column("location_id", sa.Integer, sa.ForeignKey("locations.id"), nullable=True),
sa.Column("is_planned", sa.Boolean, default=False, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_operations_user_id", "operations", ["user_id"])
op.create_index("ix_operations_account_id", "operations", ["account_id"])
op.create_index("ix_operations_date", "operations", ["date"])
op.create_table(
"operation_relations",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("operation_from_id", sa.Integer, sa.ForeignKey("operations.id"), nullable=False),
sa.Column("operation_to_id", sa.Integer, sa.ForeignKey("operations.id"), nullable=False),
sa.Column("relation_type", sa.String(50), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_table(
"calendar_events",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False),
sa.Column("title", sa.String(200), nullable=False),
sa.Column("description", sa.Text),
sa.Column("event_date", sa.Date, nullable=False),
sa.Column("event_time", sa.Time),
sa.Column("is_recurring", sa.Boolean, default=False, nullable=False),
sa.Column("recurrence_pattern", sa.String(50)),
sa.Column("recurrence_end_date", sa.Date),
sa.Column("related_operation_id", sa.Integer, sa.ForeignKey("operations.id"), nullable=True),
sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id"), nullable=True),
sa.Column("amount", sa.Numeric(20, 2)),
sa.Column("currency_id", sa.Integer, sa.ForeignKey("currencies.id"), nullable=True),
sa.Column("reminder_days", sa.Integer),
sa.Column("is_completed", sa.Boolean, default=False, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_calendar_events_user_id", "calendar_events", ["user_id"])
op.create_index("ix_calendar_events_event_date", "calendar_events", ["event_date"])
# Grant permissions to app user (runs only if the role exists)
op.execute("""
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'finance_app_user') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO finance_app_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO finance_app_user;
END IF;
END$$;
""")
def downgrade() -> None:
for table in [
"calendar_events", "operation_relations", "operations", "operation_types",
"budgets", "categories", "accounts", "account_types",
"locations", "exchange_rates", "currencies", "users", "roles",
]:
op.drop_table(table)

0
backend/app/__init__.py Normal file
View File

View File

View File

View File

@@ -0,0 +1,64 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user
from app.database import get_db
from app.models.account import Account, AccountType
from app.models.user import User
from app.schemas.account import AccountCreate, AccountUpdate, AccountOut, AccountTypeOut
router = APIRouter(prefix="/accounts", tags=["accounts"])
@router.get("/types", response_model=list[AccountTypeOut])
def list_types(db: Session = Depends(get_db), _=Depends(get_current_user)):
return db.query(AccountType).order_by(AccountType.name).all()
@router.get("", response_model=list[AccountOut])
def list_accounts(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
return db.query(Account).filter(Account.user_id == current_user.id).order_by(Account.name).all()
@router.get("/{account_id}", response_model=AccountOut)
def get_account(account_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = db.get(Account, account_id)
if not obj or obj.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Account not found")
return obj
@router.post("", response_model=AccountOut, status_code=status.HTTP_201_CREATED)
def create_account(body: AccountCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = Account(
**body.model_dump(),
user_id=current_user.id,
balance=body.initial_balance,
)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@router.patch("/{account_id}", response_model=AccountOut)
def update_account(
account_id: int, body: AccountUpdate,
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
obj = db.get(Account, account_id)
if not obj or obj.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Account not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_account(account_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = db.get(Account, account_id)
if not obj or obj.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Account not found")
db.delete(obj)
db.commit()

View File

@@ -0,0 +1,77 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from jose import JWTError
from sqlalchemy.orm import Session
from app.auth.security import verify_password, create_access_token, create_refresh_token, decode_token
from app.database import get_db
from app.models.user import User, Role
from app.schemas.auth import Token, LoginRequest
from app.schemas.user import UserCreate, UserOut
router = APIRouter(prefix="/auth", tags=["auth"])
def _authenticate(db: Session, username: str, password: str) -> User:
user = db.query(User).filter(
(User.username == username) | (User.email == username)
).first()
if not user or not verify_password(password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account is disabled")
return user
@router.post("/login", response_model=Token)
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = _authenticate(db, form.username, form.password)
return Token(
access_token=create_access_token(user.id, user.role.name),
refresh_token=create_refresh_token(user.id),
)
@router.post("/login/json", response_model=Token)
def login_json(body: LoginRequest, db: Session = Depends(get_db)):
user = _authenticate(db, body.username, body.password)
return Token(
access_token=create_access_token(user.id, user.role.name),
refresh_token=create_refresh_token(user.id),
)
@router.post("/refresh", response_model=Token)
def refresh(refresh_token: str, db: Session = Depends(get_db)):
try:
payload = decode_token(refresh_token)
if payload.get("type") != "refresh":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
user = db.get(User, int(payload["sub"]))
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired refresh token")
return Token(
access_token=create_access_token(user.id, user.role.name),
refresh_token=create_refresh_token(user.id),
)
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register(body: UserCreate, db: Session = Depends(get_db)):
from app.auth.security import hash_password
if db.query(User).filter(User.username == body.username).first():
raise HTTPException(status_code=400, detail="Username already taken")
if db.query(User).filter(User.email == body.email).first():
raise HTTPException(status_code=400, detail="Email already registered")
role = db.get(Role, body.role_id) or db.query(Role).filter(Role.name == "user").first()
user = User(
username=body.username,
email=body.email,
password_hash=hash_password(body.password),
role_id=role.id,
)
db.add(user)
db.commit()
db.refresh(user)
return user

View File

@@ -0,0 +1,80 @@
from datetime import date
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user
from app.database import get_db
from app.models.budget import Budget
from app.models.operation import Operation
from app.models.user import User
from app.schemas.budget import BudgetCreate, BudgetUpdate, BudgetOut
router = APIRouter(prefix="/budgets", tags=["budgets"])
def _compute_spent(db: Session, budget: Budget, current_user: User) -> Decimal:
period_start = budget.start_date
period_end = budget.end_date or date.today()
total = db.query(func.sum(Operation.amount)).filter(
Operation.user_id == current_user.id,
Operation.category_id == budget.category_id,
func.date(Operation.date) >= period_start,
func.date(Operation.date) <= period_end,
Operation.is_planned == False,
).scalar() or Decimal("0")
return total
@router.get("", response_model=list[BudgetOut])
def list_budgets(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
budgets = db.query(Budget).filter(Budget.user_id == current_user.id).all()
result = []
for b in budgets:
out = BudgetOut.model_validate(b)
out.spent = _compute_spent(db, b, current_user)
result.append(out)
return result
@router.get("/{budget_id}", response_model=BudgetOut)
def get_budget(budget_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = db.get(Budget, budget_id)
if not obj or obj.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Budget not found")
out = BudgetOut.model_validate(obj)
out.spent = _compute_spent(db, obj, current_user)
return out
@router.post("", response_model=BudgetOut, status_code=status.HTTP_201_CREATED)
def create_budget(body: BudgetCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = Budget(**body.model_dump(), user_id=current_user.id)
db.add(obj)
db.commit()
db.refresh(obj)
return BudgetOut.model_validate(obj)
@router.patch("/{budget_id}", response_model=BudgetOut)
def update_budget(
budget_id: int, body: BudgetUpdate,
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
obj = db.get(Budget, budget_id)
if not obj or obj.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Budget not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return BudgetOut.model_validate(obj)
@router.delete("/{budget_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_budget(budget_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = db.get(Budget, budget_id)
if not obj or obj.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Budget not found")
db.delete(obj)
db.commit()

View File

@@ -0,0 +1,73 @@
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user
from app.database import get_db
from app.models.calendar import CalendarEvent
from app.models.user import User
from app.schemas.calendar import CalendarEventCreate, CalendarEventUpdate, CalendarEventOut
router = APIRouter(prefix="/calendar", tags=["calendar"])
@router.get("", response_model=list[CalendarEventOut])
def list_events(
date_from: date | None = None,
date_to: date | None = None,
is_completed: bool | None = None,
category_id: int | None = None,
limit: int = Query(100, le=500),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = db.query(CalendarEvent).filter(CalendarEvent.user_id == current_user.id)
if date_from:
q = q.filter(CalendarEvent.event_date >= date_from)
if date_to:
q = q.filter(CalendarEvent.event_date <= date_to)
if is_completed is not None:
q = q.filter(CalendarEvent.is_completed == is_completed)
if category_id:
q = q.filter(CalendarEvent.category_id == category_id)
return q.order_by(CalendarEvent.event_date, CalendarEvent.event_time).limit(limit).all()
@router.get("/{event_id}", response_model=CalendarEventOut)
def get_event(event_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = db.get(CalendarEvent, event_id)
if not obj or obj.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Event not found")
return obj
@router.post("", response_model=CalendarEventOut, status_code=status.HTTP_201_CREATED)
def create_event(body: CalendarEventCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = CalendarEvent(**body.model_dump(), user_id=current_user.id)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@router.patch("/{event_id}", response_model=CalendarEventOut)
def update_event(
event_id: int, body: CalendarEventUpdate,
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
obj = db.get(CalendarEvent, event_id)
if not obj or obj.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Event not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
@router.delete("/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_event(event_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = db.get(CalendarEvent, event_id)
if not obj or obj.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Event not found")
db.delete(obj)
db.commit()

View File

@@ -0,0 +1,49 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user
from app.database import get_db
from app.models.category import Category
from app.models.user import User
from app.schemas.category import CategoryCreate, CategoryUpdate, CategoryOut
router = APIRouter(prefix="/categories", tags=["categories"])
@router.get("", response_model=list[CategoryOut])
def list_categories(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
return db.query(Category).filter(
(Category.user_id == current_user.id) | (Category.user_id == None)
).order_by(Category.type, Category.name).all()
@router.post("", response_model=CategoryOut, status_code=status.HTTP_201_CREATED)
def create_category(body: CategoryCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = Category(**body.model_dump(), user_id=current_user.id)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@router.patch("/{category_id}", response_model=CategoryOut)
def update_category(
category_id: int, body: CategoryUpdate,
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
obj = db.get(Category, category_id)
if not obj or (obj.user_id and obj.user_id != current_user.id):
raise HTTPException(status_code=404, detail="Category not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_category(category_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = db.get(Category, category_id)
if not obj or (obj.user_id and obj.user_id != current_user.id):
raise HTTPException(status_code=404, detail="Category not found")
db.delete(obj)
db.commit()

View File

@@ -0,0 +1,55 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, require_role
from app.database import get_db
from app.models.currency import Currency
from app.schemas.currency import CurrencyCreate, CurrencyUpdate, CurrencyOut
router = APIRouter(prefix="/currencies", tags=["currencies"])
require_accountant = require_role("accountant")
@router.get("", response_model=list[CurrencyOut])
def list_currencies(db: Session = Depends(get_db), _=Depends(get_current_user)):
return db.query(Currency).order_by(Currency.code).all()
@router.get("/{currency_id}", response_model=CurrencyOut)
def get_currency(currency_id: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
obj = db.get(Currency, currency_id)
if not obj:
raise HTTPException(status_code=404, detail="Currency not found")
return obj
@router.post("", response_model=CurrencyOut, status_code=status.HTTP_201_CREATED)
def create_currency(body: CurrencyCreate, db: Session = Depends(get_db), _=Depends(require_accountant)):
if db.query(Currency).filter(Currency.code == body.code.upper()).first():
raise HTTPException(status_code=400, detail="Currency code already exists")
obj = Currency(**body.model_dump(), code=body.code.upper())
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@router.patch("/{currency_id}", response_model=CurrencyOut)
def update_currency(currency_id: int, body: CurrencyUpdate, db: Session = Depends(get_db), _=Depends(require_accountant)):
obj = db.get(Currency, currency_id)
if not obj:
raise HTTPException(status_code=404, detail="Currency not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
@router.delete("/{currency_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_currency(currency_id: int, db: Session = Depends(get_db), _=Depends(require_accountant)):
obj = db.get(Currency, currency_id)
if not obj:
raise HTTPException(status_code=404, detail="Currency not found")
db.delete(obj)
db.commit()

View File

@@ -0,0 +1,61 @@
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, require_role
from app.database import get_db
from app.models.currency import ExchangeRate
from app.schemas.currency import ExchangeRateCreate, ExchangeRateOut
router = APIRouter(prefix="/exchange-rates", tags=["exchange-rates"])
require_accountant = require_role("accountant")
@router.get("", response_model=list[ExchangeRateOut])
def list_rates(
currency_from_id: int | None = None,
currency_to_id: int | None = None,
rate_date: date | None = None,
limit: int = Query(50, le=500),
db: Session = Depends(get_db),
_=Depends(get_current_user),
):
q = db.query(ExchangeRate)
if currency_from_id:
q = q.filter(ExchangeRate.currency_from_id == currency_from_id)
if currency_to_id:
q = q.filter(ExchangeRate.currency_to_id == currency_to_id)
if rate_date:
q = q.filter(ExchangeRate.rate_date == rate_date)
return q.order_by(ExchangeRate.rate_date.desc()).limit(limit).all()
@router.post("", response_model=ExchangeRateOut, status_code=status.HTTP_201_CREATED)
def create_rate(body: ExchangeRateCreate, db: Session = Depends(get_db), _=Depends(require_accountant)):
if body.currency_from_id == body.currency_to_id:
raise HTTPException(status_code=400, detail="Cannot set rate between the same currency")
existing = db.query(ExchangeRate).filter(
ExchangeRate.currency_from_id == body.currency_from_id,
ExchangeRate.currency_to_id == body.currency_to_id,
ExchangeRate.rate_date == body.rate_date,
).first()
if existing:
for field, value in body.model_dump(exclude_none=True).items():
setattr(existing, field, value)
db.commit()
db.refresh(existing)
return existing
obj = ExchangeRate(**body.model_dump())
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@router.delete("/{rate_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_rate(rate_id: int, db: Session = Depends(get_db), _=Depends(require_accountant)):
obj = db.get(ExchangeRate, rate_id)
if not obj:
raise HTTPException(status_code=404, detail="Exchange rate not found")
db.delete(obj)
db.commit()

View File

@@ -0,0 +1,49 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user
from app.database import get_db
from app.models.location import Location
from app.models.user import User
from app.schemas.location import LocationCreate, LocationUpdate, LocationOut
router = APIRouter(prefix="/locations", tags=["locations"])
@router.get("", response_model=list[LocationOut])
def list_locations(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
return db.query(Location).filter(
(Location.user_id == current_user.id) | (Location.user_id == None)
).order_by(Location.name).all()
@router.post("", response_model=LocationOut, status_code=status.HTTP_201_CREATED)
def create_location(body: LocationCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = Location(**body.model_dump(), user_id=current_user.id)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@router.patch("/{location_id}", response_model=LocationOut)
def update_location(
location_id: int, body: LocationUpdate,
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
obj = db.get(Location, location_id)
if not obj or (obj.user_id and obj.user_id != current_user.id and current_user.role.name != "admin"):
raise HTTPException(status_code=404, detail="Location not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
@router.delete("/{location_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_location(location_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = db.get(Location, location_id)
if not obj or (obj.user_id and obj.user_id != current_user.id and current_user.role.name != "admin"):
raise HTTPException(status_code=404, detail="Location not found")
db.delete(obj)
db.commit()

View File

@@ -0,0 +1,163 @@
from datetime import datetime
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user
from app.database import get_db
from app.models.account import Account
from app.models.operation import Operation, OperationType, OperationRelation
from app.models.user import User
from app.schemas.operation import (
OperationCreate, OperationUpdate, OperationOut,
OperationTypeOut, TransferCreate,
OperationRelationOut,
)
router = APIRouter(prefix="/operations", tags=["operations"])
def _assert_account_ownership(db: Session, account_id: int, user_id: int) -> Account:
account = db.get(Account, account_id)
if not account or account.user_id != user_id:
raise HTTPException(status_code=404, detail="Account not found")
return account
def _apply_balance(account: Account, op_type_name: str, amount: Decimal, sign: int = 1):
if op_type_name == "income":
account.balance += amount * sign
elif op_type_name == "expense":
account.balance -= amount * sign
@router.get("/types", response_model=list[OperationTypeOut])
def list_types(db: Session = Depends(get_db), _=Depends(get_current_user)):
return db.query(OperationType).all()
@router.get("", response_model=list[OperationOut])
def list_operations(
account_id: int | None = None,
category_id: int | None = None,
operation_type_id: int | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
is_planned: bool | None = None,
limit: int = Query(100, le=1000),
offset: int = 0,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = db.query(Operation).filter(Operation.user_id == current_user.id)
if account_id:
q = q.filter(Operation.account_id == account_id)
if category_id:
q = q.filter(Operation.category_id == category_id)
if operation_type_id:
q = q.filter(Operation.operation_type_id == operation_type_id)
if date_from:
q = q.filter(Operation.date >= date_from)
if date_to:
q = q.filter(Operation.date <= date_to)
if is_planned is not None:
q = q.filter(Operation.is_planned == is_planned)
return q.order_by(Operation.date.desc()).offset(offset).limit(limit).all()
@router.get("/{operation_id}", response_model=OperationOut)
def get_operation(operation_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = db.get(Operation, operation_id)
if not obj or obj.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Operation not found")
return obj
@router.post("", response_model=OperationOut, status_code=status.HTTP_201_CREATED)
def create_operation(
body: OperationCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
account = _assert_account_ownership(db, body.account_id, current_user.id)
op_type = db.get(OperationType, body.operation_type_id)
if not op_type:
raise HTTPException(status_code=400, detail="Invalid operation type")
obj = Operation(**body.model_dump(), user_id=current_user.id)
db.add(obj)
_apply_balance(account, op_type.name, body.amount)
db.commit()
db.refresh(obj)
return obj
@router.post("/transfer", response_model=list[OperationOut], status_code=status.HTTP_201_CREATED)
def create_transfer(
body: TransferCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
from_account = _assert_account_ownership(db, body.from_account_id, current_user.id)
to_account = _assert_account_ownership(db, body.to_account_id, current_user.id)
transfer_type = db.query(OperationType).filter(OperationType.name == "transfer").first()
if not transfer_type:
raise HTTPException(status_code=500, detail="Transfer operation type not configured")
op_out = Operation(
user_id=current_user.id, account_id=body.from_account_id,
operation_type_id=transfer_type.id, amount=body.amount,
currency_id=body.currency_id, exchange_rate=body.exchange_rate,
amount_converted=body.amount_converted, date=body.date,
description=body.description, location_id=body.location_id,
)
amount_in = body.amount_converted or body.amount
op_in = Operation(
user_id=current_user.id, account_id=body.to_account_id,
operation_type_id=transfer_type.id, amount=amount_in,
currency_id=to_account.currency_id, date=body.date,
description=body.description, location_id=body.location_id,
)
from_account.balance -= body.amount
to_account.balance += amount_in
db.add_all([op_out, op_in])
db.flush()
relation = OperationRelation(
operation_from_id=op_out.id, operation_to_id=op_in.id, relation_type="transfer"
)
db.add(relation)
db.commit()
db.refresh(op_out)
db.refresh(op_in)
return [op_out, op_in]
@router.patch("/{operation_id}", response_model=OperationOut)
def update_operation(
operation_id: int, body: OperationUpdate,
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
obj = db.get(Operation, operation_id)
if not obj or obj.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Operation not found")
if body.amount is not None and body.amount != obj.amount:
account = db.get(Account, obj.account_id)
op_type = db.get(OperationType, obj.operation_type_id)
_apply_balance(account, op_type.name, obj.amount, sign=-1)
_apply_balance(account, op_type.name, body.amount, sign=1)
for field, value in body.model_dump(exclude_none=True).items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
@router.delete("/{operation_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_operation(
operation_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
obj = db.get(Operation, operation_id)
if not obj or obj.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Operation not found")
account = db.get(Account, obj.account_id)
op_type = db.get(OperationType, obj.operation_type_id)
_apply_balance(account, op_type.name, obj.amount, sign=-1)
db.delete(obj)
db.commit()

View File

@@ -0,0 +1,51 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user
from app.database import get_db
from app.models.operation import Operation, OperationRelation
from app.models.user import User
from app.schemas.operation import OperationRelationCreate, OperationRelationOut
router = APIRouter(prefix="/operation-relations", tags=["operation-relations"])
@router.get("/{operation_id}", response_model=list[OperationRelationOut])
def list_relations(
operation_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
op = db.get(Operation, operation_id)
if not op or op.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Operation not found")
return db.query(OperationRelation).filter(
(OperationRelation.operation_from_id == operation_id) |
(OperationRelation.operation_to_id == operation_id)
).all()
@router.post("", response_model=OperationRelationOut, status_code=status.HTTP_201_CREATED)
def create_relation(
body: OperationRelationCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
for op_id in (body.operation_from_id, body.operation_to_id):
op = db.get(Operation, op_id)
if not op or op.user_id != current_user.id:
raise HTTPException(status_code=404, detail=f"Operation {op_id} not found")
obj = OperationRelation(**body.model_dump())
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@router.delete("/{relation_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_relation(
relation_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
obj = db.get(OperationRelation, relation_id)
if not obj:
raise HTTPException(status_code=404, detail="Relation not found")
op = db.get(Operation, obj.operation_from_id)
if not op or op.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
db.delete(obj)
db.commit()

View File

@@ -0,0 +1,15 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, currencies, exchange_rates, locations, accounts, categories, budgets, operations, relations, calendar
router = APIRouter()
router.include_router(auth.router)
router.include_router(users.router)
router.include_router(currencies.router)
router.include_router(exchange_rates.router)
router.include_router(locations.router)
router.include_router(accounts.router)
router.include_router(categories.router)
router.include_router(budgets.router)
router.include_router(operations.router)
router.include_router(relations.router)
router.include_router(calendar.router)

View File

@@ -0,0 +1,52 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, require_role
from app.auth.security import hash_password
from app.database import get_db
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate, UserOut, UserWithRole
router = APIRouter(prefix="/users", tags=["users"])
require_admin = require_role("admin")
@router.get("/me", response_model=UserWithRole)
def get_me(current_user: User = Depends(get_current_user)):
return current_user
@router.get("", response_model=list[UserWithRole])
def list_users(db: Session = Depends(get_db), _=Depends(require_admin)):
return db.query(User).all()
@router.get("/{user_id}", response_model=UserWithRole)
def get_user(user_id: int, db: Session = Depends(get_db), _=Depends(require_admin)):
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.patch("/{user_id}", response_model=UserOut)
def update_user(user_id: int, body: UserUpdate, db: Session = Depends(get_db), _=Depends(require_admin)):
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(user, field, value)
db.commit()
db.refresh(user)
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(user_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_admin)):
if user_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
db.delete(user)
db.commit()

View File

View File

@@ -0,0 +1,54 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError
from sqlalchemy.orm import Session
from app.auth.security import decode_token
from app.database import get_db
from app.models.user import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
ROLES = {"admin": 4, "accountant": 3, "user": 2, "viewer": 1}
def _get_current_user(token: str, db: Session) -> User:
credentials_exc = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(token)
if payload.get("type") != "access":
raise credentials_exc
user_id = int(payload["sub"])
except (JWTError, KeyError, ValueError):
raise credentials_exc
user = db.get(User, user_id)
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User inactive or not found")
return user
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
return _get_current_user(token, db)
def require_role(minimum_role: str):
"""Dependency factory — blocks users whose role rank is below minimum_role."""
min_rank = ROLES[minimum_role]
def _check(current_user: User = Depends(get_current_user)) -> User:
user_rank = ROLES.get(current_user.role.name, 0)
if user_rank < min_rank:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
return current_user
return _check
require_viewer = Depends(require_role("viewer"))
require_user = Depends(require_role("user"))
require_accountant = Depends(require_role("accountant"))
require_admin = Depends(require_role("admin"))

View File

@@ -0,0 +1,41 @@
from datetime import datetime, timedelta, timezone
from typing import Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.config import get_settings
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def _create_token(data: dict[str, Any], expires_delta: timedelta) -> str:
payload = data.copy()
payload["exp"] = datetime.now(timezone.utc) + expires_delta
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def create_access_token(user_id: int, role: str) -> str:
return _create_token(
{"sub": str(user_id), "role": role, "type": "access"},
timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
)
def create_refresh_token(user_id: int) -> str:
return _create_token(
{"sub": str(user_id), "type": "refresh"},
timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
)
def decode_token(token: str) -> dict[str, Any]:
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])

25
backend/app/config.py Normal file
View File

@@ -0,0 +1,25 @@
from functools import lru_cache
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str
SECRET_KEY: str
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
DEBUG: bool = False
API_V1_PREFIX: str = "/api/v1"
ALLOWED_ORIGINS: str = "http://localhost:3000"
@property
def origins_list(self) -> list[str]:
return [o.strip() for o in self.ALLOWED_ORIGINS.split(",")]
class Config:
env_file = ".env"
@lru_cache()
def get_settings() -> Settings:
return Settings()

33
backend/app/database.py Normal file
View File

@@ -0,0 +1,33 @@
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from app.config import get_settings
settings = get_settings()
_connect_args: dict = {}
if settings.DATABASE_URL.startswith("postgresql"):
_connect_args = {"sslmode": "prefer"}
engine = create_engine(
settings.DATABASE_URL,
connect_args=_connect_args,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
pool_recycle=3600,
)
class Base(DeclarativeBase):
pass
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

29
backend/app/main.py Normal file
View File

@@ -0,0 +1,29 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import get_settings
from app.api.v1.router import router as api_router
settings = get_settings()
app = FastAPI(
title="Finance App API",
description="Personal finance management inspired by 1C:Money",
version="1.0.0",
docs_url="/docs" if settings.DEBUG else None,
redoc_url="/redoc" if settings.DEBUG else None,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix=settings.API_V1_PREFIX)
@app.get("/health")
def health():
return {"status": "ok"}

View File

@@ -0,0 +1,19 @@
from app.models.user import Role, User
from app.models.currency import Currency, ExchangeRate
from app.models.location import Location
from app.models.account import AccountType, Account
from app.models.category import Category
from app.models.budget import Budget
from app.models.operation import OperationType, Operation, OperationRelation
from app.models.calendar import CalendarEvent
__all__ = [
"Role", "User",
"Currency", "ExchangeRate",
"Location",
"AccountType", "Account",
"Category",
"Budget",
"OperationType", "Operation", "OperationRelation",
"CalendarEvent",
]

View File

@@ -0,0 +1,42 @@
from datetime import datetime
from decimal import Decimal
from sqlalchemy import String, Boolean, Text, Numeric, ForeignKey, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class AccountType(Base):
__tablename__ = "account_types"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
icon: Mapped[str | None] = mapped_column(String(50))
description: Mapped[str | None] = mapped_column(Text)
accounts: Mapped[list["Account"]] = relationship("Account", back_populates="account_type")
class Account(Base):
__tablename__ = "accounts"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
account_type_id: Mapped[int] = mapped_column(ForeignKey("account_types.id"), nullable=False)
currency_id: Mapped[int] = mapped_column(ForeignKey("currencies.id"), nullable=False)
balance: Mapped[Decimal] = mapped_column(Numeric(20, 2), default=0, nullable=False)
initial_balance: Mapped[Decimal] = mapped_column(Numeric(20, 2), default=0, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
credit_limit: Mapped[Decimal | None] = mapped_column(Numeric(20, 2))
description: Mapped[str | None] = mapped_column(Text)
icon: Mapped[str | None] = mapped_column(String(50))
color: Mapped[str | None] = mapped_column(String(7)) # hex #RRGGBB
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
user: Mapped["User"] = relationship("User", back_populates="accounts")
account_type: Mapped["AccountType"] = relationship("AccountType", back_populates="accounts")
currency: Mapped["Currency"] = relationship("Currency", back_populates="accounts")
operations: Mapped[list] = relationship("Operation", back_populates="account")

View File

@@ -0,0 +1,25 @@
from datetime import datetime, date
from decimal import Decimal
from sqlalchemy import String, Boolean, Numeric, ForeignKey, Date, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Budget(Base):
__tablename__ = "budgets"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"), nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
currency_id: Mapped[int] = mapped_column(ForeignKey("currencies.id"), nullable=False)
period_type: Mapped[str] = mapped_column(String(20), nullable=False) # monthly | weekly | yearly | custom
start_date: Mapped[date] = mapped_column(Date, nullable=False)
end_date: Mapped[date | None] = mapped_column(Date)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship("User", back_populates="budgets")
category: Mapped["Category"] = relationship("Category", back_populates="budgets")
currency: Mapped["Currency"] = relationship("Currency", back_populates="budgets")

View File

@@ -0,0 +1,34 @@
from datetime import datetime, date, time
from decimal import Decimal
from sqlalchemy import String, Boolean, Text, Numeric, ForeignKey, Date, Time, DateTime, Integer, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class CalendarEvent(Base):
__tablename__ = "calendar_events"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
event_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
event_time: Mapped[time | None] = mapped_column(Time)
is_recurring: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
recurrence_pattern: Mapped[str | None] = mapped_column(String(50)) # daily | weekly | monthly | yearly
recurrence_end_date: Mapped[date | None] = mapped_column(Date)
related_operation_id: Mapped[int | None] = mapped_column(ForeignKey("operations.id"), nullable=True)
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
amount: Mapped[Decimal | None] = mapped_column(Numeric(20, 2))
currency_id: Mapped[int | None] = mapped_column(ForeignKey("currencies.id"), nullable=True)
reminder_days: Mapped[int | None] = mapped_column(Integer)
is_completed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
user: Mapped["User"] = relationship("User", back_populates="calendar_events")
related_operation: Mapped["Operation | None"] = relationship("Operation", back_populates="calendar_events")
category: Mapped["Category | None"] = relationship("Category", back_populates="calendar_events")
currency: Mapped["Currency | None"] = relationship("Currency", back_populates="calendar_events")

View File

@@ -0,0 +1,26 @@
from datetime import datetime
from sqlalchemy import String, ForeignKey, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
type: Mapped[str] = mapped_column(String(20), nullable=False) # income | expense | transfer
parent_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
icon: Mapped[str | None] = mapped_column(String(50))
color: Mapped[str | None] = mapped_column(String(7))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User | None"] = relationship("User", back_populates="categories")
parent: Mapped["Category | None"] = relationship(
"Category", remote_side="Category.id", back_populates="children"
)
children: Mapped[list["Category"]] = relationship("Category", back_populates="parent")
operations: Mapped[list] = relationship("Operation", back_populates="category")
budgets: Mapped[list] = relationship("Budget", back_populates="category")
calendar_events: Mapped[list] = relationship("CalendarEvent", back_populates="category")

View File

@@ -0,0 +1,49 @@
from datetime import datetime, date
from decimal import Decimal
from sqlalchemy import String, Boolean, Numeric, ForeignKey, Date, DateTime, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Currency(Base):
__tablename__ = "currencies"
id: Mapped[int] = mapped_column(primary_key=True)
code: Mapped[str] = mapped_column(String(3), unique=True, nullable=False) # ISO 4217
name: Mapped[str] = mapped_column(String(100), nullable=False)
symbol: Mapped[str | None] = mapped_column(String(10))
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
exchange_rates_from: Mapped[list["ExchangeRate"]] = relationship(
"ExchangeRate", foreign_keys="ExchangeRate.currency_from_id", back_populates="currency_from"
)
exchange_rates_to: Mapped[list["ExchangeRate"]] = relationship(
"ExchangeRate", foreign_keys="ExchangeRate.currency_to_id", back_populates="currency_to"
)
accounts: Mapped[list] = relationship("Account", back_populates="currency")
operations: Mapped[list] = relationship("Operation", back_populates="currency")
budgets: Mapped[list] = relationship("Budget", back_populates="currency")
calendar_events: Mapped[list] = relationship("CalendarEvent", back_populates="currency")
class ExchangeRate(Base):
__tablename__ = "exchange_rates"
__table_args__ = (
UniqueConstraint("currency_from_id", "currency_to_id", "rate_date", name="uq_exchange_rate_day"),
)
id: Mapped[int] = mapped_column(primary_key=True)
currency_from_id: Mapped[int] = mapped_column(ForeignKey("currencies.id"), nullable=False)
currency_to_id: Mapped[int] = mapped_column(ForeignKey("currencies.id"), nullable=False)
rate: Mapped[Decimal] = mapped_column(Numeric(20, 6), nullable=False)
rate_date: Mapped[date] = mapped_column(Date, nullable=False)
source: Mapped[str | None] = mapped_column(String(100))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
currency_from: Mapped["Currency"] = relationship(
"Currency", foreign_keys=[currency_from_id], back_populates="exchange_rates_from"
)
currency_to: Mapped["Currency"] = relationship(
"Currency", foreign_keys=[currency_to_id], back_populates="exchange_rates_to"
)

View File

@@ -0,0 +1,26 @@
from datetime import datetime
from decimal import Decimal
from sqlalchemy import String, Text, Numeric, ForeignKey, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Location(Base):
__tablename__ = "locations"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) # None = system location
name: Mapped[str] = mapped_column(String(200), nullable=False)
type: Mapped[str | None] = mapped_column(String(50)) # store, bank, atm, online, other
address: Mapped[str | None] = mapped_column(Text)
city: Mapped[str | None] = mapped_column(String(100))
country: Mapped[str | None] = mapped_column(String(100))
latitude: Mapped[Decimal | None] = mapped_column(Numeric(9, 6))
longitude: Mapped[Decimal | None] = mapped_column(Numeric(9, 6))
parent_id: Mapped[int | None] = mapped_column(ForeignKey("locations.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User | None"] = relationship("User", back_populates="locations")
parent: Mapped["Location | None"] = relationship("Location", remote_side="Location.id", back_populates="children")
children: Mapped[list["Location"]] = relationship("Location", back_populates="parent")
operations: Mapped[list] = relationship("Operation", back_populates="location")

View File

@@ -0,0 +1,70 @@
from datetime import datetime
from decimal import Decimal
from sqlalchemy import String, Boolean, Text, Numeric, ForeignKey, DateTime, func, ARRAY
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class OperationType(Base):
__tablename__ = "operation_types"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) # income | expense | transfer
description: Mapped[str | None] = mapped_column(Text)
operations: Mapped[list["Operation"]] = relationship("Operation", back_populates="operation_type")
class Operation(Base):
__tablename__ = "operations"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id"), nullable=False, index=True)
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
operation_type_id: Mapped[int] = mapped_column(ForeignKey("operation_types.id"), nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
currency_id: Mapped[int] = mapped_column(ForeignKey("currencies.id"), nullable=False)
amount_converted: Mapped[Decimal | None] = mapped_column(Numeric(20, 2)) # in account currency
exchange_rate: Mapped[Decimal | None] = mapped_column(Numeric(20, 6))
date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
description: Mapped[str | None] = mapped_column(Text)
location_id: Mapped[int | None] = mapped_column(ForeignKey("locations.id"), nullable=True)
is_planned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
user: Mapped["User"] = relationship("User", back_populates="operations")
account: Mapped["Account"] = relationship("Account", back_populates="operations")
category: Mapped["Category | None"] = relationship("Category", back_populates="operations")
operation_type: Mapped["OperationType"] = relationship("OperationType", back_populates="operations")
currency: Mapped["Currency"] = relationship("Currency", back_populates="operations")
location: Mapped["Location | None"] = relationship("Location", back_populates="operations")
relations_from: Mapped[list["OperationRelation"]] = relationship(
"OperationRelation", foreign_keys="OperationRelation.operation_from_id", back_populates="operation_from"
)
relations_to: Mapped[list["OperationRelation"]] = relationship(
"OperationRelation", foreign_keys="OperationRelation.operation_to_id", back_populates="operation_to"
)
calendar_events: Mapped[list] = relationship("CalendarEvent", back_populates="related_operation")
class OperationRelation(Base):
"""Links two operations — e.g., the debit and credit legs of a transfer."""
__tablename__ = "operation_relations"
id: Mapped[int] = mapped_column(primary_key=True)
operation_from_id: Mapped[int] = mapped_column(ForeignKey("operations.id"), nullable=False)
operation_to_id: Mapped[int] = mapped_column(ForeignKey("operations.id"), nullable=False)
relation_type: Mapped[str] = mapped_column(String(50), nullable=False) # transfer | split | correction
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
operation_from: Mapped["Operation"] = relationship(
"Operation", foreign_keys=[operation_from_id], back_populates="relations_from"
)
operation_to: Mapped["Operation"] = relationship(
"Operation", foreign_keys=[operation_to_id], back_populates="relations_to"
)

View File

@@ -0,0 +1,38 @@
from datetime import datetime
from sqlalchemy import String, Boolean, ForeignKey, DateTime, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Role(Base):
__tablename__ = "roles"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
users: Mapped[list["User"]] = relationship("User", back_populates="role")
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
role: Mapped["Role"] = relationship("Role", back_populates="users")
accounts: Mapped[list] = relationship("Account", back_populates="user")
operations: Mapped[list] = relationship("Operation", back_populates="user")
budgets: Mapped[list] = relationship("Budget", back_populates="user")
categories: Mapped[list] = relationship("Category", back_populates="user")
calendar_events: Mapped[list] = relationship("CalendarEvent", back_populates="user")
locations: Mapped[list] = relationship("Location", back_populates="user")

View File

@@ -0,0 +1,29 @@
from app.schemas.user import RoleOut, UserCreate, UserUpdate, UserOut, UserWithRole
from app.schemas.currency import CurrencyCreate, CurrencyUpdate, CurrencyOut, ExchangeRateCreate, ExchangeRateOut
from app.schemas.location import LocationCreate, LocationUpdate, LocationOut
from app.schemas.account import AccountTypeOut, AccountCreate, AccountUpdate, AccountOut
from app.schemas.category import CategoryCreate, CategoryUpdate, CategoryOut
from app.schemas.budget import BudgetCreate, BudgetUpdate, BudgetOut
from app.schemas.operation import (
OperationTypeOut,
OperationCreate, OperationUpdate, OperationOut,
OperationRelationCreate, OperationRelationOut,
TransferCreate,
)
from app.schemas.calendar import CalendarEventCreate, CalendarEventUpdate, CalendarEventOut
from app.schemas.auth import Token, TokenData, LoginRequest
__all__ = [
"RoleOut", "UserCreate", "UserUpdate", "UserOut", "UserWithRole",
"CurrencyCreate", "CurrencyUpdate", "CurrencyOut",
"ExchangeRateCreate", "ExchangeRateOut",
"LocationCreate", "LocationUpdate", "LocationOut",
"AccountTypeOut", "AccountCreate", "AccountUpdate", "AccountOut",
"CategoryCreate", "CategoryUpdate", "CategoryOut",
"BudgetCreate", "BudgetUpdate", "BudgetOut",
"OperationTypeOut",
"OperationCreate", "OperationUpdate", "OperationOut",
"OperationRelationCreate", "OperationRelationOut", "TransferCreate",
"CalendarEventCreate", "CalendarEventUpdate", "CalendarEventOut",
"Token", "TokenData", "LoginRequest",
]

View File

@@ -0,0 +1,53 @@
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, Field
from app.schemas.currency import CurrencyOut
class AccountTypeOut(BaseModel):
id: int
name: str
icon: str | None = None
description: str | None = None
model_config = {"from_attributes": True}
class AccountCreate(BaseModel):
name: str = Field(..., max_length=200)
account_type_id: int
currency_id: int
initial_balance: Decimal = Decimal("0.00")
credit_limit: Decimal | None = None
description: str | None = None
icon: str | None = None
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
class AccountUpdate(BaseModel):
name: str | None = Field(None, max_length=200)
is_active: bool | None = None
credit_limit: Decimal | None = None
description: str | None = None
icon: str | None = None
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
class AccountOut(BaseModel):
id: int
user_id: int
name: str
account_type_id: int
account_type: AccountTypeOut
currency_id: int
currency: CurrencyOut
balance: Decimal
initial_balance: Decimal
is_active: bool
credit_limit: Decimal | None = None
description: str | None = None
icon: str | None = None
color: str | None = None
created_at: datetime
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,17 @@
from pydantic import BaseModel
class LoginRequest(BaseModel):
username: str
password: str
class Token(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
user_id: int | None = None
role: str | None = None

View File

@@ -0,0 +1,43 @@
from datetime import datetime, date
from decimal import Decimal
from pydantic import BaseModel, Field
from app.schemas.category import CategoryOut
from app.schemas.currency import CurrencyOut
class BudgetCreate(BaseModel):
category_id: int
name: str = Field(..., max_length=200)
amount: Decimal = Field(..., gt=0)
currency_id: int
period_type: str = Field(..., pattern="^(monthly|weekly|yearly|custom)$")
start_date: date
end_date: date | None = None
is_active: bool = True
class BudgetUpdate(BaseModel):
name: str | None = None
amount: Decimal | None = Field(None, gt=0)
period_type: str | None = None
end_date: date | None = None
is_active: bool | None = None
class BudgetOut(BaseModel):
id: int
user_id: int
category_id: int
category: CategoryOut
name: str
amount: Decimal
currency_id: int
currency: CurrencyOut
period_type: str
start_date: date
end_date: date | None = None
is_active: bool
created_at: datetime
spent: Decimal | None = None # computed field
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,55 @@
from datetime import datetime, date, time
from decimal import Decimal
from pydantic import BaseModel, Field
class CalendarEventCreate(BaseModel):
title: str = Field(..., max_length=200)
description: str | None = None
event_date: date
event_time: time | None = None
is_recurring: bool = False
recurrence_pattern: str | None = Field(None, pattern="^(daily|weekly|monthly|yearly)$")
recurrence_end_date: date | None = None
related_operation_id: int | None = None
category_id: int | None = None
amount: Decimal | None = None
currency_id: int | None = None
reminder_days: int | None = Field(None, ge=0)
is_completed: bool = False
class CalendarEventUpdate(BaseModel):
title: str | None = Field(None, max_length=200)
description: str | None = None
event_date: date | None = None
event_time: time | None = None
is_recurring: bool | None = None
recurrence_pattern: str | None = None
recurrence_end_date: date | None = None
related_operation_id: int | None = None
category_id: int | None = None
amount: Decimal | None = None
reminder_days: int | None = None
is_completed: bool | None = None
class CalendarEventOut(BaseModel):
id: int
user_id: int
title: str
description: str | None = None
event_date: date
event_time: time | None = None
is_recurring: bool
recurrence_pattern: str | None = None
recurrence_end_date: date | None = None
related_operation_id: int | None = None
category_id: int | None = None
amount: Decimal | None = None
currency_id: int | None = None
reminder_days: int | None = None
is_completed: bool
created_at: datetime
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,30 @@
from datetime import datetime
from pydantic import BaseModel, Field
class CategoryCreate(BaseModel):
name: str = Field(..., max_length=200)
type: str = Field(..., pattern="^(income|expense|transfer)$")
parent_id: int | None = None
icon: str | None = None
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
class CategoryUpdate(BaseModel):
name: str | None = Field(None, max_length=200)
parent_id: int | None = None
icon: str | None = None
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
class CategoryOut(BaseModel):
id: int
user_id: int | None = None
name: str
type: str
parent_id: int | None = None
icon: str | None = None
color: str | None = None
created_at: datetime
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,48 @@
from datetime import datetime, date
from decimal import Decimal
from pydantic import BaseModel, Field
class CurrencyCreate(BaseModel):
code: str = Field(..., min_length=3, max_length=3)
name: str = Field(..., max_length=100)
symbol: str | None = Field(None, max_length=10)
is_active: bool = True
class CurrencyUpdate(BaseModel):
name: str | None = Field(None, max_length=100)
symbol: str | None = Field(None, max_length=10)
is_active: bool | None = None
class CurrencyOut(BaseModel):
id: int
code: str
name: str
symbol: str | None = None
is_active: bool
model_config = {"from_attributes": True}
class ExchangeRateCreate(BaseModel):
currency_from_id: int
currency_to_id: int
rate: Decimal = Field(..., gt=0)
rate_date: date
source: str | None = None
class ExchangeRateOut(BaseModel):
id: int
currency_from_id: int
currency_to_id: int
rate: Decimal
rate_date: date
source: str | None = None
created_at: datetime
currency_from: CurrencyOut
currency_to: CurrencyOut
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,41 @@
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, Field
class LocationCreate(BaseModel):
name: str = Field(..., max_length=200)
type: str | None = Field(None, max_length=50)
address: str | None = None
city: str | None = Field(None, max_length=100)
country: str | None = Field(None, max_length=100)
latitude: Decimal | None = None
longitude: Decimal | None = None
parent_id: int | None = None
class LocationUpdate(BaseModel):
name: str | None = Field(None, max_length=200)
type: str | None = None
address: str | None = None
city: str | None = None
country: str | None = None
latitude: Decimal | None = None
longitude: Decimal | None = None
parent_id: int | None = None
class LocationOut(BaseModel):
id: int
user_id: int | None = None
name: str
type: str | None = None
address: str | None = None
city: str | None = None
country: str | None = None
latitude: Decimal | None = None
longitude: Decimal | None = None
parent_id: int | None = None
created_at: datetime
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,89 @@
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, Field
from app.schemas.currency import CurrencyOut
from app.schemas.category import CategoryOut
from app.schemas.location import LocationOut
class OperationTypeOut(BaseModel):
id: int
name: str
description: str | None = None
model_config = {"from_attributes": True}
class OperationCreate(BaseModel):
account_id: int
category_id: int | None = None
operation_type_id: int
amount: Decimal = Field(..., gt=0)
currency_id: int
amount_converted: Decimal | None = None
exchange_rate: Decimal | None = None
date: datetime
description: str | None = None
location_id: int | None = None
is_planned: bool = False
class OperationUpdate(BaseModel):
category_id: int | None = None
amount: Decimal | None = Field(None, gt=0)
date: datetime | None = None
description: str | None = None
location_id: int | None = None
is_planned: bool | None = None
class OperationOut(BaseModel):
id: int
user_id: int
account_id: int
category_id: int | None = None
category: CategoryOut | None = None
operation_type_id: int
operation_type: OperationTypeOut
amount: Decimal
currency_id: int
currency: CurrencyOut
amount_converted: Decimal | None = None
exchange_rate: Decimal | None = None
date: datetime
description: str | None = None
location_id: int | None = None
location: LocationOut | None = None
is_planned: bool
created_at: datetime
model_config = {"from_attributes": True}
class OperationRelationCreate(BaseModel):
operation_from_id: int
operation_to_id: int
relation_type: str = Field(..., pattern="^(transfer|split|correction)$")
class OperationRelationOut(BaseModel):
id: int
operation_from_id: int
operation_to_id: int
relation_type: str
created_at: datetime
model_config = {"from_attributes": True}
class TransferCreate(BaseModel):
"""Convenience schema: creates two linked operations in one request."""
from_account_id: int
to_account_id: int
amount: Decimal = Field(..., gt=0)
currency_id: int
amount_converted: Decimal | None = None
exchange_rate: Decimal | None = None
date: datetime
description: str | None = None
location_id: int | None = None

View File

@@ -0,0 +1,39 @@
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field
class RoleOut(BaseModel):
id: int
name: str
description: str | None = None
model_config = {"from_attributes": True}
class UserCreate(BaseModel):
username: str = Field(..., min_length=3, max_length=100)
email: EmailStr
password: str = Field(..., min_length=8)
role_id: int = 3 # default: user
class UserUpdate(BaseModel):
username: str | None = Field(None, min_length=3, max_length=100)
email: EmailStr | None = None
is_active: bool | None = None
role_id: int | None = None
class UserOut(BaseModel):
id: int
username: str
email: str
role_id: int
is_active: bool
created_at: datetime
model_config = {"from_attributes": True}
class UserWithRole(UserOut):
role: RoleOut

12
backend/requirements.txt Normal file
View File

@@ -0,0 +1,12 @@
fastapi==0.111.0
uvicorn[standard]==0.29.0
sqlalchemy==2.0.30
alembic==1.13.1
psycopg2-binary==2.9.9
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.9
pydantic-settings==2.2.1
pydantic[email]==2.7.1
httpx==0.27.0
python-dotenv==1.0.1

121
backend/seed.py Normal file
View File

@@ -0,0 +1,121 @@
"""Seed initial reference data. Run once after migrations: python seed.py"""
import os
from dotenv import load_dotenv
load_dotenv()
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.auth.security import hash_password
from app.models import Role, User, Currency, AccountType, OperationType, Category
engine = create_engine(os.environ["DATABASE_URL"])
ROLES = [
("admin", "Full access — manages users, all financial data"),
("accountant", "Manages all financial data, cannot manage users"),
("user", "Manages own financial data"),
("viewer", "Read-only access to own data"),
]
CURRENCIES = [
("RUB", "Российский рубль", "₽"),
("USD", "Доллар США", "$"),
("EUR", "Евро", "€"),
("GBP", "Фунт стерлингов", "£"),
("CNY", "Китайский юань", "¥"),
("BTC", "Биткоин", "₿"),
]
ACCOUNT_TYPES = [
("Наличные", "wallet", "Наличные деньги"),
("Банковский счёт", "bank", "Текущий/расчётный счёт"),
("Кредитная карта", "credit-card", "Кредитная карта"),
("Накопительный", "piggy-bank", "Вклад / накопительный счёт"),
("Инвестиционный", "chart-line", "Брокерский или инвест. счёт"),
("Криптовалюта", "bitcoin", "Криптовалютный кошелёк"),
("Другое", "dots", "Прочий тип счёта"),
]
OPERATION_TYPES = [
("income", "Доход — пополнение счёта"),
("expense", "Расход — списание со счёта"),
("transfer", "Перевод между счетами"),
]
CATEGORIES = [
# (name, type, icon, color)
# --- Доходы ---
("Зарплата", "income", "briefcase", "#4CAF50"),
("Фриланс", "income", "laptop", "#8BC34A"),
("Инвестиции", "income", "chart-line", "#00BCD4"),
("Подарки", "income", "gift", "#E91E63"),
("Прочие доходы", "income", "plus-circle", "#9E9E9E"),
# --- Расходы ---
("Продукты", "expense", "shopping-cart","#FF5722"),
("Транспорт", "expense", "car", "#FF9800"),
("ЖКХ", "expense", "home", "#795548"),
("Здоровье", "expense", "heart", "#F44336"),
("Рестораны и кафе", "expense", "utensils", "#E91E63"),
("Развлечения", "expense", "gamepad", "#9C27B0"),
("Одежда", "expense", "tshirt", "#3F51B5"),
("Образование", "expense", "book", "#2196F3"),
("Путешествия", "expense", "plane", "#03A9F4"),
("Связь и интернет", "expense", "wifi", "#009688"),
("Подписки", "expense", "repeat", "#607D8B"),
("Прочие расходы", "expense", "minus-circle", "#9E9E9E"),
# --- Переводы ---
("Переводы", "transfer", "arrows-h", "#607D8B"),
]
def seed():
with Session(engine) as db:
# Roles
role_map: dict[str, Role] = {}
for name, desc in ROLES:
role = db.query(Role).filter(Role.name == name).first()
if not role:
role = Role(name=name, description=desc)
db.add(role)
db.flush()
role_map[name] = role
# Admin user
if not db.query(User).filter(User.username == "admin").first():
admin_pwd = os.getenv("ADMIN_PASSWORD", "Admin1234!")
admin = User(
username="admin",
email="admin@finance.local",
password_hash=hash_password(admin_pwd),
role_id=role_map["admin"].id,
)
db.add(admin)
print(f"[seed] Admin user created. Password from ADMIN_PASSWORD env (default: Admin1234!)")
# Currencies
for code, name, symbol in CURRENCIES:
if not db.query(Currency).filter(Currency.code == code).first():
db.add(Currency(code=code, name=name, symbol=symbol))
# Account types
for name, icon, desc in ACCOUNT_TYPES:
if not db.query(AccountType).filter(AccountType.name == name).first():
db.add(AccountType(name=name, icon=icon, description=desc))
# Operation types
for name, desc in OPERATION_TYPES:
if not db.query(OperationType).filter(OperationType.name == name).first():
db.add(OperationType(name=name, description=desc))
# System categories (user_id=None)
for name, ctype, icon, color in CATEGORIES:
if not db.query(Category).filter(Category.name == name, Category.user_id == None).first():
db.add(Category(name=name, type=ctype, icon=icon, color=color, user_id=None))
db.commit()
print("[seed] Done.")
if __name__ == "__main__":
seed()

View File

@@ -0,0 +1,9 @@
-- PostgreSQL DB-level roles for connection security
-- App uses a single app user; RLS enforces row-level isolation
CREATE ROLE finance_app_user WITH LOGIN PASSWORD 'CHANGE_IN_ENV' NOSUPERUSER NOCREATEDB NOCREATEROLE;
GRANT CONNECT ON DATABASE financedb TO finance_app_user;
GRANT USAGE ON SCHEMA public TO finance_app_user;
-- Permissions granted after table creation via Alembic migration
-- See backend/alembic/versions/001_initial_schema.py for GRANT statements

81
docker-compose.yml Normal file
View File

@@ -0,0 +1,81 @@
version: "3.9"
services:
postgres:
image: postgres:16-alpine
container_name: finance_db
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-financedb}
POSTGRES_USER: ${POSTGRES_USER:-financeuser}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./backend/sql/init_roles.sql:/docker-entrypoint-initdb.d/01_init_roles.sql:ro
ports:
- "5432:5432"
networks:
- finance_net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-financeuser} -d ${POSTGRES_DB:-financedb}"]
interval: 10s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: finance_backend
restart: unless-stopped
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-financeuser}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-financedb}
SECRET_KEY: ${SECRET_KEY}
ALGORITHM: HS256
ACCESS_TOKEN_EXPIRE_MINUTES: 30
REFRESH_TOKEN_EXPIRE_DAYS: 7
DEBUG: "false"
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost,http://localhost:3000}
depends_on:
postgres:
condition: service_healthy
networks:
- finance_net
volumes:
- ./backend:/app
expose:
- "8000"
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: finance_frontend
restart: unless-stopped
networks:
- finance_net
expose:
- "80"
nginx:
image: nginx:alpine
container_name: finance_nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/certs:/etc/nginx/certs:ro
depends_on:
- backend
- frontend
networks:
- finance_net
volumes:
postgres_data:
networks:
finance_net:
driver: bridge

230
docs/ER_diagram.md Normal file
View File

@@ -0,0 +1,230 @@
# ER-диаграмма базы данных Finance App
```mermaid
erDiagram
roles {
int id PK
varchar(50) name UK
text description
timestamptz created_at
}
users {
int id PK
varchar(100) username UK
varchar(255) email UK
varchar(255) password_hash
int role_id FK
boolean is_active
timestamptz created_at
timestamptz updated_at
}
currencies {
int id PK
varchar(3) code UK "ISO 4217"
varchar(100) name
varchar(10) symbol
boolean is_active
timestamptz created_at
}
exchange_rates {
int id PK
int currency_from_id FK
int currency_to_id FK
numeric(20_6) rate
date rate_date
varchar(100) source
timestamptz created_at
}
locations {
int id PK
int user_id FK "NULL = системное"
varchar(200) name
varchar(50) type "store|bank|atm|online|other"
text address
varchar(100) city
varchar(100) country
numeric(9_6) latitude
numeric(9_6) longitude
int parent_id FK "самоссылка"
timestamptz created_at
}
account_types {
int id PK
varchar(100) name UK
varchar(50) icon
text description
}
accounts {
int id PK
int user_id FK
varchar(200) name
int account_type_id FK
int currency_id FK
numeric(20_2) balance
numeric(20_2) initial_balance
boolean is_active
numeric(20_2) credit_limit
text description
varchar(50) icon
varchar(7) color "hex #RRGGBB"
timestamptz created_at
timestamptz updated_at
}
categories {
int id PK
int user_id FK "NULL = системная"
varchar(200) name
varchar(20) type "income|expense|transfer"
int parent_id FK "иерархия"
varchar(50) icon
varchar(7) color
timestamptz created_at
}
budgets {
int id PK
int user_id FK
int category_id FK
varchar(200) name
numeric(20_2) amount
int currency_id FK
varchar(20) period_type "monthly|weekly|yearly|custom"
date start_date
date end_date
boolean is_active
timestamptz created_at
}
operation_types {
int id PK
varchar(50) name UK "income|expense|transfer"
text description
}
operations {
int id PK
int user_id FK
int account_id FK
int category_id FK
int operation_type_id FK
numeric(20_2) amount
int currency_id FK
numeric(20_2) amount_converted "в валюте счёта"
numeric(20_6) exchange_rate
timestamptz date
text description
int location_id FK
boolean is_planned
timestamptz created_at
timestamptz updated_at
}
operation_relations {
int id PK
int operation_from_id FK
int operation_to_id FK
varchar(50) relation_type "transfer|split|correction"
timestamptz created_at
}
calendar_events {
int id PK
int user_id FK
varchar(200) title
text description
date event_date
time event_time
boolean is_recurring
varchar(50) recurrence_pattern "daily|weekly|monthly|yearly"
date recurrence_end_date
int related_operation_id FK
int category_id FK
numeric(20_2) amount
int currency_id FK
int reminder_days
boolean is_completed
timestamptz created_at
timestamptz updated_at
}
%% ── Связи ──────────────────────────────────────────────────────
roles ||--o{ users : "назначается"
users ||--o{ accounts : "владеет"
users ||--o{ operations : "создаёт"
users ||--o{ budgets : "планирует"
users ||--o{ calendar_events : "назначает"
users ||--o{ categories : "определяет"
users ||--o{ locations : "добавляет"
currencies ||--o{ accounts : "деноминирует"
currencies ||--o{ exchange_rates : "исходная"
currencies ||--o{ exchange_rates : "целевая"
currencies ||--o{ operations : "валюта операции"
currencies ||--o{ budgets : "валюта бюджета"
currencies ||--o{ calendar_events : "валюта события"
account_types ||--o{ accounts : "классифицирует"
accounts ||--o{ operations : "содержит"
categories ||--o{ operations : "классифицирует"
categories ||--o{ budgets : "ограничивает"
categories ||--o{ calendar_events : "ассоциирует"
categories ||--o{ categories : "родитель→ребёнок"
operation_types ||--o{ operations : "определяет тип"
locations ||--o{ operations : "место проведения"
locations ||--o{ locations : "родитель→дочернее"
operations ||--o{ operation_relations : "от (from)"
operations ||--o{ operation_relations : "к (to)"
operations ||--o{ calendar_events : "привязанная операция"
```
---
## Описание сущностей
| Таблица | Назначение |
|---|---|
| **roles** | Роли доступа: `admin`, `accountant`, `user`, `viewer` |
| **users** | Учётные записи пользователей |
| **currencies** | Справочник валют (ISO 4217: RUB, USD, EUR…) |
| **exchange_rates** | Ежедневные курсы между парами валют |
| **locations** | Места проведения операций (магазины, банки, банкоматы) с иерархией |
| **account_types** | Типы счетов: наличные, банковский счёт, кредитная карта, вклад, инвест., крипто |
| **accounts** | Финансовые счета пользователя с балансом и валютой |
| **categories** | Иерархические категории доходов/расходов (системные + пользовательские) |
| **budgets** | Лимиты расходов по категориям за период |
| **operation_types** | Типы операций: `income`, `expense`, `transfer` |
| **operations** | Финансовые операции — транзакции по счетам |
| **operation_relations** | Связи операций: перевод (debit ↔ credit), разделение, коррекция |
| **calendar_events** | Плановые события с напоминанием (разовые и повторяющиеся) |
## Разграничение прав доступа (RBAC)
| Роль | Описание | Права |
|---|---|---|
| **admin** | Администратор | Полный доступ + управление пользователями |
| **accountant** | Бухгалтер | Все финансовые операции, без управления пользователями |
| **user** | Пользователь | CRUD своих данных (счета, операции, бюджеты) |
| **viewer** | Наблюдатель | Только чтение своих данных |
Изоляция данных обеспечивается на уровне приложения: каждый запрос фильтруется по `user_id`. При необходимости — PostgreSQL Row Level Security (RLS).
## Безопасность подключения к БД
- **SSL/TLS**: `sslmode=prefer` (может быть переключён на `require` для продакшена)
- **Переменные окружения**: учётные данные никогда не хранятся в коде
- **Connection pooling**: SQLAlchemy pool (size=10, max_overflow=20, recycle=3600s)
- **Nginx**: HTTPS терминация + rate limiting на auth-эндпоинтах (10 req/min)
- **JWT**: access token (30 мин) + refresh token (7 дней)
- **Пароли**: bcrypt с salt

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 },
},
},
});

View File

@@ -1,31 +0,0 @@
import os
from app import db, app, User, Transaction
from datetime import date, datetime
app.app_context().push()
# Создание таблиц
db.create_all()
# Добавление тестового пользователя
if not User.query.first():
test_user = User(username="testuser", email="test@example.com")
test_user.set_password("testpassword")
db.session.add(test_user)
db.session.commit()
# Добавление тестовых транзакций
transactions = [
Transaction(type='income', amount=50000, category='Зарплата', date=date(2023, 10, 15), description='Основная зарплата'),
Transaction(type='expense', amount=15000, category='Аренда', date=date(2023, 10, 5), description='Аренда квартиры'),
Transaction(type='expense', amount=3500, category='Продукты', date=date(2023, 10, 12), description='Супермаркет'),
Transaction(type='expense', amount=2500, category='Транспорт', date=date(2023, 10, 18), description='Бензин'),
Transaction(type='income', amount=10000, category='Фриланс', date=date(2023, 10, 20), description='Веб-разработка')
]
for t in transactions:
db.session.add(t)
db.session.commit()
print("База данных инициализирована с тестовыми данными")
else:
print("База данных уже содержит данные")

95
nginx/nginx.conf Normal file
View File

@@ -0,0 +1,95 @@
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
# Rate limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m;
limit_req_zone $binary_remote_addr zone=auth:10m rate=10r/m;
# Upstream services
upstream backend {
server backend:8000;
}
upstream frontend {
server frontend:80;
}
# HTTP → HTTPS redirect
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name _;
ssl_certificate /etc/nginx/certs/server.crt;
ssl_certificate_key /etc/nginx/certs/server.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Security headers
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
client_max_body_size 10M;
# API routes
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# Auth endpoints — stricter rate limiting
location /api/v1/auth/ {
limit_req zone=auth burst=5 nodelay;
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API docs (disable in production)
location /docs {
return 404;
}
# Web frontend
location / {
proxy_pass http://frontend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
}

View File

@@ -1,4 +0,0 @@
flask
flask-sqlalchemy
psycopg2-binary
python-dotenv

View File

@@ -1,462 +0,0 @@
:root {
--primary: #2c3e50;
--secondary: #3498db;
--income: #2ecc71;
--expense: #e74c3c;
--light: #ecf0f1;
--dark: #34495e;
--gray: #7f8c8d;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
body {
background: linear-gradient(135deg, #1a2a6c, #b21f1f, #1a2a6c);
color: var(--dark);
padding: 20px;
min-height: 100vh;
}
.container {
background: white;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
overflow: hidden;
max-width: 1200px;
margin: 0 auto;
}
.form-container {
max-width: 600px;
padding: 30px;
}
header {
background: var(--primary);
color: white;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
}
header h1 {
display: flex;
align-items: center;
gap: 15px;
font-size: 1.8rem;
}
.summary {
display: flex;
gap: 20px;
margin-top: 15px;
width: 100%;
}
.summary-item {
flex: 1;
background: white;
border-radius: 10px;
padding: 15px;
text-align: center;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.1);
}
.summary-item.income {
border-top: 5px solid var(--income);
}
.summary-item.expense {
border-top: 5px solid var(--expense);
}
.summary-item.balance {
border-top: 5px solid var(--secondary);
}
.summary-item span {
display: block;
color: var(--gray);
font-size: 0.9rem;
margin-bottom: 5px;
}
.summary-item strong {
font-size: 1.5rem;
display: block;
}
.controls {
padding: 20px;
background: var(--light);
border-bottom: 1px solid #ddd;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: 50px;
text-decoration: none;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
border: none;
font-size: 1rem;
}
.add-btn {
background: var(--secondary);
color: white;
}
.add-btn:hover {
background: #2980b9;
}
.back-btn {
background: var(--gray);
color: white;
}
.save-btn {
background: var(--income);
color: white;
padding: 12px 25px;
font-size: 1.1rem;
}
.back-btn:hover {
background: #666;
}
section {
padding: 20px;
}
h2 {
margin-bottom: 20px;
color: var(--primary);
border-bottom: 2px solid var(--light);
padding-bottom: 10px;
}
.category-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 15px;
margin-bottom: 30px;
}
.category-item {
background: white;
border-radius: 10px;
padding: 15px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
border-left: 5px solid var(--expense);
}
.category-item span.amount {
font-weight: bold;
font-size: 1.2rem;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
table th, table td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #eee;
}
table th {
background: var(--primary);
color: white;
font-weight: 500;
}
table tbody tr:hover {
background: rgba(52, 152, 219, 0.05);
}
table .income {
color: var(--income);
font-weight: bold;
}
table .expense {
color: var(--expense);
font-weight: bold;
}
.actions {
display: flex;
gap: 10px;
}
.btn.edit-btn {
background: #f39c12;
color: white;
padding: 8px 12px;
}
.btn.delete-btn {
background: var(--expense);
color: white;
padding: 8px 12px;
}
.btn.edit-btn:hover {
background: #e67e22;
}
.btn.delete-btn:hover {
background: #c0392b;
}
.empty {
text-align: center;
padding: 20px;
color: var(--gray);
font-style: italic;
}
footer {
text-align: center;
padding: 20px;
color: var(--gray);
font-size: 0.9rem;
border-top: 1px solid #eee;
}
.tip {
font-style: italic;
margin-top: 10px;
color: var(--primary);
}
/* Формы */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: var(--dark);
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 1rem;
}
.form-group textarea {
min-height: 100px;
resize: vertical;
}
.radio-group {
display: flex;
gap: 15px;
}
.radio-label {
display: flex;
align-items: center;
cursor: pointer;
}
.radio-custom {
display: inline-block;
padding: 10px 20px;
border-radius: 50px;
font-weight: 500;
transition: all 0.3s ease;
}
.radio-custom.income {
background: rgba(46, 204, 113, 0.2);
color: var(--income);
}
.radio-custom.expense {
background: rgba(231, 76, 60, 0.2);
color: var(--expense);
}
input[type="radio"] {
display: none;
}
input[type="radio"]:checked + .radio-custom.income {
background: var(--income);
color: white;
}
input[type="radio"]:checked + .radio-custom.expense {
background: var(--expense);
color: white;
}
/* Адаптивность */
@media (max-width: 768px) {
.summary {
flex-direction: column;
}
table {
display: block;
overflow-x: auto;
}
.category-list {
grid-template-columns: 1fr;
}
}
/* Стили для аутентификации */
.auth-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #1a2a6c, #b21f1f, #1a2a6c);
}
.auth-card {
background: white;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
padding: 30px;
width: 100%;
max-width: 500px;
}
.auth-card h1 {
text-align: center;
margin-bottom: 30px;
color: #2c3e50;
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
}
.auth-links {
margin-top: 20px;
text-align: center;
}
.auth-links a {
color: #3498db;
text-decoration: none;
}
.auth-links a:hover {
text-decoration: underline;
}
.login-btn {
background: #3498db;
color: white;
width: 100%;
padding: 12px;
font-size: 1.1rem;
}
.register-btn {
background: #2ecc71;
color: white;
width: 100%;
padding: 12px;
font-size: 1.1rem;
}
.remember {
display: flex;
align-items: center;
gap: 10px;
}
/* Меню пользователя */
.user-menu {
position: absolute;
top: 20px;
right: 20px;
display: flex;
align-items: center;
gap: 15px;
color: white;
font-weight: 500;
}
.dropbtn {
background: rgba(255, 255, 255, 0.2);
color: white;
border: none;
border-radius: 50px;
padding: 8px 15px;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
font-size: 1rem;
}
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-content {
display: none;
position: absolute;
right: 0;
background: white;
min-width: 160px;
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
z-index: 1;
border-radius: 5px;
overflow: hidden;
}
.dropdown-content a {
color: #2c3e50;
padding: 12px 16px;
text-decoration: none;
display: flex;
align-items: center;
gap: 10px;
font-size: 0.9rem;
}
.dropdown-content a:hover {
background: #f5f5f5;
}
.dropdown:hover .dropdown-content {
display: block;
}

View File

@@ -1,60 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Добавить операцию</title>
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body>
<div class="container form-container">
<header>
<h1><i class="fas fa-plus-circle"></i> Добавить операцию</h1>
<a href="{{ url_for('index') }}" class="btn back-btn">
<i class="fas fa-arrow-left"></i> Назад
</a>
</header>
<form method="POST">
<div class="form-group">
<label for="type">Тип операции:</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="type" value="income" checked>
<span class="radio-custom income">Доход</span>
</label>
<label class="radio-label">
<input type="radio" name="type" value="expense">
<span class="radio-custom expense">Расход</span>
</label>
</div>
</div>
<div class="form-group">
<label for="amount">Сумма (₽):</label>
<input type="number" step="0.01" min="0.01" name="amount" required>
</div>
<div class="form-group">
<label for="category">Категория:</label>
<input type="text" name="category" required>
</div>
<div class="form-group">
<label for="date">Дата:</label>
<input type="date" name="date" value="{{ current_date }}" required>
</div>
<div class="form-group">
<label for="description">Описание:</label>
<textarea name="description" rows="3"></textarea>
</div>
<button type="submit" class="btn save-btn">
<i class="fas fa-save"></i> Сохранить
</button>
</form>
</div>
</body>
</html>

View File

@@ -1,60 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Редактировать операцию</title>
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body>
<div class="container form-container">
<header>
<h1><i class="fas fa-edit"></i> Редактировать операцию</h1>
<a href="{{ url_for('index') }}" class="btn back-btn">
<i class="fas fa-arrow-left"></i> Назад
</a>
</header>
<form method="POST">
<div class="form-group">
<label for="type">Тип операции:</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="type" value="income" {% if transaction.type == 'income' %}checked{% endif %}>
<span class="radio-custom income">Доход</span>
</label>
<label class="radio-label">
<input type="radio" name="type" value="expense" {% if transaction.type == 'expense' %}checked{% endif %}>
<span class="radio-custom expense">Расход</span>
</label>
</div>
</div>
<div class="form-group">
<label for="amount">Сумма (₽):</label>
<input type="number" step="0.01" min="0.01" name="amount" value="{{ transaction.amount }}" required>
</div>
<div class="form-group">
<label for="category">Категория:</label>
<input type="text" name="category" value="{{ transaction.category }}" required>
</div>
<div class="form-group">
<label for="date">Дата:</label>
<input type="date" name="date" value="{{ transaction.date }}" required>
</div>
<div class="form-group">
<label for="description">Описание:</label>
<textarea name="description" rows="3">{{ transaction.description }}</textarea>
</div>
<button type="submit" class="btn save-btn">
<i class="fas fa-save"></i> Сохранить изменения
</button>
</form>
</div>
</body>
</html>

View File

@@ -1,121 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Финансовый учет</title>
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body>
<div class="container">
<header>
<h1><i class="fas fa-coins"></i> Финансовый учет</h1>
<div class="summary">
<div class="summary-item income">
<span>Доходы</span>
<strong>{{ total_income|int }} ₽</strong>
</div>
<div class="summary-item expense">
<span>Расходы</span>
<strong>{{ total_expense|int }} ₽</strong>
</div>
<div class="summary-item balance">
<span>Баланс</span>
<strong>{{ balance|int }} ₽</strong>
</div>
</div>
<div class="user-menu">
<span>Привет, {{ username }}!</span>
<div class="dropdown">
<button class="dropbtn">
<i class="fas fa-user-circle"></i>
<i class="fas fa-caret-down"></i>
</button>
<div class="dropdown-content">
<a href="#"><i class="fas fa-user"></i> Профиль</a>
<a href="{{ url_for('logout') }}"><i class="fas fa-sign-out-alt"></i> Выход</a>
</div>
</div>
</div>
</header>
<div class="controls">
<a href="{{ url_for('add_transaction') }}" class="btn add-btn">
<i class="fas fa-plus"></i> Добавить операцию
</a>
</div>
<section class="categories">
<h2>Расходы по категориям</h2>
<div class="category-list">
{% for category, amounts in categories.items() %}
{% if amounts['expense'] > 0 %}
<div class="category-item">
<span>{{ category }}</span>
<span class="amount expense">{{ amounts['expense']|int }} ₽</span>
</div>
{% endif %}
{% endfor %}
</div>
</section>
<section class="transactions">
<h2>История операций</h2>
<table>
<thead>
<tr>
<th>Дата</th>
<th>Категория</th>
<th>Описание</th>
<th>Сумма</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for t in transactions %}
<tr>
<td>{{ t.date.strftime('%d.%m.%Y') }}</td>
<td>{{ t.category }}</td>
<td>{{ t.description }}</td>
<td class="{{ 'income' if t.type == 'income' else 'expense' }}">
{{ t.amount|int }} ₽
</td>
<td class="actions">
<a href="{{ url_for('edit_transaction', id=t.id) }}" class="btn edit-btn">
<i class="fas fa-edit"></i>
</a>
<a href="{{ url_for('delete_transaction', id=t.id) }}" class="btn delete-btn">
<i class="fas fa-trash"></i>
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="empty">Нет операций</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<footer>
<p>Сегодня: {{ current_date.strftime('%d.%m.%Y') }}</p>
<p class="tip">"Учет финансов - первый шаг к финансовой свободе!"</p>
</footer>
</div>
<!-- Обработка сообщений flash -->
<script>
document.addEventListener('DOMContentLoaded', function() {
setTimeout(function() {
const messages = document.querySelectorAll('.flash-message');
messages.forEach(msg => {
msg.style.opacity = '0';
setTimeout(() => msg.remove(), 500);
});
}, 3000);
});
</script>
</body>
</html>

View File

@@ -1,42 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Вход в систему</title>
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body>
<div class="container auth-container">
<div class="auth-card">
<h1><i class="fas fa-lock"></i> Вход в систему</h1>
<form method="POST">
<div class="form-group">
<label for="username">Имя пользователя:</label>
<input type="text" name="username" required>
</div>
<div class="form-group">
<label for="password">Пароль:</label>
<input type="password" name="password" required>
</div>
<div class="form-group remember">
<input type="checkbox" name="remember" id="remember">
<label for="remember">Запомнить меня</label>
</div>
<button type="submit" class="btn login-btn">
<i class="fas fa-sign-in-alt"></i> Войти
</button>
<div class="auth-links">
<p>Ещё нет аккаунта? <a href="{{ url_for('register') }}">Зарегистрируйтесь</a></p>
</div>
</form>
</div>
</div>
</body>
</html>

View File

@@ -1,47 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Регистрация</title>
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body>
<div class="container auth-container">
<div class="auth-card">
<h1><i class="fas fa-user-plus"></i> Регистрация</h1>
<form method="POST">
<div class="form-group">
<label for="username">Имя пользователя:</label>
<input type="text" name="username" required>
</div>
<div class="form-group">
<label for="email">Email:</label>
<input type="email" name="email" required>
</div>
<div class="form-group">
<label for="password">Пароль:</label>
<input type="password" name="password" required>
</div>
<div class="form-group">
<label for="confirm_password">Подтвердите пароль:</label>
<input type="password" name="confirm_password" required>
</div>
<button type="submit" class="btn register-btn">
<i class="fas fa-user-check"></i> Зарегистрироваться
</button>
<div class="auth-links">
<p>Уже есть аккаунт? <a href="{{ url_for('login') }}">Войдите</a></p>
</div>
</form>
</div>
</div>
</body>
</html>