From 8ff14e50906050e72566e8088db92f9283a7b4f7 Mon Sep 17 00:00:00 2001 From: 24_ZhurovichAM <24_ZhurovichAM@iux.local> Date: Mon, 30 Mar 2026 23:52:21 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BA=D0=B0=D1=80=D0=BA=D0=B0=D1=81=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F,=20?= =?UTF-8?q?=D0=B1=D0=B0=D0=B7=D0=B0=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85?= =?UTF-8?q?,=20=D1=81=D1=87=D0=B5=D1=82=D0=B0,=20=D0=BA=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=B3=D0=BE=D1=80=D0=B8=D0=B8=20=D0=B8=20=D0=BE=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 7 + app/__init__.py | 1 + app/config.py | 5 + app/database.py | 81 ++++++++++ app/ui/__init__.py | 1 + app/ui/accounts_frame.py | 163 ++++++++++++++++++++ app/ui/categories_frame.py | 157 +++++++++++++++++++ app/ui/main_window.py | 123 +++++++++++++++ app/ui/transactions_frame.py | 291 +++++++++++++++++++++++++++++++++++ main.py | 10 ++ requirements.txt | 2 + 11 files changed, 841 insertions(+) create mode 100644 .gitignore create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/database.py create mode 100644 app/ui/__init__.py create mode 100644 app/ui/accounts_frame.py create mode 100644 app/ui/categories_frame.py create mode 100644 app/ui/main_window.py create mode 100644 app/ui/transactions_frame.py create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c76512 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +.venv/ +finance_manager.db +.idea/ +.vscode/ +.vscode/ diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ + diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..39357ac --- /dev/null +++ b/app/config.py @@ -0,0 +1,5 @@ +APP_TITLE = "Персональный менеджер финансов и подписок" +DB_NAME = "finance_manager.db" +WINDOW_SIZE = "1280x760" +MIN_WIDTH = 1100 +MIN_HEIGHT = 700 diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..9fb6a44 --- /dev/null +++ b/app/database.py @@ -0,0 +1,81 @@ +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]] = [ + ("Продукты", "Расход"), + ("Транспорт", "Расход"), + ("Кафе", "Расход"), + ("Жильё", "Расход"), + ("Здоровье", "Расход"), + ("Развлечения", "Расход"), + ("Одежда", "Расход"), + ("Образование", "Расход"), + ("Зарплата", "Доход"), + ("Фриланс", "Доход"), + ("Подарки", "Доход"), + ("Возврат", "Доход"), +] + +def init_database() -> None: + with get_connection() as connection: + cursor = connection.cursor() + cursor.executescript( + """ + CREATE TABLE IF NOT EXISTS accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + account_type TEXT NOT NULL, + balance REAL NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + category_type TEXT NOT NULL, + UNIQUE(name, category_type) + ); + + CREATE TABLE IF NOT EXISTS transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + amount REAL NOT NULL, + transaction_type TEXT NOT NULL, + 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 + ); + + CREATE TABLE IF NOT EXISTS subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + amount REAL NOT NULL, + billing_day INTEGER NOT NULL, + period TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'Активна' + ); + + 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 + ); + """ + ) + cursor.executemany( + """ + INSERT OR IGNORE INTO categories (name, category_type) + VALUES (?, ?) + """, + DEFAULT_CATEGORIES, + ) + connection.commit() diff --git a/app/ui/__init__.py b/app/ui/__init__.py new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/app/ui/__init__.py @@ -0,0 +1 @@ + diff --git a/app/ui/accounts_frame.py b/app/ui/accounts_frame.py new file mode 100644 index 0000000..378fcf9 --- /dev/null +++ b/app/ui/accounts_frame.py @@ -0,0 +1,163 @@ +import sqlite3 +from tkinter import messagebox + +import customtkinter as ctk + +from app.database import get_connection + + +class AccountsFrame(ctk.CTkFrame): + def __init__(self, master, refresh_callback) -> None: + super().__init__(master) + self.refresh_callback = 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.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"), + ) + title.pack(anchor="w", padx=18, pady=(18, 6)) + + subtitle = ctk.CTkLabel( + form_frame, + text="Добавьте карту, кошелёк или наличные.", + text_color="gray75", + ) + subtitle.pack(anchor="w", padx=18, pady=(0, 18)) + + self.name_entry = ctk.CTkEntry( + form_frame, + width=280, + placeholder_text="Название", + ) + self.name_entry.pack(padx=18, pady=8) + + self.type_menu = ctk.CTkOptionMenu( + form_frame, + width=280, + values=["Карта", "Наличные", "Счёт", "Электронный кошелёк"], + ) + self.type_menu.pack(padx=18, pady=8) + self.type_menu.set("Карта") + + self.balance_entry = ctk.CTkEntry( + form_frame, + width=280, + placeholder_text="Стартовый баланс", + ) + self.balance_entry.pack(padx=18, pady=8) + + add_button = ctk.CTkButton( + form_frame, + text="Добавить счёт", + width=280, + height=42, + command=self.add_account, + ) + add_button.pack(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() + 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() + + self.name_entry.delete(0, "end") + self.balance_entry.delete(0, "end") + self.refresh_callback() + + def delete_account(self, account_id: int) -> None: + try: + with get_connection() as connection: + connection.execute("DELETE FROM accounts WHERE id = ?", (account_id,)) + connection.commit() + 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) + return + + for account in accounts: + card = ctk.CTkFrame(self.list_frame, corner_radius=16) + card.pack(fill="x", padx=6, pady=6) + + title = ctk.CTkLabel( + card, + text=account["name"], + font=ctk.CTkFont(size=18, weight="bold"), + ) + title.grid(row=0, column=0, sticky="w", padx=16, pady=(12, 6)) + + delete_button = ctk.CTkButton( + card, + text="Удалить", + width=90, + fg_color="#b33939", + hover_color="#932f2f", + command=lambda account_id=account["id"]: self.delete_account(account_id), + ) + delete_button.grid(row=0, column=1, sticky="e", padx=16, pady=(12, 6)) + + info = 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 diff --git a/app/ui/categories_frame.py b/app/ui/categories_frame.py new file mode 100644 index 0000000..272a2c3 --- /dev/null +++ b/app/ui/categories_frame.py @@ -0,0 +1,157 @@ +import sqlite3 +from tkinter import messagebox + +import customtkinter as ctk + +from app.database import get_connection + + +class CategoriesFrame(ctk.CTkFrame): + def __init__(self, master, refresh_callback) -> None: + super().__init__(master) + self.refresh_callback = 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.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"), + ) + title.pack(anchor="w", padx=18, pady=(18, 6)) + + subtitle = ctk.CTkLabel( + form_frame, + text="Создавайте свои категории доходов и расходов.", + text_color="gray75", + ) + subtitle.pack(anchor="w", padx=18, pady=(0, 18)) + + self.name_entry = ctk.CTkEntry( + form_frame, + width=280, + placeholder_text="Название категории", + ) + self.name_entry.pack(padx=18, pady=8) + + self.type_menu = ctk.CTkOptionMenu( + form_frame, + width=280, + values=["Расход", "Доход"], + ) + self.type_menu.set("Расход") + self.type_menu.pack(padx=18, pady=8) + + add_button = ctk.CTkButton( + form_frame, + text="Добавить категорию", + width=280, + height=42, + command=self.add_category, + ) + add_button.pack(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() + 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() + except sqlite3.IntegrityError: + messagebox.showerror( + "Ошибка", + "Такая категория уже существует.", + ) + return + + self.name_entry.delete(0, "end") + self.refresh_callback() + + def delete_category(self, category_id: int) -> None: + try: + with get_connection() as connection: + connection.execute( + "DELETE FROM categories WHERE id = ?", + (category_id,), + ) + connection.commit() + 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.list_frame, + text="Категорий пока нет.", + text_color="gray70", + ) + empty_label.pack(anchor="w", padx=10, pady=10) + return + + for category in categories: + card = ctk.CTkFrame(self.list_frame, corner_radius=16) + card.pack(fill="x", padx=6, pady=6) + + title = ctk.CTkLabel( + card, + text=category["name"], + font=ctk.CTkFont(size=18, weight="bold"), + ) + title.grid(row=0, column=0, sticky="w", padx=16, pady=(12, 6)) + + delete_button = ctk.CTkButton( + card, + text="Удалить", + width=90, + fg_color="#b33939", + hover_color="#932f2f", + command=lambda category_id=category["id"]: self.delete_category( + category_id + ), + ) + delete_button.grid(row=0, column=1, sticky="e", padx=16, pady=(12, 6)) + + info = 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 diff --git a/app/ui/main_window.py b/app/ui/main_window.py new file mode 100644 index 0000000..6a77ee5 --- /dev/null +++ b/app/ui/main_window.py @@ -0,0 +1,123 @@ +import customtkinter as ctk + +from app.config import APP_TITLE, MIN_HEIGHT, MIN_WIDTH, WINDOW_SIZE +from app.ui.accounts_frame import AccountsFrame +from app.ui.budgets_frame import BudgetsFrame +from app.ui.categories_frame import CategoriesFrame +from app.ui.dashboard_frame import DashboardFrame +from app.ui.reports_frame import ReportsFrame +from app.ui.subscriptions_frame import SubscriptionsFrame +from app.ui.transactions_frame import TransactionsFrame + + +class FinanceApp(ctk.CTk): + def __init__(self) -> None: + super().__init__() + + ctk.set_appearance_mode("dark") + ctk.set_default_color_theme("blue") + + self.title(APP_TITLE) + self.geometry(WINDOW_SIZE) + self.minsize(MIN_WIDTH, MIN_HEIGHT) + + self.grid_columnconfigure(1, weight=1) + self.grid_rowconfigure(0, weight=1) + + self.sidebar = ctk.CTkFrame(self, width=240, corner_radius=0) + self.sidebar.grid(row=0, column=0, sticky="nsew") + self.sidebar.grid_rowconfigure(9, 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_rowconfigure(0, weight=1) + self.content.grid_columnconfigure(0, weight=1) + + self._build_sidebar() + self._build_frames() + self.show_frame("Главная") + self.refresh_all() + + def _build_sidebar(self) -> None: + title_label = 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") + + subtitle_label = ctk.CTkLabel( + self.sidebar, + text="курсовой проект", + text_color="gray70", + ) + subtitle_label.grid(row=1, column=0, padx=20, pady=(0, 16), sticky="w") + + pages = [ + "Главная", + "Счета", + "Категории", + "Операции", + "Подписки", + "Бюджеты", + "Отчёты", + ] + + for index, page_name in enumerate(pages, start=2): + button = ctk.CTkButton( + self.sidebar, + text=page_name, + height=42, + anchor="w", + command=lambda name=page_name: self.show_frame(name), + ) + button.grid(row=index, column=0, padx=20, pady=6, sticky="ew") + + theme_label = ctk.CTkLabel( + self.sidebar, + text="Тема", + text_color="gray70", + ) + theme_label.grid(row=10, 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") + + def _build_frames(self) -> None: + self.frames = { + "Главная": DashboardFrame(self.content, self.refresh_all), + "Счета": AccountsFrame(self.content, self.refresh_all), + "Категории": CategoriesFrame(self.content, self.refresh_all), + "Операции": TransactionsFrame(self.content, self.refresh_all), + "Подписки": SubscriptionsFrame(self.content, self.refresh_all), + "Бюджеты": 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") + + def show_frame(self, name: str) -> None: + frame = self.frames[name] + if hasattr(frame, "refresh_data"): + frame.refresh_data() + frame.tkraise() + + def refresh_all(self) -> None: + for frame in self.frames.values(): + if hasattr(frame, "refresh_data"): + frame.refresh_data() + + @staticmethod + def change_theme(theme_name: str) -> None: + theme_map = { + "Тёмная": "dark", + "Светлая": "light", + "Системная": "system", + } + ctk.set_appearance_mode(theme_map[theme_name]) \ No newline at end of file diff --git a/app/ui/transactions_frame.py b/app/ui/transactions_frame.py new file mode 100644 index 0000000..6a755c0 --- /dev/null +++ b/app/ui/transactions_frame.py @@ -0,0 +1,291 @@ +from tkinter import messagebox + +import customtkinter as ctk + +from app.database import get_connection + + +class TransactionsFrame(ctk.CTkFrame): + def __init__(self, master, refresh_callback) -> None: + super().__init__(master) + self.refresh_callback = 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.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"), + ) + title.pack(anchor="w", padx=18, pady=(18, 6)) + + subtitle = ctk.CTkLabel( + form_frame, + text="Добавляйте доходы и расходы по категориям.", + text_color="gray75", + ) + subtitle.pack(anchor="w", padx=18, pady=(0, 18)) + + self.title_entry = ctk.CTkEntry( + form_frame, + width=300, + placeholder_text="Название", + ) + self.title_entry.pack(padx=18, pady=8) + + self.amount_entry = ctk.CTkEntry( + form_frame, + width=300, + placeholder_text="Сумма", + ) + self.amount_entry.pack(padx=18, pady=8) + + self.type_menu = ctk.CTkOptionMenu( + form_frame, + width=300, + values=["Расход", "Доход"], + command=self._on_type_change, + ) + self.type_menu.set("Расход") + self.type_menu.pack(padx=18, pady=8) + + self.account_menu = ctk.CTkOptionMenu(form_frame, width=300, values=["Нет счетов"]) + self.account_menu.pack(padx=18, pady=8) + + self.category_menu = ctk.CTkOptionMenu( + form_frame, + width=300, + values=["Нет категорий"], + ) + self.category_menu.pack(padx=18, pady=8) + + add_button = ctk.CTkButton( + form_frame, + text="Добавить операцию", + width=300, + height=42, + command=self.add_transaction, + ) + add_button.pack(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 _on_type_change(self, _value: str) -> None: + self._load_category_menu() + + def _load_account_menu(self) -> None: + with get_connection() as connection: + rows = connection.execute( + "SELECT id, name FROM accounts ORDER BY name" + ).fetchall() + + 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']})" + values.append(label) + self.account_map[label] = row["id"] + + self.account_menu.configure(values=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() + + 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']})" + values.append(label) + self.category_map[label] = row["id"] + + self.category_menu.configure(values=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 + + if category_label not in self.category_map: + messagebox.showerror("Ошибка", "Сначала создайте подходящую категорию.") + return + + 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() + + self.title_entry.delete(0, "end") + self.amount_entry.delete(0, "end") + 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() + + self.refresh_callback() + + def refresh_data(self) -> None: + self._load_account_menu() + self._load_category_menu() + + 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() + + if not rows: + empty_label = ctk.CTkLabel( + self.list_frame, + text="Операций пока нет.", + text_color="gray70", + ) + empty_label.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) + + 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)) + + 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)) + + 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)) diff --git a/main.py b/main.py new file mode 100644 index 0000000..6dcf410 --- /dev/null +++ b/main.py @@ -0,0 +1,10 @@ +from app.database import init_database +from app.ui.main_window import FinanceApp + +def main() -> None: + init_database() + app = FinanceApp() + app.mainloop() + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..eb7766c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +customtkinter +matplotlib