финальная версия 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

@@ -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))