Добавлены главная панель, подписки, бюджеты и отчеты

This commit is contained in:
2026-03-30 23:53:45 +03:00
parent 8ff14e5090
commit 7dcb1a23a7
4 changed files with 780 additions and 0 deletions

237
app/ui/budgets_frame.py Normal file
View File

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

177
app/ui/dashboard_frame.py Normal file
View File

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

175
app/ui/reports_frame.py Normal file
View File

@@ -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("Готово", "Отчёт сохранён.")

View File

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