финальная версия Finance Control

This commit is contained in:
2026-05-25 22:06:16 +03:00
parent 7dcb1a23a7
commit 5890e4ae64
22 changed files with 1441 additions and 927 deletions

14
CHANGELOG_FINAL.md Normal file
View File

@@ -0,0 +1,14 @@
# Доработки финальной версии
1. Улучшен интерфейс: обновлены размеры окна, карточки главной панели, боковое меню и единый стиль экранов.
2. Добавлен сервисный слой `FinanceService`, который отделяет бизнес-логику и SQL-запросы от интерфейса.
3. Добавлены вспомогательные модули форматирования и валидации пользовательского ввода.
4. Добавлена защита от многократных быстрых нажатий на кнопки через общий механизм `run_safe`.
5. Добавлены поиск и фильтрация операций.
6. Добавлен экспорт операций в CSV.
7. Добавлено переключение статуса подписки «Активна / Пауза».
8. Раздел бюджетов теперь обновляет существующий лимит, если лимит по категории уже был создан.
9. Добавлено сохранение выбранной темы интерфейса в таблицу `settings`.
10. Добавлены индексы SQLite для операций, категорий, счетов и дат.
11. Добавлена миграция старой базы альфа-версии: приложение корректно открывает базу без новых колонок.
12. Подготовлены `README.md`, `run.bat`, `run.sh` и обновлённый `requirements.txt` для запуска на пустом компьютере.

102
README.md Normal file
View File

@@ -0,0 +1,102 @@
# Finance Control
Finance Control — настольное приложение для персонального учёта финансов и подписок.
Проект разработан на Python с использованием CustomTkinter, SQLite и Matplotlib.
## Основные возможности финальной версии
- учёт счетов, карт, наличных и электронных кошельков;
- категории доходов и расходов;
- добавление и удаление операций с автоматическим изменением баланса;
- поиск и фильтрация операций;
- экспорт истории операций в CSV;
- контроль подписок, переключение статуса «Активна / Пауза»;
- бюджеты и лимиты по расходным категориям с прогресс-барами;
- главная панель со сводкой, ближайшими списаниями, последними операциями и предупреждениями по лимитам;
- аналитика и текстовый отчёт с диаграммой расходов;
- экспорт отчёта в TXT;
- сохранение выбранной темы интерфейса;
- защита от быстрых повторных нажатий на кнопки;
- локальная SQLite-база, создаётся автоматически при первом запуске.
## Запуск на пустом компьютере
1. Установить Python 3.11 или новее.
2. Скачать репозиторий с Gitea.
3. Открыть терминал в папке проекта.
4. Создать виртуальное окружение:
```bash
python -m venv .venv
```
5. Активировать окружение:
Windows:
```bash
.venv\Scripts\activate
```
Linux / macOS:
```bash
source .venv/bin/activate
```
6. Установить зависимости:
```bash
pip install -r requirements.txt
```
7. Запустить приложение:
```bash
python main.py
```
При первом запуске файл `finance_manager.db` будет создан автоматически.
## Быстрый запуск Windows
Можно запустить файл:
```bat
run.bat
```
Он создаст виртуальное окружение, установит зависимости и откроет приложение.
## Структура проекта
```text
main.py
app/
config.py
database.py
services/
finance_service.py
utils/
formatters.py
validators.py
ui/
base_frame.py
main_window.py
dashboard_frame.py
accounts_frame.py
categories_frame.py
transactions_frame.py
subscriptions_frame.py
budgets_frame.py
reports_frame.py
requirements.txt
```
## Проверка кода
Для быстрой проверки синтаксиса:
```bash
python -m py_compile main.py app/*.py app/ui/*.py app/services/*.py app/utils/*.py
```

View File

@@ -1,5 +1,16 @@
APP_TITLE = "Персональный менеджер финансов и подписок"
DB_NAME = "finance_manager.db"
WINDOW_SIZE = "1280x760"
MIN_WIDTH = 1100
MIN_HEIGHT = 700
from pathlib import Path
APP_TITLE = "Finance Control — персональный менеджер финансов"
APP_SUBTITLE = "финальная версия курсового проекта"
BASE_DIR = Path(__file__).resolve().parent.parent
DB_NAME = str(BASE_DIR / "finance_manager.db")
WINDOW_SIZE = "1320x820"
MIN_WIDTH = 1180
MIN_HEIGHT = 760
PRIMARY_COLOR = "#2563eb"
PRIMARY_HOVER_COLOR = "#1d4ed8"
DANGER_COLOR = "#b91c1c"
DANGER_HOVER_COLOR = "#991b1b"
SUCCESS_COLOR = "#15803d"
WARNING_COLOR = "#ca8a04"

View File

@@ -1,12 +1,8 @@
import sqlite3
from typing import Iterable
from app.config import DB_NAME
def get_connection() -> sqlite3.Connection:
connection = sqlite3.connect(DB_NAME)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
return connection
DEFAULT_CATEGORIES: Iterable[tuple[str, str]] = [
("Продукты", "Расход"),
@@ -17,13 +13,26 @@ DEFAULT_CATEGORIES: Iterable[tuple[str, str]] = [
("Развлечения", "Расход"),
("Одежда", "Расход"),
("Образование", "Расход"),
("Коммунальные услуги", "Расход"),
("Связь и интернет", "Расход"),
("Зарплата", "Доход"),
("Фриланс", "Доход"),
("Подарки", "Доход"),
("Возврат", "Доход"),
("Проценты по вкладу", "Доход"),
]
def get_connection() -> sqlite3.Connection:
"""Open SQLite connection with row access by column name."""
connection = sqlite3.connect(DB_NAME)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
return connection
def init_database() -> None:
"""Create all tables, indexes and default records."""
with get_connection() as connection:
cursor = connection.cursor()
cursor.executescript(
@@ -32,13 +41,15 @@ def init_database() -> None:
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
account_type TEXT NOT NULL,
balance REAL NOT NULL DEFAULT 0
balance REAL NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
category_type TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(name, category_type)
);
@@ -50,8 +61,10 @@ def init_database() -> None:
account_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE RESTRICT,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT
FOREIGN KEY (account_id)
REFERENCES accounts(id) ON DELETE RESTRICT,
FOREIGN KEY (category_id)
REFERENCES categories(id) ON DELETE RESTRICT
);
CREATE TABLE IF NOT EXISTS subscriptions (
@@ -60,17 +73,38 @@ def init_database() -> None:
amount REAL NOT NULL,
billing_day INTEGER NOT NULL,
period TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'Активна'
status TEXT NOT NULL DEFAULT 'Активна',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS budgets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL UNIQUE,
limit_amount REAL NOT NULL,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id)
REFERENCES categories(id) ON DELETE RESTRICT
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_transactions_created_at
ON transactions(created_at);
CREATE INDEX IF NOT EXISTS idx_transactions_type
ON transactions(transaction_type);
CREATE INDEX IF NOT EXISTS idx_transactions_account
ON transactions(account_id);
CREATE INDEX IF NOT EXISTS idx_transactions_category
ON transactions(category_id);
"""
)
_ensure_column(cursor, "accounts", "created_at", "TEXT")
_ensure_column(cursor, "categories", "created_at", "TEXT")
_ensure_column(cursor, "subscriptions", "created_at", "TEXT")
_ensure_column(cursor, "budgets", "created_at", "TEXT")
cursor.executemany(
"""
INSERT OR IGNORE INTO categories (name, category_type)
@@ -79,3 +113,14 @@ def init_database() -> None:
DEFAULT_CATEGORIES,
)
connection.commit()
def _ensure_column(cursor: sqlite3.Cursor, table: str, column: str, column_type: str) -> None:
"""Add a column when an old alpha database is opened."""
columns = [row[1] for row in cursor.execute(f"PRAGMA table_info({table})")]
if column not in columns:
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {column_type}")
cursor.execute(
f"UPDATE {table} SET {column} = CURRENT_TIMESTAMP "
f"WHERE {column} IS NULL"
)

3
app/services/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from app.services.finance_service import FinanceService
__all__ = ["FinanceService"]

View File

@@ -0,0 +1,433 @@
import csv
from pathlib import Path
from sqlite3 import IntegrityError, Row
from typing import Iterable
from app.database import get_connection
from app.utils.formatters import current_month_key, format_money
class FinanceService:
"""Business layer for Finance Control.
UI classes call this object instead of duplicating SQL logic. It makes the
program easier to test, maintain and explain from the OOP point of view.
"""
def get_setting(self, key: str, default: str) -> str:
with get_connection() as connection:
row = connection.execute(
"SELECT value FROM settings WHERE key = ?",
(key,),
).fetchone()
return default if row is None else row["value"]
def set_setting(self, key: str, value: str) -> None:
with get_connection() as connection:
connection.execute(
"""
INSERT INTO settings (key, value)
VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
""",
(key, value),
)
connection.commit()
def add_account(self, name: str, account_type: str, balance: float) -> None:
with get_connection() as connection:
connection.execute(
"""
INSERT INTO accounts (name, account_type, balance)
VALUES (?, ?, ?)
""",
(name, account_type, balance),
)
connection.commit()
def delete_account(self, account_id: int) -> None:
with get_connection() as connection:
connection.execute("DELETE FROM accounts WHERE id = ?", (account_id,))
connection.commit()
def list_accounts(self) -> list[Row]:
with get_connection() as connection:
return connection.execute(
"SELECT * FROM accounts ORDER BY id DESC"
).fetchall()
def list_account_options(self) -> list[Row]:
with get_connection() as connection:
return connection.execute(
"SELECT id, name, account_type, balance FROM accounts ORDER BY name"
).fetchall()
def add_category(self, name: str, category_type: str) -> None:
with get_connection() as connection:
connection.execute(
"""
INSERT INTO categories (name, category_type)
VALUES (?, ?)
""",
(name, category_type),
)
connection.commit()
def delete_category(self, category_id: int) -> None:
with get_connection() as connection:
connection.execute("DELETE FROM categories WHERE id = ?", (category_id,))
connection.commit()
def list_categories(self, category_type: str | None = None) -> list[Row]:
with get_connection() as connection:
if category_type is None:
return connection.execute(
"""
SELECT * FROM categories
ORDER BY category_type DESC, name
"""
).fetchall()
return connection.execute(
"""
SELECT * FROM categories
WHERE category_type = ?
ORDER BY name
""",
(category_type,),
).fetchall()
def add_transaction(
self,
title: str,
amount: float,
transaction_type: str,
account_id: int,
category_id: int,
created_at: str,
) -> None:
sign = 1 if transaction_type == "Доход" else -1
delta = amount * sign
with get_connection() as connection:
connection.execute("BEGIN")
connection.execute(
"""
INSERT INTO transactions (
title, amount, transaction_type,
account_id, category_id, created_at
)
VALUES (?, ?, ?, ?, ?, ?)
""",
(title, amount, transaction_type, account_id, category_id, created_at),
)
connection.execute(
"UPDATE accounts SET balance = balance + ? WHERE id = ?",
(delta, account_id),
)
connection.commit()
def delete_transaction(self, transaction_id: int) -> None:
with get_connection() as connection:
row = connection.execute(
"SELECT * FROM transactions WHERE id = ?",
(transaction_id,),
).fetchone()
if row is None:
return
sign = -1 if row["transaction_type"] == "Доход" else 1
delta = row["amount"] * sign
connection.execute("BEGIN")
connection.execute(
"UPDATE accounts SET balance = balance + ? WHERE id = ?",
(delta, row["account_id"]),
)
connection.execute(
"DELETE FROM transactions WHERE id = ?",
(transaction_id,),
)
connection.commit()
def list_transactions(
self,
search: str = "",
transaction_type: str = "Все",
limit: int = 100,
) -> list[Row]:
clauses = []
params: list[object] = []
if search:
clauses.append("t.title LIKE ?")
params.append(f"%{search}%")
if transaction_type != "Все":
clauses.append("t.transaction_type = ?")
params.append(transaction_type)
where_sql = ""
if clauses:
where_sql = "WHERE " + " AND ".join(clauses)
params.append(limit)
with get_connection() as connection:
return connection.execute(
f"""
SELECT t.id,
t.title,
t.amount,
t.transaction_type,
t.created_at,
a.name AS account_name,
c.name AS category_name
FROM transactions AS t
JOIN accounts AS a ON a.id = t.account_id
JOIN categories AS c ON c.id = t.category_id
{where_sql}
ORDER BY t.created_at DESC, t.id DESC
LIMIT ?
""",
params,
).fetchall()
def add_subscription(
self,
name: str,
amount: float,
billing_day: int,
period: str,
status: str,
) -> None:
with get_connection() as connection:
connection.execute(
"""
INSERT INTO subscriptions (name, amount, billing_day, period, status)
VALUES (?, ?, ?, ?, ?)
""",
(name, amount, billing_day, period, status),
)
connection.commit()
def delete_subscription(self, subscription_id: int) -> None:
with get_connection() as connection:
connection.execute(
"DELETE FROM subscriptions WHERE id = ?",
(subscription_id,),
)
connection.commit()
def toggle_subscription_status(self, subscription_id: int) -> None:
with get_connection() as connection:
row = connection.execute(
"SELECT status FROM subscriptions WHERE id = ?",
(subscription_id,),
).fetchone()
if row is None:
return
new_status = "Пауза" if row["status"] == "Активна" else "Активна"
connection.execute(
"UPDATE subscriptions SET status = ? WHERE id = ?",
(new_status, subscription_id),
)
connection.commit()
def list_subscriptions(self) -> list[Row]:
with get_connection() as connection:
return connection.execute(
"SELECT * FROM subscriptions ORDER BY status, billing_day, name"
).fetchall()
def add_or_update_budget(self, category_id: int, limit_amount: float) -> None:
with get_connection() as connection:
connection.execute(
"""
INSERT INTO budgets (category_id, limit_amount)
VALUES (?, ?)
ON CONFLICT(category_id)
DO UPDATE SET limit_amount = excluded.limit_amount
""",
(category_id, limit_amount),
)
connection.commit()
def delete_budget(self, budget_id: int) -> None:
with get_connection() as connection:
connection.execute("DELETE FROM budgets WHERE id = ?", (budget_id,))
connection.commit()
def list_budgets(self) -> list[Row]:
month_key = current_month_key()
with get_connection() as connection:
return connection.execute(
"""
SELECT b.id,
b.limit_amount,
c.name AS category_name,
COALESCE(SUM(t.amount), 0) AS spent
FROM budgets AS b
JOIN categories AS c ON c.id = b.category_id
LEFT JOIN transactions AS t
ON t.category_id = b.category_id
AND t.transaction_type = 'Расход'
AND strftime('%Y-%m', t.created_at) = ?
GROUP BY b.id, b.limit_amount, c.name
ORDER BY c.name
""",
(month_key,),
).fetchall()
def dashboard_summary(self) -> dict[str, object]:
month_key = current_month_key()
with get_connection() as connection:
balance = connection.execute(
"SELECT COALESCE(SUM(balance), 0) AS total FROM accounts"
).fetchone()["total"]
summary = connection.execute(
"""
SELECT
COALESCE(SUM(CASE WHEN transaction_type = 'Доход'
THEN amount END), 0) AS income,
COALESCE(SUM(CASE WHEN transaction_type = 'Расход'
THEN amount END), 0) AS expense
FROM transactions
WHERE strftime('%Y-%m', created_at) = ?
""",
(month_key,),
).fetchone()
subscriptions = connection.execute(
"""
SELECT COUNT(*) AS count,
COALESCE(SUM(
CASE WHEN period = 'Год'
THEN amount / 12.0
ELSE amount
END
), 0) AS monthly_total
FROM subscriptions
WHERE status = 'Активна'
"""
).fetchone()
upcoming = connection.execute(
"""
SELECT id, name, amount, billing_day, period
FROM subscriptions
WHERE status = 'Активна'
ORDER BY billing_day, name
LIMIT 8
"""
).fetchall()
recent = self.list_transactions(limit=8)
warnings = self.list_budget_warnings()
return {
"balance": balance,
"income": summary["income"],
"expense": summary["expense"],
"net": summary["income"] - summary["expense"],
"subscriptions_count": subscriptions["count"],
"subscriptions_total": subscriptions["monthly_total"],
"upcoming": upcoming,
"recent": recent,
"warnings": warnings,
}
def list_budget_warnings(self) -> list[Row]:
rows = self.list_budgets()
return [row for row in rows if row["limit_amount"] and row["spent"] >= row["limit_amount"]]
def report_data(self) -> dict[str, object]:
month_key = current_month_key()
with get_connection() as connection:
summary = connection.execute(
"""
SELECT
COALESCE(SUM(CASE WHEN transaction_type = 'Доход'
THEN amount END), 0) AS income,
COALESCE(SUM(CASE WHEN transaction_type = 'Расход'
THEN amount END), 0) AS expense
FROM transactions
WHERE strftime('%Y-%m', created_at) = ?
""",
(month_key,),
).fetchone()
balance = connection.execute(
"SELECT COALESCE(SUM(balance), 0) AS total FROM accounts"
).fetchone()["total"]
subscription_total = connection.execute(
"""
SELECT COALESCE(SUM(
CASE WHEN period = 'Год'
THEN amount / 12.0
ELSE amount
END
), 0) AS total
FROM subscriptions
WHERE status = 'Активна'
"""
).fetchone()["total"]
categories = connection.execute(
"""
SELECT c.name, SUM(t.amount) AS total
FROM transactions AS t
JOIN categories AS c ON c.id = t.category_id
WHERE t.transaction_type = 'Расход'
AND strftime('%Y-%m', t.created_at) = ?
GROUP BY c.name
HAVING total > 0
ORDER BY total DESC
""",
(month_key,),
).fetchall()
return {
"income": summary["income"],
"expense": summary["expense"],
"net": summary["income"] - summary["expense"],
"balance": balance,
"subscription_total": subscription_total,
"categories": categories,
}
def build_report_text(self) -> str:
data = self.report_data()
categories: Iterable[Row] = data["categories"]
top_category = "Нет данных"
top_category_sum = 0.0
category_lines = []
for row in categories:
if top_category == "Нет данных":
top_category = row["name"]
top_category_sum = row["total"]
category_lines.append(f"- {row['name']}: {format_money(row['total'])}")
details = "\n".join(category_lines) or "Нет расходов за текущий месяц."
return (
"ОТЧЁТ ЗА ТЕКУЩИЙ МЕСЯЦ\n\n"
f"Доходы: {format_money(data['income'])}\n"
f"Расходы: {format_money(data['expense'])}\n"
f"Финансовый результат: {format_money(data['net'])}\n"
f"Общий баланс по всем счетам: {format_money(data['balance'])}\n"
"Активные подписки в пересчёте на месяц: "
f"{format_money(data['subscription_total'])}\n"
"Самая затратная категория: "
f"{top_category} ({format_money(top_category_sum)})\n\n"
"Расходы по категориям:\n"
f"{details}\n"
)
def export_transactions_csv(self, path: str | Path) -> None:
rows = self.list_transactions(limit=10_000)
with open(path, "w", encoding="utf-8-sig", newline="") as file:
writer = csv.writer(file, delimiter=";")
writer.writerow(
["Дата", "Название", "Тип", "Сумма", "Счёт", "Категория"]
)
for row in rows:
writer.writerow(
[
row["created_at"],
row["title"],
row["transaction_type"],
row["amount"],
row["account_name"],
row["category_name"],
]
)
@staticmethod
def is_integrity_error(error: Exception) -> bool:
return isinstance(error, IntegrityError)

View File

@@ -3,161 +3,121 @@ from tkinter import messagebox
import customtkinter as ctk
from app.database import get_connection
from app.ui.base_frame import BaseAppFrame
from app.utils.validators import normalize_text, parse_float
class AccountsFrame(ctk.CTkFrame):
class AccountsFrame(BaseAppFrame):
"""Screen for account management."""
def __init__(self, master, refresh_callback) -> None:
super().__init__(master)
self.refresh_callback = refresh_callback
super().__init__(master, refresh_callback)
self.grid_columnconfigure(1, weight=1)
self.grid_rowconfigure(0, weight=1)
self._build_form()
self._build_list()
def _build_form(self) -> None:
form_frame = ctk.CTkFrame(self, corner_radius=18)
form_frame = ctk.CTkFrame(self, corner_radius=20)
form_frame.grid(row=0, column=0, sticky="ns", padx=(8, 12), pady=8)
title = ctk.CTkLabel(
form_frame,
text="Счета и карты",
font=ctk.CTkFont(size=26, weight="bold"),
self.title_label(form_frame, "Счета и карты").pack(
anchor="w", padx=18, pady=(18, 4)
)
title.pack(anchor="w", padx=18, pady=(18, 6))
subtitle = ctk.CTkLabel(
self.subtitle_label(
form_frame,
text="Добавьте карту, кошелёк или наличные.",
text_color="gray75",
)
subtitle.pack(anchor="w", padx=18, pady=(0, 18))
"Добавьте карту, кошелёк, счёт или наличные.",
).pack(anchor="w", padx=18, pady=(0, 18))
self.name_entry = ctk.CTkEntry(
form_frame,
width=280,
placeholder_text="Название",
width=300,
placeholder_text="Название счёта",
)
self.name_entry.pack(padx=18, pady=8)
self.type_menu = ctk.CTkOptionMenu(
form_frame,
width=280,
width=300,
values=["Карта", "Наличные", "Счёт", "Электронный кошелёк"],
)
self.type_menu.pack(padx=18, pady=8)
self.type_menu.set("Карта")
self.type_menu.pack(padx=18, pady=8)
self.balance_entry = ctk.CTkEntry(
form_frame,
width=280,
width=300,
placeholder_text="Стартовый баланс",
)
self.balance_entry.pack(padx=18, pady=8)
add_button = ctk.CTkButton(
self.add_button = self.primary_button(
form_frame,
text="Добавить счёт",
width=280,
height=42,
command=self.add_account,
"Добавить счёт",
lambda: self.run_safe(self.add_account, self.add_button),
)
add_button.pack(padx=18, pady=(12, 18))
self.add_button.pack(fill="x", padx=18, pady=(12, 18))
def _build_list(self) -> None:
self.list_frame = ctk.CTkScrollableFrame(self, label_text="Список счетов")
self.list_frame.grid(row=0, column=1, sticky="nsew", padx=(0, 8), pady=8)
def add_account(self) -> None:
name = self.name_entry.get().strip()
name = normalize_text(self.name_entry.get(), "Название")
account_type = self.type_menu.get()
balance_text = self.balance_entry.get().strip().replace(",", ".")
if not name or not balance_text:
messagebox.showerror("Ошибка", "Заполните все поля.")
return
try:
balance = float(balance_text)
except ValueError:
messagebox.showerror("Ошибка", "Баланс должен быть числом.")
return
with get_connection() as connection:
connection.execute(
"""
INSERT INTO accounts (name, account_type, balance)
VALUES (?, ?, ?)
""",
(name, account_type, balance),
)
connection.commit()
balance = parse_float(self.balance_entry.get(), "Стартовый баланс")
self.service.add_account(name, account_type, balance)
self.name_entry.delete(0, "end")
self.balance_entry.delete(0, "end")
self.refresh_callback()
def delete_account(self, account_id: int) -> None:
if not messagebox.askyesno("Подтверждение", "Удалить выбранный счёт?"):
return
try:
with get_connection() as connection:
connection.execute("DELETE FROM accounts WHERE id = ?", (account_id,))
connection.commit()
self.service.delete_account(account_id)
except sqlite3.IntegrityError:
messagebox.showerror(
"Ошибка",
"Нельзя удалить счёт, пока к нему привязаны операции.",
)
return
self.refresh_callback()
def refresh_data(self) -> None:
for widget in self.list_frame.winfo_children():
widget.destroy()
with get_connection() as connection:
accounts = connection.execute(
"SELECT * FROM accounts ORDER BY id DESC"
).fetchall()
if not accounts:
empty_label = ctk.CTkLabel(
self.list_frame,
text="Пока нет ни одного счёта.",
text_color="gray70",
)
empty_label.pack(anchor="w", padx=10, pady=10)
self.clear(self.list_frame)
rows = self.service.list_accounts()
if not rows:
self._empty("Пока нет ни одного счёта.")
return
for account in accounts:
for row in rows:
card = ctk.CTkFrame(self.list_frame, corner_radius=16)
card.pack(fill="x", padx=6, pady=6)
card.grid_columnconfigure(0, weight=1)
title = ctk.CTkLabel(
ctk.CTkLabel(
card,
text=account["name"],
text=row["name"],
font=ctk.CTkFont(size=18, weight="bold"),
)
title.grid(row=0, column=0, sticky="w", padx=16, pady=(12, 6))
).grid(row=0, column=0, sticky="w", padx=16, pady=(12, 4))
delete_button = ctk.CTkButton(
button = self.danger_button(
card,
text="Удалить",
width=90,
fg_color="#b33939",
hover_color="#932f2f",
command=lambda account_id=account["id"]: self.delete_account(account_id),
"Удалить",
lambda account_id=row["id"]: self.run_safe(
lambda: self.delete_account(account_id)
),
)
delete_button.grid(row=0, column=1, sticky="e", padx=16, pady=(12, 6))
button.grid(row=0, column=1, sticky="e", padx=16, pady=(12, 4))
info = ctk.CTkLabel(
ctk.CTkLabel(
card,
text=(
f"Тип: {account['account_type']} | Баланс: "
f"{account['balance']:,.2f}"
).replace(",", " "),
text_color="gray75",
)
info.grid(row=1, column=0, columnspan=2, sticky="w", padx=16, pady=(0, 14))
text=f"Тип: {row['account_type']} | Баланс: {self.money(row['balance'])}",
text_color="gray70",
).grid(row=1, column=0, columnspan=2, sticky="w", padx=16, pady=(0, 14))
def _empty(self, text: str) -> None:
ctk.CTkLabel(self.list_frame, text=text, text_color="gray70").pack(
anchor="w", padx=10, pady=10
)

111
app/ui/base_frame.py Normal file
View File

@@ -0,0 +1,111 @@
from collections.abc import Callable
from tkinter import messagebox
import customtkinter as ctk
from app.config import (
DANGER_COLOR,
DANGER_HOVER_COLOR,
PRIMARY_COLOR,
PRIMARY_HOVER_COLOR,
SUCCESS_COLOR,
WARNING_COLOR,
)
from app.services import FinanceService
from app.utils.formatters import format_money
class BaseAppFrame(ctk.CTkFrame):
"""Base class for application screens with shared UI helpers."""
def __init__(self, master, refresh_callback: Callable[[], None]) -> None:
super().__init__(master)
self.refresh_callback = refresh_callback
self.service = FinanceService()
self._busy = False
def run_safe(
self,
action: Callable[[], None],
button: ctk.CTkButton | None = None,
) -> None:
"""Run UI action with protection from repeated rapid clicks."""
if self._busy:
return
self._busy = True
if button is not None:
button.configure(state="disabled")
try:
action()
except ValueError as error:
messagebox.showerror("Ошибка ввода", str(error))
except Exception as error: # noqa: BLE001 - user-facing desktop app guard
messagebox.showerror("Ошибка", f"Действие не выполнено: {error}")
finally:
if button is not None:
self.after(350, lambda: button.configure(state="normal"))
self.after(350, self._unlock)
else:
self._unlock()
def _unlock(self) -> None:
self._busy = False
@staticmethod
def clear(container: ctk.CTkBaseClass) -> None:
for widget in container.winfo_children():
widget.destroy()
@staticmethod
def money(value: float | int | None) -> str:
return format_money(value)
@staticmethod
def title_label(master, text: str) -> ctk.CTkLabel:
return ctk.CTkLabel(
master,
text=text,
font=ctk.CTkFont(size=28, weight="bold"),
)
@staticmethod
def section_label(master, text: str) -> ctk.CTkLabel:
return ctk.CTkLabel(
master,
text=text,
font=ctk.CTkFont(size=18, weight="bold"),
)
@staticmethod
def subtitle_label(master, text: str) -> ctk.CTkLabel:
return ctk.CTkLabel(master, text=text, text_color="gray70")
@staticmethod
def primary_button(master, text: str, command) -> ctk.CTkButton:
return ctk.CTkButton(
master,
text=text,
height=42,
fg_color=PRIMARY_COLOR,
hover_color=PRIMARY_HOVER_COLOR,
command=command,
)
@staticmethod
def danger_button(master, text: str, command, width: int = 92) -> ctk.CTkButton:
return ctk.CTkButton(
master,
text=text,
width=width,
fg_color=DANGER_COLOR,
hover_color=DANGER_HOVER_COLOR,
command=command,
)
@staticmethod
def status_color(value: float) -> str:
if value >= 1:
return DANGER_COLOR
if value >= 0.8:
return WARNING_COLOR
return SUCCESS_COLOR

View File

@@ -1,63 +1,52 @@
import sqlite3
from tkinter import messagebox
import customtkinter as ctk
from app.database import get_connection
from app.ui.base_frame import BaseAppFrame
from app.utils.validators import parse_positive_float
class BudgetsFrame(ctk.CTkFrame):
class BudgetsFrame(BaseAppFrame):
"""Screen for monthly category limits."""
def __init__(self, master, refresh_callback) -> None:
super().__init__(master)
self.refresh_callback = refresh_callback
super().__init__(master, refresh_callback)
self.category_map: dict[str, int] = {}
self.grid_columnconfigure(1, weight=1)
self.grid_rowconfigure(0, weight=1)
self._build_form()
self._build_list()
def _build_form(self) -> None:
form_frame = ctk.CTkFrame(self, corner_radius=18)
form_frame = ctk.CTkFrame(self, corner_radius=20)
form_frame.grid(row=0, column=0, sticky="ns", padx=(8, 12), pady=8)
title = ctk.CTkLabel(
form_frame,
text="Бюджеты и лимиты",
font=ctk.CTkFont(size=26, weight="bold"),
self.title_label(form_frame, "Бюджеты").pack(
anchor="w", padx=18, pady=(18, 4)
)
title.pack(anchor="w", padx=18, pady=(18, 6))
subtitle = ctk.CTkLabel(
self.subtitle_label(
form_frame,
text="Ограничения по категориям на месяц.",
text_color="gray75",
)
subtitle.pack(anchor="w", padx=18, pady=(0, 18))
"Лимиты расходов по категориям на текущий месяц.",
).pack(anchor="w", padx=18, pady=(0, 18))
self.category_menu = ctk.CTkOptionMenu(
form_frame,
width=300,
width=320,
values=["Нет категорий"],
)
self.category_menu.pack(padx=18, pady=8)
self.limit_entry = ctk.CTkEntry(
form_frame,
width=300,
width=320,
placeholder_text="Лимит",
)
self.limit_entry.pack(padx=18, pady=8)
add_button = ctk.CTkButton(
self.add_button = self.primary_button(
form_frame,
text="Добавить лимит",
width=300,
height=42,
command=self.add_budget,
"Добавить / обновить лимит",
lambda: self.run_safe(self.add_budget, self.add_button),
)
add_button.pack(padx=18, pady=(12, 18))
self.add_button.pack(fill="x", padx=18, pady=(12, 18))
def _build_list(self) -> None:
self.list_frame = ctk.CTkScrollableFrame(
@@ -67,171 +56,92 @@ class BudgetsFrame(ctk.CTkFrame):
self.list_frame.grid(row=0, column=1, sticky="nsew", padx=(0, 8), pady=8)
def _load_categories(self) -> None:
with get_connection() as connection:
rows = connection.execute(
"""
SELECT id, name
FROM categories
WHERE category_type = 'Расход'
ORDER BY name
"""
).fetchall()
rows = self.service.list_categories("Расход")
if not rows:
self.category_map = {}
self.category_menu.configure(values=["Нет категорий"])
self.category_menu.set("Нет категорий")
return
values = []
self.category_map = {}
for row in rows:
label = f"{row['name']} (ID {row['id']})"
label = row["name"]
values.append(label)
self.category_map[label] = row["id"]
self.category_menu.configure(values=values)
self.category_menu.set(values[0])
if self.category_menu.get() not in values:
self.category_menu.set(values[0])
def add_budget(self) -> None:
category_label = self.category_menu.get()
limit_text = self.limit_entry.get().strip().replace(",", ".")
if category_label not in self.category_map or not limit_text:
messagebox.showerror("Ошибка", "Выберите категорию и введите лимит.")
return
try:
limit_amount = float(limit_text)
except ValueError:
messagebox.showerror("Ошибка", "Лимит должен быть числом.")
return
if limit_amount <= 0:
messagebox.showerror("Ошибка", "Лимит должен быть больше нуля.")
return
category_id = self.category_map[category_label]
try:
with get_connection() as connection:
connection.execute(
"""
INSERT INTO budgets (category_id, limit_amount)
VALUES (?, ?)
""",
(category_id, limit_amount),
)
connection.commit()
except sqlite3.IntegrityError:
messagebox.showerror(
"Ошибка",
"Для этой категории лимит уже создан.",
)
return
if category_label not in self.category_map:
raise ValueError("Выберите расходную категорию.")
limit_amount = parse_positive_float(self.limit_entry.get(), "Лимит")
self.service.add_or_update_budget(
self.category_map[category_label],
limit_amount,
)
self.limit_entry.delete(0, "end")
self.refresh_callback()
def delete_budget(self, budget_id: int) -> None:
with get_connection() as connection:
connection.execute(
"DELETE FROM budgets WHERE id = ?",
(budget_id,),
)
connection.commit()
self.service.delete_budget(budget_id)
self.refresh_callback()
def refresh_data(self) -> None:
self._load_categories()
for widget in self.list_frame.winfo_children():
widget.destroy()
with get_connection() as connection:
rows = connection.execute(
"""
SELECT b.id,
b.limit_amount,
c.name AS category_name,
COALESCE(
SUM(
CASE
WHEN strftime('%Y-%m', t.created_at) = strftime('%Y-%m', 'now')
THEN t.amount
ELSE 0
END
),
0
) AS spent
FROM budgets AS b
JOIN categories AS c ON c.id = b.category_id
LEFT JOIN transactions AS t
ON t.category_id = b.category_id
AND t.transaction_type = 'Расход'
GROUP BY b.id, b.limit_amount, c.name
ORDER BY c.name
"""
).fetchall()
self.clear(self.list_frame)
rows = self.service.list_budgets()
if not rows:
empty_label = ctk.CTkLabel(
ctk.CTkLabel(
self.list_frame,
text="Лимитов пока нет.",
text_color="gray70",
)
empty_label.pack(anchor="w", padx=10, pady=10)
).pack(anchor="w", padx=10, pady=10)
return
for row in rows:
card = ctk.CTkFrame(self.list_frame, corner_radius=16)
card.pack(fill="x", padx=6, pady=6)
self._render_budget(row)
title = ctk.CTkLabel(
card,
text=row["category_name"],
font=ctk.CTkFont(size=18, weight="bold"),
)
title.grid(row=0, column=0, sticky="w", padx=16, pady=(12, 4))
def _render_budget(self, row) -> None:
card = ctk.CTkFrame(self.list_frame, corner_radius=16)
card.pack(fill="x", padx=6, pady=6)
card.grid_columnconfigure(0, weight=1)
delete_button = ctk.CTkButton(
card,
text="Удалить",
width=90,
fg_color="#b33939",
hover_color="#932f2f",
command=lambda budget_id=row["id"]: self.delete_budget(budget_id),
)
delete_button.grid(row=0, column=1, sticky="e", padx=16, pady=(12, 4))
ctk.CTkLabel(
card,
text=row["category_name"],
font=ctk.CTkFont(size=18, weight="bold"),
).grid(row=0, column=0, sticky="w", padx=16, pady=(12, 4))
percent = 0 if row["limit_amount"] == 0 else row["spent"] / row["limit_amount"]
percent = max(0, percent)
self.danger_button(
card,
"Удалить",
lambda budget_id=row["id"]: self.run_safe(
lambda: self.delete_budget(budget_id)
),
).grid(row=0, column=1, sticky="e", padx=16, pady=(12, 4))
info = ctk.CTkLabel(
card,
text=(
f"Потрачено: {row['spent']:,.2f}"
f"из {row['limit_amount']:,.2f}"
).replace(",", " "),
text_color="gray75",
)
info.grid(row=1, column=0, columnspan=2, sticky="w", padx=16, pady=(0, 10))
percent = 0 if row["limit_amount"] == 0 else row["spent"] / row["limit_amount"]
percent = max(0, percent)
status_color = self.status_color(percent)
progress = ctk.CTkProgressBar(card, width=360)
progress.grid(row=2, column=0, columnspan=2, sticky="w", padx=16, pady=(0, 8))
progress.set(min(percent, 1))
ctk.CTkLabel(
card,
text=f"Потрачено: {self.money(row['spent'])} из {self.money(row['limit_amount'])}",
text_color="gray70",
).grid(row=1, column=0, columnspan=2, sticky="w", padx=16, pady=(0, 8))
status_text = (
"Лимит превышен"
if percent > 1
else f"Использовано: {percent * 100:.0f}%"
)
progress = ctk.CTkProgressBar(card, width=460, progress_color=status_color)
progress.grid(row=2, column=0, columnspan=2, sticky="w", padx=16, pady=(0, 8))
progress.set(min(percent, 1))
status = ctk.CTkLabel(
card,
text=status_text,
text_color="gray75",
)
status.grid(row=3, column=0, columnspan=2, sticky="w", padx=16, pady=(0, 14))
status_text = "Лимит превышен" if percent > 1 else f"Использовано: {percent * 100:.0f}%"
ctk.CTkLabel(card, text=status_text, text_color=status_color).grid(
row=3,
column=0,
columnspan=2,
sticky="w",
padx=16,
pady=(0, 14),
)

View File

@@ -3,155 +3,114 @@ from tkinter import messagebox
import customtkinter as ctk
from app.database import get_connection
from app.ui.base_frame import BaseAppFrame
from app.utils.validators import normalize_text
class CategoriesFrame(ctk.CTkFrame):
class CategoriesFrame(BaseAppFrame):
"""Screen for income and expense categories."""
def __init__(self, master, refresh_callback) -> None:
super().__init__(master)
self.refresh_callback = refresh_callback
super().__init__(master, refresh_callback)
self.grid_columnconfigure(1, weight=1)
self.grid_rowconfigure(0, weight=1)
self._build_form()
self._build_list()
def _build_form(self) -> None:
form_frame = ctk.CTkFrame(self, corner_radius=18)
form_frame = ctk.CTkFrame(self, corner_radius=20)
form_frame.grid(row=0, column=0, sticky="ns", padx=(8, 12), pady=8)
title = ctk.CTkLabel(
form_frame,
text="Категории",
font=ctk.CTkFont(size=26, weight="bold"),
self.title_label(form_frame, "Категории").pack(
anchor="w", padx=18, pady=(18, 4)
)
title.pack(anchor="w", padx=18, pady=(18, 6))
subtitle = ctk.CTkLabel(
self.subtitle_label(
form_frame,
text="Создавайте свои категории доходов и расходов.",
text_color="gray75",
)
subtitle.pack(anchor="w", padx=18, pady=(0, 18))
"Группируйте доходы и расходы по смыслу.",
).pack(anchor="w", padx=18, pady=(0, 18))
self.name_entry = ctk.CTkEntry(
form_frame,
width=280,
width=300,
placeholder_text="Название категории",
)
self.name_entry.pack(padx=18, pady=8)
self.type_menu = ctk.CTkOptionMenu(
form_frame,
width=280,
width=300,
values=["Расход", "Доход"],
)
self.type_menu.set("Расход")
self.type_menu.pack(padx=18, pady=8)
add_button = ctk.CTkButton(
self.add_button = self.primary_button(
form_frame,
text="Добавить категорию",
width=280,
height=42,
command=self.add_category,
"Добавить категорию",
lambda: self.run_safe(self.add_category, self.add_button),
)
add_button.pack(padx=18, pady=(12, 18))
self.add_button.pack(fill="x", padx=18, pady=(12, 18))
def _build_list(self) -> None:
self.list_frame = ctk.CTkScrollableFrame(self, label_text="Список категорий")
self.list_frame.grid(row=0, column=1, sticky="nsew", padx=(0, 8), pady=8)
def add_category(self) -> None:
name = self.name_entry.get().strip()
name = normalize_text(self.name_entry.get(), "Название")
category_type = self.type_menu.get()
if not name:
messagebox.showerror("Ошибка", "Введите название категории.")
return
try:
with get_connection() as connection:
connection.execute(
"""
INSERT INTO categories (name, category_type)
VALUES (?, ?)
""",
(name, category_type),
)
connection.commit()
self.service.add_category(name, category_type)
except sqlite3.IntegrityError:
messagebox.showerror(
"Ошибка",
"Такая категория уже существует.",
)
messagebox.showerror("Ошибка", "Такая категория уже существует.")
return
self.name_entry.delete(0, "end")
self.refresh_callback()
def delete_category(self, category_id: int) -> None:
if not messagebox.askyesno("Подтверждение", "Удалить выбранную категорию?"):
return
try:
with get_connection() as connection:
connection.execute(
"DELETE FROM categories WHERE id = ?",
(category_id,),
)
connection.commit()
self.service.delete_category(category_id)
except sqlite3.IntegrityError:
messagebox.showerror(
"Ошибка",
"Нельзя удалить категорию, пока она используется в операциях или бюджете.",
"Категория используется в операциях или бюджетах.",
)
return
self.refresh_callback()
def refresh_data(self) -> None:
for widget in self.list_frame.winfo_children():
widget.destroy()
with get_connection() as connection:
categories = connection.execute(
"SELECT * FROM categories ORDER BY category_type, name"
).fetchall()
if not categories:
empty_label = ctk.CTkLabel(
self.clear(self.list_frame)
rows = self.service.list_categories()
if not rows:
ctk.CTkLabel(
self.list_frame,
text="Категорий пока нет.",
text_color="gray70",
)
empty_label.pack(anchor="w", padx=10, pady=10)
).pack(anchor="w", padx=10, pady=10)
return
for category in categories:
for row in rows:
card = ctk.CTkFrame(self.list_frame, corner_radius=16)
card.pack(fill="x", padx=6, pady=6)
card.grid_columnconfigure(0, weight=1)
title = ctk.CTkLabel(
ctk.CTkLabel(
card,
text=category["name"],
text=row["name"],
font=ctk.CTkFont(size=18, weight="bold"),
)
title.grid(row=0, column=0, sticky="w", padx=16, pady=(12, 6))
).grid(row=0, column=0, sticky="w", padx=16, pady=(12, 4))
delete_button = ctk.CTkButton(
self.danger_button(
card,
text="Удалить",
width=90,
fg_color="#b33939",
hover_color="#932f2f",
command=lambda category_id=category["id"]: self.delete_category(
category_id
"Удалить",
lambda category_id=row["id"]: self.run_safe(
lambda: self.delete_category(category_id)
),
)
delete_button.grid(row=0, column=1, sticky="e", padx=16, pady=(12, 6))
).grid(row=0, column=1, sticky="e", padx=16, pady=(12, 4))
info = ctk.CTkLabel(
ctk.CTkLabel(
card,
text=f"Тип: {category['category_type']}",
text_color="gray75",
)
info.grid(row=1, column=0, columnspan=2, sticky="w", padx=16, pady=(0, 14))
text=f"Тип: {row['category_type']}",
text_color="gray70",
).grid(row=1, column=0, columnspan=2, sticky="w", padx=16, pady=(0, 14))

View File

@@ -3,158 +3,153 @@ from datetime import date
import customtkinter as ctk
from app.database import get_connection
from app.ui.base_frame import BaseAppFrame
class DashboardFrame(ctk.CTkFrame):
class DashboardFrame(BaseAppFrame):
"""Main summary page."""
def __init__(self, master, refresh_callback) -> None:
super().__init__(master)
self.refresh_callback = refresh_callback
super().__init__(master, refresh_callback)
self.grid_columnconfigure((0, 1, 2), weight=1)
self.grid_rowconfigure(3, weight=1)
self.cards: dict[str, ctk.CTkLabel] = {}
self._build_ui()
self.grid_columnconfigure((0, 1), weight=1)
self.grid_rowconfigure(2, weight=1)
def _build_ui(self) -> None:
header = ctk.CTkFrame(self, fg_color="transparent")
header.grid(row=0, column=0, columnspan=3, sticky="ew", padx=8, pady=(8, 12))
header.grid_columnconfigure(0, weight=1)
header = ctk.CTkLabel(
self.title_label(header, "Главная панель").grid(row=0, column=0, sticky="w")
self.subtitle_label(
header,
"Краткая финансовая сводка, ближайшие списания и последние операции.",
).grid(row=1, column=0, sticky="w", pady=(4, 0))
card_data = [
("balance", "Общий баланс"),
("income", "Доходы за месяц"),
("expense", "Расходы за месяц"),
("net", "Финансовый результат"),
("subscriptions", "Активные подписки"),
("warnings", "Превышенные лимиты"),
]
for index, (key, title) in enumerate(card_data):
row = 1 + index // 3
column = index % 3
self.cards[key] = self._create_card(row, column, title)
self.upcoming_frame = ctk.CTkScrollableFrame(
self,
text="Главная панель",
font=ctk.CTkFont(size=28, weight="bold"),
label_text="Ближайшие списания",
)
header.grid(row=0, column=0, columnspan=2, sticky="w", padx=8, pady=(8, 16))
self.upcoming_frame.grid(row=3, column=0, sticky="nsew", padx=8, pady=8)
self.balance_card = self._create_card(1, 0, "Общий баланс")
self.month_expense_card = self._create_card(1, 1, "Расходы за месяц")
self.month_income_card = self._create_card(2, 0, "Доходы за месяц")
self.subscriptions_card = self._create_card(2, 1, "Активные подписки")
self.upcoming_frame = ctk.CTkScrollableFrame(self, label_text="Ближайшие списания")
self.upcoming_frame.grid(
row=3,
column=0,
columnspan=2,
sticky="nsew",
padx=8,
pady=(16, 8),
self.recent_frame = ctk.CTkScrollableFrame(
self,
label_text="Последние операции",
)
self.recent_frame.grid(row=3, column=1, sticky="nsew", padx=8, pady=8)
self.warning_frame = ctk.CTkScrollableFrame(
self,
label_text="Контроль лимитов",
)
self.warning_frame.grid(row=3, column=2, sticky="nsew", padx=8, pady=8)
def _create_card(self, row: int, column: int, title: str) -> ctk.CTkLabel:
card = ctk.CTkFrame(self, corner_radius=18)
card = ctk.CTkFrame(self, corner_radius=20)
card.grid(row=row, column=column, sticky="nsew", padx=8, pady=8)
card.grid_columnconfigure(0, weight=1)
title_label = ctk.CTkLabel(
card,
text=title,
text_color="gray75",
font=ctk.CTkFont(size=15),
ctk.CTkLabel(card, text=title, text_color="gray70").grid(
row=0,
column=0,
sticky="w",
padx=18,
pady=(16, 4),
)
title_label.pack(anchor="w", padx=18, pady=(16, 8))
value_label = ctk.CTkLabel(
card,
text="0 ₽",
font=ctk.CTkFont(size=30, weight="bold"),
font=ctk.CTkFont(size=28, weight="bold"),
)
value_label.pack(anchor="w", padx=18, pady=(0, 16))
value_label.grid(row=1, column=0, sticky="w", padx=18, pady=(0, 16))
return value_label
def refresh_data(self) -> None:
with get_connection() as connection:
cursor = connection.cursor()
data = self.service.dashboard_summary()
self.cards["balance"].configure(text=self.money(data["balance"]))
self.cards["income"].configure(text=self.money(data["income"]))
self.cards["expense"].configure(text=self.money(data["expense"]))
self.cards["net"].configure(text=self.money(data["net"]))
self.cards["subscriptions"].configure(
text=f"{data['subscriptions_count']} шт. / {self.money(data['subscriptions_total'])}"
)
self.cards["warnings"].configure(text=f"{len(data['warnings'])} категорий")
balance = cursor.execute(
"SELECT COALESCE(SUM(balance), 0) AS total FROM accounts"
).fetchone()["total"]
self._render_upcoming(data["upcoming"])
self._render_recent(data["recent"])
self._render_warnings(data["warnings"])
month_expense = cursor.execute(
"""
SELECT COALESCE(SUM(amount), 0) AS total
FROM transactions
WHERE transaction_type = 'Расход'
AND strftime('%Y-%m', created_at) = strftime('%Y-%m', 'now')
"""
).fetchone()["total"]
month_income = cursor.execute(
"""
SELECT COALESCE(SUM(amount), 0) AS total
FROM transactions
WHERE transaction_type = 'Доход'
AND strftime('%Y-%m', created_at) = strftime('%Y-%m', 'now')
"""
).fetchone()["total"]
subscriptions = cursor.execute(
"""
SELECT COUNT(*) AS total_count,
COALESCE(
SUM(
CASE
WHEN period = 'Год' THEN amount / 12.0
ELSE amount
END
),
0
) AS monthly_total
FROM subscriptions
WHERE status = 'Активна'
"""
).fetchone()
self.balance_card.configure(text=f"{balance:,.2f}".replace(",", " "))
self.month_expense_card.configure(
text=f"{month_expense:,.2f}".replace(",", " ")
)
self.month_income_card.configure(
text=f"{month_income:,.2f}".replace(",", " ")
)
self.subscriptions_card.configure(
text=(
f"{subscriptions['total_count']} шт. / "
f"{subscriptions['monthly_total']:,.2f}"
).replace(",", " ")
)
items = cursor.execute(
"""
SELECT id, name, amount, billing_day, period
FROM subscriptions
WHERE status = 'Активна'
ORDER BY billing_day
"""
).fetchall()
for widget in self.upcoming_frame.winfo_children():
widget.destroy()
if not items:
empty_label = ctk.CTkLabel(
self.upcoming_frame,
text="Нет активных подписок.",
text_color="gray70",
)
empty_label.pack(anchor="w", padx=10, pady=10)
def _render_upcoming(self, rows) -> None:
self.clear(self.upcoming_frame)
if not rows:
self._empty(self.upcoming_frame, "Нет активных подписок.")
return
for item in items:
days_left, next_date_text = self._get_next_payment_info(item["billing_day"])
card = ctk.CTkFrame(self.upcoming_frame, corner_radius=14)
card.pack(fill="x", padx=6, pady=6)
title = ctk.CTkLabel(
card,
text=item["name"],
font=ctk.CTkFont(size=18, weight="bold"),
for row in rows:
days_left, next_date_text = self._get_next_payment_info(row["billing_day"])
text = (
f"{self.money(row['amount'])} | {row['period']} | "
f"{next_date_text} | через {days_left} дн."
)
title.pack(anchor="w", padx=14, pady=(12, 4))
self._small_card(self.upcoming_frame, row["name"], text)
info = ctk.CTkLabel(
card,
text=(
f"{item['amount']:,.2f} ₽ | {item['period']} | "
f"следующее списание: {next_date_text} | через {days_left} дн."
).replace(",", " "),
text_color="gray75",
def _render_recent(self, rows) -> None:
self.clear(self.recent_frame)
if not rows:
self._empty(self.recent_frame, "Операций пока нет.")
return
for row in rows:
sign = "+" if row["transaction_type"] == "Доход" else "-"
text = (
f"{row['created_at'][:10]} | {row['account_name']} | "
f"{row['category_name']} | {sign}{self.money(row['amount'])}"
)
info.pack(anchor="w", padx=14, pady=(0, 12))
self._small_card(self.recent_frame, row["title"], text)
def _render_warnings(self, rows) -> None:
self.clear(self.warning_frame)
if not rows:
self._empty(self.warning_frame, "Превышенных лимитов нет.")
return
for row in rows:
text = f"Потрачено {self.money(row['spent'])} из {self.money(row['limit_amount'])}"
self._small_card(self.warning_frame, row["category_name"], text)
@staticmethod
def _small_card(master, title: str, text: str) -> None:
card = ctk.CTkFrame(master, corner_radius=14)
card.pack(fill="x", padx=6, pady=6)
ctk.CTkLabel(
card,
text=title,
font=ctk.CTkFont(size=16, weight="bold"),
).pack(anchor="w", padx=14, pady=(10, 2))
ctk.CTkLabel(card, text=text, text_color="gray70").pack(
anchor="w",
padx=14,
pady=(0, 10),
)
@staticmethod
def _empty(master, text: str) -> None:
ctk.CTkLabel(master, text=text, text_color="gray70").pack(
anchor="w",
padx=10,
pady=10,
)
@staticmethod
def _get_next_payment_info(billing_day: int) -> tuple[int, str]:
@@ -173,5 +168,4 @@ class DashboardFrame(ctk.CTkFrame):
safe_day = min(billing_day, next_last_day)
candidate = date(next_year, next_month, safe_day)
days_left = (candidate - today).days
return days_left, candidate.strftime("%d.%m.%Y")
return (candidate - today).days, candidate.strftime("%d.%m.%Y")

View File

@@ -1,6 +1,7 @@
import customtkinter as ctk
from app.config import APP_TITLE, MIN_HEIGHT, MIN_WIDTH, WINDOW_SIZE
from app.config import APP_SUBTITLE, APP_TITLE, MIN_HEIGHT, MIN_WIDTH, WINDOW_SIZE
from app.services import FinanceService
from app.ui.accounts_frame import AccountsFrame
from app.ui.budgets_frame import BudgetsFrame
from app.ui.categories_frame import CategoriesFrame
@@ -11,10 +12,16 @@ from app.ui.transactions_frame import TransactionsFrame
class FinanceApp(ctk.CTk):
"""Main application window and screen coordinator."""
def __init__(self) -> None:
super().__init__()
self.service = FinanceService()
self.frames: dict[str, ctk.CTkFrame] = {}
self.nav_buttons: dict[str, ctk.CTkButton] = {}
ctk.set_appearance_mode("dark")
saved_theme = self.service.get_setting("theme", "Тёмная")
ctk.set_appearance_mode(self._theme_to_mode(saved_theme))
ctk.set_default_color_theme("blue")
self.title(APP_TITLE)
@@ -24,34 +31,33 @@ class FinanceApp(ctk.CTk):
self.grid_columnconfigure(1, weight=1)
self.grid_rowconfigure(0, weight=1)
self.sidebar = ctk.CTkFrame(self, width=240, corner_radius=0)
self.sidebar = ctk.CTkFrame(self, width=260, corner_radius=0)
self.sidebar.grid(row=0, column=0, sticky="nsew")
self.sidebar.grid_rowconfigure(9, weight=1)
self.sidebar.grid_rowconfigure(12, weight=1)
self.content = ctk.CTkFrame(self, fg_color="transparent")
self.content.grid(row=0, column=1, sticky="nsew", padx=16, pady=16)
self.content.grid(row=0, column=1, sticky="nsew", padx=18, pady=18)
self.content.grid_rowconfigure(0, weight=1)
self.content.grid_columnconfigure(0, weight=1)
self._build_sidebar()
self._build_sidebar(saved_theme)
self._build_frames()
self.show_frame("Главная")
self.refresh_all()
def _build_sidebar(self) -> None:
title_label = ctk.CTkLabel(
def _build_sidebar(self, saved_theme: str) -> None:
ctk.CTkLabel(
self.sidebar,
text="Finance Control",
font=ctk.CTkFont(size=24, weight="bold"),
)
title_label.grid(row=0, column=0, padx=20, pady=(24, 8), sticky="w")
font=ctk.CTkFont(size=26, weight="bold"),
).grid(row=0, column=0, padx=20, pady=(24, 4), sticky="w")
subtitle_label = ctk.CTkLabel(
ctk.CTkLabel(
self.sidebar,
text="курсовой проект",
text=APP_SUBTITLE,
text_color="gray70",
)
subtitle_label.grid(row=1, column=0, padx=20, pady=(0, 16), sticky="w")
wraplength=210,
).grid(row=1, column=0, padx=20, pady=(0, 18), sticky="w")
pages = [
"Главная",
@@ -62,31 +68,30 @@ class FinanceApp(ctk.CTk):
"Бюджеты",
"Отчёты",
]
for index, page_name in enumerate(pages, start=2):
button = ctk.CTkButton(
self.sidebar,
text=page_name,
height=42,
height=44,
anchor="w",
command=lambda name=page_name: self.show_frame(name),
)
button.grid(row=index, column=0, padx=20, pady=6, sticky="ew")
button.grid(row=index, column=0, padx=20, pady=5, sticky="ew")
self.nav_buttons[page_name] = button
theme_label = ctk.CTkLabel(
ctk.CTkLabel(
self.sidebar,
text="Тема",
text="Тема интерфейса",
text_color="gray70",
)
theme_label.grid(row=10, column=0, padx=20, pady=(12, 6), sticky="w")
).grid(row=13, column=0, padx=20, pady=(12, 6), sticky="w")
self.theme_menu = ctk.CTkOptionMenu(
self.sidebar,
values=["Тёмная", "Светлая", "Системная"],
command=self.change_theme,
)
self.theme_menu.set("Тёмная")
self.theme_menu.grid(row=11, column=0, padx=20, pady=(0, 20), sticky="ew")
self.theme_menu.set(saved_theme)
self.theme_menu.grid(row=14, column=0, padx=20, pady=(0, 20), sticky="ew")
def _build_frames(self) -> None:
self.frames = {
@@ -98,7 +103,6 @@ class FinanceApp(ctk.CTk):
"Бюджеты": BudgetsFrame(self.content, self.refresh_all),
"Отчёты": ReportsFrame(self.content, self.refresh_all),
}
for frame in self.frames.values():
frame.grid(row=0, column=0, sticky="nsew")
@@ -107,17 +111,29 @@ class FinanceApp(ctk.CTk):
if hasattr(frame, "refresh_data"):
frame.refresh_data()
frame.tkraise()
self._highlight_nav_button(name)
def refresh_all(self) -> None:
for frame in self.frames.values():
if hasattr(frame, "refresh_data"):
frame.refresh_data()
def _highlight_nav_button(self, active_name: str) -> None:
for name, button in self.nav_buttons.items():
if name == active_name:
button.configure(fg_color=("#2563eb", "#2563eb"))
else:
button.configure(fg_color=("#3b8ed0", "#1f6aa5"))
def change_theme(self, theme_name: str) -> None:
ctk.set_appearance_mode(self._theme_to_mode(theme_name))
self.service.set_setting("theme", theme_name)
@staticmethod
def change_theme(theme_name: str) -> None:
def _theme_to_mode(theme_name: str) -> str:
theme_map = {
"Тёмная": "dark",
"Светлая": "light",
"Системная": "system",
}
ctk.set_appearance_mode(theme_map[theme_name])
return theme_map.get(theme_name, "dark")

View File

@@ -4,71 +4,69 @@ import customtkinter as ctk
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure
from app.database import get_connection
from app.ui.base_frame import BaseAppFrame
class ReportsFrame(ctk.CTkFrame):
class ReportsFrame(BaseAppFrame):
"""Screen with text reports and charts."""
def __init__(self, master, refresh_callback) -> None:
super().__init__(master)
self.refresh_callback = refresh_callback
super().__init__(master, refresh_callback)
self.last_report_text = ""
self.grid_columnconfigure((0, 1), weight=1)
self.grid_rowconfigure(1, weight=1)
self._build_header()
self._build_left_panel()
self._build_right_panel()
def _build_header(self) -> None:
header_frame = ctk.CTkFrame(self, fg_color="transparent")
header_frame.grid(row=0, column=0, columnspan=2, sticky="ew", padx=8, pady=(8, 12))
header_frame.grid_columnconfigure(0, weight=1)
header = ctk.CTkFrame(self, fg_color="transparent")
header.grid(row=0, column=0, columnspan=2, sticky="ew", padx=8, pady=(8, 12))
header.grid_columnconfigure(0, weight=1)
title = ctk.CTkLabel(
header_frame,
text="Аналитика и отчёты",
font=ctk.CTkFont(size=28, weight="bold"),
)
title.grid(row=0, column=0, sticky="w")
self.title_label(header, "Аналитика и отчёты").grid(row=0, column=0, sticky="w")
self.subtitle_label(
header,
"Текстовая сводка, диаграмма расходов и экспорт отчёта.",
).grid(row=1, column=0, sticky="w", pady=(4, 0))
export_button = ctk.CTkButton(
header_frame,
text="Сохранить отчёт в TXT",
command=self.export_report,
self.export_button = self.primary_button(
header,
"Сохранить отчёт в TXT",
lambda: self.run_safe(self.export_report, self.export_button),
)
export_button.grid(row=0, column=1, padx=(12, 0))
self.export_button.grid(row=0, column=1, rowspan=2, padx=(12, 0))
def _build_left_panel(self) -> None:
left_frame = ctk.CTkFrame(self, corner_radius=18)
left_frame.grid(row=1, column=0, sticky="nsew", padx=(8, 12), pady=8)
left_frame.grid_rowconfigure(1, weight=1)
left_frame.grid_columnconfigure(0, weight=1)
left = ctk.CTkFrame(self, corner_radius=20)
left.grid(row=1, column=0, sticky="nsew", padx=(8, 12), pady=8)
left.grid_rowconfigure(1, weight=1)
left.grid_columnconfigure(0, weight=1)
info_label = ctk.CTkLabel(
left_frame,
text="Текстовый отчёт за текущий месяц",
font=ctk.CTkFont(size=20, weight="bold"),
self.section_label(left, "Отчёт за текущий месяц").grid(
row=0,
column=0,
sticky="w",
padx=18,
pady=(18, 8),
)
info_label.grid(row=0, column=0, sticky="w", padx=18, pady=(18, 8))
self.report_box = ctk.CTkTextbox(left_frame)
self.report_box = ctk.CTkTextbox(left)
self.report_box.grid(row=1, column=0, sticky="nsew", padx=18, pady=(0, 18))
def _build_right_panel(self) -> None:
right_frame = ctk.CTkFrame(self, corner_radius=18)
right_frame.grid(row=1, column=1, sticky="nsew", padx=(0, 8), pady=8)
right_frame.grid_rowconfigure(1, weight=1)
right_frame.grid_columnconfigure(0, weight=1)
right = ctk.CTkFrame(self, corner_radius=20)
right.grid(row=1, column=1, sticky="nsew", padx=(0, 8), pady=8)
right.grid_rowconfigure(1, weight=1)
right.grid_columnconfigure(0, weight=1)
chart_label = ctk.CTkLabel(
right_frame,
text="Структура расходов по категориям",
font=ctk.CTkFont(size=20, weight="bold"),
self.section_label(right, "Структура расходов").grid(
row=0,
column=0,
sticky="w",
padx=18,
pady=(18, 8),
)
chart_label.grid(row=0, column=0, sticky="w", padx=18, pady=(18, 8))
self.chart_container = ctk.CTkFrame(right_frame, fg_color="transparent")
self.chart_container = ctk.CTkFrame(right, fg_color="transparent")
self.chart_container.grid(row=1, column=0, sticky="nsew", padx=18, pady=(0, 18))
self.figure = Figure(figsize=(5, 4), dpi=100)
@@ -76,100 +74,36 @@ class ReportsFrame(ctk.CTkFrame):
self.canvas.get_tk_widget().pack(fill="both", expand=True)
def refresh_data(self) -> None:
with get_connection() as connection:
cursor = connection.cursor()
summary = cursor.execute(
"""
SELECT
COALESCE(SUM(CASE WHEN transaction_type = 'Доход' THEN amount END), 0) AS income,
COALESCE(SUM(CASE WHEN transaction_type = 'Расход' THEN amount END), 0) AS expense
FROM transactions
WHERE strftime('%Y-%m', created_at) = strftime('%Y-%m', 'now')
"""
).fetchone()
balance = cursor.execute(
"SELECT COALESCE(SUM(balance), 0) AS total FROM accounts"
).fetchone()["total"]
subscription_total = cursor.execute(
"""
SELECT COALESCE(
SUM(
CASE
WHEN period = 'Год' THEN amount / 12.0
ELSE amount
END
),
0
) AS total
FROM subscriptions
WHERE status = 'Активна'
"""
).fetchone()["total"]
category_rows = cursor.execute(
"""
SELECT c.name,
SUM(t.amount) AS total
FROM transactions AS t
JOIN categories AS c ON c.id = t.category_id
WHERE t.transaction_type = 'Расход'
AND strftime('%Y-%m', t.created_at) = strftime('%Y-%m', 'now')
GROUP BY c.name
HAVING total > 0
ORDER BY total DESC
"""
).fetchall()
net_result = summary["income"] - summary["expense"]
top_category = category_rows[0]["name"] if category_rows else "Нет данных"
top_category_value = category_rows[0]["total"] if category_rows else 0
report_text = (
"ОТЧЁТ ЗА ТЕКУЩИЙ МЕСЯЦ\n\n"
f"Доходы: {summary['income']:,.2f}\n"
f"Расходы: {summary['expense']:,.2f}\n"
f"Финансовый результат: {net_result:,.2f}\n"
f"Общий баланс по всем счетам: {balance:,.2f}\n"
f"Активные подписки в пересчёте на месяц: {subscription_total:,.2f}\n"
f"Самая затратная категория: {top_category} ({top_category_value:,.2f} ₽)\n"
).replace(",", " ")
self.last_report_text = report_text
self.last_report_text = self.service.build_report_text()
self.report_box.delete("1.0", "end")
self.report_box.insert("1.0", report_text)
self.report_box.insert("1.0", self.last_report_text)
self._draw_chart()
def _draw_chart(self) -> None:
data = self.service.report_data()
rows = data["categories"]
self.figure.clear()
axis = self.figure.add_subplot(111)
axis.set_title("Расходы по категориям")
if category_rows:
labels = [row["name"] for row in category_rows]
values = [row["total"] for row in category_rows]
if rows:
labels = [row["name"] for row in rows]
values = [row["total"] for row in rows]
axis.pie(values, labels=labels, autopct="%1.1f%%")
else:
axis.text(0.5, 0.5, "Нет данных для диаграммы", ha="center", va="center")
axis.axis("off")
self.canvas.draw()
def export_report(self) -> None:
if not self.last_report_text:
messagebox.showinfo("Информация", "Сначала обновите отчёт.")
return
self.refresh_data()
file_path = filedialog.asksaveasfilename(
defaultextension=".txt",
filetypes=[("Text files", "*.txt")],
title="Сохранить отчёт",
)
if not file_path:
return
with open(file_path, "w", encoding="utf-8") as file:
file.write(self.last_report_text)
messagebox.showinfo("Готово", "Отчёт сохранён.")

View File

@@ -2,190 +2,161 @@ from tkinter import messagebox
import customtkinter as ctk
from app.database import get_connection
from app.ui.base_frame import BaseAppFrame
from app.utils.validators import (
normalize_text,
parse_billing_day,
parse_positive_float,
)
class SubscriptionsFrame(ctk.CTkFrame):
class SubscriptionsFrame(BaseAppFrame):
"""Screen for recurring payments and subscriptions."""
def __init__(self, master, refresh_callback) -> None:
super().__init__(master)
self.refresh_callback = refresh_callback
super().__init__(master, refresh_callback)
self.grid_columnconfigure(1, weight=1)
self.grid_rowconfigure(0, weight=1)
self._build_form()
self._build_list()
def _build_form(self) -> None:
form_frame = ctk.CTkFrame(self, corner_radius=18)
form_frame = ctk.CTkFrame(self, corner_radius=20)
form_frame.grid(row=0, column=0, sticky="ns", padx=(8, 12), pady=8)
title = ctk.CTkLabel(
form_frame,
text="Менеджер подписок",
font=ctk.CTkFont(size=26, weight="bold"),
self.title_label(form_frame, "Подписки").pack(
anchor="w", padx=18, pady=(18, 4)
)
title.pack(anchor="w", padx=18, pady=(18, 6))
subtitle = ctk.CTkLabel(
self.subtitle_label(
form_frame,
text="Контроль платных сервисов и автосписаний.",
text_color="gray75",
)
subtitle.pack(anchor="w", padx=18, pady=(0, 18))
"Контроль регулярных списаний и платных сервисов.",
).pack(anchor="w", padx=18, pady=(0, 18))
self.name_entry = ctk.CTkEntry(
form_frame,
width=300,
placeholder_text="Название",
width=320,
placeholder_text="Название сервиса",
)
self.name_entry.pack(padx=18, pady=8)
self.name_entry.pack(padx=18, pady=7)
self.amount_entry = ctk.CTkEntry(
form_frame,
width=300,
width=320,
placeholder_text="Стоимость",
)
self.amount_entry.pack(padx=18, pady=8)
self.amount_entry.pack(padx=18, pady=7)
self.day_entry = ctk.CTkEntry(
form_frame,
width=300,
placeholder_text="День списания (1-31)",
width=320,
placeholder_text="День списания: 131",
)
self.day_entry.pack(padx=18, pady=8)
self.day_entry.pack(padx=18, pady=7)
self.period_menu = ctk.CTkOptionMenu(
form_frame,
width=300,
width=320,
values=["Месяц", "Год"],
)
self.period_menu.set("Месяц")
self.period_menu.pack(padx=18, pady=8)
self.period_menu.pack(padx=18, pady=7)
self.status_menu = ctk.CTkOptionMenu(
form_frame,
width=300,
width=320,
values=["Активна", "Пауза"],
)
self.status_menu.set("Активна")
self.status_menu.pack(padx=18, pady=8)
self.status_menu.pack(padx=18, pady=7)
add_button = ctk.CTkButton(
self.add_button = self.primary_button(
form_frame,
text="Добавить подписку",
width=300,
height=42,
command=self.add_subscription,
"Добавить подписку",
lambda: self.run_safe(self.add_subscription, self.add_button),
)
add_button.pack(padx=18, pady=(12, 18))
self.add_button.pack(fill="x", padx=18, pady=(12, 18))
def _build_list(self) -> None:
self.list_frame = ctk.CTkScrollableFrame(self, label_text="Список подписок")
self.list_frame.grid(row=0, column=1, sticky="nsew", padx=(0, 8), pady=8)
def add_subscription(self) -> None:
name = self.name_entry.get().strip()
amount_text = self.amount_entry.get().strip().replace(",", ".")
billing_day_text = self.day_entry.get().strip()
period = self.period_menu.get()
status = self.status_menu.get()
if not name or not amount_text or not billing_day_text:
messagebox.showerror("Ошибка", "Заполните все поля.")
return
try:
amount = float(amount_text)
billing_day = int(billing_day_text)
except ValueError:
messagebox.showerror(
"Ошибка",
"Стоимость и день списания должны быть числами.",
)
return
if amount <= 0 or not 1 <= billing_day <= 31:
messagebox.showerror("Ошибка", "Проверьте стоимость и день списания.")
return
with get_connection() as connection:
connection.execute(
"""
INSERT INTO subscriptions (name, amount, billing_day, period, status)
VALUES (?, ?, ?, ?, ?)
""",
(name, amount, billing_day, period, status),
)
connection.commit()
name = normalize_text(self.name_entry.get(), "Название")
amount = parse_positive_float(self.amount_entry.get(), "Стоимость")
day = parse_billing_day(self.day_entry.get())
self.service.add_subscription(
name=name,
amount=amount,
billing_day=day,
period=self.period_menu.get(),
status=self.status_menu.get(),
)
self.name_entry.delete(0, "end")
self.amount_entry.delete(0, "end")
self.day_entry.delete(0, "end")
self.refresh_callback()
def delete_subscription(self, subscription_id: int) -> None:
with get_connection() as connection:
connection.execute(
"DELETE FROM subscriptions WHERE id = ?",
(subscription_id,),
)
connection.commit()
if not messagebox.askyesno("Подтверждение", "Удалить выбранную подписку?"):
return
self.service.delete_subscription(subscription_id)
self.refresh_callback()
def toggle_status(self, subscription_id: int) -> None:
self.service.toggle_subscription_status(subscription_id)
self.refresh_callback()
def refresh_data(self) -> None:
for widget in self.list_frame.winfo_children():
widget.destroy()
with get_connection() as connection:
rows = connection.execute(
"SELECT * FROM subscriptions ORDER BY id DESC"
).fetchall()
self.clear(self.list_frame)
rows = self.service.list_subscriptions()
if not rows:
empty_label = ctk.CTkLabel(
ctk.CTkLabel(
self.list_frame,
text="Подписок пока нет.",
text_color="gray70",
)
empty_label.pack(anchor="w", padx=10, pady=10)
).pack(anchor="w", padx=10, pady=10)
return
for row in rows:
card = ctk.CTkFrame(self.list_frame, corner_radius=16)
card.pack(fill="x", padx=6, pady=6)
card.grid_columnconfigure(0, weight=1)
title = ctk.CTkLabel(
ctk.CTkLabel(
card,
text=row["name"],
font=ctk.CTkFont(size=18, weight="bold"),
)
title.grid(row=0, column=0, sticky="w", padx=16, pady=(12, 4))
).grid(row=0, column=0, sticky="w", padx=16, pady=(12, 4))
delete_button = ctk.CTkButton(
toggle_text = "Пауза" if row["status"] == "Активна" else "Активировать"
ctk.CTkButton(
card,
text="Удалить",
width=90,
fg_color="#b33939",
hover_color="#932f2f",
command=lambda subscription_id=row["id"]: self.delete_subscription(
subscription_id
text=toggle_text,
width=110,
command=lambda sub_id=row["id"]: self.run_safe(
lambda: self.toggle_status(sub_id)
),
)
delete_button.grid(row=0, column=1, sticky="e", padx=16, pady=(12, 4))
).grid(row=0, column=1, sticky="e", padx=(8, 4), pady=(12, 4))
monthly_equivalent = (
row["amount"] / 12 if row["period"] == "Год" else row["amount"]
)
info = ctk.CTkLabel(
self.danger_button(
card,
text=(
f"{row['amount']:,.2f} ₽ | {row['period']} | "
f"списание: {row['billing_day']} числа | {row['status']} | "
f"{monthly_equivalent:,.2f} ₽ в месяц"
).replace(",", " "),
text_color="gray75",
"Удалить",
lambda sub_id=row["id"]: self.run_safe(
lambda: self.delete_subscription(sub_id)
),
).grid(row=0, column=2, sticky="e", padx=(4, 16), pady=(12, 4))
monthly = row["amount"] / 12 if row["period"] == "Год" else row["amount"]
info = (
f"{self.money(row['amount'])} | {row['period']} | "
f"списание {row['billing_day']} числа | {row['status']} | "
f"{self.money(monthly)} в месяц"
)
ctk.CTkLabel(card, text=info, text_color="gray70").grid(
row=1,
column=0,
columnspan=3,
sticky="w",
padx=16,
pady=(0, 14),
)
info.grid(row=1, column=0, columnspan=2, sticky="w", padx=16, pady=(0, 14))

View File

@@ -1,291 +1,255 @@
from tkinter import messagebox
from tkinter import filedialog, messagebox
import customtkinter as ctk
from app.database import get_connection
from app.ui.base_frame import BaseAppFrame
from app.utils.formatters import today_text, validate_date
from app.utils.validators import normalize_text, parse_positive_float
class TransactionsFrame(ctk.CTkFrame):
class TransactionsFrame(BaseAppFrame):
"""Screen for adding, deleting and filtering transactions."""
def __init__(self, master, refresh_callback) -> None:
super().__init__(master)
self.refresh_callback = refresh_callback
super().__init__(master, refresh_callback)
self.account_map: dict[str, int] = {}
self.category_map: dict[str, int] = {}
self.grid_columnconfigure(1, weight=1)
self.grid_rowconfigure(0, weight=1)
self._build_form()
self._build_list()
def _build_form(self) -> None:
form_frame = ctk.CTkFrame(self, corner_radius=18)
form_frame = ctk.CTkFrame(self, corner_radius=20)
form_frame.grid(row=0, column=0, sticky="ns", padx=(8, 12), pady=8)
title = ctk.CTkLabel(
form_frame,
text="Операции",
font=ctk.CTkFont(size=26, weight="bold"),
self.title_label(form_frame, "Операции").pack(
anchor="w", padx=18, pady=(18, 4)
)
title.pack(anchor="w", padx=18, pady=(18, 6))
subtitle = ctk.CTkLabel(
self.subtitle_label(
form_frame,
text="Добавляйте доходы и расходы по категориям.",
text_color="gray75",
)
subtitle.pack(anchor="w", padx=18, pady=(0, 18))
"Добавляйте доходы и расходы с автоматическим изменением баланса.",
).pack(anchor="w", padx=18, pady=(0, 18))
self.title_entry = ctk.CTkEntry(
form_frame,
width=300,
placeholder_text="Название",
width=330,
placeholder_text="Название операции",
)
self.title_entry.pack(padx=18, pady=8)
self.title_entry.pack(padx=18, pady=7)
self.amount_entry = ctk.CTkEntry(
form_frame,
width=300,
width=330,
placeholder_text="Сумма",
)
self.amount_entry.pack(padx=18, pady=8)
self.amount_entry.pack(padx=18, pady=7)
self.type_menu = ctk.CTkOptionMenu(
form_frame,
width=300,
width=330,
values=["Расход", "Доход"],
command=self._on_type_change,
command=lambda _: self._load_category_menu(),
)
self.type_menu.set("Расход")
self.type_menu.pack(padx=18, pady=8)
self.type_menu.pack(padx=18, pady=7)
self.account_menu = ctk.CTkOptionMenu(form_frame, width=300, values=["Нет счетов"])
self.account_menu.pack(padx=18, pady=8)
self.account_menu = ctk.CTkOptionMenu(
form_frame,
width=330,
values=["Нет счетов"],
)
self.account_menu.pack(padx=18, pady=7)
self.category_menu = ctk.CTkOptionMenu(
form_frame,
width=300,
width=330,
values=["Нет категорий"],
)
self.category_menu.pack(padx=18, pady=8)
self.category_menu.pack(padx=18, pady=7)
add_button = ctk.CTkButton(
self.date_entry = ctk.CTkEntry(
form_frame,
text="Добавить операцию",
width=300,
height=42,
command=self.add_transaction,
width=330,
placeholder_text="Дата: ГГГГ-ММ-ДД",
)
add_button.pack(padx=18, pady=(12, 18))
self.date_entry.insert(0, today_text())
self.date_entry.pack(padx=18, pady=7)
self.add_button = self.primary_button(
form_frame,
"Добавить операцию",
lambda: self.run_safe(self.add_transaction, self.add_button),
)
self.add_button.pack(fill="x", padx=18, pady=(12, 8))
self.export_button = ctk.CTkButton(
form_frame,
text="Экспорт операций в CSV",
height=38,
command=lambda: self.run_safe(self.export_csv, self.export_button),
)
self.export_button.pack(fill="x", padx=18, pady=(0, 18))
def _build_list(self) -> None:
self.list_frame = ctk.CTkScrollableFrame(self, label_text="История операций")
self.list_frame.grid(row=0, column=1, sticky="nsew", padx=(0, 8), pady=8)
right = ctk.CTkFrame(self, fg_color="transparent")
right.grid(row=0, column=1, sticky="nsew", padx=(0, 8), pady=8)
right.grid_rowconfigure(1, weight=1)
right.grid_columnconfigure(0, weight=1)
def _on_type_change(self, _value: str) -> None:
self._load_category_menu()
filter_frame = ctk.CTkFrame(right, corner_radius=18)
filter_frame.grid(row=0, column=0, sticky="ew", pady=(0, 8))
filter_frame.grid_columnconfigure(0, weight=1)
self.search_entry = ctk.CTkEntry(
filter_frame,
placeholder_text="Поиск по названию операции",
)
self.search_entry.grid(row=0, column=0, sticky="ew", padx=12, pady=12)
self.search_entry.bind("<KeyRelease>", lambda _: self.refresh_data())
self.filter_type_menu = ctk.CTkOptionMenu(
filter_frame,
width=140,
values=["Все", "Расход", "Доход"],
command=lambda _: self.refresh_data(),
)
self.filter_type_menu.set("Все")
self.filter_type_menu.grid(row=0, column=1, padx=12, pady=12)
self.list_frame = ctk.CTkScrollableFrame(right, label_text="История операций")
self.list_frame.grid(row=1, column=0, sticky="nsew")
def _load_account_menu(self) -> None:
with get_connection() as connection:
rows = connection.execute(
"SELECT id, name FROM accounts ORDER BY name"
).fetchall()
rows = self.service.list_account_options()
if not rows:
self.account_map = {}
self.account_menu.configure(values=["Нет счетов"])
self.account_menu.set("Нет счетов")
return
values = []
self.account_map = {}
for row in rows:
label = f"{row['name']} (ID {row['id']})"
label = f"{row['name']} {self.money(row['balance'])}"
values.append(label)
self.account_map[label] = row["id"]
self.account_menu.configure(values=values)
self.account_menu.set(values[0])
if self.account_menu.get() not in values:
self.account_menu.set(values[0])
def _load_category_menu(self) -> None:
transaction_type = self.type_menu.get()
with get_connection() as connection:
rows = connection.execute(
"""
SELECT id, name
FROM categories
WHERE category_type = ?
ORDER BY name
""",
(transaction_type,),
).fetchall()
rows = self.service.list_categories(self.type_menu.get())
if not rows:
self.category_map = {}
self.category_menu.configure(values=["Нет категорий"])
self.category_menu.set("Нет категорий")
return
values = []
self.category_map = {}
for row in rows:
label = f"{row['name']} (ID {row['id']})"
label = row["name"]
values.append(label)
self.category_map[label] = row["id"]
self.category_menu.configure(values=values)
self.category_menu.set(values[0])
if self.category_menu.get() not in values:
self.category_menu.set(values[0])
def add_transaction(self) -> None:
title = self.title_entry.get().strip()
amount_text = self.amount_entry.get().strip().replace(",", ".")
transaction_type = self.type_menu.get()
account_label = self.account_menu.get()
category_label = self.category_menu.get()
if not title or not amount_text:
messagebox.showerror("Ошибка", "Заполните все поля.")
return
if account_label not in self.account_map:
messagebox.showerror("Ошибка", "Сначала добавьте хотя бы один счёт.")
return
raise ValueError("Сначала добавьте хотя бы один счёт.")
if category_label not in self.category_map:
messagebox.showerror("Ошибка", "Сначала создайте подходящую категорию.")
return
raise ValueError("Для выбранного типа операции нет категории.")
try:
amount = float(amount_text)
except ValueError:
messagebox.showerror("Ошибка", "Сумма должна быть числом.")
return
if amount <= 0:
messagebox.showerror("Ошибка", "Сумма должна быть больше нуля.")
return
account_id = self.account_map[account_label]
category_id = self.category_map[category_label]
balance_delta = -amount if transaction_type == "Расход" else amount
with get_connection() as connection:
connection.execute(
"""
INSERT INTO transactions (
title,
amount,
transaction_type,
account_id,
category_id
)
VALUES (?, ?, ?, ?, ?)
""",
(title, amount, transaction_type, account_id, category_id),
)
connection.execute(
"UPDATE accounts SET balance = balance + ? WHERE id = ?",
(balance_delta, account_id),
)
connection.commit()
title = normalize_text(self.title_entry.get(), "Название")
amount = parse_positive_float(self.amount_entry.get(), "Сумма")
transaction_type = self.type_menu.get()
created_at = validate_date(self.date_entry.get())
self.service.add_transaction(
title=title,
amount=amount,
transaction_type=transaction_type,
account_id=self.account_map[account_label],
category_id=self.category_map[category_label],
created_at=created_at,
)
self.title_entry.delete(0, "end")
self.amount_entry.delete(0, "end")
self.date_entry.delete(0, "end")
self.date_entry.insert(0, today_text())
self.refresh_callback()
def delete_transaction(self, transaction_id: int) -> None:
with get_connection() as connection:
row = connection.execute(
"""
SELECT amount, transaction_type, account_id
FROM transactions
WHERE id = ?
""",
(transaction_id,),
).fetchone()
if row is None:
return
reverse_delta = (
row["amount"]
if row["transaction_type"] == "Расход"
else -row["amount"]
)
connection.execute(
"UPDATE accounts SET balance = balance + ? WHERE id = ?",
(reverse_delta, row["account_id"]),
)
connection.execute("DELETE FROM transactions WHERE id = ?", (transaction_id,))
connection.commit()
if not messagebox.askyesno("Подтверждение", "Удалить выбранную операцию?"):
return
self.service.delete_transaction(transaction_id)
self.refresh_callback()
def export_csv(self) -> None:
file_path = filedialog.asksaveasfilename(
defaultextension=".csv",
filetypes=[("CSV files", "*.csv")],
title="Сохранить операции",
)
if not file_path:
return
self.service.export_transactions_csv(file_path)
messagebox.showinfo("Готово", "Операции сохранены в CSV.")
def refresh_data(self) -> None:
self._load_account_menu()
self._load_category_menu()
self.clear(self.list_frame)
for widget in self.list_frame.winfo_children():
widget.destroy()
with get_connection() as connection:
rows = connection.execute(
"""
SELECT t.id,
t.title,
t.amount,
t.transaction_type,
t.created_at,
a.name AS account_name,
c.name AS category_name
FROM transactions AS t
JOIN accounts AS a ON a.id = t.account_id
JOIN categories AS c ON c.id = t.category_id
ORDER BY t.id DESC
"""
).fetchall()
rows = self.service.list_transactions(
search=self.search_entry.get().strip() if hasattr(self, "search_entry") else "",
transaction_type=self.filter_type_menu.get()
if hasattr(self, "filter_type_menu")
else "Все",
)
if not rows:
empty_label = ctk.CTkLabel(
ctk.CTkLabel(
self.list_frame,
text="Операций пока нет.",
text="Операций пока нет или фильтр ничего не нашёл.",
text_color="gray70",
)
empty_label.pack(anchor="w", padx=10, pady=10)
).pack(anchor="w", padx=10, pady=10)
return
for row in rows:
card = ctk.CTkFrame(self.list_frame, corner_radius=16)
card.pack(fill="x", padx=6, pady=6)
self._render_row(row)
title = ctk.CTkLabel(
card,
text=row["title"],
font=ctk.CTkFont(size=18, weight="bold"),
)
title.grid(row=0, column=0, sticky="w", padx=16, pady=(12, 4))
def _render_row(self, row) -> None:
card = ctk.CTkFrame(self.list_frame, corner_radius=16)
card.pack(fill="x", padx=6, pady=6)
card.grid_columnconfigure(0, weight=1)
delete_button = ctk.CTkButton(
card,
text="Удалить",
width=90,
fg_color="#b33939",
hover_color="#932f2f",
command=lambda transaction_id=row["id"]: self.delete_transaction(
transaction_id
),
)
delete_button.grid(row=0, column=1, sticky="e", padx=16, pady=(12, 4))
sign = "+" if row["transaction_type"] == "Доход" else "-"
title_text = f"{row['title']} {sign}{self.money(row['amount'])}"
ctk.CTkLabel(
card,
text=title_text,
font=ctk.CTkFont(size=18, weight="bold"),
).grid(row=0, column=0, sticky="w", padx=16, pady=(12, 4))
amount_prefix = "-" if row["transaction_type"] == "Расход" else "+"
info = ctk.CTkLabel(
card,
text=(
f"{amount_prefix}{row['amount']:,.2f} ₽ | "
f"{row['transaction_type']} | {row['account_name']} | "
f"{row['category_name']} | {row['created_at'][:16]}"
).replace(",", " "),
text_color="gray75",
)
info.grid(row=1, column=0, columnspan=2, sticky="w", padx=16, pady=(0, 14))
self.danger_button(
card,
"Удалить",
lambda transaction_id=row["id"]: self.run_safe(
lambda: self.delete_transaction(transaction_id)
),
).grid(row=0, column=1, sticky="e", padx=16, pady=(12, 4))
info = (
f"{row['created_at'][:10]} | {row['transaction_type']} | "
f"{row['account_name']} | {row['category_name']}"
)
ctk.CTkLabel(card, text=info, text_color="gray70").grid(
row=1,
column=0,
columnspan=2,
sticky="w",
padx=16,
pady=(0, 14),
)

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

25
app/utils/formatters.py Normal file
View File

@@ -0,0 +1,25 @@
from datetime import date, datetime
def format_money(value: float | int | None) -> str:
"""Return money value in a readable Russian format."""
amount = 0 if value is None else float(value)
return f"{amount:,.2f}".replace(",", " ")
def current_month_key() -> str:
"""Return current month in SQLite-friendly format YYYY-MM."""
return date.today().strftime("%Y-%m")
def today_text() -> str:
"""Return current date in ISO format for entry fields."""
return date.today().strftime("%Y-%m-%d")
def validate_date(value: str) -> str:
"""Validate user date and return normalized ISO date."""
try:
return datetime.strptime(value.strip(), "%Y-%m-%d").strftime("%Y-%m-%d")
except ValueError as error:
raise ValueError("Дата должна быть в формате ГГГГ-ММ-ДД.") from error

40
app/utils/validators.py Normal file
View File

@@ -0,0 +1,40 @@
def normalize_text(value: str, field_name: str, max_length: int = 80) -> str:
"""Strip and validate a required text value."""
text = value.strip()
if not text:
raise ValueError(f"Поле «{field_name}» не должно быть пустым.")
if len(text) > max_length:
raise ValueError(
f"Поле «{field_name}» должно быть не длиннее {max_length} символов."
)
return text
def parse_float(value: str, field_name: str) -> float:
"""Parse a float from Russian-style user input."""
prepared = value.strip().replace(" ", "").replace(",", ".")
try:
number = float(prepared)
except ValueError as error:
raise ValueError(f"Поле «{field_name}» должно быть числом.") from error
return number
def parse_positive_float(value: str, field_name: str) -> float:
"""Parse a strictly positive float."""
number = parse_float(value, field_name)
if number <= 0:
raise ValueError(f"Поле «{field_name}» должно быть больше нуля.")
return number
def parse_billing_day(value: str) -> int:
"""Parse subscription billing day."""
try:
day = int(value.strip())
except ValueError as error:
raise ValueError("День списания должен быть целым числом от 1 до 31.") from error
if not 1 <= day <= 31:
raise ValueError("День списания должен быть в диапазоне от 1 до 31.")
return day

View File

@@ -1,10 +1,13 @@
from app.database import init_database
from app.ui.main_window import FinanceApp
def main() -> None:
"""Initialize database and start Finance Control."""
init_database()
app = FinanceApp()
app.mainloop()
if __name__ == "__main__":
main()

View File

@@ -1,2 +1,2 @@
customtkinter
matplotlib
customtkinter>=5.2.2
matplotlib>=3.8.0

10
run.bat Normal file
View File

@@ -0,0 +1,10 @@
@echo off
chcp 65001 > nul
if not exist .venv (
python -m venv .venv
)
call .venv\Scripts\activate
python -m pip install --upgrade pip
pip install -r requirements.txt
python main.py
pause

9
run.sh Normal file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -e
if [ ! -d .venv ]; then
python3 -m venv .venv
fi
source .venv/bin/activate
python -m pip install --upgrade pip
pip install -r requirements.txt
python main.py