256 lines
9.4 KiB
Python
256 lines
9.4 KiB
Python
from tkinter import filedialog, messagebox
|
||
|
||
import customtkinter as ctk
|
||
|
||
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(BaseAppFrame):
|
||
"""Screen for adding, deleting and filtering transactions."""
|
||
|
||
def __init__(self, master, refresh_callback) -> None:
|
||
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=20)
|
||
form_frame.grid(row=0, column=0, sticky="ns", padx=(8, 12), pady=8)
|
||
|
||
self.title_label(form_frame, "Операции").pack(
|
||
anchor="w", padx=18, pady=(18, 4)
|
||
)
|
||
self.subtitle_label(
|
||
form_frame,
|
||
"Добавляйте доходы и расходы с автоматическим изменением баланса.",
|
||
).pack(anchor="w", padx=18, pady=(0, 18))
|
||
|
||
self.title_entry = ctk.CTkEntry(
|
||
form_frame,
|
||
width=330,
|
||
placeholder_text="Название операции",
|
||
)
|
||
self.title_entry.pack(padx=18, pady=7)
|
||
|
||
self.amount_entry = ctk.CTkEntry(
|
||
form_frame,
|
||
width=330,
|
||
placeholder_text="Сумма",
|
||
)
|
||
self.amount_entry.pack(padx=18, pady=7)
|
||
|
||
self.type_menu = ctk.CTkOptionMenu(
|
||
form_frame,
|
||
width=330,
|
||
values=["Расход", "Доход"],
|
||
command=lambda _: self._load_category_menu(),
|
||
)
|
||
self.type_menu.set("Расход")
|
||
self.type_menu.pack(padx=18, pady=7)
|
||
|
||
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=330,
|
||
values=["Нет категорий"],
|
||
)
|
||
self.category_menu.pack(padx=18, pady=7)
|
||
|
||
self.date_entry = ctk.CTkEntry(
|
||
form_frame,
|
||
width=330,
|
||
placeholder_text="Дата: ГГГГ-ММ-ДД",
|
||
)
|
||
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:
|
||
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)
|
||
|
||
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:
|
||
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']} — {self.money(row['balance'])}"
|
||
values.append(label)
|
||
self.account_map[label] = row["id"]
|
||
self.account_menu.configure(values=values)
|
||
if self.account_menu.get() not in values:
|
||
self.account_menu.set(values[0])
|
||
|
||
def _load_category_menu(self) -> None:
|
||
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 = row["name"]
|
||
values.append(label)
|
||
self.category_map[label] = row["id"]
|
||
self.category_menu.configure(values=values)
|
||
if self.category_menu.get() not in values:
|
||
self.category_menu.set(values[0])
|
||
|
||
def add_transaction(self) -> None:
|
||
account_label = self.account_menu.get()
|
||
category_label = self.category_menu.get()
|
||
if account_label not in self.account_map:
|
||
raise ValueError("Сначала добавьте хотя бы один счёт.")
|
||
if category_label not in self.category_map:
|
||
raise ValueError("Для выбранного типа операции нет категории.")
|
||
|
||
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:
|
||
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)
|
||
|
||
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:
|
||
ctk.CTkLabel(
|
||
self.list_frame,
|
||
text="Операций пока нет или фильтр ничего не нашёл.",
|
||
text_color="gray70",
|
||
).pack(anchor="w", padx=10, pady=10)
|
||
return
|
||
|
||
for row in rows:
|
||
self._render_row(row)
|
||
|
||
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)
|
||
|
||
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))
|
||
|
||
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),
|
||
)
|