Обновление приложения
This commit is contained in:
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git -C c:/Data/git/finance_app status --short)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
2
.env
@@ -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
10
.env.example
Normal 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
32
.gitignore
vendored
Normal 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
148
README.md
Normal 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
16
android/App.tsx
Normal 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
25
android/app.json
Normal 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
31
android/package.json
Normal 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
83
android/src/api/client.ts
Normal 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;
|
||||
50
android/src/contexts/AuthContext.tsx
Normal file
50
android/src/contexts/AuthContext.tsx
Normal 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)!;
|
||||
70
android/src/navigation/AppNavigator.tsx
Normal file
70
android/src/navigation/AppNavigator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
android/src/screens/AccountsScreen.tsx
Normal file
150
android/src/screens/AccountsScreen.tsx
Normal 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";
|
||||
94
android/src/screens/BudgetsScreen.tsx
Normal file
94
android/src/screens/BudgetsScreen.tsx
Normal 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 },
|
||||
});
|
||||
118
android/src/screens/CalendarScreen.tsx
Normal file
118
android/src/screens/CalendarScreen.tsx
Normal 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" },
|
||||
});
|
||||
155
android/src/screens/DashboardScreen.tsx
Normal file
155
android/src/screens/DashboardScreen.tsx
Normal 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 },
|
||||
});
|
||||
54
android/src/screens/LoginScreen.tsx
Normal file
54
android/src/screens/LoginScreen.tsx
Normal 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 },
|
||||
});
|
||||
171
android/src/screens/OperationsScreen.tsx
Normal file
171
android/src/screens/OperationsScreen.tsx
Normal 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
164
app.py
@@ -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
18
backend/.env.example
Normal 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
15
backend/Dockerfile
Normal 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
38
backend/alembic.ini
Normal 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
44
backend/alembic/env.py
Normal 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()
|
||||
211
backend/alembic/versions/001_initial_schema.py
Normal file
211
backend/alembic/versions/001_initial_schema.py
Normal 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
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/v1/__init__.py
Normal file
0
backend/app/api/v1/__init__.py
Normal file
64
backend/app/api/v1/accounts.py
Normal file
64
backend/app/api/v1/accounts.py
Normal 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()
|
||||
77
backend/app/api/v1/auth.py
Normal file
77
backend/app/api/v1/auth.py
Normal 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
|
||||
80
backend/app/api/v1/budgets.py
Normal file
80
backend/app/api/v1/budgets.py
Normal 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()
|
||||
73
backend/app/api/v1/calendar.py
Normal file
73
backend/app/api/v1/calendar.py
Normal 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()
|
||||
49
backend/app/api/v1/categories.py
Normal file
49
backend/app/api/v1/categories.py
Normal 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()
|
||||
55
backend/app/api/v1/currencies.py
Normal file
55
backend/app/api/v1/currencies.py
Normal 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()
|
||||
61
backend/app/api/v1/exchange_rates.py
Normal file
61
backend/app/api/v1/exchange_rates.py
Normal 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()
|
||||
49
backend/app/api/v1/locations.py
Normal file
49
backend/app/api/v1/locations.py
Normal 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()
|
||||
163
backend/app/api/v1/operations.py
Normal file
163
backend/app/api/v1/operations.py
Normal 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()
|
||||
51
backend/app/api/v1/relations.py
Normal file
51
backend/app/api/v1/relations.py
Normal 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()
|
||||
15
backend/app/api/v1/router.py
Normal file
15
backend/app/api/v1/router.py
Normal 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)
|
||||
52
backend/app/api/v1/users.py
Normal file
52
backend/app/api/v1/users.py
Normal 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()
|
||||
0
backend/app/auth/__init__.py
Normal file
0
backend/app/auth/__init__.py
Normal file
54
backend/app/auth/dependencies.py
Normal file
54
backend/app/auth/dependencies.py
Normal 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"))
|
||||
41
backend/app/auth/security.py
Normal file
41
backend/app/auth/security.py
Normal 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
25
backend/app/config.py
Normal 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
33
backend/app/database.py
Normal 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
29
backend/app/main.py
Normal 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"}
|
||||
19
backend/app/models/__init__.py
Normal file
19
backend/app/models/__init__.py
Normal 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",
|
||||
]
|
||||
42
backend/app/models/account.py
Normal file
42
backend/app/models/account.py
Normal 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")
|
||||
25
backend/app/models/budget.py
Normal file
25
backend/app/models/budget.py
Normal 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")
|
||||
34
backend/app/models/calendar.py
Normal file
34
backend/app/models/calendar.py
Normal 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")
|
||||
26
backend/app/models/category.py
Normal file
26
backend/app/models/category.py
Normal 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")
|
||||
49
backend/app/models/currency.py
Normal file
49
backend/app/models/currency.py
Normal 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"
|
||||
)
|
||||
26
backend/app/models/location.py
Normal file
26
backend/app/models/location.py
Normal 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")
|
||||
70
backend/app/models/operation.py
Normal file
70
backend/app/models/operation.py
Normal 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"
|
||||
)
|
||||
38
backend/app/models/user.py
Normal file
38
backend/app/models/user.py
Normal 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")
|
||||
29
backend/app/schemas/__init__.py
Normal file
29
backend/app/schemas/__init__.py
Normal 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",
|
||||
]
|
||||
53
backend/app/schemas/account.py
Normal file
53
backend/app/schemas/account.py
Normal 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}
|
||||
17
backend/app/schemas/auth.py
Normal file
17
backend/app/schemas/auth.py
Normal 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
|
||||
43
backend/app/schemas/budget.py
Normal file
43
backend/app/schemas/budget.py
Normal 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}
|
||||
55
backend/app/schemas/calendar.py
Normal file
55
backend/app/schemas/calendar.py
Normal 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}
|
||||
30
backend/app/schemas/category.py
Normal file
30
backend/app/schemas/category.py
Normal 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}
|
||||
48
backend/app/schemas/currency.py
Normal file
48
backend/app/schemas/currency.py
Normal 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}
|
||||
41
backend/app/schemas/location.py
Normal file
41
backend/app/schemas/location.py
Normal 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}
|
||||
89
backend/app/schemas/operation.py
Normal file
89
backend/app/schemas/operation.py
Normal 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
|
||||
39
backend/app/schemas/user.py
Normal file
39
backend/app/schemas/user.py
Normal 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
12
backend/requirements.txt
Normal 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
121
backend/seed.py
Normal 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()
|
||||
9
backend/sql/init_roles.sql
Normal file
9
backend/sql/init_roles.sql
Normal 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
81
docker-compose.yml
Normal 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
230
docs/ER_diagram.md
Normal 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
12
frontend/Dockerfile
Normal 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
13
frontend/index.html
Normal 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
12
frontend/nginx.conf
Normal 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
23
frontend/package.json
Normal 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
49
frontend/src/App.jsx
Normal 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
101
frontend/src/api/client.js
Normal 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;
|
||||
156
frontend/src/assets/styles.css
Normal file
156
frontend/src/assets/styles.css
Normal 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; }
|
||||
80
frontend/src/components/Layout.jsx
Normal file
80
frontend/src/components/Layout.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
frontend/src/contexts/AuthContext.jsx
Normal file
48
frontend/src/contexts/AuthContext.jsx
Normal 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
10
frontend/src/main.jsx
Normal 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>
|
||||
);
|
||||
137
frontend/src/pages/Accounts.jsx
Normal file
137
frontend/src/pages/Accounts.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
165
frontend/src/pages/Admin.jsx
Normal file
165
frontend/src/pages/Admin.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
155
frontend/src/pages/Budgets.jsx
Normal file
155
frontend/src/pages/Budgets.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
215
frontend/src/pages/Calendar.jsx
Normal file
215
frontend/src/pages/Calendar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
frontend/src/pages/Dashboard.jsx
Normal file
184
frontend/src/pages/Dashboard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
frontend/src/pages/ExchangeRates.jsx
Normal file
103
frontend/src/pages/ExchangeRates.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
frontend/src/pages/Locations.jsx
Normal file
119
frontend/src/pages/Locations.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
frontend/src/pages/Login.jsx
Normal file
57
frontend/src/pages/Login.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
253
frontend/src/pages/Operations.jsx
Normal file
253
frontend/src/pages/Operations.jsx
Normal 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
12
frontend/vite.config.js
Normal 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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
31
init_db.py
31
init_db.py
@@ -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
95
nginx/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
flask
|
||||
flask-sqlalchemy
|
||||
psycopg2-binary
|
||||
python-dotenv
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user