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("", 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), )