import calendar from datetime import date import customtkinter as ctk from app.ui.base_frame import BaseAppFrame class DashboardFrame(BaseAppFrame): """Main summary page.""" def __init__(self, master, refresh_callback) -> None: 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() 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) 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, label_text="Ближайшие списания", ) self.upcoming_frame.grid(row=3, column=0, sticky="nsew", padx=8, pady=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=20) card.grid(row=row, column=column, sticky="nsew", padx=8, pady=8) card.grid_columnconfigure(0, weight=1) ctk.CTkLabel(card, text=title, text_color="gray70").grid( row=0, column=0, sticky="w", padx=18, pady=(16, 4), ) value_label = ctk.CTkLabel( card, text="0 ₽", font=ctk.CTkFont(size=28, weight="bold"), ) value_label.grid(row=1, column=0, sticky="w", padx=18, pady=(0, 16)) return value_label def refresh_data(self) -> None: 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'])} категорий") self._render_upcoming(data["upcoming"]) self._render_recent(data["recent"]) self._render_warnings(data["warnings"]) def _render_upcoming(self, rows) -> None: self.clear(self.upcoming_frame) if not rows: self._empty(self.upcoming_frame, "Нет активных подписок.") return 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} дн." ) self._small_card(self.upcoming_frame, row["name"], text) 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'])}" ) 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]: 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) return (candidate - today).days, candidate.strftime("%d.%m.%Y")