From 7dcb1a23a73a1ace9f3220bc3925f3fe4bb4064d Mon Sep 17 00:00:00 2001 From: 24_MokanGV <24_MokanGV@iux.local> Date: Mon, 30 Mar 2026 23:53:45 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=B3=D0=BB=D0=B0=D0=B2=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C,=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=BA=D0=B8,=20=D0=B1=D1=8E=D0=B4=D0=B6?= =?UTF-8?q?=D0=B5=D1=82=D1=8B=20=D0=B8=20=D0=BE=D1=82=D1=87=D0=B5=D1=82?= =?UTF-8?q?=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/ui/budgets_frame.py | 237 ++++++++++++++++++++++++++++++++++ app/ui/dashboard_frame.py | 177 +++++++++++++++++++++++++ app/ui/reports_frame.py | 175 +++++++++++++++++++++++++ app/ui/subscriptions_frame.py | 191 +++++++++++++++++++++++++++ 4 files changed, 780 insertions(+) create mode 100644 app/ui/budgets_frame.py create mode 100644 app/ui/dashboard_frame.py create mode 100644 app/ui/reports_frame.py create mode 100644 app/ui/subscriptions_frame.py diff --git a/app/ui/budgets_frame.py b/app/ui/budgets_frame.py new file mode 100644 index 0000000..b194949 --- /dev/null +++ b/app/ui/budgets_frame.py @@ -0,0 +1,237 @@ +import sqlite3 +from tkinter import messagebox + +import customtkinter as ctk + +from app.database import get_connection + + +class BudgetsFrame(ctk.CTkFrame): + def __init__(self, master, refresh_callback) -> None: + super().__init__(master) + self.refresh_callback = 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.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.category_menu = ctk.CTkOptionMenu( + form_frame, + width=300, + values=["Нет категорий"], + ) + self.category_menu.pack(padx=18, pady=8) + + self.limit_entry = ctk.CTkEntry( + form_frame, + width=300, + placeholder_text="Лимит", + ) + self.limit_entry.pack(padx=18, pady=8) + + add_button = ctk.CTkButton( + form_frame, + text="Добавить лимит", + width=300, + height=42, + command=self.add_budget, + ) + 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 _load_categories(self) -> None: + with get_connection() as connection: + rows = connection.execute( + """ + SELECT id, name + FROM categories + WHERE category_type = 'Расход' + ORDER BY name + """ + ).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_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 + + 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.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() + + 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["category_name"], + 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 budget_id=row["id"]: self.delete_budget(budget_id), + ) + delete_button.grid(row=0, column=1, sticky="e", padx=16, pady=(12, 4)) + + percent = 0 if row["limit_amount"] == 0 else row["spent"] / row["limit_amount"] + percent = max(0, percent) + + 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)) + + 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)) + + status_text = ( + "Лимит превышен" + if percent > 1 + else f"Использовано: {percent * 100:.0f}%" + ) + + 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)) diff --git a/app/ui/dashboard_frame.py b/app/ui/dashboard_frame.py new file mode 100644 index 0000000..0d5a33b --- /dev/null +++ b/app/ui/dashboard_frame.py @@ -0,0 +1,177 @@ +import calendar +from datetime import date + +import customtkinter as ctk + +from app.database import get_connection + + +class DashboardFrame(ctk.CTkFrame): + def __init__(self, master, refresh_callback) -> None: + super().__init__(master) + self.refresh_callback = refresh_callback + + self.grid_columnconfigure((0, 1), weight=1) + self.grid_rowconfigure(2, weight=1) + + header = ctk.CTkLabel( + self, + text="Главная панель", + font=ctk.CTkFont(size=28, weight="bold"), + ) + header.grid(row=0, column=0, columnspan=2, sticky="w", padx=8, pady=(8, 16)) + + 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), + ) + + def _create_card(self, row: int, column: int, title: str) -> ctk.CTkLabel: + card = ctk.CTkFrame(self, corner_radius=18) + card.grid(row=row, column=column, sticky="nsew", padx=8, pady=8) + + title_label = ctk.CTkLabel( + card, + text=title, + text_color="gray75", + font=ctk.CTkFont(size=15), + ) + title_label.pack(anchor="w", padx=18, pady=(16, 8)) + + value_label = ctk.CTkLabel( + card, + text="0 ₽", + font=ctk.CTkFont(size=30, weight="bold"), + ) + value_label.pack(anchor="w", padx=18, pady=(0, 16)) + return value_label + + def refresh_data(self) -> None: + with get_connection() as connection: + cursor = connection.cursor() + + balance = cursor.execute( + "SELECT COALESCE(SUM(balance), 0) AS total FROM accounts" + ).fetchone()["total"] + + 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) + 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"), + ) + title.pack(anchor="w", padx=14, pady=(12, 4)) + + info = ctk.CTkLabel( + card, + text=( + f"{item['amount']:,.2f} ₽ | {item['period']} | " + f"следующее списание: {next_date_text} | через {days_left} дн." + ).replace(",", " "), + text_color="gray75", + ) + info.pack(anchor="w", padx=14, pady=(0, 12)) + + @staticmethod + def _get_next_payment_info(billing_day: int) -> tuple[int, str]: + today = date.today() + current_last_day = calendar.monthrange(today.year, today.month)[1] + safe_day = min(billing_day, current_last_day) + candidate = date(today.year, today.month, safe_day) + + if candidate < today: + next_month = today.month + 1 + next_year = today.year + if next_month == 13: + next_month = 1 + next_year += 1 + next_last_day = calendar.monthrange(next_year, next_month)[1] + 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") diff --git a/app/ui/reports_frame.py b/app/ui/reports_frame.py new file mode 100644 index 0000000..8304e5f --- /dev/null +++ b/app/ui/reports_frame.py @@ -0,0 +1,175 @@ +from tkinter import filedialog, messagebox + +import customtkinter as ctk +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +from matplotlib.figure import Figure + +from app.database import get_connection + + +class ReportsFrame(ctk.CTkFrame): + def __init__(self, master, refresh_callback) -> None: + super().__init__(master) + self.refresh_callback = 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) + + title = ctk.CTkLabel( + header_frame, + text="Аналитика и отчёты", + font=ctk.CTkFont(size=28, weight="bold"), + ) + title.grid(row=0, column=0, sticky="w") + + export_button = ctk.CTkButton( + header_frame, + text="Сохранить отчёт в TXT", + command=self.export_report, + ) + export_button.grid(row=0, column=1, 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) + + info_label = ctk.CTkLabel( + left_frame, + text="Текстовый отчёт за текущий месяц", + font=ctk.CTkFont(size=20, weight="bold"), + ) + info_label.grid(row=0, column=0, sticky="w", padx=18, pady=(18, 8)) + + self.report_box = ctk.CTkTextbox(left_frame) + 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) + + chart_label = ctk.CTkLabel( + right_frame, + text="Структура расходов по категориям", + font=ctk.CTkFont(size=20, weight="bold"), + ) + 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.grid(row=1, column=0, sticky="nsew", padx=18, pady=(0, 18)) + + self.figure = Figure(figsize=(5, 4), dpi=100) + self.canvas = FigureCanvasTkAgg(self.figure, master=self.chart_container) + 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.report_box.delete("1.0", "end") + self.report_box.insert("1.0", report_text) + + 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] + 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 + + 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("Готово", "Отчёт сохранён.") diff --git a/app/ui/subscriptions_frame.py b/app/ui/subscriptions_frame.py new file mode 100644 index 0000000..8da5c5b --- /dev/null +++ b/app/ui/subscriptions_frame.py @@ -0,0 +1,191 @@ +from tkinter import messagebox + +import customtkinter as ctk + +from app.database import get_connection + + +class SubscriptionsFrame(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=300, + placeholder_text="Название", + ) + self.name_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.day_entry = ctk.CTkEntry( + form_frame, + width=300, + placeholder_text="День списания (1-31)", + ) + self.day_entry.pack(padx=18, pady=8) + + self.period_menu = ctk.CTkOptionMenu( + form_frame, + width=300, + values=["Месяц", "Год"], + ) + self.period_menu.set("Месяц") + self.period_menu.pack(padx=18, pady=8) + + self.status_menu = ctk.CTkOptionMenu( + form_frame, + width=300, + values=["Активна", "Пауза"], + ) + self.status_menu.set("Активна") + self.status_menu.pack(padx=18, pady=8) + + add_button = ctk.CTkButton( + form_frame, + text="Добавить подписку", + width=300, + height=42, + command=self.add_subscription, + ) + 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_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() + + 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() + + 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() + + 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["name"], + 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 subscription_id=row["id"]: self.delete_subscription( + subscription_id + ), + ) + delete_button.grid(row=0, column=1, sticky="e", padx=16, pady=(12, 4)) + + monthly_equivalent = ( + row["amount"] / 12 if row["period"] == "Год" else row["amount"] + ) + + info = ctk.CTkLabel( + card, + text=( + f"{row['amount']:,.2f} ₽ | {row['period']} | " + f"списание: {row['billing_day']} числа | {row['status']} | " + f"≈ {monthly_equivalent:,.2f} ₽ в месяц" + ).replace(",", " "), + text_color="gray75", + ) + info.grid(row=1, column=0, columnspan=2, sticky="w", padx=16, pady=(0, 14))