diff --git a/CHANGELOG_FINAL.md b/CHANGELOG_FINAL.md new file mode 100644 index 0000000..db31eda --- /dev/null +++ b/CHANGELOG_FINAL.md @@ -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` для запуска на пустом компьютере. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c7a7f32 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/app/config.py b/app/config.py index 39357ac..d16369a 100644 --- a/app/config.py +++ b/app/config.py @@ -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" diff --git a/app/database.py b/app/database.py index 9fb6a44..81f2343 100644 --- a/app/database.py +++ b/app/database.py @@ -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" + ) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..51bf4c5 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,3 @@ +from app.services.finance_service import FinanceService + +__all__ = ["FinanceService"] diff --git a/app/services/finance_service.py b/app/services/finance_service.py new file mode 100644 index 0000000..bc7b52f --- /dev/null +++ b/app/services/finance_service.py @@ -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) diff --git a/app/ui/accounts_frame.py b/app/ui/accounts_frame.py index 378fcf9..70cda22 100644 --- a/app/ui/accounts_frame.py +++ b/app/ui/accounts_frame.py @@ -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)) \ No newline at end of file + 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 + ) diff --git a/app/ui/base_frame.py b/app/ui/base_frame.py new file mode 100644 index 0000000..65e280f --- /dev/null +++ b/app/ui/base_frame.py @@ -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 diff --git a/app/ui/budgets_frame.py b/app/ui/budgets_frame.py index b194949..3330e42 100644 --- a/app/ui/budgets_frame.py +++ b/app/ui/budgets_frame.py @@ -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), + ) diff --git a/app/ui/categories_frame.py b/app/ui/categories_frame.py index 272a2c3..6d27996 100644 --- a/app/ui/categories_frame.py +++ b/app/ui/categories_frame.py @@ -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)) \ No newline at end of file + text=f"Тип: {row['category_type']}", + text_color="gray70", + ).grid(row=1, column=0, columnspan=2, sticky="w", padx=16, pady=(0, 14)) diff --git a/app/ui/dashboard_frame.py b/app/ui/dashboard_frame.py index 0d5a33b..ec8d6cb 100644 --- a/app/ui/dashboard_frame.py +++ b/app/ui/dashboard_frame.py @@ -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") diff --git a/app/ui/main_window.py b/app/ui/main_window.py index 6a77ee5..78d22bb 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -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]) \ No newline at end of file + return theme_map.get(theme_name, "dark") diff --git a/app/ui/reports_frame.py b/app/ui/reports_frame.py index 8304e5f..d07efe3 100644 --- a/app/ui/reports_frame.py +++ b/app/ui/reports_frame.py @@ -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("Готово", "Отчёт сохранён.") diff --git a/app/ui/subscriptions_frame.py b/app/ui/subscriptions_frame.py index 8da5c5b..8a80a0a 100644 --- a/app/ui/subscriptions_frame.py +++ b/app/ui/subscriptions_frame.py @@ -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="День списания: 1–31", ) - 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)) diff --git a/app/ui/transactions_frame.py b/app/ui/transactions_frame.py index 6a755c0..289b493 100644 --- a/app/ui/transactions_frame.py +++ b/app/ui/transactions_frame.py @@ -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("", 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), + ) diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/formatters.py b/app/utils/formatters.py new file mode 100644 index 0000000..9c9cc69 --- /dev/null +++ b/app/utils/formatters.py @@ -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 diff --git a/app/utils/validators.py b/app/utils/validators.py new file mode 100644 index 0000000..e12be70 --- /dev/null +++ b/app/utils/validators.py @@ -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 diff --git a/main.py b/main.py index 6dcf410..18e372a 100644 --- a/main.py +++ b/main.py @@ -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() diff --git a/requirements.txt b/requirements.txt index eb7766c..47179b6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -customtkinter -matplotlib +customtkinter>=5.2.2 +matplotlib>=3.8.0 diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..9bdd1fc --- /dev/null +++ b/run.bat @@ -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 diff --git a/run.sh b/run.sh new file mode 100644 index 0000000..b04531f --- /dev/null +++ b/run.sh @@ -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