Добавлены каркас приложения, база данных, счета, категории и операции
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
finance_manager.db
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.vscode/
|
||||||
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
5
app/config.py
Normal file
5
app/config.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
APP_TITLE = "Персональный менеджер финансов и подписок"
|
||||||
|
DB_NAME = "finance_manager.db"
|
||||||
|
WINDOW_SIZE = "1280x760"
|
||||||
|
MIN_WIDTH = 1100
|
||||||
|
MIN_HEIGHT = 700
|
||||||
81
app/database.py
Normal file
81
app/database.py
Normal file
@@ -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()
|
||||||
1
app/ui/__init__.py
Normal file
1
app/ui/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
163
app/ui/accounts_frame.py
Normal file
163
app/ui/accounts_frame.py
Normal file
@@ -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))
|
||||||
157
app/ui/categories_frame.py
Normal file
157
app/ui/categories_frame.py
Normal file
@@ -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))
|
||||||
123
app/ui/main_window.py
Normal file
123
app/ui/main_window.py
Normal file
@@ -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])
|
||||||
291
app/ui/transactions_frame.py
Normal file
291
app/ui/transactions_frame.py
Normal file
@@ -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))
|
||||||
10
main.py
Normal file
10
main.py
Normal file
@@ -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()
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
customtkinter
|
||||||
|
matplotlib
|
||||||
Reference in New Issue
Block a user