Compare commits
7 Commits
55cf693978
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52594b5dc6 | ||
|
|
7d00cf062e | ||
|
|
5a43fbbfc7 | ||
|
|
f07c172600 | ||
|
|
e5b217c5b7 | ||
|
|
1e9cc84c8a | ||
|
|
1552ef2fbb |
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)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
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`
|
||||||
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 },
|
||||||
|
});
|
||||||
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/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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user