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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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