финальная версия Finance Control
This commit is contained in:
14
CHANGELOG_FINAL.md
Normal file
14
CHANGELOG_FINAL.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Доработки финальной версии
|
||||||
|
|
||||||
|
1. Улучшен интерфейс: обновлены размеры окна, карточки главной панели, боковое меню и единый стиль экранов.
|
||||||
|
2. Добавлен сервисный слой `FinanceService`, который отделяет бизнес-логику и SQL-запросы от интерфейса.
|
||||||
|
3. Добавлены вспомогательные модули форматирования и валидации пользовательского ввода.
|
||||||
|
4. Добавлена защита от многократных быстрых нажатий на кнопки через общий механизм `run_safe`.
|
||||||
|
5. Добавлены поиск и фильтрация операций.
|
||||||
|
6. Добавлен экспорт операций в CSV.
|
||||||
|
7. Добавлено переключение статуса подписки «Активна / Пауза».
|
||||||
|
8. Раздел бюджетов теперь обновляет существующий лимит, если лимит по категории уже был создан.
|
||||||
|
9. Добавлено сохранение выбранной темы интерфейса в таблицу `settings`.
|
||||||
|
10. Добавлены индексы SQLite для операций, категорий, счетов и дат.
|
||||||
|
11. Добавлена миграция старой базы альфа-версии: приложение корректно открывает базу без новых колонок.
|
||||||
|
12. Подготовлены `README.md`, `run.bat`, `run.sh` и обновлённый `requirements.txt` для запуска на пустом компьютере.
|
||||||
102
README.md
Normal file
102
README.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# Finance Control
|
||||||
|
|
||||||
|
Finance Control — настольное приложение для персонального учёта финансов и подписок.
|
||||||
|
Проект разработан на Python с использованием CustomTkinter, SQLite и Matplotlib.
|
||||||
|
|
||||||
|
## Основные возможности финальной версии
|
||||||
|
|
||||||
|
- учёт счетов, карт, наличных и электронных кошельков;
|
||||||
|
- категории доходов и расходов;
|
||||||
|
- добавление и удаление операций с автоматическим изменением баланса;
|
||||||
|
- поиск и фильтрация операций;
|
||||||
|
- экспорт истории операций в CSV;
|
||||||
|
- контроль подписок, переключение статуса «Активна / Пауза»;
|
||||||
|
- бюджеты и лимиты по расходным категориям с прогресс-барами;
|
||||||
|
- главная панель со сводкой, ближайшими списаниями, последними операциями и предупреждениями по лимитам;
|
||||||
|
- аналитика и текстовый отчёт с диаграммой расходов;
|
||||||
|
- экспорт отчёта в TXT;
|
||||||
|
- сохранение выбранной темы интерфейса;
|
||||||
|
- защита от быстрых повторных нажатий на кнопки;
|
||||||
|
- локальная SQLite-база, создаётся автоматически при первом запуске.
|
||||||
|
|
||||||
|
## Запуск на пустом компьютере
|
||||||
|
|
||||||
|
1. Установить Python 3.11 или новее.
|
||||||
|
2. Скачать репозиторий с Gitea.
|
||||||
|
3. Открыть терминал в папке проекта.
|
||||||
|
4. Создать виртуальное окружение:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Активировать окружение:
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv\Scripts\activate
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux / macOS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Установить зависимости:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Запустить приложение:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
При первом запуске файл `finance_manager.db` будет создан автоматически.
|
||||||
|
|
||||||
|
## Быстрый запуск Windows
|
||||||
|
|
||||||
|
Можно запустить файл:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
run.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
Он создаст виртуальное окружение, установит зависимости и откроет приложение.
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```text
|
||||||
|
main.py
|
||||||
|
app/
|
||||||
|
config.py
|
||||||
|
database.py
|
||||||
|
services/
|
||||||
|
finance_service.py
|
||||||
|
utils/
|
||||||
|
formatters.py
|
||||||
|
validators.py
|
||||||
|
ui/
|
||||||
|
base_frame.py
|
||||||
|
main_window.py
|
||||||
|
dashboard_frame.py
|
||||||
|
accounts_frame.py
|
||||||
|
categories_frame.py
|
||||||
|
transactions_frame.py
|
||||||
|
subscriptions_frame.py
|
||||||
|
budgets_frame.py
|
||||||
|
reports_frame.py
|
||||||
|
requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Проверка кода
|
||||||
|
|
||||||
|
Для быстрой проверки синтаксиса:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m py_compile main.py app/*.py app/ui/*.py app/services/*.py app/utils/*.py
|
||||||
|
```
|
||||||
@@ -1,5 +1,16 @@
|
|||||||
APP_TITLE = "Персональный менеджер финансов и подписок"
|
from pathlib import Path
|
||||||
DB_NAME = "finance_manager.db"
|
|
||||||
WINDOW_SIZE = "1280x760"
|
APP_TITLE = "Finance Control — персональный менеджер финансов"
|
||||||
MIN_WIDTH = 1100
|
APP_SUBTITLE = "финальная версия курсового проекта"
|
||||||
MIN_HEIGHT = 700
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
DB_NAME = str(BASE_DIR / "finance_manager.db")
|
||||||
|
WINDOW_SIZE = "1320x820"
|
||||||
|
MIN_WIDTH = 1180
|
||||||
|
MIN_HEIGHT = 760
|
||||||
|
|
||||||
|
PRIMARY_COLOR = "#2563eb"
|
||||||
|
PRIMARY_HOVER_COLOR = "#1d4ed8"
|
||||||
|
DANGER_COLOR = "#b91c1c"
|
||||||
|
DANGER_HOVER_COLOR = "#991b1b"
|
||||||
|
SUCCESS_COLOR = "#15803d"
|
||||||
|
WARNING_COLOR = "#ca8a04"
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
from app.config import DB_NAME
|
from app.config import DB_NAME
|
||||||
|
|
||||||
def get_connection() -> sqlite3.Connection:
|
|
||||||
connection = sqlite3.connect(DB_NAME)
|
|
||||||
connection.row_factory = sqlite3.Row
|
|
||||||
connection.execute("PRAGMA foreign_keys = ON")
|
|
||||||
return connection
|
|
||||||
|
|
||||||
DEFAULT_CATEGORIES: Iterable[tuple[str, str]] = [
|
DEFAULT_CATEGORIES: Iterable[tuple[str, str]] = [
|
||||||
("Продукты", "Расход"),
|
("Продукты", "Расход"),
|
||||||
@@ -17,13 +13,26 @@ DEFAULT_CATEGORIES: Iterable[tuple[str, str]] = [
|
|||||||
("Развлечения", "Расход"),
|
("Развлечения", "Расход"),
|
||||||
("Одежда", "Расход"),
|
("Одежда", "Расход"),
|
||||||
("Образование", "Расход"),
|
("Образование", "Расход"),
|
||||||
|
("Коммунальные услуги", "Расход"),
|
||||||
|
("Связь и интернет", "Расход"),
|
||||||
("Зарплата", "Доход"),
|
("Зарплата", "Доход"),
|
||||||
("Фриланс", "Доход"),
|
("Фриланс", "Доход"),
|
||||||
("Подарки", "Доход"),
|
("Подарки", "Доход"),
|
||||||
("Возврат", "Доход"),
|
("Возврат", "Доход"),
|
||||||
|
("Проценты по вкладу", "Доход"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_connection() -> sqlite3.Connection:
|
||||||
|
"""Open SQLite connection with row access by column name."""
|
||||||
|
connection = sqlite3.connect(DB_NAME)
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
connection.execute("PRAGMA foreign_keys = ON")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
|
||||||
def init_database() -> None:
|
def init_database() -> None:
|
||||||
|
"""Create all tables, indexes and default records."""
|
||||||
with get_connection() as connection:
|
with get_connection() as connection:
|
||||||
cursor = connection.cursor()
|
cursor = connection.cursor()
|
||||||
cursor.executescript(
|
cursor.executescript(
|
||||||
@@ -32,13 +41,15 @@ def init_database() -> None:
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
account_type TEXT NOT NULL,
|
account_type TEXT NOT NULL,
|
||||||
balance REAL NOT NULL DEFAULT 0
|
balance REAL NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS categories (
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
category_type TEXT NOT NULL,
|
category_type TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE(name, category_type)
|
UNIQUE(name, category_type)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -50,8 +61,10 @@ def init_database() -> None:
|
|||||||
account_id INTEGER NOT NULL,
|
account_id INTEGER NOT NULL,
|
||||||
category_id INTEGER NOT NULL,
|
category_id INTEGER NOT NULL,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE RESTRICT,
|
FOREIGN KEY (account_id)
|
||||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT
|
REFERENCES accounts(id) ON DELETE RESTRICT,
|
||||||
|
FOREIGN KEY (category_id)
|
||||||
|
REFERENCES categories(id) ON DELETE RESTRICT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||||
@@ -60,17 +73,38 @@ def init_database() -> None:
|
|||||||
amount REAL NOT NULL,
|
amount REAL NOT NULL,
|
||||||
billing_day INTEGER NOT NULL,
|
billing_day INTEGER NOT NULL,
|
||||||
period TEXT NOT NULL,
|
period TEXT NOT NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'Активна'
|
status TEXT NOT NULL DEFAULT 'Активна',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS budgets (
|
CREATE TABLE IF NOT EXISTS budgets (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
category_id INTEGER NOT NULL UNIQUE,
|
category_id INTEGER NOT NULL UNIQUE,
|
||||||
limit_amount REAL NOT NULL,
|
limit_amount REAL NOT NULL,
|
||||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (category_id)
|
||||||
|
REFERENCES categories(id) ON DELETE RESTRICT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transactions_created_at
|
||||||
|
ON transactions(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transactions_type
|
||||||
|
ON transactions(transaction_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transactions_account
|
||||||
|
ON transactions(account_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transactions_category
|
||||||
|
ON transactions(category_id);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
_ensure_column(cursor, "accounts", "created_at", "TEXT")
|
||||||
|
_ensure_column(cursor, "categories", "created_at", "TEXT")
|
||||||
|
_ensure_column(cursor, "subscriptions", "created_at", "TEXT")
|
||||||
|
_ensure_column(cursor, "budgets", "created_at", "TEXT")
|
||||||
cursor.executemany(
|
cursor.executemany(
|
||||||
"""
|
"""
|
||||||
INSERT OR IGNORE INTO categories (name, category_type)
|
INSERT OR IGNORE INTO categories (name, category_type)
|
||||||
@@ -79,3 +113,14 @@ def init_database() -> None:
|
|||||||
DEFAULT_CATEGORIES,
|
DEFAULT_CATEGORIES,
|
||||||
)
|
)
|
||||||
connection.commit()
|
connection.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_column(cursor: sqlite3.Cursor, table: str, column: str, column_type: str) -> None:
|
||||||
|
"""Add a column when an old alpha database is opened."""
|
||||||
|
columns = [row[1] for row in cursor.execute(f"PRAGMA table_info({table})")]
|
||||||
|
if column not in columns:
|
||||||
|
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {column_type}")
|
||||||
|
cursor.execute(
|
||||||
|
f"UPDATE {table} SET {column} = CURRENT_TIMESTAMP "
|
||||||
|
f"WHERE {column} IS NULL"
|
||||||
|
)
|
||||||
|
|||||||
3
app/services/__init__.py
Normal file
3
app/services/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from app.services.finance_service import FinanceService
|
||||||
|
|
||||||
|
__all__ = ["FinanceService"]
|
||||||
433
app/services/finance_service.py
Normal file
433
app/services/finance_service.py
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
import csv
|
||||||
|
from pathlib import Path
|
||||||
|
from sqlite3 import IntegrityError, Row
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from app.database import get_connection
|
||||||
|
from app.utils.formatters import current_month_key, format_money
|
||||||
|
|
||||||
|
|
||||||
|
class FinanceService:
|
||||||
|
"""Business layer for Finance Control.
|
||||||
|
|
||||||
|
UI classes call this object instead of duplicating SQL logic. It makes the
|
||||||
|
program easier to test, maintain and explain from the OOP point of view.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_setting(self, key: str, default: str) -> str:
|
||||||
|
with get_connection() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT value FROM settings WHERE key = ?",
|
||||||
|
(key,),
|
||||||
|
).fetchone()
|
||||||
|
return default if row is None else row["value"]
|
||||||
|
|
||||||
|
def set_setting(self, key: str, value: str) -> None:
|
||||||
|
with get_connection() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO settings (key, value)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||||
|
""",
|
||||||
|
(key, value),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
def add_account(self, name: str, account_type: str, balance: float) -> None:
|
||||||
|
with get_connection() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO accounts (name, account_type, balance)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(name, account_type, balance),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
def delete_account(self, account_id: int) -> None:
|
||||||
|
with get_connection() as connection:
|
||||||
|
connection.execute("DELETE FROM accounts WHERE id = ?", (account_id,))
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
def list_accounts(self) -> list[Row]:
|
||||||
|
with get_connection() as connection:
|
||||||
|
return connection.execute(
|
||||||
|
"SELECT * FROM accounts ORDER BY id DESC"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
def list_account_options(self) -> list[Row]:
|
||||||
|
with get_connection() as connection:
|
||||||
|
return connection.execute(
|
||||||
|
"SELECT id, name, account_type, balance FROM accounts ORDER BY name"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
def add_category(self, name: str, category_type: str) -> None:
|
||||||
|
with get_connection() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO categories (name, category_type)
|
||||||
|
VALUES (?, ?)
|
||||||
|
""",
|
||||||
|
(name, category_type),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
def delete_category(self, category_id: int) -> None:
|
||||||
|
with get_connection() as connection:
|
||||||
|
connection.execute("DELETE FROM categories WHERE id = ?", (category_id,))
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
def list_categories(self, category_type: str | None = None) -> list[Row]:
|
||||||
|
with get_connection() as connection:
|
||||||
|
if category_type is None:
|
||||||
|
return connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM categories
|
||||||
|
ORDER BY category_type DESC, name
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM categories
|
||||||
|
WHERE category_type = ?
|
||||||
|
ORDER BY name
|
||||||
|
""",
|
||||||
|
(category_type,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
def add_transaction(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
amount: float,
|
||||||
|
transaction_type: str,
|
||||||
|
account_id: int,
|
||||||
|
category_id: int,
|
||||||
|
created_at: str,
|
||||||
|
) -> None:
|
||||||
|
sign = 1 if transaction_type == "Доход" else -1
|
||||||
|
delta = amount * sign
|
||||||
|
with get_connection() as connection:
|
||||||
|
connection.execute("BEGIN")
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO transactions (
|
||||||
|
title, amount, transaction_type,
|
||||||
|
account_id, category_id, created_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(title, amount, transaction_type, account_id, category_id, created_at),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE accounts SET balance = balance + ? WHERE id = ?",
|
||||||
|
(delta, account_id),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
def delete_transaction(self, transaction_id: int) -> None:
|
||||||
|
with get_connection() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT * FROM transactions WHERE id = ?",
|
||||||
|
(transaction_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return
|
||||||
|
sign = -1 if row["transaction_type"] == "Доход" else 1
|
||||||
|
delta = row["amount"] * sign
|
||||||
|
connection.execute("BEGIN")
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE accounts SET balance = balance + ? WHERE id = ?",
|
||||||
|
(delta, row["account_id"]),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"DELETE FROM transactions WHERE id = ?",
|
||||||
|
(transaction_id,),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
def list_transactions(
|
||||||
|
self,
|
||||||
|
search: str = "",
|
||||||
|
transaction_type: str = "Все",
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[Row]:
|
||||||
|
clauses = []
|
||||||
|
params: list[object] = []
|
||||||
|
if search:
|
||||||
|
clauses.append("t.title LIKE ?")
|
||||||
|
params.append(f"%{search}%")
|
||||||
|
if transaction_type != "Все":
|
||||||
|
clauses.append("t.transaction_type = ?")
|
||||||
|
params.append(transaction_type)
|
||||||
|
|
||||||
|
where_sql = ""
|
||||||
|
if clauses:
|
||||||
|
where_sql = "WHERE " + " AND ".join(clauses)
|
||||||
|
|
||||||
|
params.append(limit)
|
||||||
|
with get_connection() as connection:
|
||||||
|
return connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT t.id,
|
||||||
|
t.title,
|
||||||
|
t.amount,
|
||||||
|
t.transaction_type,
|
||||||
|
t.created_at,
|
||||||
|
a.name AS account_name,
|
||||||
|
c.name AS category_name
|
||||||
|
FROM transactions AS t
|
||||||
|
JOIN accounts AS a ON a.id = t.account_id
|
||||||
|
JOIN categories AS c ON c.id = t.category_id
|
||||||
|
{where_sql}
|
||||||
|
ORDER BY t.created_at DESC, t.id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
def add_subscription(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
amount: float,
|
||||||
|
billing_day: int,
|
||||||
|
period: str,
|
||||||
|
status: str,
|
||||||
|
) -> None:
|
||||||
|
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()
|
||||||
|
|
||||||
|
def delete_subscription(self, subscription_id: int) -> None:
|
||||||
|
with get_connection() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"DELETE FROM subscriptions WHERE id = ?",
|
||||||
|
(subscription_id,),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
def toggle_subscription_status(self, subscription_id: int) -> None:
|
||||||
|
with get_connection() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT status FROM subscriptions WHERE id = ?",
|
||||||
|
(subscription_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return
|
||||||
|
new_status = "Пауза" if row["status"] == "Активна" else "Активна"
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE subscriptions SET status = ? WHERE id = ?",
|
||||||
|
(new_status, subscription_id),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
def list_subscriptions(self) -> list[Row]:
|
||||||
|
with get_connection() as connection:
|
||||||
|
return connection.execute(
|
||||||
|
"SELECT * FROM subscriptions ORDER BY status, billing_day, name"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
def add_or_update_budget(self, category_id: int, limit_amount: float) -> None:
|
||||||
|
with get_connection() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO budgets (category_id, limit_amount)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT(category_id)
|
||||||
|
DO UPDATE SET limit_amount = excluded.limit_amount
|
||||||
|
""",
|
||||||
|
(category_id, limit_amount),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
def delete_budget(self, budget_id: int) -> None:
|
||||||
|
with get_connection() as connection:
|
||||||
|
connection.execute("DELETE FROM budgets WHERE id = ?", (budget_id,))
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
def list_budgets(self) -> list[Row]:
|
||||||
|
month_key = current_month_key()
|
||||||
|
with get_connection() as connection:
|
||||||
|
return connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT b.id,
|
||||||
|
b.limit_amount,
|
||||||
|
c.name AS category_name,
|
||||||
|
COALESCE(SUM(t.amount), 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 = 'Расход'
|
||||||
|
AND strftime('%Y-%m', t.created_at) = ?
|
||||||
|
GROUP BY b.id, b.limit_amount, c.name
|
||||||
|
ORDER BY c.name
|
||||||
|
""",
|
||||||
|
(month_key,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
def dashboard_summary(self) -> dict[str, object]:
|
||||||
|
month_key = current_month_key()
|
||||||
|
with get_connection() as connection:
|
||||||
|
balance = connection.execute(
|
||||||
|
"SELECT COALESCE(SUM(balance), 0) AS total FROM accounts"
|
||||||
|
).fetchone()["total"]
|
||||||
|
summary = connection.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) = ?
|
||||||
|
""",
|
||||||
|
(month_key,),
|
||||||
|
).fetchone()
|
||||||
|
subscriptions = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS count,
|
||||||
|
COALESCE(SUM(
|
||||||
|
CASE WHEN period = 'Год'
|
||||||
|
THEN amount / 12.0
|
||||||
|
ELSE amount
|
||||||
|
END
|
||||||
|
), 0) AS monthly_total
|
||||||
|
FROM subscriptions
|
||||||
|
WHERE status = 'Активна'
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
upcoming = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name, amount, billing_day, period
|
||||||
|
FROM subscriptions
|
||||||
|
WHERE status = 'Активна'
|
||||||
|
ORDER BY billing_day, name
|
||||||
|
LIMIT 8
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
recent = self.list_transactions(limit=8)
|
||||||
|
warnings = self.list_budget_warnings()
|
||||||
|
return {
|
||||||
|
"balance": balance,
|
||||||
|
"income": summary["income"],
|
||||||
|
"expense": summary["expense"],
|
||||||
|
"net": summary["income"] - summary["expense"],
|
||||||
|
"subscriptions_count": subscriptions["count"],
|
||||||
|
"subscriptions_total": subscriptions["monthly_total"],
|
||||||
|
"upcoming": upcoming,
|
||||||
|
"recent": recent,
|
||||||
|
"warnings": warnings,
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_budget_warnings(self) -> list[Row]:
|
||||||
|
rows = self.list_budgets()
|
||||||
|
return [row for row in rows if row["limit_amount"] and row["spent"] >= row["limit_amount"]]
|
||||||
|
|
||||||
|
def report_data(self) -> dict[str, object]:
|
||||||
|
month_key = current_month_key()
|
||||||
|
with get_connection() as connection:
|
||||||
|
summary = connection.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) = ?
|
||||||
|
""",
|
||||||
|
(month_key,),
|
||||||
|
).fetchone()
|
||||||
|
balance = connection.execute(
|
||||||
|
"SELECT COALESCE(SUM(balance), 0) AS total FROM accounts"
|
||||||
|
).fetchone()["total"]
|
||||||
|
subscription_total = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(SUM(
|
||||||
|
CASE WHEN period = 'Год'
|
||||||
|
THEN amount / 12.0
|
||||||
|
ELSE amount
|
||||||
|
END
|
||||||
|
), 0) AS total
|
||||||
|
FROM subscriptions
|
||||||
|
WHERE status = 'Активна'
|
||||||
|
"""
|
||||||
|
).fetchone()["total"]
|
||||||
|
categories = connection.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) = ?
|
||||||
|
GROUP BY c.name
|
||||||
|
HAVING total > 0
|
||||||
|
ORDER BY total DESC
|
||||||
|
""",
|
||||||
|
(month_key,),
|
||||||
|
).fetchall()
|
||||||
|
return {
|
||||||
|
"income": summary["income"],
|
||||||
|
"expense": summary["expense"],
|
||||||
|
"net": summary["income"] - summary["expense"],
|
||||||
|
"balance": balance,
|
||||||
|
"subscription_total": subscription_total,
|
||||||
|
"categories": categories,
|
||||||
|
}
|
||||||
|
|
||||||
|
def build_report_text(self) -> str:
|
||||||
|
data = self.report_data()
|
||||||
|
categories: Iterable[Row] = data["categories"]
|
||||||
|
top_category = "Нет данных"
|
||||||
|
top_category_sum = 0.0
|
||||||
|
category_lines = []
|
||||||
|
for row in categories:
|
||||||
|
if top_category == "Нет данных":
|
||||||
|
top_category = row["name"]
|
||||||
|
top_category_sum = row["total"]
|
||||||
|
category_lines.append(f"- {row['name']}: {format_money(row['total'])}")
|
||||||
|
|
||||||
|
details = "\n".join(category_lines) or "Нет расходов за текущий месяц."
|
||||||
|
return (
|
||||||
|
"ОТЧЁТ ЗА ТЕКУЩИЙ МЕСЯЦ\n\n"
|
||||||
|
f"Доходы: {format_money(data['income'])}\n"
|
||||||
|
f"Расходы: {format_money(data['expense'])}\n"
|
||||||
|
f"Финансовый результат: {format_money(data['net'])}\n"
|
||||||
|
f"Общий баланс по всем счетам: {format_money(data['balance'])}\n"
|
||||||
|
"Активные подписки в пересчёте на месяц: "
|
||||||
|
f"{format_money(data['subscription_total'])}\n"
|
||||||
|
"Самая затратная категория: "
|
||||||
|
f"{top_category} ({format_money(top_category_sum)})\n\n"
|
||||||
|
"Расходы по категориям:\n"
|
||||||
|
f"{details}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def export_transactions_csv(self, path: str | Path) -> None:
|
||||||
|
rows = self.list_transactions(limit=10_000)
|
||||||
|
with open(path, "w", encoding="utf-8-sig", newline="") as file:
|
||||||
|
writer = csv.writer(file, delimiter=";")
|
||||||
|
writer.writerow(
|
||||||
|
["Дата", "Название", "Тип", "Сумма", "Счёт", "Категория"]
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
writer.writerow(
|
||||||
|
[
|
||||||
|
row["created_at"],
|
||||||
|
row["title"],
|
||||||
|
row["transaction_type"],
|
||||||
|
row["amount"],
|
||||||
|
row["account_name"],
|
||||||
|
row["category_name"],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_integrity_error(error: Exception) -> bool:
|
||||||
|
return isinstance(error, IntegrityError)
|
||||||
@@ -3,161 +3,121 @@ from tkinter import messagebox
|
|||||||
|
|
||||||
import customtkinter as ctk
|
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_float
|
||||||
|
|
||||||
|
|
||||||
class AccountsFrame(ctk.CTkFrame):
|
class AccountsFrame(BaseAppFrame):
|
||||||
|
"""Screen for account management."""
|
||||||
|
|
||||||
def __init__(self, master, refresh_callback) -> None:
|
def __init__(self, master, refresh_callback) -> None:
|
||||||
super().__init__(master)
|
super().__init__(master, refresh_callback)
|
||||||
self.refresh_callback = refresh_callback
|
|
||||||
|
|
||||||
self.grid_columnconfigure(1, weight=1)
|
self.grid_columnconfigure(1, weight=1)
|
||||||
self.grid_rowconfigure(0, weight=1)
|
self.grid_rowconfigure(0, weight=1)
|
||||||
|
|
||||||
self._build_form()
|
self._build_form()
|
||||||
self._build_list()
|
self._build_list()
|
||||||
|
|
||||||
def _build_form(self) -> None:
|
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)
|
form_frame.grid(row=0, column=0, sticky="ns", padx=(8, 12), pady=8)
|
||||||
|
|
||||||
title = ctk.CTkLabel(
|
self.title_label(form_frame, "Счета и карты").pack(
|
||||||
form_frame,
|
anchor="w", padx=18, pady=(18, 4)
|
||||||
text="Счета и карты",
|
|
||||||
font=ctk.CTkFont(size=26, weight="bold"),
|
|
||||||
)
|
)
|
||||||
title.pack(anchor="w", padx=18, pady=(18, 6))
|
self.subtitle_label(
|
||||||
|
|
||||||
subtitle = ctk.CTkLabel(
|
|
||||||
form_frame,
|
form_frame,
|
||||||
text="Добавьте карту, кошелёк или наличные.",
|
"Добавьте карту, кошелёк, счёт или наличные.",
|
||||||
text_color="gray75",
|
).pack(anchor="w", padx=18, pady=(0, 18))
|
||||||
)
|
|
||||||
subtitle.pack(anchor="w", padx=18, pady=(0, 18))
|
|
||||||
|
|
||||||
self.name_entry = ctk.CTkEntry(
|
self.name_entry = ctk.CTkEntry(
|
||||||
form_frame,
|
form_frame,
|
||||||
width=280,
|
width=300,
|
||||||
placeholder_text="Название",
|
placeholder_text="Название счёта",
|
||||||
)
|
)
|
||||||
self.name_entry.pack(padx=18, pady=8)
|
self.name_entry.pack(padx=18, pady=8)
|
||||||
|
|
||||||
self.type_menu = ctk.CTkOptionMenu(
|
self.type_menu = ctk.CTkOptionMenu(
|
||||||
form_frame,
|
form_frame,
|
||||||
width=280,
|
width=300,
|
||||||
values=["Карта", "Наличные", "Счёт", "Электронный кошелёк"],
|
values=["Карта", "Наличные", "Счёт", "Электронный кошелёк"],
|
||||||
)
|
)
|
||||||
self.type_menu.pack(padx=18, pady=8)
|
|
||||||
self.type_menu.set("Карта")
|
self.type_menu.set("Карта")
|
||||||
|
self.type_menu.pack(padx=18, pady=8)
|
||||||
|
|
||||||
self.balance_entry = ctk.CTkEntry(
|
self.balance_entry = ctk.CTkEntry(
|
||||||
form_frame,
|
form_frame,
|
||||||
width=280,
|
width=300,
|
||||||
placeholder_text="Стартовый баланс",
|
placeholder_text="Стартовый баланс",
|
||||||
)
|
)
|
||||||
self.balance_entry.pack(padx=18, pady=8)
|
self.balance_entry.pack(padx=18, pady=8)
|
||||||
|
|
||||||
add_button = ctk.CTkButton(
|
self.add_button = self.primary_button(
|
||||||
form_frame,
|
form_frame,
|
||||||
text="Добавить счёт",
|
"Добавить счёт",
|
||||||
width=280,
|
lambda: self.run_safe(self.add_account, self.add_button),
|
||||||
height=42,
|
|
||||||
command=self.add_account,
|
|
||||||
)
|
)
|
||||||
add_button.pack(padx=18, pady=(12, 18))
|
self.add_button.pack(fill="x", padx=18, pady=(12, 18))
|
||||||
|
|
||||||
def _build_list(self) -> None:
|
def _build_list(self) -> None:
|
||||||
self.list_frame = ctk.CTkScrollableFrame(self, label_text="Список счетов")
|
self.list_frame = ctk.CTkScrollableFrame(self, label_text="Список счетов")
|
||||||
self.list_frame.grid(row=0, column=1, sticky="nsew", padx=(0, 8), pady=8)
|
self.list_frame.grid(row=0, column=1, sticky="nsew", padx=(0, 8), pady=8)
|
||||||
|
|
||||||
def add_account(self) -> None:
|
def add_account(self) -> None:
|
||||||
name = self.name_entry.get().strip()
|
name = normalize_text(self.name_entry.get(), "Название")
|
||||||
account_type = self.type_menu.get()
|
account_type = self.type_menu.get()
|
||||||
balance_text = self.balance_entry.get().strip().replace(",", ".")
|
balance = parse_float(self.balance_entry.get(), "Стартовый баланс")
|
||||||
|
self.service.add_account(name, account_type, balance)
|
||||||
if not name or not balance_text:
|
|
||||||
messagebox.showerror("Ошибка", "Заполните все поля.")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
balance = float(balance_text)
|
|
||||||
except ValueError:
|
|
||||||
messagebox.showerror("Ошибка", "Баланс должен быть числом.")
|
|
||||||
return
|
|
||||||
|
|
||||||
with get_connection() as connection:
|
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO accounts (name, account_type, balance)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
""",
|
|
||||||
(name, account_type, balance),
|
|
||||||
)
|
|
||||||
connection.commit()
|
|
||||||
|
|
||||||
self.name_entry.delete(0, "end")
|
self.name_entry.delete(0, "end")
|
||||||
self.balance_entry.delete(0, "end")
|
self.balance_entry.delete(0, "end")
|
||||||
self.refresh_callback()
|
self.refresh_callback()
|
||||||
|
|
||||||
def delete_account(self, account_id: int) -> None:
|
def delete_account(self, account_id: int) -> None:
|
||||||
|
if not messagebox.askyesno("Подтверждение", "Удалить выбранный счёт?"):
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
with get_connection() as connection:
|
self.service.delete_account(account_id)
|
||||||
connection.execute("DELETE FROM accounts WHERE id = ?", (account_id,))
|
|
||||||
connection.commit()
|
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
messagebox.showerror(
|
messagebox.showerror(
|
||||||
"Ошибка",
|
"Ошибка",
|
||||||
"Нельзя удалить счёт, пока к нему привязаны операции.",
|
"Нельзя удалить счёт, пока к нему привязаны операции.",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
self.refresh_callback()
|
self.refresh_callback()
|
||||||
|
|
||||||
def refresh_data(self) -> None:
|
def refresh_data(self) -> None:
|
||||||
for widget in self.list_frame.winfo_children():
|
self.clear(self.list_frame)
|
||||||
widget.destroy()
|
rows = self.service.list_accounts()
|
||||||
|
if not rows:
|
||||||
with get_connection() as connection:
|
self._empty("Пока нет ни одного счёта.")
|
||||||
accounts = connection.execute(
|
|
||||||
"SELECT * FROM accounts ORDER BY id DESC"
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
if not accounts:
|
|
||||||
empty_label = ctk.CTkLabel(
|
|
||||||
self.list_frame,
|
|
||||||
text="Пока нет ни одного счёта.",
|
|
||||||
text_color="gray70",
|
|
||||||
)
|
|
||||||
empty_label.pack(anchor="w", padx=10, pady=10)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
for account in accounts:
|
for row in rows:
|
||||||
card = ctk.CTkFrame(self.list_frame, corner_radius=16)
|
card = ctk.CTkFrame(self.list_frame, corner_radius=16)
|
||||||
card.pack(fill="x", padx=6, pady=6)
|
card.pack(fill="x", padx=6, pady=6)
|
||||||
|
card.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
title = ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
card,
|
card,
|
||||||
text=account["name"],
|
text=row["name"],
|
||||||
font=ctk.CTkFont(size=18, weight="bold"),
|
font=ctk.CTkFont(size=18, weight="bold"),
|
||||||
)
|
).grid(row=0, column=0, sticky="w", padx=16, pady=(12, 4))
|
||||||
title.grid(row=0, column=0, sticky="w", padx=16, pady=(12, 6))
|
|
||||||
|
|
||||||
delete_button = ctk.CTkButton(
|
button = self.danger_button(
|
||||||
card,
|
card,
|
||||||
text="Удалить",
|
"Удалить",
|
||||||
width=90,
|
lambda account_id=row["id"]: self.run_safe(
|
||||||
fg_color="#b33939",
|
lambda: self.delete_account(account_id)
|
||||||
hover_color="#932f2f",
|
),
|
||||||
command=lambda account_id=account["id"]: self.delete_account(account_id),
|
|
||||||
)
|
)
|
||||||
delete_button.grid(row=0, column=1, sticky="e", padx=16, pady=(12, 6))
|
button.grid(row=0, column=1, sticky="e", padx=16, pady=(12, 4))
|
||||||
|
|
||||||
info = ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
card,
|
card,
|
||||||
text=(
|
text=f"Тип: {row['account_type']} | Баланс: {self.money(row['balance'])}",
|
||||||
f"Тип: {account['account_type']} | Баланс: "
|
text_color="gray70",
|
||||||
f"{account['balance']:,.2f} ₽"
|
).grid(row=1, column=0, columnspan=2, sticky="w", padx=16, pady=(0, 14))
|
||||||
).replace(",", " "),
|
|
||||||
text_color="gray75",
|
def _empty(self, text: str) -> None:
|
||||||
)
|
ctk.CTkLabel(self.list_frame, text=text, text_color="gray70").pack(
|
||||||
info.grid(row=1, column=0, columnspan=2, sticky="w", padx=16, pady=(0, 14))
|
anchor="w", padx=10, pady=10
|
||||||
|
)
|
||||||
|
|||||||
111
app/ui/base_frame.py
Normal file
111
app/ui/base_frame.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
from collections.abc import Callable
|
||||||
|
from tkinter import messagebox
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
from app.config import (
|
||||||
|
DANGER_COLOR,
|
||||||
|
DANGER_HOVER_COLOR,
|
||||||
|
PRIMARY_COLOR,
|
||||||
|
PRIMARY_HOVER_COLOR,
|
||||||
|
SUCCESS_COLOR,
|
||||||
|
WARNING_COLOR,
|
||||||
|
)
|
||||||
|
from app.services import FinanceService
|
||||||
|
from app.utils.formatters import format_money
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAppFrame(ctk.CTkFrame):
|
||||||
|
"""Base class for application screens with shared UI helpers."""
|
||||||
|
|
||||||
|
def __init__(self, master, refresh_callback: Callable[[], None]) -> None:
|
||||||
|
super().__init__(master)
|
||||||
|
self.refresh_callback = refresh_callback
|
||||||
|
self.service = FinanceService()
|
||||||
|
self._busy = False
|
||||||
|
|
||||||
|
def run_safe(
|
||||||
|
self,
|
||||||
|
action: Callable[[], None],
|
||||||
|
button: ctk.CTkButton | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Run UI action with protection from repeated rapid clicks."""
|
||||||
|
if self._busy:
|
||||||
|
return
|
||||||
|
self._busy = True
|
||||||
|
if button is not None:
|
||||||
|
button.configure(state="disabled")
|
||||||
|
try:
|
||||||
|
action()
|
||||||
|
except ValueError as error:
|
||||||
|
messagebox.showerror("Ошибка ввода", str(error))
|
||||||
|
except Exception as error: # noqa: BLE001 - user-facing desktop app guard
|
||||||
|
messagebox.showerror("Ошибка", f"Действие не выполнено: {error}")
|
||||||
|
finally:
|
||||||
|
if button is not None:
|
||||||
|
self.after(350, lambda: button.configure(state="normal"))
|
||||||
|
self.after(350, self._unlock)
|
||||||
|
else:
|
||||||
|
self._unlock()
|
||||||
|
|
||||||
|
def _unlock(self) -> None:
|
||||||
|
self._busy = False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def clear(container: ctk.CTkBaseClass) -> None:
|
||||||
|
for widget in container.winfo_children():
|
||||||
|
widget.destroy()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def money(value: float | int | None) -> str:
|
||||||
|
return format_money(value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def title_label(master, text: str) -> ctk.CTkLabel:
|
||||||
|
return ctk.CTkLabel(
|
||||||
|
master,
|
||||||
|
text=text,
|
||||||
|
font=ctk.CTkFont(size=28, weight="bold"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def section_label(master, text: str) -> ctk.CTkLabel:
|
||||||
|
return ctk.CTkLabel(
|
||||||
|
master,
|
||||||
|
text=text,
|
||||||
|
font=ctk.CTkFont(size=18, weight="bold"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def subtitle_label(master, text: str) -> ctk.CTkLabel:
|
||||||
|
return ctk.CTkLabel(master, text=text, text_color="gray70")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def primary_button(master, text: str, command) -> ctk.CTkButton:
|
||||||
|
return ctk.CTkButton(
|
||||||
|
master,
|
||||||
|
text=text,
|
||||||
|
height=42,
|
||||||
|
fg_color=PRIMARY_COLOR,
|
||||||
|
hover_color=PRIMARY_HOVER_COLOR,
|
||||||
|
command=command,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def danger_button(master, text: str, command, width: int = 92) -> ctk.CTkButton:
|
||||||
|
return ctk.CTkButton(
|
||||||
|
master,
|
||||||
|
text=text,
|
||||||
|
width=width,
|
||||||
|
fg_color=DANGER_COLOR,
|
||||||
|
hover_color=DANGER_HOVER_COLOR,
|
||||||
|
command=command,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def status_color(value: float) -> str:
|
||||||
|
if value >= 1:
|
||||||
|
return DANGER_COLOR
|
||||||
|
if value >= 0.8:
|
||||||
|
return WARNING_COLOR
|
||||||
|
return SUCCESS_COLOR
|
||||||
@@ -1,63 +1,52 @@
|
|||||||
import sqlite3
|
|
||||||
from tkinter import messagebox
|
|
||||||
|
|
||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
|
|
||||||
from app.database import get_connection
|
from app.ui.base_frame import BaseAppFrame
|
||||||
|
from app.utils.validators import parse_positive_float
|
||||||
|
|
||||||
|
|
||||||
class BudgetsFrame(ctk.CTkFrame):
|
class BudgetsFrame(BaseAppFrame):
|
||||||
|
"""Screen for monthly category limits."""
|
||||||
|
|
||||||
def __init__(self, master, refresh_callback) -> None:
|
def __init__(self, master, refresh_callback) -> None:
|
||||||
super().__init__(master)
|
super().__init__(master, refresh_callback)
|
||||||
self.refresh_callback = refresh_callback
|
|
||||||
self.category_map: dict[str, int] = {}
|
self.category_map: dict[str, int] = {}
|
||||||
|
|
||||||
self.grid_columnconfigure(1, weight=1)
|
self.grid_columnconfigure(1, weight=1)
|
||||||
self.grid_rowconfigure(0, weight=1)
|
self.grid_rowconfigure(0, weight=1)
|
||||||
|
|
||||||
self._build_form()
|
self._build_form()
|
||||||
self._build_list()
|
self._build_list()
|
||||||
|
|
||||||
def _build_form(self) -> None:
|
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)
|
form_frame.grid(row=0, column=0, sticky="ns", padx=(8, 12), pady=8)
|
||||||
|
|
||||||
title = ctk.CTkLabel(
|
self.title_label(form_frame, "Бюджеты").pack(
|
||||||
form_frame,
|
anchor="w", padx=18, pady=(18, 4)
|
||||||
text="Бюджеты и лимиты",
|
|
||||||
font=ctk.CTkFont(size=26, weight="bold"),
|
|
||||||
)
|
)
|
||||||
title.pack(anchor="w", padx=18, pady=(18, 6))
|
self.subtitle_label(
|
||||||
|
|
||||||
subtitle = ctk.CTkLabel(
|
|
||||||
form_frame,
|
form_frame,
|
||||||
text="Ограничения по категориям на месяц.",
|
"Лимиты расходов по категориям на текущий месяц.",
|
||||||
text_color="gray75",
|
).pack(anchor="w", padx=18, pady=(0, 18))
|
||||||
)
|
|
||||||
subtitle.pack(anchor="w", padx=18, pady=(0, 18))
|
|
||||||
|
|
||||||
self.category_menu = ctk.CTkOptionMenu(
|
self.category_menu = ctk.CTkOptionMenu(
|
||||||
form_frame,
|
form_frame,
|
||||||
width=300,
|
width=320,
|
||||||
values=["Нет категорий"],
|
values=["Нет категорий"],
|
||||||
)
|
)
|
||||||
self.category_menu.pack(padx=18, pady=8)
|
self.category_menu.pack(padx=18, pady=8)
|
||||||
|
|
||||||
self.limit_entry = ctk.CTkEntry(
|
self.limit_entry = ctk.CTkEntry(
|
||||||
form_frame,
|
form_frame,
|
||||||
width=300,
|
width=320,
|
||||||
placeholder_text="Лимит",
|
placeholder_text="Лимит",
|
||||||
)
|
)
|
||||||
self.limit_entry.pack(padx=18, pady=8)
|
self.limit_entry.pack(padx=18, pady=8)
|
||||||
|
|
||||||
add_button = ctk.CTkButton(
|
self.add_button = self.primary_button(
|
||||||
form_frame,
|
form_frame,
|
||||||
text="Добавить лимит",
|
"Добавить / обновить лимит",
|
||||||
width=300,
|
lambda: self.run_safe(self.add_budget, self.add_button),
|
||||||
height=42,
|
|
||||||
command=self.add_budget,
|
|
||||||
)
|
)
|
||||||
add_button.pack(padx=18, pady=(12, 18))
|
self.add_button.pack(fill="x", padx=18, pady=(12, 18))
|
||||||
|
|
||||||
def _build_list(self) -> None:
|
def _build_list(self) -> None:
|
||||||
self.list_frame = ctk.CTkScrollableFrame(
|
self.list_frame = ctk.CTkScrollableFrame(
|
||||||
@@ -67,171 +56,92 @@ class BudgetsFrame(ctk.CTkFrame):
|
|||||||
self.list_frame.grid(row=0, column=1, sticky="nsew", padx=(0, 8), pady=8)
|
self.list_frame.grid(row=0, column=1, sticky="nsew", padx=(0, 8), pady=8)
|
||||||
|
|
||||||
def _load_categories(self) -> None:
|
def _load_categories(self) -> None:
|
||||||
with get_connection() as connection:
|
rows = self.service.list_categories("Расход")
|
||||||
rows = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, name
|
|
||||||
FROM categories
|
|
||||||
WHERE category_type = 'Расход'
|
|
||||||
ORDER BY name
|
|
||||||
"""
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
self.category_map = {}
|
self.category_map = {}
|
||||||
self.category_menu.configure(values=["Нет категорий"])
|
self.category_menu.configure(values=["Нет категорий"])
|
||||||
self.category_menu.set("Нет категорий")
|
self.category_menu.set("Нет категорий")
|
||||||
return
|
return
|
||||||
|
|
||||||
values = []
|
values = []
|
||||||
self.category_map = {}
|
self.category_map = {}
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
label = f"{row['name']} (ID {row['id']})"
|
label = row["name"]
|
||||||
values.append(label)
|
values.append(label)
|
||||||
self.category_map[label] = row["id"]
|
self.category_map[label] = row["id"]
|
||||||
|
|
||||||
self.category_menu.configure(values=values)
|
self.category_menu.configure(values=values)
|
||||||
self.category_menu.set(values[0])
|
if self.category_menu.get() not in values:
|
||||||
|
self.category_menu.set(values[0])
|
||||||
|
|
||||||
def add_budget(self) -> None:
|
def add_budget(self) -> None:
|
||||||
category_label = self.category_menu.get()
|
category_label = self.category_menu.get()
|
||||||
limit_text = self.limit_entry.get().strip().replace(",", ".")
|
if category_label not in self.category_map:
|
||||||
|
raise ValueError("Выберите расходную категорию.")
|
||||||
if category_label not in self.category_map or not limit_text:
|
limit_amount = parse_positive_float(self.limit_entry.get(), "Лимит")
|
||||||
messagebox.showerror("Ошибка", "Выберите категорию и введите лимит.")
|
self.service.add_or_update_budget(
|
||||||
return
|
self.category_map[category_label],
|
||||||
|
limit_amount,
|
||||||
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.limit_entry.delete(0, "end")
|
||||||
self.refresh_callback()
|
self.refresh_callback()
|
||||||
|
|
||||||
def delete_budget(self, budget_id: int) -> None:
|
def delete_budget(self, budget_id: int) -> None:
|
||||||
with get_connection() as connection:
|
self.service.delete_budget(budget_id)
|
||||||
connection.execute(
|
|
||||||
"DELETE FROM budgets WHERE id = ?",
|
|
||||||
(budget_id,),
|
|
||||||
)
|
|
||||||
connection.commit()
|
|
||||||
|
|
||||||
self.refresh_callback()
|
self.refresh_callback()
|
||||||
|
|
||||||
def refresh_data(self) -> None:
|
def refresh_data(self) -> None:
|
||||||
self._load_categories()
|
self._load_categories()
|
||||||
|
self.clear(self.list_frame)
|
||||||
for widget in self.list_frame.winfo_children():
|
rows = self.service.list_budgets()
|
||||||
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:
|
if not rows:
|
||||||
empty_label = ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
self.list_frame,
|
self.list_frame,
|
||||||
text="Лимитов пока нет.",
|
text="Лимитов пока нет.",
|
||||||
text_color="gray70",
|
text_color="gray70",
|
||||||
)
|
).pack(anchor="w", padx=10, pady=10)
|
||||||
empty_label.pack(anchor="w", padx=10, pady=10)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
card = ctk.CTkFrame(self.list_frame, corner_radius=16)
|
self._render_budget(row)
|
||||||
card.pack(fill="x", padx=6, pady=6)
|
|
||||||
|
|
||||||
title = ctk.CTkLabel(
|
def _render_budget(self, row) -> None:
|
||||||
card,
|
card = ctk.CTkFrame(self.list_frame, corner_radius=16)
|
||||||
text=row["category_name"],
|
card.pack(fill="x", padx=6, pady=6)
|
||||||
font=ctk.CTkFont(size=18, weight="bold"),
|
card.grid_columnconfigure(0, weight=1)
|
||||||
)
|
|
||||||
title.grid(row=0, column=0, sticky="w", padx=16, pady=(12, 4))
|
|
||||||
|
|
||||||
delete_button = ctk.CTkButton(
|
ctk.CTkLabel(
|
||||||
card,
|
card,
|
||||||
text="Удалить",
|
text=row["category_name"],
|
||||||
width=90,
|
font=ctk.CTkFont(size=18, weight="bold"),
|
||||||
fg_color="#b33939",
|
).grid(row=0, column=0, sticky="w", padx=16, pady=(12, 4))
|
||||||
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"]
|
self.danger_button(
|
||||||
percent = max(0, percent)
|
card,
|
||||||
|
"Удалить",
|
||||||
|
lambda budget_id=row["id"]: self.run_safe(
|
||||||
|
lambda: self.delete_budget(budget_id)
|
||||||
|
),
|
||||||
|
).grid(row=0, column=1, sticky="e", padx=16, pady=(12, 4))
|
||||||
|
|
||||||
info = ctk.CTkLabel(
|
percent = 0 if row["limit_amount"] == 0 else row["spent"] / row["limit_amount"]
|
||||||
card,
|
percent = max(0, percent)
|
||||||
text=(
|
status_color = self.status_color(percent)
|
||||||
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)
|
ctk.CTkLabel(
|
||||||
progress.grid(row=2, column=0, columnspan=2, sticky="w", padx=16, pady=(0, 8))
|
card,
|
||||||
progress.set(min(percent, 1))
|
text=f"Потрачено: {self.money(row['spent'])} из {self.money(row['limit_amount'])}",
|
||||||
|
text_color="gray70",
|
||||||
|
).grid(row=1, column=0, columnspan=2, sticky="w", padx=16, pady=(0, 8))
|
||||||
|
|
||||||
status_text = (
|
progress = ctk.CTkProgressBar(card, width=460, progress_color=status_color)
|
||||||
"Лимит превышен"
|
progress.grid(row=2, column=0, columnspan=2, sticky="w", padx=16, pady=(0, 8))
|
||||||
if percent > 1
|
progress.set(min(percent, 1))
|
||||||
else f"Использовано: {percent * 100:.0f}%"
|
|
||||||
)
|
|
||||||
|
|
||||||
status = ctk.CTkLabel(
|
status_text = "Лимит превышен" if percent > 1 else f"Использовано: {percent * 100:.0f}%"
|
||||||
card,
|
ctk.CTkLabel(card, text=status_text, text_color=status_color).grid(
|
||||||
text=status_text,
|
row=3,
|
||||||
text_color="gray75",
|
column=0,
|
||||||
)
|
columnspan=2,
|
||||||
status.grid(row=3, column=0, columnspan=2, sticky="w", padx=16, pady=(0, 14))
|
sticky="w",
|
||||||
|
padx=16,
|
||||||
|
pady=(0, 14),
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,155 +3,114 @@ from tkinter import messagebox
|
|||||||
|
|
||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
|
|
||||||
from app.database import get_connection
|
from app.ui.base_frame import BaseAppFrame
|
||||||
|
from app.utils.validators import normalize_text
|
||||||
|
|
||||||
|
|
||||||
class CategoriesFrame(ctk.CTkFrame):
|
class CategoriesFrame(BaseAppFrame):
|
||||||
|
"""Screen for income and expense categories."""
|
||||||
|
|
||||||
def __init__(self, master, refresh_callback) -> None:
|
def __init__(self, master, refresh_callback) -> None:
|
||||||
super().__init__(master)
|
super().__init__(master, refresh_callback)
|
||||||
self.refresh_callback = refresh_callback
|
|
||||||
|
|
||||||
self.grid_columnconfigure(1, weight=1)
|
self.grid_columnconfigure(1, weight=1)
|
||||||
self.grid_rowconfigure(0, weight=1)
|
self.grid_rowconfigure(0, weight=1)
|
||||||
|
|
||||||
self._build_form()
|
self._build_form()
|
||||||
self._build_list()
|
self._build_list()
|
||||||
|
|
||||||
def _build_form(self) -> None:
|
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)
|
form_frame.grid(row=0, column=0, sticky="ns", padx=(8, 12), pady=8)
|
||||||
|
|
||||||
title = ctk.CTkLabel(
|
self.title_label(form_frame, "Категории").pack(
|
||||||
form_frame,
|
anchor="w", padx=18, pady=(18, 4)
|
||||||
text="Категории",
|
|
||||||
font=ctk.CTkFont(size=26, weight="bold"),
|
|
||||||
)
|
)
|
||||||
title.pack(anchor="w", padx=18, pady=(18, 6))
|
self.subtitle_label(
|
||||||
|
|
||||||
subtitle = ctk.CTkLabel(
|
|
||||||
form_frame,
|
form_frame,
|
||||||
text="Создавайте свои категории доходов и расходов.",
|
"Группируйте доходы и расходы по смыслу.",
|
||||||
text_color="gray75",
|
).pack(anchor="w", padx=18, pady=(0, 18))
|
||||||
)
|
|
||||||
subtitle.pack(anchor="w", padx=18, pady=(0, 18))
|
|
||||||
|
|
||||||
self.name_entry = ctk.CTkEntry(
|
self.name_entry = ctk.CTkEntry(
|
||||||
form_frame,
|
form_frame,
|
||||||
width=280,
|
width=300,
|
||||||
placeholder_text="Название категории",
|
placeholder_text="Название категории",
|
||||||
)
|
)
|
||||||
self.name_entry.pack(padx=18, pady=8)
|
self.name_entry.pack(padx=18, pady=8)
|
||||||
|
|
||||||
self.type_menu = ctk.CTkOptionMenu(
|
self.type_menu = ctk.CTkOptionMenu(
|
||||||
form_frame,
|
form_frame,
|
||||||
width=280,
|
width=300,
|
||||||
values=["Расход", "Доход"],
|
values=["Расход", "Доход"],
|
||||||
)
|
)
|
||||||
self.type_menu.set("Расход")
|
self.type_menu.set("Расход")
|
||||||
self.type_menu.pack(padx=18, pady=8)
|
self.type_menu.pack(padx=18, pady=8)
|
||||||
|
|
||||||
add_button = ctk.CTkButton(
|
self.add_button = self.primary_button(
|
||||||
form_frame,
|
form_frame,
|
||||||
text="Добавить категорию",
|
"Добавить категорию",
|
||||||
width=280,
|
lambda: self.run_safe(self.add_category, self.add_button),
|
||||||
height=42,
|
|
||||||
command=self.add_category,
|
|
||||||
)
|
)
|
||||||
add_button.pack(padx=18, pady=(12, 18))
|
self.add_button.pack(fill="x", padx=18, pady=(12, 18))
|
||||||
|
|
||||||
def _build_list(self) -> None:
|
def _build_list(self) -> None:
|
||||||
self.list_frame = ctk.CTkScrollableFrame(self, label_text="Список категорий")
|
self.list_frame = ctk.CTkScrollableFrame(self, label_text="Список категорий")
|
||||||
self.list_frame.grid(row=0, column=1, sticky="nsew", padx=(0, 8), pady=8)
|
self.list_frame.grid(row=0, column=1, sticky="nsew", padx=(0, 8), pady=8)
|
||||||
|
|
||||||
def add_category(self) -> None:
|
def add_category(self) -> None:
|
||||||
name = self.name_entry.get().strip()
|
name = normalize_text(self.name_entry.get(), "Название")
|
||||||
category_type = self.type_menu.get()
|
category_type = self.type_menu.get()
|
||||||
|
|
||||||
if not name:
|
|
||||||
messagebox.showerror("Ошибка", "Введите название категории.")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with get_connection() as connection:
|
self.service.add_category(name, category_type)
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO categories (name, category_type)
|
|
||||||
VALUES (?, ?)
|
|
||||||
""",
|
|
||||||
(name, category_type),
|
|
||||||
)
|
|
||||||
connection.commit()
|
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
messagebox.showerror(
|
messagebox.showerror("Ошибка", "Такая категория уже существует.")
|
||||||
"Ошибка",
|
|
||||||
"Такая категория уже существует.",
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
self.name_entry.delete(0, "end")
|
self.name_entry.delete(0, "end")
|
||||||
self.refresh_callback()
|
self.refresh_callback()
|
||||||
|
|
||||||
def delete_category(self, category_id: int) -> None:
|
def delete_category(self, category_id: int) -> None:
|
||||||
|
if not messagebox.askyesno("Подтверждение", "Удалить выбранную категорию?"):
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
with get_connection() as connection:
|
self.service.delete_category(category_id)
|
||||||
connection.execute(
|
|
||||||
"DELETE FROM categories WHERE id = ?",
|
|
||||||
(category_id,),
|
|
||||||
)
|
|
||||||
connection.commit()
|
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
messagebox.showerror(
|
messagebox.showerror(
|
||||||
"Ошибка",
|
"Ошибка",
|
||||||
"Нельзя удалить категорию, пока она используется в операциях или бюджете.",
|
"Категория используется в операциях или бюджетах.",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
self.refresh_callback()
|
self.refresh_callback()
|
||||||
|
|
||||||
def refresh_data(self) -> None:
|
def refresh_data(self) -> None:
|
||||||
for widget in self.list_frame.winfo_children():
|
self.clear(self.list_frame)
|
||||||
widget.destroy()
|
rows = self.service.list_categories()
|
||||||
|
if not rows:
|
||||||
with get_connection() as connection:
|
ctk.CTkLabel(
|
||||||
categories = connection.execute(
|
|
||||||
"SELECT * FROM categories ORDER BY category_type, name"
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
if not categories:
|
|
||||||
empty_label = ctk.CTkLabel(
|
|
||||||
self.list_frame,
|
self.list_frame,
|
||||||
text="Категорий пока нет.",
|
text="Категорий пока нет.",
|
||||||
text_color="gray70",
|
text_color="gray70",
|
||||||
)
|
).pack(anchor="w", padx=10, pady=10)
|
||||||
empty_label.pack(anchor="w", padx=10, pady=10)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
for category in categories:
|
for row in rows:
|
||||||
card = ctk.CTkFrame(self.list_frame, corner_radius=16)
|
card = ctk.CTkFrame(self.list_frame, corner_radius=16)
|
||||||
card.pack(fill="x", padx=6, pady=6)
|
card.pack(fill="x", padx=6, pady=6)
|
||||||
|
card.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
title = ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
card,
|
card,
|
||||||
text=category["name"],
|
text=row["name"],
|
||||||
font=ctk.CTkFont(size=18, weight="bold"),
|
font=ctk.CTkFont(size=18, weight="bold"),
|
||||||
)
|
).grid(row=0, column=0, sticky="w", padx=16, pady=(12, 4))
|
||||||
title.grid(row=0, column=0, sticky="w", padx=16, pady=(12, 6))
|
|
||||||
|
|
||||||
delete_button = ctk.CTkButton(
|
self.danger_button(
|
||||||
card,
|
card,
|
||||||
text="Удалить",
|
"Удалить",
|
||||||
width=90,
|
lambda category_id=row["id"]: self.run_safe(
|
||||||
fg_color="#b33939",
|
lambda: self.delete_category(category_id)
|
||||||
hover_color="#932f2f",
|
|
||||||
command=lambda category_id=category["id"]: self.delete_category(
|
|
||||||
category_id
|
|
||||||
),
|
),
|
||||||
)
|
).grid(row=0, column=1, sticky="e", padx=16, pady=(12, 4))
|
||||||
delete_button.grid(row=0, column=1, sticky="e", padx=16, pady=(12, 6))
|
|
||||||
|
|
||||||
info = ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
card,
|
card,
|
||||||
text=f"Тип: {category['category_type']}",
|
text=f"Тип: {row['category_type']}",
|
||||||
text_color="gray75",
|
text_color="gray70",
|
||||||
)
|
).grid(row=1, column=0, columnspan=2, sticky="w", padx=16, pady=(0, 14))
|
||||||
info.grid(row=1, column=0, columnspan=2, sticky="w", padx=16, pady=(0, 14))
|
|
||||||
|
|||||||
@@ -3,158 +3,153 @@ from datetime import date
|
|||||||
|
|
||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
|
|
||||||
from app.database import get_connection
|
from app.ui.base_frame import BaseAppFrame
|
||||||
|
|
||||||
|
|
||||||
class DashboardFrame(ctk.CTkFrame):
|
class DashboardFrame(BaseAppFrame):
|
||||||
|
"""Main summary page."""
|
||||||
|
|
||||||
def __init__(self, master, refresh_callback) -> None:
|
def __init__(self, master, refresh_callback) -> None:
|
||||||
super().__init__(master)
|
super().__init__(master, refresh_callback)
|
||||||
self.refresh_callback = 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()
|
||||||
|
|
||||||
self.grid_columnconfigure((0, 1), weight=1)
|
def _build_ui(self) -> None:
|
||||||
self.grid_rowconfigure(2, weight=1)
|
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)
|
||||||
|
|
||||||
header = ctk.CTkLabel(
|
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,
|
self,
|
||||||
text="Главная панель",
|
label_text="Ближайшие списания",
|
||||||
font=ctk.CTkFont(size=28, weight="bold"),
|
|
||||||
)
|
)
|
||||||
header.grid(row=0, column=0, columnspan=2, sticky="w", padx=8, pady=(8, 16))
|
self.upcoming_frame.grid(row=3, column=0, sticky="nsew", padx=8, pady=8)
|
||||||
|
|
||||||
self.balance_card = self._create_card(1, 0, "Общий баланс")
|
self.recent_frame = ctk.CTkScrollableFrame(
|
||||||
self.month_expense_card = self._create_card(1, 1, "Расходы за месяц")
|
self,
|
||||||
self.month_income_card = self._create_card(2, 0, "Доходы за месяц")
|
label_text="Последние операции",
|
||||||
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),
|
|
||||||
)
|
)
|
||||||
|
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:
|
def _create_card(self, row: int, column: int, title: str) -> ctk.CTkLabel:
|
||||||
card = ctk.CTkFrame(self, corner_radius=18)
|
card = ctk.CTkFrame(self, corner_radius=20)
|
||||||
card.grid(row=row, column=column, sticky="nsew", padx=8, pady=8)
|
card.grid(row=row, column=column, sticky="nsew", padx=8, pady=8)
|
||||||
|
card.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
title_label = ctk.CTkLabel(
|
ctk.CTkLabel(card, text=title, text_color="gray70").grid(
|
||||||
card,
|
row=0,
|
||||||
text=title,
|
column=0,
|
||||||
text_color="gray75",
|
sticky="w",
|
||||||
font=ctk.CTkFont(size=15),
|
padx=18,
|
||||||
|
pady=(16, 4),
|
||||||
)
|
)
|
||||||
title_label.pack(anchor="w", padx=18, pady=(16, 8))
|
|
||||||
|
|
||||||
value_label = ctk.CTkLabel(
|
value_label = ctk.CTkLabel(
|
||||||
card,
|
card,
|
||||||
text="0 ₽",
|
text="0 ₽",
|
||||||
font=ctk.CTkFont(size=30, weight="bold"),
|
font=ctk.CTkFont(size=28, weight="bold"),
|
||||||
)
|
)
|
||||||
value_label.pack(anchor="w", padx=18, pady=(0, 16))
|
value_label.grid(row=1, column=0, sticky="w", padx=18, pady=(0, 16))
|
||||||
return value_label
|
return value_label
|
||||||
|
|
||||||
def refresh_data(self) -> None:
|
def refresh_data(self) -> None:
|
||||||
with get_connection() as connection:
|
data = self.service.dashboard_summary()
|
||||||
cursor = connection.cursor()
|
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'])} категорий")
|
||||||
|
|
||||||
balance = cursor.execute(
|
self._render_upcoming(data["upcoming"])
|
||||||
"SELECT COALESCE(SUM(balance), 0) AS total FROM accounts"
|
self._render_recent(data["recent"])
|
||||||
).fetchone()["total"]
|
self._render_warnings(data["warnings"])
|
||||||
|
|
||||||
month_expense = cursor.execute(
|
def _render_upcoming(self, rows) -> None:
|
||||||
"""
|
self.clear(self.upcoming_frame)
|
||||||
SELECT COALESCE(SUM(amount), 0) AS total
|
if not rows:
|
||||||
FROM transactions
|
self._empty(self.upcoming_frame, "Нет активных подписок.")
|
||||||
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
|
return
|
||||||
|
for row in rows:
|
||||||
for item in items:
|
days_left, next_date_text = self._get_next_payment_info(row["billing_day"])
|
||||||
days_left, next_date_text = self._get_next_payment_info(item["billing_day"])
|
text = (
|
||||||
card = ctk.CTkFrame(self.upcoming_frame, corner_radius=14)
|
f"{self.money(row['amount'])} | {row['period']} | "
|
||||||
card.pack(fill="x", padx=6, pady=6)
|
f"{next_date_text} | через {days_left} дн."
|
||||||
|
|
||||||
title = ctk.CTkLabel(
|
|
||||||
card,
|
|
||||||
text=item["name"],
|
|
||||||
font=ctk.CTkFont(size=18, weight="bold"),
|
|
||||||
)
|
)
|
||||||
title.pack(anchor="w", padx=14, pady=(12, 4))
|
self._small_card(self.upcoming_frame, row["name"], text)
|
||||||
|
|
||||||
info = ctk.CTkLabel(
|
def _render_recent(self, rows) -> None:
|
||||||
card,
|
self.clear(self.recent_frame)
|
||||||
text=(
|
if not rows:
|
||||||
f"{item['amount']:,.2f} ₽ | {item['period']} | "
|
self._empty(self.recent_frame, "Операций пока нет.")
|
||||||
f"следующее списание: {next_date_text} | через {days_left} дн."
|
return
|
||||||
).replace(",", " "),
|
for row in rows:
|
||||||
text_color="gray75",
|
sign = "+" if row["transaction_type"] == "Доход" else "-"
|
||||||
|
text = (
|
||||||
|
f"{row['created_at'][:10]} | {row['account_name']} | "
|
||||||
|
f"{row['category_name']} | {sign}{self.money(row['amount'])}"
|
||||||
)
|
)
|
||||||
info.pack(anchor="w", padx=14, pady=(0, 12))
|
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
|
@staticmethod
|
||||||
def _get_next_payment_info(billing_day: int) -> tuple[int, str]:
|
def _get_next_payment_info(billing_day: int) -> tuple[int, str]:
|
||||||
@@ -173,5 +168,4 @@ class DashboardFrame(ctk.CTkFrame):
|
|||||||
safe_day = min(billing_day, next_last_day)
|
safe_day = min(billing_day, next_last_day)
|
||||||
candidate = date(next_year, next_month, safe_day)
|
candidate = date(next_year, next_month, safe_day)
|
||||||
|
|
||||||
days_left = (candidate - today).days
|
return (candidate - today).days, candidate.strftime("%d.%m.%Y")
|
||||||
return days_left, candidate.strftime("%d.%m.%Y")
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
|
|
||||||
from app.config import APP_TITLE, MIN_HEIGHT, MIN_WIDTH, WINDOW_SIZE
|
from app.config import APP_SUBTITLE, APP_TITLE, MIN_HEIGHT, MIN_WIDTH, WINDOW_SIZE
|
||||||
|
from app.services import FinanceService
|
||||||
from app.ui.accounts_frame import AccountsFrame
|
from app.ui.accounts_frame import AccountsFrame
|
||||||
from app.ui.budgets_frame import BudgetsFrame
|
from app.ui.budgets_frame import BudgetsFrame
|
||||||
from app.ui.categories_frame import CategoriesFrame
|
from app.ui.categories_frame import CategoriesFrame
|
||||||
@@ -11,10 +12,16 @@ from app.ui.transactions_frame import TransactionsFrame
|
|||||||
|
|
||||||
|
|
||||||
class FinanceApp(ctk.CTk):
|
class FinanceApp(ctk.CTk):
|
||||||
|
"""Main application window and screen coordinator."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
self.service = FinanceService()
|
||||||
|
self.frames: dict[str, ctk.CTkFrame] = {}
|
||||||
|
self.nav_buttons: dict[str, ctk.CTkButton] = {}
|
||||||
|
|
||||||
ctk.set_appearance_mode("dark")
|
saved_theme = self.service.get_setting("theme", "Тёмная")
|
||||||
|
ctk.set_appearance_mode(self._theme_to_mode(saved_theme))
|
||||||
ctk.set_default_color_theme("blue")
|
ctk.set_default_color_theme("blue")
|
||||||
|
|
||||||
self.title(APP_TITLE)
|
self.title(APP_TITLE)
|
||||||
@@ -24,34 +31,33 @@ class FinanceApp(ctk.CTk):
|
|||||||
self.grid_columnconfigure(1, weight=1)
|
self.grid_columnconfigure(1, weight=1)
|
||||||
self.grid_rowconfigure(0, weight=1)
|
self.grid_rowconfigure(0, weight=1)
|
||||||
|
|
||||||
self.sidebar = ctk.CTkFrame(self, width=240, corner_radius=0)
|
self.sidebar = ctk.CTkFrame(self, width=260, corner_radius=0)
|
||||||
self.sidebar.grid(row=0, column=0, sticky="nsew")
|
self.sidebar.grid(row=0, column=0, sticky="nsew")
|
||||||
self.sidebar.grid_rowconfigure(9, weight=1)
|
self.sidebar.grid_rowconfigure(12, weight=1)
|
||||||
|
|
||||||
self.content = ctk.CTkFrame(self, fg_color="transparent")
|
self.content = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
self.content.grid(row=0, column=1, sticky="nsew", padx=16, pady=16)
|
self.content.grid(row=0, column=1, sticky="nsew", padx=18, pady=18)
|
||||||
self.content.grid_rowconfigure(0, weight=1)
|
self.content.grid_rowconfigure(0, weight=1)
|
||||||
self.content.grid_columnconfigure(0, weight=1)
|
self.content.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
self._build_sidebar()
|
self._build_sidebar(saved_theme)
|
||||||
self._build_frames()
|
self._build_frames()
|
||||||
self.show_frame("Главная")
|
self.show_frame("Главная")
|
||||||
self.refresh_all()
|
self.refresh_all()
|
||||||
|
|
||||||
def _build_sidebar(self) -> None:
|
def _build_sidebar(self, saved_theme: str) -> None:
|
||||||
title_label = ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
self.sidebar,
|
self.sidebar,
|
||||||
text="Finance Control",
|
text="Finance Control",
|
||||||
font=ctk.CTkFont(size=24, weight="bold"),
|
font=ctk.CTkFont(size=26, weight="bold"),
|
||||||
)
|
).grid(row=0, column=0, padx=20, pady=(24, 4), sticky="w")
|
||||||
title_label.grid(row=0, column=0, padx=20, pady=(24, 8), sticky="w")
|
|
||||||
|
|
||||||
subtitle_label = ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
self.sidebar,
|
self.sidebar,
|
||||||
text="курсовой проект",
|
text=APP_SUBTITLE,
|
||||||
text_color="gray70",
|
text_color="gray70",
|
||||||
)
|
wraplength=210,
|
||||||
subtitle_label.grid(row=1, column=0, padx=20, pady=(0, 16), sticky="w")
|
).grid(row=1, column=0, padx=20, pady=(0, 18), sticky="w")
|
||||||
|
|
||||||
pages = [
|
pages = [
|
||||||
"Главная",
|
"Главная",
|
||||||
@@ -62,31 +68,30 @@ class FinanceApp(ctk.CTk):
|
|||||||
"Бюджеты",
|
"Бюджеты",
|
||||||
"Отчёты",
|
"Отчёты",
|
||||||
]
|
]
|
||||||
|
|
||||||
for index, page_name in enumerate(pages, start=2):
|
for index, page_name in enumerate(pages, start=2):
|
||||||
button = ctk.CTkButton(
|
button = ctk.CTkButton(
|
||||||
self.sidebar,
|
self.sidebar,
|
||||||
text=page_name,
|
text=page_name,
|
||||||
height=42,
|
height=44,
|
||||||
anchor="w",
|
anchor="w",
|
||||||
command=lambda name=page_name: self.show_frame(name),
|
command=lambda name=page_name: self.show_frame(name),
|
||||||
)
|
)
|
||||||
button.grid(row=index, column=0, padx=20, pady=6, sticky="ew")
|
button.grid(row=index, column=0, padx=20, pady=5, sticky="ew")
|
||||||
|
self.nav_buttons[page_name] = button
|
||||||
|
|
||||||
theme_label = ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
self.sidebar,
|
self.sidebar,
|
||||||
text="Тема",
|
text="Тема интерфейса",
|
||||||
text_color="gray70",
|
text_color="gray70",
|
||||||
)
|
).grid(row=13, column=0, padx=20, pady=(12, 6), sticky="w")
|
||||||
theme_label.grid(row=10, column=0, padx=20, pady=(12, 6), sticky="w")
|
|
||||||
|
|
||||||
self.theme_menu = ctk.CTkOptionMenu(
|
self.theme_menu = ctk.CTkOptionMenu(
|
||||||
self.sidebar,
|
self.sidebar,
|
||||||
values=["Тёмная", "Светлая", "Системная"],
|
values=["Тёмная", "Светлая", "Системная"],
|
||||||
command=self.change_theme,
|
command=self.change_theme,
|
||||||
)
|
)
|
||||||
self.theme_menu.set("Тёмная")
|
self.theme_menu.set(saved_theme)
|
||||||
self.theme_menu.grid(row=11, column=0, padx=20, pady=(0, 20), sticky="ew")
|
self.theme_menu.grid(row=14, column=0, padx=20, pady=(0, 20), sticky="ew")
|
||||||
|
|
||||||
def _build_frames(self) -> None:
|
def _build_frames(self) -> None:
|
||||||
self.frames = {
|
self.frames = {
|
||||||
@@ -98,7 +103,6 @@ class FinanceApp(ctk.CTk):
|
|||||||
"Бюджеты": BudgetsFrame(self.content, self.refresh_all),
|
"Бюджеты": BudgetsFrame(self.content, self.refresh_all),
|
||||||
"Отчёты": ReportsFrame(self.content, self.refresh_all),
|
"Отчёты": ReportsFrame(self.content, self.refresh_all),
|
||||||
}
|
}
|
||||||
|
|
||||||
for frame in self.frames.values():
|
for frame in self.frames.values():
|
||||||
frame.grid(row=0, column=0, sticky="nsew")
|
frame.grid(row=0, column=0, sticky="nsew")
|
||||||
|
|
||||||
@@ -107,17 +111,29 @@ class FinanceApp(ctk.CTk):
|
|||||||
if hasattr(frame, "refresh_data"):
|
if hasattr(frame, "refresh_data"):
|
||||||
frame.refresh_data()
|
frame.refresh_data()
|
||||||
frame.tkraise()
|
frame.tkraise()
|
||||||
|
self._highlight_nav_button(name)
|
||||||
|
|
||||||
def refresh_all(self) -> None:
|
def refresh_all(self) -> None:
|
||||||
for frame in self.frames.values():
|
for frame in self.frames.values():
|
||||||
if hasattr(frame, "refresh_data"):
|
if hasattr(frame, "refresh_data"):
|
||||||
frame.refresh_data()
|
frame.refresh_data()
|
||||||
|
|
||||||
|
def _highlight_nav_button(self, active_name: str) -> None:
|
||||||
|
for name, button in self.nav_buttons.items():
|
||||||
|
if name == active_name:
|
||||||
|
button.configure(fg_color=("#2563eb", "#2563eb"))
|
||||||
|
else:
|
||||||
|
button.configure(fg_color=("#3b8ed0", "#1f6aa5"))
|
||||||
|
|
||||||
|
def change_theme(self, theme_name: str) -> None:
|
||||||
|
ctk.set_appearance_mode(self._theme_to_mode(theme_name))
|
||||||
|
self.service.set_setting("theme", theme_name)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def change_theme(theme_name: str) -> None:
|
def _theme_to_mode(theme_name: str) -> str:
|
||||||
theme_map = {
|
theme_map = {
|
||||||
"Тёмная": "dark",
|
"Тёмная": "dark",
|
||||||
"Светлая": "light",
|
"Светлая": "light",
|
||||||
"Системная": "system",
|
"Системная": "system",
|
||||||
}
|
}
|
||||||
ctk.set_appearance_mode(theme_map[theme_name])
|
return theme_map.get(theme_name, "dark")
|
||||||
|
|||||||
@@ -4,71 +4,69 @@ import customtkinter as ctk
|
|||||||
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
||||||
from matplotlib.figure import Figure
|
from matplotlib.figure import Figure
|
||||||
|
|
||||||
from app.database import get_connection
|
from app.ui.base_frame import BaseAppFrame
|
||||||
|
|
||||||
|
|
||||||
class ReportsFrame(ctk.CTkFrame):
|
class ReportsFrame(BaseAppFrame):
|
||||||
|
"""Screen with text reports and charts."""
|
||||||
|
|
||||||
def __init__(self, master, refresh_callback) -> None:
|
def __init__(self, master, refresh_callback) -> None:
|
||||||
super().__init__(master)
|
super().__init__(master, refresh_callback)
|
||||||
self.refresh_callback = refresh_callback
|
|
||||||
self.last_report_text = ""
|
self.last_report_text = ""
|
||||||
|
|
||||||
self.grid_columnconfigure((0, 1), weight=1)
|
self.grid_columnconfigure((0, 1), weight=1)
|
||||||
self.grid_rowconfigure(1, weight=1)
|
self.grid_rowconfigure(1, weight=1)
|
||||||
|
|
||||||
self._build_header()
|
self._build_header()
|
||||||
self._build_left_panel()
|
self._build_left_panel()
|
||||||
self._build_right_panel()
|
self._build_right_panel()
|
||||||
|
|
||||||
def _build_header(self) -> None:
|
def _build_header(self) -> None:
|
||||||
header_frame = ctk.CTkFrame(self, fg_color="transparent")
|
header = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
header_frame.grid(row=0, column=0, columnspan=2, sticky="ew", padx=8, pady=(8, 12))
|
header.grid(row=0, column=0, columnspan=2, sticky="ew", padx=8, pady=(8, 12))
|
||||||
header_frame.grid_columnconfigure(0, weight=1)
|
header.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
title = ctk.CTkLabel(
|
self.title_label(header, "Аналитика и отчёты").grid(row=0, column=0, sticky="w")
|
||||||
header_frame,
|
self.subtitle_label(
|
||||||
text="Аналитика и отчёты",
|
header,
|
||||||
font=ctk.CTkFont(size=28, weight="bold"),
|
"Текстовая сводка, диаграмма расходов и экспорт отчёта.",
|
||||||
)
|
).grid(row=1, column=0, sticky="w", pady=(4, 0))
|
||||||
title.grid(row=0, column=0, sticky="w")
|
|
||||||
|
|
||||||
export_button = ctk.CTkButton(
|
self.export_button = self.primary_button(
|
||||||
header_frame,
|
header,
|
||||||
text="Сохранить отчёт в TXT",
|
"Сохранить отчёт в TXT",
|
||||||
command=self.export_report,
|
lambda: self.run_safe(self.export_report, self.export_button),
|
||||||
)
|
)
|
||||||
export_button.grid(row=0, column=1, padx=(12, 0))
|
self.export_button.grid(row=0, column=1, rowspan=2, padx=(12, 0))
|
||||||
|
|
||||||
def _build_left_panel(self) -> None:
|
def _build_left_panel(self) -> None:
|
||||||
left_frame = ctk.CTkFrame(self, corner_radius=18)
|
left = ctk.CTkFrame(self, corner_radius=20)
|
||||||
left_frame.grid(row=1, column=0, sticky="nsew", padx=(8, 12), pady=8)
|
left.grid(row=1, column=0, sticky="nsew", padx=(8, 12), pady=8)
|
||||||
left_frame.grid_rowconfigure(1, weight=1)
|
left.grid_rowconfigure(1, weight=1)
|
||||||
left_frame.grid_columnconfigure(0, weight=1)
|
left.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
info_label = ctk.CTkLabel(
|
self.section_label(left, "Отчёт за текущий месяц").grid(
|
||||||
left_frame,
|
row=0,
|
||||||
text="Текстовый отчёт за текущий месяц",
|
column=0,
|
||||||
font=ctk.CTkFont(size=20, weight="bold"),
|
sticky="w",
|
||||||
|
padx=18,
|
||||||
|
pady=(18, 8),
|
||||||
)
|
)
|
||||||
info_label.grid(row=0, column=0, sticky="w", padx=18, pady=(18, 8))
|
self.report_box = ctk.CTkTextbox(left)
|
||||||
|
|
||||||
self.report_box = ctk.CTkTextbox(left_frame)
|
|
||||||
self.report_box.grid(row=1, column=0, sticky="nsew", padx=18, pady=(0, 18))
|
self.report_box.grid(row=1, column=0, sticky="nsew", padx=18, pady=(0, 18))
|
||||||
|
|
||||||
def _build_right_panel(self) -> None:
|
def _build_right_panel(self) -> None:
|
||||||
right_frame = ctk.CTkFrame(self, corner_radius=18)
|
right = ctk.CTkFrame(self, corner_radius=20)
|
||||||
right_frame.grid(row=1, column=1, sticky="nsew", padx=(0, 8), pady=8)
|
right.grid(row=1, column=1, sticky="nsew", padx=(0, 8), pady=8)
|
||||||
right_frame.grid_rowconfigure(1, weight=1)
|
right.grid_rowconfigure(1, weight=1)
|
||||||
right_frame.grid_columnconfigure(0, weight=1)
|
right.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
chart_label = ctk.CTkLabel(
|
self.section_label(right, "Структура расходов").grid(
|
||||||
right_frame,
|
row=0,
|
||||||
text="Структура расходов по категориям",
|
column=0,
|
||||||
font=ctk.CTkFont(size=20, weight="bold"),
|
sticky="w",
|
||||||
|
padx=18,
|
||||||
|
pady=(18, 8),
|
||||||
)
|
)
|
||||||
chart_label.grid(row=0, column=0, sticky="w", padx=18, pady=(18, 8))
|
self.chart_container = ctk.CTkFrame(right, fg_color="transparent")
|
||||||
|
|
||||||
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.chart_container.grid(row=1, column=0, sticky="nsew", padx=18, pady=(0, 18))
|
||||||
|
|
||||||
self.figure = Figure(figsize=(5, 4), dpi=100)
|
self.figure = Figure(figsize=(5, 4), dpi=100)
|
||||||
@@ -76,100 +74,36 @@ class ReportsFrame(ctk.CTkFrame):
|
|||||||
self.canvas.get_tk_widget().pack(fill="both", expand=True)
|
self.canvas.get_tk_widget().pack(fill="both", expand=True)
|
||||||
|
|
||||||
def refresh_data(self) -> None:
|
def refresh_data(self) -> None:
|
||||||
with get_connection() as connection:
|
self.last_report_text = self.service.build_report_text()
|
||||||
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.delete("1.0", "end")
|
||||||
self.report_box.insert("1.0", report_text)
|
self.report_box.insert("1.0", self.last_report_text)
|
||||||
|
self._draw_chart()
|
||||||
|
|
||||||
|
def _draw_chart(self) -> None:
|
||||||
|
data = self.service.report_data()
|
||||||
|
rows = data["categories"]
|
||||||
self.figure.clear()
|
self.figure.clear()
|
||||||
axis = self.figure.add_subplot(111)
|
axis = self.figure.add_subplot(111)
|
||||||
axis.set_title("Расходы по категориям")
|
axis.set_title("Расходы по категориям")
|
||||||
|
if rows:
|
||||||
if category_rows:
|
labels = [row["name"] for row in rows]
|
||||||
labels = [row["name"] for row in category_rows]
|
values = [row["total"] for row in rows]
|
||||||
values = [row["total"] for row in category_rows]
|
|
||||||
axis.pie(values, labels=labels, autopct="%1.1f%%")
|
axis.pie(values, labels=labels, autopct="%1.1f%%")
|
||||||
else:
|
else:
|
||||||
axis.text(0.5, 0.5, "Нет данных для диаграммы", ha="center", va="center")
|
axis.text(0.5, 0.5, "Нет данных для диаграммы", ha="center", va="center")
|
||||||
axis.axis("off")
|
axis.axis("off")
|
||||||
|
|
||||||
self.canvas.draw()
|
self.canvas.draw()
|
||||||
|
|
||||||
def export_report(self) -> None:
|
def export_report(self) -> None:
|
||||||
if not self.last_report_text:
|
if not self.last_report_text:
|
||||||
messagebox.showinfo("Информация", "Сначала обновите отчёт.")
|
self.refresh_data()
|
||||||
return
|
|
||||||
|
|
||||||
file_path = filedialog.asksaveasfilename(
|
file_path = filedialog.asksaveasfilename(
|
||||||
defaultextension=".txt",
|
defaultextension=".txt",
|
||||||
filetypes=[("Text files", "*.txt")],
|
filetypes=[("Text files", "*.txt")],
|
||||||
title="Сохранить отчёт",
|
title="Сохранить отчёт",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not file_path:
|
if not file_path:
|
||||||
return
|
return
|
||||||
|
|
||||||
with open(file_path, "w", encoding="utf-8") as file:
|
with open(file_path, "w", encoding="utf-8") as file:
|
||||||
file.write(self.last_report_text)
|
file.write(self.last_report_text)
|
||||||
|
|
||||||
messagebox.showinfo("Готово", "Отчёт сохранён.")
|
messagebox.showinfo("Готово", "Отчёт сохранён.")
|
||||||
|
|||||||
@@ -2,190 +2,161 @@ from tkinter import messagebox
|
|||||||
|
|
||||||
import customtkinter as ctk
|
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:
|
def __init__(self, master, refresh_callback) -> None:
|
||||||
super().__init__(master)
|
super().__init__(master, refresh_callback)
|
||||||
self.refresh_callback = refresh_callback
|
|
||||||
|
|
||||||
self.grid_columnconfigure(1, weight=1)
|
self.grid_columnconfigure(1, weight=1)
|
||||||
self.grid_rowconfigure(0, weight=1)
|
self.grid_rowconfigure(0, weight=1)
|
||||||
|
|
||||||
self._build_form()
|
self._build_form()
|
||||||
self._build_list()
|
self._build_list()
|
||||||
|
|
||||||
def _build_form(self) -> None:
|
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)
|
form_frame.grid(row=0, column=0, sticky="ns", padx=(8, 12), pady=8)
|
||||||
|
|
||||||
title = ctk.CTkLabel(
|
self.title_label(form_frame, "Подписки").pack(
|
||||||
form_frame,
|
anchor="w", padx=18, pady=(18, 4)
|
||||||
text="Менеджер подписок",
|
|
||||||
font=ctk.CTkFont(size=26, weight="bold"),
|
|
||||||
)
|
)
|
||||||
title.pack(anchor="w", padx=18, pady=(18, 6))
|
self.subtitle_label(
|
||||||
|
|
||||||
subtitle = ctk.CTkLabel(
|
|
||||||
form_frame,
|
form_frame,
|
||||||
text="Контроль платных сервисов и автосписаний.",
|
"Контроль регулярных списаний и платных сервисов.",
|
||||||
text_color="gray75",
|
).pack(anchor="w", padx=18, pady=(0, 18))
|
||||||
)
|
|
||||||
subtitle.pack(anchor="w", padx=18, pady=(0, 18))
|
|
||||||
|
|
||||||
self.name_entry = ctk.CTkEntry(
|
self.name_entry = ctk.CTkEntry(
|
||||||
form_frame,
|
form_frame,
|
||||||
width=300,
|
width=320,
|
||||||
placeholder_text="Название",
|
placeholder_text="Название сервиса",
|
||||||
)
|
)
|
||||||
self.name_entry.pack(padx=18, pady=8)
|
self.name_entry.pack(padx=18, pady=7)
|
||||||
|
|
||||||
self.amount_entry = ctk.CTkEntry(
|
self.amount_entry = ctk.CTkEntry(
|
||||||
form_frame,
|
form_frame,
|
||||||
width=300,
|
width=320,
|
||||||
placeholder_text="Стоимость",
|
placeholder_text="Стоимость",
|
||||||
)
|
)
|
||||||
self.amount_entry.pack(padx=18, pady=8)
|
self.amount_entry.pack(padx=18, pady=7)
|
||||||
|
|
||||||
self.day_entry = ctk.CTkEntry(
|
self.day_entry = ctk.CTkEntry(
|
||||||
form_frame,
|
form_frame,
|
||||||
width=300,
|
width=320,
|
||||||
placeholder_text="День списания (1-31)",
|
placeholder_text="День списания: 1–31",
|
||||||
)
|
)
|
||||||
self.day_entry.pack(padx=18, pady=8)
|
self.day_entry.pack(padx=18, pady=7)
|
||||||
|
|
||||||
self.period_menu = ctk.CTkOptionMenu(
|
self.period_menu = ctk.CTkOptionMenu(
|
||||||
form_frame,
|
form_frame,
|
||||||
width=300,
|
width=320,
|
||||||
values=["Месяц", "Год"],
|
values=["Месяц", "Год"],
|
||||||
)
|
)
|
||||||
self.period_menu.set("Месяц")
|
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(
|
self.status_menu = ctk.CTkOptionMenu(
|
||||||
form_frame,
|
form_frame,
|
||||||
width=300,
|
width=320,
|
||||||
values=["Активна", "Пауза"],
|
values=["Активна", "Пауза"],
|
||||||
)
|
)
|
||||||
self.status_menu.set("Активна")
|
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,
|
form_frame,
|
||||||
text="Добавить подписку",
|
"Добавить подписку",
|
||||||
width=300,
|
lambda: self.run_safe(self.add_subscription, self.add_button),
|
||||||
height=42,
|
|
||||||
command=self.add_subscription,
|
|
||||||
)
|
)
|
||||||
add_button.pack(padx=18, pady=(12, 18))
|
self.add_button.pack(fill="x", padx=18, pady=(12, 18))
|
||||||
|
|
||||||
def _build_list(self) -> None:
|
def _build_list(self) -> None:
|
||||||
self.list_frame = ctk.CTkScrollableFrame(self, label_text="Список подписок")
|
self.list_frame = ctk.CTkScrollableFrame(self, label_text="Список подписок")
|
||||||
self.list_frame.grid(row=0, column=1, sticky="nsew", padx=(0, 8), pady=8)
|
self.list_frame.grid(row=0, column=1, sticky="nsew", padx=(0, 8), pady=8)
|
||||||
|
|
||||||
def add_subscription(self) -> None:
|
def add_subscription(self) -> None:
|
||||||
name = self.name_entry.get().strip()
|
name = normalize_text(self.name_entry.get(), "Название")
|
||||||
amount_text = self.amount_entry.get().strip().replace(",", ".")
|
amount = parse_positive_float(self.amount_entry.get(), "Стоимость")
|
||||||
billing_day_text = self.day_entry.get().strip()
|
day = parse_billing_day(self.day_entry.get())
|
||||||
period = self.period_menu.get()
|
self.service.add_subscription(
|
||||||
status = self.status_menu.get()
|
name=name,
|
||||||
|
amount=amount,
|
||||||
if not name or not amount_text or not billing_day_text:
|
billing_day=day,
|
||||||
messagebox.showerror("Ошибка", "Заполните все поля.")
|
period=self.period_menu.get(),
|
||||||
return
|
status=self.status_menu.get(),
|
||||||
|
)
|
||||||
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.name_entry.delete(0, "end")
|
||||||
self.amount_entry.delete(0, "end")
|
self.amount_entry.delete(0, "end")
|
||||||
self.day_entry.delete(0, "end")
|
self.day_entry.delete(0, "end")
|
||||||
self.refresh_callback()
|
self.refresh_callback()
|
||||||
|
|
||||||
def delete_subscription(self, subscription_id: int) -> None:
|
def delete_subscription(self, subscription_id: int) -> None:
|
||||||
with get_connection() as connection:
|
if not messagebox.askyesno("Подтверждение", "Удалить выбранную подписку?"):
|
||||||
connection.execute(
|
return
|
||||||
"DELETE FROM subscriptions WHERE id = ?",
|
self.service.delete_subscription(subscription_id)
|
||||||
(subscription_id,),
|
self.refresh_callback()
|
||||||
)
|
|
||||||
connection.commit()
|
|
||||||
|
|
||||||
|
def toggle_status(self, subscription_id: int) -> None:
|
||||||
|
self.service.toggle_subscription_status(subscription_id)
|
||||||
self.refresh_callback()
|
self.refresh_callback()
|
||||||
|
|
||||||
def refresh_data(self) -> None:
|
def refresh_data(self) -> None:
|
||||||
for widget in self.list_frame.winfo_children():
|
self.clear(self.list_frame)
|
||||||
widget.destroy()
|
rows = self.service.list_subscriptions()
|
||||||
|
|
||||||
with get_connection() as connection:
|
|
||||||
rows = connection.execute(
|
|
||||||
"SELECT * FROM subscriptions ORDER BY id DESC"
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
empty_label = ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
self.list_frame,
|
self.list_frame,
|
||||||
text="Подписок пока нет.",
|
text="Подписок пока нет.",
|
||||||
text_color="gray70",
|
text_color="gray70",
|
||||||
)
|
).pack(anchor="w", padx=10, pady=10)
|
||||||
empty_label.pack(anchor="w", padx=10, pady=10)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
card = ctk.CTkFrame(self.list_frame, corner_radius=16)
|
card = ctk.CTkFrame(self.list_frame, corner_radius=16)
|
||||||
card.pack(fill="x", padx=6, pady=6)
|
card.pack(fill="x", padx=6, pady=6)
|
||||||
|
card.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
title = ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
card,
|
card,
|
||||||
text=row["name"],
|
text=row["name"],
|
||||||
font=ctk.CTkFont(size=18, weight="bold"),
|
font=ctk.CTkFont(size=18, weight="bold"),
|
||||||
)
|
).grid(row=0, column=0, sticky="w", padx=16, pady=(12, 4))
|
||||||
title.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,
|
card,
|
||||||
text="Удалить",
|
text=toggle_text,
|
||||||
width=90,
|
width=110,
|
||||||
fg_color="#b33939",
|
command=lambda sub_id=row["id"]: self.run_safe(
|
||||||
hover_color="#932f2f",
|
lambda: self.toggle_status(sub_id)
|
||||||
command=lambda subscription_id=row["id"]: self.delete_subscription(
|
|
||||||
subscription_id
|
|
||||||
),
|
),
|
||||||
)
|
).grid(row=0, column=1, sticky="e", padx=(8, 4), pady=(12, 4))
|
||||||
delete_button.grid(row=0, column=1, sticky="e", padx=16, pady=(12, 4))
|
|
||||||
|
|
||||||
monthly_equivalent = (
|
self.danger_button(
|
||||||
row["amount"] / 12 if row["period"] == "Год" else row["amount"]
|
|
||||||
)
|
|
||||||
|
|
||||||
info = ctk.CTkLabel(
|
|
||||||
card,
|
card,
|
||||||
text=(
|
"Удалить",
|
||||||
f"{row['amount']:,.2f} ₽ | {row['period']} | "
|
lambda sub_id=row["id"]: self.run_safe(
|
||||||
f"списание: {row['billing_day']} числа | {row['status']} | "
|
lambda: self.delete_subscription(sub_id)
|
||||||
f"≈ {monthly_equivalent:,.2f} ₽ в месяц"
|
),
|
||||||
).replace(",", " "),
|
).grid(row=0, column=2, sticky="e", padx=(4, 16), pady=(12, 4))
|
||||||
text_color="gray75",
|
|
||||||
|
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))
|
|
||||||
|
|||||||
@@ -1,291 +1,255 @@
|
|||||||
from tkinter import messagebox
|
from tkinter import filedialog, messagebox
|
||||||
|
|
||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
|
|
||||||
from app.database import get_connection
|
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(ctk.CTkFrame):
|
class TransactionsFrame(BaseAppFrame):
|
||||||
|
"""Screen for adding, deleting and filtering transactions."""
|
||||||
|
|
||||||
def __init__(self, master, refresh_callback) -> None:
|
def __init__(self, master, refresh_callback) -> None:
|
||||||
super().__init__(master)
|
super().__init__(master, refresh_callback)
|
||||||
self.refresh_callback = refresh_callback
|
|
||||||
self.account_map: dict[str, int] = {}
|
self.account_map: dict[str, int] = {}
|
||||||
self.category_map: dict[str, int] = {}
|
self.category_map: dict[str, int] = {}
|
||||||
|
|
||||||
self.grid_columnconfigure(1, weight=1)
|
self.grid_columnconfigure(1, weight=1)
|
||||||
self.grid_rowconfigure(0, weight=1)
|
self.grid_rowconfigure(0, weight=1)
|
||||||
|
|
||||||
self._build_form()
|
self._build_form()
|
||||||
self._build_list()
|
self._build_list()
|
||||||
|
|
||||||
def _build_form(self) -> None:
|
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)
|
form_frame.grid(row=0, column=0, sticky="ns", padx=(8, 12), pady=8)
|
||||||
|
|
||||||
title = ctk.CTkLabel(
|
self.title_label(form_frame, "Операции").pack(
|
||||||
form_frame,
|
anchor="w", padx=18, pady=(18, 4)
|
||||||
text="Операции",
|
|
||||||
font=ctk.CTkFont(size=26, weight="bold"),
|
|
||||||
)
|
)
|
||||||
title.pack(anchor="w", padx=18, pady=(18, 6))
|
self.subtitle_label(
|
||||||
|
|
||||||
subtitle = ctk.CTkLabel(
|
|
||||||
form_frame,
|
form_frame,
|
||||||
text="Добавляйте доходы и расходы по категориям.",
|
"Добавляйте доходы и расходы с автоматическим изменением баланса.",
|
||||||
text_color="gray75",
|
).pack(anchor="w", padx=18, pady=(0, 18))
|
||||||
)
|
|
||||||
subtitle.pack(anchor="w", padx=18, pady=(0, 18))
|
|
||||||
|
|
||||||
self.title_entry = ctk.CTkEntry(
|
self.title_entry = ctk.CTkEntry(
|
||||||
form_frame,
|
form_frame,
|
||||||
width=300,
|
width=330,
|
||||||
placeholder_text="Название",
|
placeholder_text="Название операции",
|
||||||
)
|
)
|
||||||
self.title_entry.pack(padx=18, pady=8)
|
self.title_entry.pack(padx=18, pady=7)
|
||||||
|
|
||||||
self.amount_entry = ctk.CTkEntry(
|
self.amount_entry = ctk.CTkEntry(
|
||||||
form_frame,
|
form_frame,
|
||||||
width=300,
|
width=330,
|
||||||
placeholder_text="Сумма",
|
placeholder_text="Сумма",
|
||||||
)
|
)
|
||||||
self.amount_entry.pack(padx=18, pady=8)
|
self.amount_entry.pack(padx=18, pady=7)
|
||||||
|
|
||||||
self.type_menu = ctk.CTkOptionMenu(
|
self.type_menu = ctk.CTkOptionMenu(
|
||||||
form_frame,
|
form_frame,
|
||||||
width=300,
|
width=330,
|
||||||
values=["Расход", "Доход"],
|
values=["Расход", "Доход"],
|
||||||
command=self._on_type_change,
|
command=lambda _: self._load_category_menu(),
|
||||||
)
|
)
|
||||||
self.type_menu.set("Расход")
|
self.type_menu.set("Расход")
|
||||||
self.type_menu.pack(padx=18, pady=8)
|
self.type_menu.pack(padx=18, pady=7)
|
||||||
|
|
||||||
self.account_menu = ctk.CTkOptionMenu(form_frame, width=300, values=["Нет счетов"])
|
self.account_menu = ctk.CTkOptionMenu(
|
||||||
self.account_menu.pack(padx=18, pady=8)
|
form_frame,
|
||||||
|
width=330,
|
||||||
|
values=["Нет счетов"],
|
||||||
|
)
|
||||||
|
self.account_menu.pack(padx=18, pady=7)
|
||||||
|
|
||||||
self.category_menu = ctk.CTkOptionMenu(
|
self.category_menu = ctk.CTkOptionMenu(
|
||||||
form_frame,
|
form_frame,
|
||||||
width=300,
|
width=330,
|
||||||
values=["Нет категорий"],
|
values=["Нет категорий"],
|
||||||
)
|
)
|
||||||
self.category_menu.pack(padx=18, pady=8)
|
self.category_menu.pack(padx=18, pady=7)
|
||||||
|
|
||||||
add_button = ctk.CTkButton(
|
self.date_entry = ctk.CTkEntry(
|
||||||
form_frame,
|
form_frame,
|
||||||
text="Добавить операцию",
|
width=330,
|
||||||
width=300,
|
placeholder_text="Дата: ГГГГ-ММ-ДД",
|
||||||
height=42,
|
|
||||||
command=self.add_transaction,
|
|
||||||
)
|
)
|
||||||
add_button.pack(padx=18, pady=(12, 18))
|
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:
|
def _build_list(self) -> None:
|
||||||
self.list_frame = ctk.CTkScrollableFrame(self, label_text="История операций")
|
right = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
self.list_frame.grid(row=0, column=1, sticky="nsew", padx=(0, 8), pady=8)
|
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)
|
||||||
|
|
||||||
def _on_type_change(self, _value: str) -> None:
|
filter_frame = ctk.CTkFrame(right, corner_radius=18)
|
||||||
self._load_category_menu()
|
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("<KeyRelease>", 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:
|
def _load_account_menu(self) -> None:
|
||||||
with get_connection() as connection:
|
rows = self.service.list_account_options()
|
||||||
rows = connection.execute(
|
|
||||||
"SELECT id, name FROM accounts ORDER BY name"
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
self.account_map = {}
|
self.account_map = {}
|
||||||
self.account_menu.configure(values=["Нет счетов"])
|
self.account_menu.configure(values=["Нет счетов"])
|
||||||
self.account_menu.set("Нет счетов")
|
self.account_menu.set("Нет счетов")
|
||||||
return
|
return
|
||||||
|
|
||||||
values = []
|
values = []
|
||||||
self.account_map = {}
|
self.account_map = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
label = f"{row['name']} (ID {row['id']})"
|
label = f"{row['name']} — {self.money(row['balance'])}"
|
||||||
values.append(label)
|
values.append(label)
|
||||||
self.account_map[label] = row["id"]
|
self.account_map[label] = row["id"]
|
||||||
|
|
||||||
self.account_menu.configure(values=values)
|
self.account_menu.configure(values=values)
|
||||||
self.account_menu.set(values[0])
|
if self.account_menu.get() not in values:
|
||||||
|
self.account_menu.set(values[0])
|
||||||
|
|
||||||
def _load_category_menu(self) -> None:
|
def _load_category_menu(self) -> None:
|
||||||
transaction_type = self.type_menu.get()
|
rows = self.service.list_categories(self.type_menu.get())
|
||||||
with get_connection() as connection:
|
|
||||||
rows = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, name
|
|
||||||
FROM categories
|
|
||||||
WHERE category_type = ?
|
|
||||||
ORDER BY name
|
|
||||||
""",
|
|
||||||
(transaction_type,),
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
self.category_map = {}
|
self.category_map = {}
|
||||||
self.category_menu.configure(values=["Нет категорий"])
|
self.category_menu.configure(values=["Нет категорий"])
|
||||||
self.category_menu.set("Нет категорий")
|
self.category_menu.set("Нет категорий")
|
||||||
return
|
return
|
||||||
|
|
||||||
values = []
|
values = []
|
||||||
self.category_map = {}
|
self.category_map = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
label = f"{row['name']} (ID {row['id']})"
|
label = row["name"]
|
||||||
values.append(label)
|
values.append(label)
|
||||||
self.category_map[label] = row["id"]
|
self.category_map[label] = row["id"]
|
||||||
|
|
||||||
self.category_menu.configure(values=values)
|
self.category_menu.configure(values=values)
|
||||||
self.category_menu.set(values[0])
|
if self.category_menu.get() not in values:
|
||||||
|
self.category_menu.set(values[0])
|
||||||
|
|
||||||
def add_transaction(self) -> None:
|
def add_transaction(self) -> None:
|
||||||
title = self.title_entry.get().strip()
|
|
||||||
amount_text = self.amount_entry.get().strip().replace(",", ".")
|
|
||||||
transaction_type = self.type_menu.get()
|
|
||||||
account_label = self.account_menu.get()
|
account_label = self.account_menu.get()
|
||||||
category_label = self.category_menu.get()
|
category_label = self.category_menu.get()
|
||||||
|
|
||||||
if not title or not amount_text:
|
|
||||||
messagebox.showerror("Ошибка", "Заполните все поля.")
|
|
||||||
return
|
|
||||||
|
|
||||||
if account_label not in self.account_map:
|
if account_label not in self.account_map:
|
||||||
messagebox.showerror("Ошибка", "Сначала добавьте хотя бы один счёт.")
|
raise ValueError("Сначала добавьте хотя бы один счёт.")
|
||||||
return
|
|
||||||
|
|
||||||
if category_label not in self.category_map:
|
if category_label not in self.category_map:
|
||||||
messagebox.showerror("Ошибка", "Сначала создайте подходящую категорию.")
|
raise ValueError("Для выбранного типа операции нет категории.")
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
title = normalize_text(self.title_entry.get(), "Название")
|
||||||
amount = float(amount_text)
|
amount = parse_positive_float(self.amount_entry.get(), "Сумма")
|
||||||
except ValueError:
|
transaction_type = self.type_menu.get()
|
||||||
messagebox.showerror("Ошибка", "Сумма должна быть числом.")
|
created_at = validate_date(self.date_entry.get())
|
||||||
return
|
|
||||||
|
|
||||||
if amount <= 0:
|
|
||||||
messagebox.showerror("Ошибка", "Сумма должна быть больше нуля.")
|
|
||||||
return
|
|
||||||
|
|
||||||
account_id = self.account_map[account_label]
|
|
||||||
category_id = self.category_map[category_label]
|
|
||||||
balance_delta = -amount if transaction_type == "Расход" else amount
|
|
||||||
|
|
||||||
with get_connection() as connection:
|
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO transactions (
|
|
||||||
title,
|
|
||||||
amount,
|
|
||||||
transaction_type,
|
|
||||||
account_id,
|
|
||||||
category_id
|
|
||||||
)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(title, amount, transaction_type, account_id, category_id),
|
|
||||||
)
|
|
||||||
connection.execute(
|
|
||||||
"UPDATE accounts SET balance = balance + ? WHERE id = ?",
|
|
||||||
(balance_delta, account_id),
|
|
||||||
)
|
|
||||||
connection.commit()
|
|
||||||
|
|
||||||
|
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.title_entry.delete(0, "end")
|
||||||
self.amount_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()
|
self.refresh_callback()
|
||||||
|
|
||||||
def delete_transaction(self, transaction_id: int) -> None:
|
def delete_transaction(self, transaction_id: int) -> None:
|
||||||
with get_connection() as connection:
|
if not messagebox.askyesno("Подтверждение", "Удалить выбранную операцию?"):
|
||||||
row = connection.execute(
|
return
|
||||||
"""
|
self.service.delete_transaction(transaction_id)
|
||||||
SELECT amount, transaction_type, account_id
|
|
||||||
FROM transactions
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(transaction_id,),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if row is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
reverse_delta = (
|
|
||||||
row["amount"]
|
|
||||||
if row["transaction_type"] == "Расход"
|
|
||||||
else -row["amount"]
|
|
||||||
)
|
|
||||||
|
|
||||||
connection.execute(
|
|
||||||
"UPDATE accounts SET balance = balance + ? WHERE id = ?",
|
|
||||||
(reverse_delta, row["account_id"]),
|
|
||||||
)
|
|
||||||
connection.execute("DELETE FROM transactions WHERE id = ?", (transaction_id,))
|
|
||||||
connection.commit()
|
|
||||||
|
|
||||||
self.refresh_callback()
|
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:
|
def refresh_data(self) -> None:
|
||||||
self._load_account_menu()
|
self._load_account_menu()
|
||||||
self._load_category_menu()
|
self._load_category_menu()
|
||||||
|
self.clear(self.list_frame)
|
||||||
|
|
||||||
for widget in self.list_frame.winfo_children():
|
rows = self.service.list_transactions(
|
||||||
widget.destroy()
|
search=self.search_entry.get().strip() if hasattr(self, "search_entry") else "",
|
||||||
|
transaction_type=self.filter_type_menu.get()
|
||||||
with get_connection() as connection:
|
if hasattr(self, "filter_type_menu")
|
||||||
rows = connection.execute(
|
else "Все",
|
||||||
"""
|
)
|
||||||
SELECT t.id,
|
|
||||||
t.title,
|
|
||||||
t.amount,
|
|
||||||
t.transaction_type,
|
|
||||||
t.created_at,
|
|
||||||
a.name AS account_name,
|
|
||||||
c.name AS category_name
|
|
||||||
FROM transactions AS t
|
|
||||||
JOIN accounts AS a ON a.id = t.account_id
|
|
||||||
JOIN categories AS c ON c.id = t.category_id
|
|
||||||
ORDER BY t.id DESC
|
|
||||||
"""
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
empty_label = ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
self.list_frame,
|
self.list_frame,
|
||||||
text="Операций пока нет.",
|
text="Операций пока нет или фильтр ничего не нашёл.",
|
||||||
text_color="gray70",
|
text_color="gray70",
|
||||||
)
|
).pack(anchor="w", padx=10, pady=10)
|
||||||
empty_label.pack(anchor="w", padx=10, pady=10)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
card = ctk.CTkFrame(self.list_frame, corner_radius=16)
|
self._render_row(row)
|
||||||
card.pack(fill="x", padx=6, pady=6)
|
|
||||||
|
|
||||||
title = ctk.CTkLabel(
|
def _render_row(self, row) -> None:
|
||||||
card,
|
card = ctk.CTkFrame(self.list_frame, corner_radius=16)
|
||||||
text=row["title"],
|
card.pack(fill="x", padx=6, pady=6)
|
||||||
font=ctk.CTkFont(size=18, weight="bold"),
|
card.grid_columnconfigure(0, weight=1)
|
||||||
)
|
|
||||||
title.grid(row=0, column=0, sticky="w", padx=16, pady=(12, 4))
|
|
||||||
|
|
||||||
delete_button = ctk.CTkButton(
|
sign = "+" if row["transaction_type"] == "Доход" else "-"
|
||||||
card,
|
title_text = f"{row['title']} {sign}{self.money(row['amount'])}"
|
||||||
text="Удалить",
|
ctk.CTkLabel(
|
||||||
width=90,
|
card,
|
||||||
fg_color="#b33939",
|
text=title_text,
|
||||||
hover_color="#932f2f",
|
font=ctk.CTkFont(size=18, weight="bold"),
|
||||||
command=lambda transaction_id=row["id"]: self.delete_transaction(
|
).grid(row=0, column=0, sticky="w", padx=16, pady=(12, 4))
|
||||||
transaction_id
|
|
||||||
),
|
|
||||||
)
|
|
||||||
delete_button.grid(row=0, column=1, sticky="e", padx=16, pady=(12, 4))
|
|
||||||
|
|
||||||
amount_prefix = "-" if row["transaction_type"] == "Расход" else "+"
|
self.danger_button(
|
||||||
info = ctk.CTkLabel(
|
card,
|
||||||
card,
|
"Удалить",
|
||||||
text=(
|
lambda transaction_id=row["id"]: self.run_safe(
|
||||||
f"{amount_prefix}{row['amount']:,.2f} ₽ | "
|
lambda: self.delete_transaction(transaction_id)
|
||||||
f"{row['transaction_type']} | {row['account_name']} | "
|
),
|
||||||
f"{row['category_name']} | {row['created_at'][:16]}"
|
).grid(row=0, column=1, sticky="e", padx=16, pady=(12, 4))
|
||||||
).replace(",", " "),
|
|
||||||
text_color="gray75",
|
info = (
|
||||||
)
|
f"{row['created_at'][:10]} | {row['transaction_type']} | "
|
||||||
info.grid(row=1, column=0, columnspan=2, sticky="w", padx=16, pady=(0, 14))
|
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),
|
||||||
|
)
|
||||||
|
|||||||
0
app/utils/__init__.py
Normal file
0
app/utils/__init__.py
Normal file
25
app/utils/formatters.py
Normal file
25
app/utils/formatters.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
|
||||||
|
def format_money(value: float | int | None) -> str:
|
||||||
|
"""Return money value in a readable Russian format."""
|
||||||
|
amount = 0 if value is None else float(value)
|
||||||
|
return f"{amount:,.2f} ₽".replace(",", " ")
|
||||||
|
|
||||||
|
|
||||||
|
def current_month_key() -> str:
|
||||||
|
"""Return current month in SQLite-friendly format YYYY-MM."""
|
||||||
|
return date.today().strftime("%Y-%m")
|
||||||
|
|
||||||
|
|
||||||
|
def today_text() -> str:
|
||||||
|
"""Return current date in ISO format for entry fields."""
|
||||||
|
return date.today().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_date(value: str) -> str:
|
||||||
|
"""Validate user date and return normalized ISO date."""
|
||||||
|
try:
|
||||||
|
return datetime.strptime(value.strip(), "%Y-%m-%d").strftime("%Y-%m-%d")
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError("Дата должна быть в формате ГГГГ-ММ-ДД.") from error
|
||||||
40
app/utils/validators.py
Normal file
40
app/utils/validators.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
|
||||||
|
def normalize_text(value: str, field_name: str, max_length: int = 80) -> str:
|
||||||
|
"""Strip and validate a required text value."""
|
||||||
|
text = value.strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError(f"Поле «{field_name}» не должно быть пустым.")
|
||||||
|
if len(text) > max_length:
|
||||||
|
raise ValueError(
|
||||||
|
f"Поле «{field_name}» должно быть не длиннее {max_length} символов."
|
||||||
|
)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def parse_float(value: str, field_name: str) -> float:
|
||||||
|
"""Parse a float from Russian-style user input."""
|
||||||
|
prepared = value.strip().replace(" ", "").replace(",", ".")
|
||||||
|
try:
|
||||||
|
number = float(prepared)
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError(f"Поле «{field_name}» должно быть числом.") from error
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
|
def parse_positive_float(value: str, field_name: str) -> float:
|
||||||
|
"""Parse a strictly positive float."""
|
||||||
|
number = parse_float(value, field_name)
|
||||||
|
if number <= 0:
|
||||||
|
raise ValueError(f"Поле «{field_name}» должно быть больше нуля.")
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
|
def parse_billing_day(value: str) -> int:
|
||||||
|
"""Parse subscription billing day."""
|
||||||
|
try:
|
||||||
|
day = int(value.strip())
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError("День списания должен быть целым числом от 1 до 31.") from error
|
||||||
|
if not 1 <= day <= 31:
|
||||||
|
raise ValueError("День списания должен быть в диапазоне от 1 до 31.")
|
||||||
|
return day
|
||||||
3
main.py
3
main.py
@@ -1,10 +1,13 @@
|
|||||||
from app.database import init_database
|
from app.database import init_database
|
||||||
from app.ui.main_window import FinanceApp
|
from app.ui.main_window import FinanceApp
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
"""Initialize database and start Finance Control."""
|
||||||
init_database()
|
init_database()
|
||||||
app = FinanceApp()
|
app = FinanceApp()
|
||||||
app.mainloop()
|
app.mainloop()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
customtkinter
|
customtkinter>=5.2.2
|
||||||
matplotlib
|
matplotlib>=3.8.0
|
||||||
|
|||||||
10
run.bat
Normal file
10
run.bat
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 > nul
|
||||||
|
if not exist .venv (
|
||||||
|
python -m venv .venv
|
||||||
|
)
|
||||||
|
call .venv\Scripts\activate
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python main.py
|
||||||
|
pause
|
||||||
Reference in New Issue
Block a user