Compare commits
2 Commits
50de359838
...
0a44753eb2
Author | SHA1 | Date | |
---|---|---|---|
![]() |
0a44753eb2 | ||
![]() |
29ccd252b8 |
12
.env.example
12
.env.example
@@ -158,4 +158,14 @@ LIB_CSS_CACHE_CONTROL="public, max-age=604800"
|
||||
# UPLOADS_CACHE_CONTROL:
|
||||
# Nagłówki Cache-Control dla wgrywanych plików (/uploads/)
|
||||
# Domyślnie: "public, max-age=2592000, immutable"
|
||||
UPLOADS_CACHE_CONTROL="public, max-age=2592000, immutable"
|
||||
UPLOADS_CACHE_CONTROL="public, max-age=2592000, immutable"
|
||||
|
||||
# DEFAULT_CATEGORIES:
|
||||
# Lista domyślnych kategorii tworzonych automatycznie przy starcie aplikacji,
|
||||
# jeśli nie istnieją w bazie danych.
|
||||
# Podaj w formacie CSV (oddzielone przecinkami) – kolejność zostanie zachowana.
|
||||
# Możesz dodać własne kategorie
|
||||
# UWAGA: Wielkość liter w nazwach jest zachowywana, ale porównywanie odbywa się
|
||||
# bez rozróżniania wielkości liter (case-insensitive).
|
||||
# Domyślnie: poniższa lista
|
||||
DEFAULT_CATEGORIES="Spożywcze,Budowlane,Zabawki,Chemia,Inne,Elektronika,Odzież i obuwie,Artykuły biurowe,Kosmetyki i higiena,Motoryzacja,Ogród i rośliny,Zwierzęta,Sprzęt sportowy,Książki i prasa,Narzędzia i majsterkowanie,RTV / AGD,Apteka i suplementy,Artykuły dekoracyjne,Gry i hobby,Usługi,Pieczywo"
|
||||
|
356
app.py
356
app.py
@@ -89,9 +89,9 @@ referrer_policy = app.config.get("REFERRER_POLICY")
|
||||
if referrer_policy:
|
||||
talisman_kwargs["referrer_policy"] = referrer_policy
|
||||
|
||||
talisman = Talisman(app,
|
||||
session_cookie_secure=app.config["SESSION_COOKIE_SECURE"],
|
||||
**talisman_kwargs)
|
||||
talisman = Talisman(
|
||||
app, session_cookie_secure=app.config["SESSION_COOKIE_SECURE"], **talisman_kwargs
|
||||
)
|
||||
|
||||
register_heif_opener() # pillow_heif dla HEIC
|
||||
SQLALCHEMY_ECHO = True
|
||||
@@ -109,7 +109,7 @@ SESSION_COOKIE_SECURE = app.config.get("SESSION_COOKIE_SECURE")
|
||||
|
||||
app.config["COMPRESS_ALGORITHM"] = ["zstd", "br", "gzip", "deflate"]
|
||||
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(minutes=SESSION_TIMEOUT_MINUTES)
|
||||
#app.config["SESSION_COOKIE_SECURE"] = True if app.config.get("SESSION_COOKIE_SECURE") is True else False
|
||||
# app.config["SESSION_COOKIE_SECURE"] = True if app.config.get("SESSION_COOKIE_SECURE") is True else False
|
||||
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
||||
DEBUG_MODE = app.config.get("DEBUG_MODE", False)
|
||||
@@ -166,14 +166,23 @@ class User(UserMixin, db.Model):
|
||||
# Tabela pośrednia
|
||||
shopping_list_category = db.Table(
|
||||
"shopping_list_category",
|
||||
db.Column("shopping_list_id", db.Integer, db.ForeignKey("shopping_list.id"), primary_key=True),
|
||||
db.Column("category_id", db.Integer, db.ForeignKey("category.id"), primary_key=True)
|
||||
db.Column(
|
||||
"shopping_list_id",
|
||||
db.Integer,
|
||||
db.ForeignKey("shopping_list.id"),
|
||||
primary_key=True,
|
||||
),
|
||||
db.Column(
|
||||
"category_id", db.Integer, db.ForeignKey("category.id"), primary_key=True
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class Category(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), unique=True, nullable=False)
|
||||
|
||||
|
||||
class ShoppingList(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(150), nullable=False)
|
||||
@@ -198,9 +207,10 @@ class ShoppingList(db.Model):
|
||||
categories = db.relationship(
|
||||
"Category",
|
||||
secondary=shopping_list_category,
|
||||
backref=db.backref("shopping_lists", lazy="dynamic")
|
||||
backref=db.backref("shopping_lists", lazy="dynamic"),
|
||||
)
|
||||
|
||||
|
||||
class Item(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id"))
|
||||
@@ -255,9 +265,14 @@ def handle_db_error(e):
|
||||
app.logger.error(f"[Błąd DB] {e}")
|
||||
|
||||
if request.accept_mimetypes.best == "application/json":
|
||||
return jsonify({
|
||||
"error": "Baza danych jest obecnie niedostępna. Spróbuj ponownie później."
|
||||
}), 503
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": "Baza danych jest obecnie niedostępna. Spróbuj ponownie później."
|
||||
}
|
||||
),
|
||||
503,
|
||||
)
|
||||
|
||||
return (
|
||||
render_template(
|
||||
@@ -288,6 +303,7 @@ def check_password(stored_hash, password_input):
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def set_authorized_cookie(response):
|
||||
|
||||
secure_flag = app.config["SESSION_COOKIE_SECURE"] # wartość z config.py
|
||||
@@ -301,7 +317,7 @@ def set_authorized_cookie(response):
|
||||
secure=secure_flag,
|
||||
httponly=True,
|
||||
samesite="Lax",
|
||||
path="/"
|
||||
path="/",
|
||||
)
|
||||
return response
|
||||
|
||||
@@ -331,32 +347,25 @@ with app.app_context():
|
||||
print(f"[INFO] Zmieniono hasło admina '{admin_username}' z konfiguracji.")
|
||||
db.session.commit()
|
||||
else:
|
||||
db.session.add(User(
|
||||
username=admin_username,
|
||||
password_hash=password_hash,
|
||||
is_admin=True
|
||||
))
|
||||
db.session.add(
|
||||
User(username=admin_username, password_hash=password_hash, is_admin=True)
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
# --- Predefiniowane kategorie ---
|
||||
default_categories = [
|
||||
"Spożywcze", "Budowlane", "Zabawki", "Chemia", "Inne",
|
||||
"Elektronika", "Odzież i obuwie", "Artykuły biurowe",
|
||||
"Kosmetyki i higiena", "Motoryzacja", "Ogród i rośliny",
|
||||
"Zwierzęta", "Sprzęt sportowy", "Książki i prasa",
|
||||
"Narzędzia i majsterkowanie", "RTV / AGD", "Apteka i suplementy",
|
||||
"Artykuły dekoracyjne", "Gry i hobby", "Usługi"
|
||||
]
|
||||
default_categories = app.config["DEFAULT_CATEGORIES"]
|
||||
|
||||
# Pobierz istniejące nazwy z bazy, ignorując puste/niewłaściwe rekordy
|
||||
existing_names = {
|
||||
c.name for c in Category.query.filter(Category.name.isnot(None)).all()
|
||||
}
|
||||
|
||||
# Znajdź brakujące
|
||||
missing = [cat for cat in default_categories if cat not in existing_names]
|
||||
# ignorujemy wielkość liter przy porównaniu
|
||||
existing_names_lower = {name.lower() for name in existing_names}
|
||||
|
||||
missing = [
|
||||
cat for cat in default_categories if cat.lower() not in existing_names_lower
|
||||
]
|
||||
|
||||
# Dodaj tylko brakujące
|
||||
if missing:
|
||||
db.session.add_all(Category(name=cat) for cat in missing)
|
||||
db.session.commit()
|
||||
@@ -656,7 +665,7 @@ def get_total_expenses_grouped_by_list_created_at(
|
||||
if category_id:
|
||||
lists_query = lists_query.join(
|
||||
shopping_list_category,
|
||||
shopping_list_category.c.shopping_list_id == ShoppingList.id
|
||||
shopping_list_category.c.shopping_list_id == ShoppingList.id,
|
||||
).filter(shopping_list_category.c.category_id == category_id)
|
||||
|
||||
if start_date and end_date:
|
||||
@@ -737,6 +746,158 @@ def recalculate_filesizes(receipt_id: int = None):
|
||||
return updated, unchanged, not_found
|
||||
|
||||
|
||||
def get_admin_expense_summary():
|
||||
now = datetime.now(timezone.utc)
|
||||
current_year = now.year
|
||||
current_month = now.month
|
||||
|
||||
def calc_sum(base_query):
|
||||
total = base_query.scalar() or 0
|
||||
year_total = (
|
||||
base_query.filter(
|
||||
extract("year", Expense.added_at) == current_year
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
month_total = (
|
||||
base_query.filter(extract("year", Expense.added_at) == current_year)
|
||||
.filter(extract("month", Expense.added_at) == current_month)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
return {"total": total, "year": year_total, "month": month_total}
|
||||
|
||||
# baza wspólna
|
||||
base = db.session.query(func.sum(Expense.amount)).join(
|
||||
ShoppingList, ShoppingList.id == Expense.list_id
|
||||
)
|
||||
|
||||
# wszystkie listy
|
||||
all_lists = calc_sum(base)
|
||||
|
||||
# aktywne listy
|
||||
active_lists = calc_sum(
|
||||
base.filter(
|
||||
ShoppingList.is_archived == False,
|
||||
or_(ShoppingList.expires_at == None, ShoppingList.expires_at > now),
|
||||
)
|
||||
)
|
||||
|
||||
# archiwalne
|
||||
archived_lists = calc_sum(base.filter(ShoppingList.is_archived == True))
|
||||
|
||||
# wygasłe
|
||||
expired_lists = calc_sum(
|
||||
base.filter(
|
||||
ShoppingList.is_archived == False,
|
||||
ShoppingList.expires_at != None,
|
||||
ShoppingList.expires_at <= now,
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"all": all_lists,
|
||||
"active": active_lists,
|
||||
"archived": archived_lists,
|
||||
"expired": expired_lists,
|
||||
}
|
||||
|
||||
|
||||
def category_to_color(name):
|
||||
"""Generuje powtarzalny pastelowy kolor HEX na podstawie nazwy kategorii."""
|
||||
hash_val = int(hashlib.md5(name.encode("utf-8")).hexdigest(), 16)
|
||||
r = (hash_val & 0xFF0000) >> 16
|
||||
g = (hash_val & 0x00FF00) >> 8
|
||||
b = hash_val & 0x0000FF
|
||||
# Rozjaśnienie (pastel)
|
||||
r = (r + 255) // 2
|
||||
g = (g + 255) // 2
|
||||
b = (b + 255) // 2
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
|
||||
|
||||
def get_total_expenses_grouped_by_category(
|
||||
show_all, range_type, start_date, end_date, user_id
|
||||
):
|
||||
lists_query = ShoppingList.query
|
||||
|
||||
if show_all:
|
||||
lists_query = lists_query.filter(
|
||||
or_(ShoppingList.owner_id == user_id, ShoppingList.is_public == True)
|
||||
)
|
||||
else:
|
||||
lists_query = lists_query.filter(ShoppingList.owner_id == user_id)
|
||||
|
||||
if start_date and end_date:
|
||||
try:
|
||||
dt_start = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
dt_end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
|
||||
except Exception:
|
||||
return {"error": "Błędne daty"}
|
||||
lists_query = lists_query.filter(
|
||||
ShoppingList.created_at >= dt_start, ShoppingList.created_at < dt_end
|
||||
)
|
||||
|
||||
lists = lists_query.options(joinedload(ShoppingList.categories)).all()
|
||||
if not lists:
|
||||
return {"labels": [], "datasets": []}
|
||||
|
||||
data_map = defaultdict(lambda: defaultdict(float))
|
||||
all_labels = set()
|
||||
|
||||
for l in lists:
|
||||
total_expense = (
|
||||
db.session.query(func.sum(Expense.amount))
|
||||
.filter(Expense.list_id == l.id)
|
||||
.scalar()
|
||||
) or 0
|
||||
|
||||
if total_expense <= 0:
|
||||
continue
|
||||
|
||||
if range_type == "monthly":
|
||||
key = l.created_at.strftime("%Y-%m")
|
||||
elif range_type == "quarterly":
|
||||
key = f"{l.created_at.year}-Q{((l.created_at.month - 1) // 3 + 1)}"
|
||||
elif range_type == "halfyearly":
|
||||
key = f"{l.created_at.year}-H{1 if l.created_at.month <= 6 else 2}"
|
||||
elif range_type == "yearly":
|
||||
key = str(l.created_at.year)
|
||||
else:
|
||||
key = l.created_at.strftime("%Y-%m-%d")
|
||||
|
||||
all_labels.add(key)
|
||||
|
||||
if not l.categories:
|
||||
data_map[key]["Inne"] += total_expense
|
||||
else:
|
||||
for c in l.categories:
|
||||
data_map[key][c.name] += total_expense
|
||||
|
||||
labels = sorted(all_labels)
|
||||
|
||||
categories_with_expenses = sorted(
|
||||
{
|
||||
cat
|
||||
for cat_data in data_map.values()
|
||||
for cat, value in cat_data.items()
|
||||
if value > 0
|
||||
}
|
||||
)
|
||||
|
||||
datasets = []
|
||||
for cat in categories_with_expenses:
|
||||
datasets.append(
|
||||
{
|
||||
"label": cat,
|
||||
"data": [round(data_map[label].get(cat, 0), 2) for label in labels],
|
||||
"backgroundColor": category_to_color(cat),
|
||||
}
|
||||
)
|
||||
|
||||
return {"labels": labels, "datasets": datasets}
|
||||
|
||||
|
||||
############# OCR ###########################
|
||||
|
||||
|
||||
@@ -1143,8 +1304,7 @@ def main_page():
|
||||
# ostatnia kwota (w tym przypadku max = suma z ostatniego zapisu)
|
||||
latest_expenses_map = dict(
|
||||
db.session.query(
|
||||
Expense.list_id,
|
||||
func.coalesce(func.sum(Expense.amount), 0)
|
||||
Expense.list_id, func.coalesce(func.sum(Expense.amount), 0)
|
||||
)
|
||||
.filter(Expense.list_id.in_(all_ids))
|
||||
.group_by(Expense.list_id)
|
||||
@@ -1185,7 +1345,10 @@ def system_auth():
|
||||
next_page = request.args.get("next") or url_for("main_page")
|
||||
|
||||
if is_ip_blocked(ip):
|
||||
flash("Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.", "danger")
|
||||
flash(
|
||||
"Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.",
|
||||
"danger",
|
||||
)
|
||||
return render_template("system_auth.html"), 403
|
||||
|
||||
if request.method == "POST":
|
||||
@@ -1196,7 +1359,10 @@ def system_auth():
|
||||
else:
|
||||
register_failed_attempt(ip)
|
||||
if is_ip_blocked(ip):
|
||||
flash("Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.", "danger")
|
||||
flash(
|
||||
"Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.",
|
||||
"danger",
|
||||
)
|
||||
return render_template("system_auth.html"), 403
|
||||
remaining = attempts_remaining(ip)
|
||||
flash(f"Nieprawidłowe hasło. Pozostało {remaining} prób.", "warning")
|
||||
@@ -1306,11 +1472,10 @@ def edit_my_list(list_id):
|
||||
list=l,
|
||||
receipts=receipts,
|
||||
categories=categories,
|
||||
selected_categories=selected_categories_ids
|
||||
selected_categories=selected_categories_ids,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@app.route("/delete_user_list/<int:list_id>", methods=["POST"])
|
||||
@login_required
|
||||
def delete_user_list(list_id):
|
||||
@@ -1452,7 +1617,28 @@ def user_expenses():
|
||||
category_id = request.args.get("category_id", type=int)
|
||||
show_all = request.args.get("show_all", "true").lower() == "true"
|
||||
|
||||
categories = Category.query.order_by(Category.name.asc()).all()
|
||||
categories = (
|
||||
Category.query.join(
|
||||
shopping_list_category, shopping_list_category.c.category_id == Category.id
|
||||
)
|
||||
.join(
|
||||
ShoppingList, ShoppingList.id == shopping_list_category.c.shopping_list_id
|
||||
)
|
||||
.join(Expense, Expense.list_id == ShoppingList.id)
|
||||
.filter(
|
||||
or_(
|
||||
ShoppingList.owner_id == current_user.id,
|
||||
(
|
||||
ShoppingList.is_public == True
|
||||
if show_all
|
||||
else ShoppingList.owner_id == current_user.id
|
||||
),
|
||||
)
|
||||
)
|
||||
.distinct()
|
||||
.order_by(Category.name.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
start = None
|
||||
end = None
|
||||
@@ -1468,15 +1654,14 @@ def user_expenses():
|
||||
else:
|
||||
expenses_query = expenses_query.filter(
|
||||
or_(
|
||||
ShoppingList.owner_id == current_user.id,
|
||||
ShoppingList.is_public == True
|
||||
ShoppingList.owner_id == current_user.id, ShoppingList.is_public == True
|
||||
)
|
||||
)
|
||||
|
||||
if category_id:
|
||||
expenses_query = expenses_query.join(
|
||||
shopping_list_category,
|
||||
shopping_list_category.c.shopping_list_id == ShoppingList.id
|
||||
shopping_list_category.c.shopping_list_id == ShoppingList.id,
|
||||
).filter(shopping_list_category.c.category_id == category_id)
|
||||
|
||||
if start_date_str and end_date_str:
|
||||
@@ -1522,7 +1707,7 @@ def user_expenses():
|
||||
"created_at": l.created_at,
|
||||
"total_expense": totals_map.get(l.id, 0),
|
||||
"owner_username": l.owner.username if l.owner else "?",
|
||||
"categories": [c.id for c in l.categories]
|
||||
"categories": [c.id for c in l.categories],
|
||||
}
|
||||
for l in {e.shopping_list for e in expenses if e.shopping_list}
|
||||
]
|
||||
@@ -1545,17 +1730,27 @@ def user_expenses_data():
|
||||
end_date = request.args.get("end_date")
|
||||
show_all = request.args.get("show_all", "true").lower() == "true"
|
||||
category_id = request.args.get("category_id", type=int)
|
||||
by_category = request.args.get("by_category", "false").lower() == "true"
|
||||
|
||||
result = get_total_expenses_grouped_by_list_created_at(
|
||||
user_only=True,
|
||||
admin=False,
|
||||
show_all=show_all,
|
||||
range_type=range_type,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
user_id=current_user.id,
|
||||
category_id=category_id
|
||||
)
|
||||
if by_category:
|
||||
result = get_total_expenses_grouped_by_category(
|
||||
show_all=show_all,
|
||||
range_type=range_type,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
else:
|
||||
result = get_total_expenses_grouped_by_list_created_at(
|
||||
user_only=True,
|
||||
admin=False,
|
||||
show_all=show_all,
|
||||
range_type=range_type,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
user_id=current_user.id,
|
||||
category_id=category_id,
|
||||
)
|
||||
|
||||
if "error" in result:
|
||||
return jsonify({"error": result["error"]}), 400
|
||||
@@ -1640,12 +1835,14 @@ def all_products():
|
||||
)
|
||||
|
||||
top_products = (
|
||||
top_products_query.order_by(
|
||||
SuggestedProduct.name.asc(), # musi być pierwsze
|
||||
SuggestedProduct.usage_count.desc(),
|
||||
db.session.query(
|
||||
func.lower(Item.name).label("name"), func.sum(Item.quantity).label("count")
|
||||
)
|
||||
.distinct(SuggestedProduct.name)
|
||||
.limit(20)
|
||||
.join(ShoppingList, ShoppingList.id == Item.list_id)
|
||||
.filter(Item.purchased.is_(True))
|
||||
.group_by(func.lower(Item.name))
|
||||
.order_by(func.sum(Item.quantity).desc())
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
|
||||
@@ -1887,6 +2084,7 @@ def admin_panel():
|
||||
joinedload(ShoppingList.items),
|
||||
joinedload(ShoppingList.receipts),
|
||||
joinedload(ShoppingList.expenses),
|
||||
joinedload(ShoppingList.categories),
|
||||
).all()
|
||||
|
||||
all_ids = [l.id for l in all_lists]
|
||||
@@ -1914,15 +2112,13 @@ def admin_panel():
|
||||
|
||||
latest_expenses_map = dict(
|
||||
db.session.query(
|
||||
Expense.list_id,
|
||||
func.coalesce(func.sum(Expense.amount), 0)
|
||||
Expense.list_id, func.coalesce(func.sum(Expense.amount), 0)
|
||||
)
|
||||
.filter(Expense.list_id.in_(all_ids))
|
||||
.group_by(Expense.list_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
enriched_lists = []
|
||||
for l in all_lists:
|
||||
total_count, purchased_count = stats_map.get(l.id, (0, 0))
|
||||
@@ -1949,6 +2145,7 @@ def admin_panel():
|
||||
"receipts_count": receipts_count,
|
||||
"total_expense": total_expense,
|
||||
"expired": is_expired,
|
||||
"categories": l.categories,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1964,24 +2161,8 @@ def admin_panel():
|
||||
|
||||
purchased_items_count = Item.query.filter_by(purchased=True).count()
|
||||
|
||||
# Podsumowania wydatków globalnych
|
||||
total_expense_sum = db.session.query(func.sum(Expense.amount)).scalar() or 0
|
||||
current_time = datetime.now(timezone.utc)
|
||||
current_year = current_time.year
|
||||
current_month = current_time.month
|
||||
|
||||
year_expense_sum = (
|
||||
db.session.query(func.sum(Expense.amount))
|
||||
.filter(extract("year", Expense.added_at) == current_year)
|
||||
.scalar()
|
||||
) or 0
|
||||
|
||||
month_expense_sum = (
|
||||
db.session.query(func.sum(Expense.amount))
|
||||
.filter(extract("year", Expense.added_at) == current_year)
|
||||
.filter(extract("month", Expense.added_at) == current_month)
|
||||
.scalar()
|
||||
) or 0
|
||||
# Nowe podsumowanie wydatków
|
||||
expense_summary = get_admin_expense_summary()
|
||||
|
||||
# Statystyki systemowe
|
||||
process = psutil.Process(os.getpid())
|
||||
@@ -1996,9 +2177,7 @@ def admin_panel():
|
||||
|
||||
inspector = inspect(db_engine)
|
||||
table_count = len(inspector.get_table_names())
|
||||
|
||||
record_total = get_total_records()
|
||||
|
||||
uptime_minutes = int(
|
||||
(datetime.now(timezone.utc) - app_start_time).total_seconds() // 60
|
||||
)
|
||||
@@ -2011,9 +2190,7 @@ def admin_panel():
|
||||
purchased_items_count=purchased_items_count,
|
||||
enriched_lists=enriched_lists,
|
||||
top_products=top_products,
|
||||
total_expense_sum=total_expense_sum,
|
||||
year_expense_sum=year_expense_sum,
|
||||
month_expense_sum=month_expense_sum,
|
||||
expense_summary=expense_summary,
|
||||
now=now,
|
||||
python_version=sys.version,
|
||||
system_info=platform.platform(),
|
||||
@@ -2025,7 +2202,6 @@ def admin_panel():
|
||||
)
|
||||
|
||||
|
||||
|
||||
@app.route("/admin/delete_list/<int:list_id>")
|
||||
@login_required
|
||||
@admin_required
|
||||
@@ -2291,7 +2467,7 @@ def edit_list(list_id):
|
||||
joinedload(ShoppingList.owner),
|
||||
joinedload(ShoppingList.items),
|
||||
joinedload(ShoppingList.categories),
|
||||
]
|
||||
],
|
||||
)
|
||||
|
||||
if l is None:
|
||||
@@ -2378,7 +2554,7 @@ def edit_list(list_id):
|
||||
db.session.commit()
|
||||
flash("Zapisano zmiany listy", "success")
|
||||
return redirect(url_for("edit_list", list_id=list_id))
|
||||
|
||||
|
||||
elif action == "add_item":
|
||||
item_name = request.form.get("item_name", "").strip()
|
||||
quantity_str = request.form.get("quantity", "1")
|
||||
@@ -2487,7 +2663,7 @@ def edit_list(list_id):
|
||||
items=items,
|
||||
receipts=receipts,
|
||||
categories=categories,
|
||||
selected_categories=selected_categories_ids
|
||||
selected_categories=selected_categories_ids,
|
||||
)
|
||||
|
||||
|
||||
@@ -2640,7 +2816,11 @@ def recalculate_filesizes_all():
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_mass_edit_categories():
|
||||
lists = ShoppingList.query.options(joinedload(ShoppingList.categories)).order_by(ShoppingList.created_at.desc()).all()
|
||||
lists = (
|
||||
ShoppingList.query.options(joinedload(ShoppingList.categories))
|
||||
.order_by(ShoppingList.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
categories = Category.query.order_by(Category.name.asc()).all()
|
||||
|
||||
if request.method == "POST":
|
||||
@@ -2654,7 +2834,9 @@ def admin_mass_edit_categories():
|
||||
flash("Zaktualizowano kategorie dla wybranych list", "success")
|
||||
return redirect(url_for("admin_mass_edit_categories"))
|
||||
|
||||
return render_template("admin/mass_edit_categories.html", lists=lists, categories=categories)
|
||||
return render_template(
|
||||
"admin/mass_edit_categories.html", lists=lists, categories=categories
|
||||
)
|
||||
|
||||
|
||||
@app.route("/healthcheck")
|
||||
|
10
config.py
10
config.py
@@ -71,3 +71,13 @@ class Config:
|
||||
UPLOADS_CACHE_CONTROL = os.environ.get(
|
||||
"UPLOADS_CACHE_CONTROL", "public, max-age=2592000, immutable"
|
||||
)
|
||||
|
||||
DEFAULT_CATEGORIES = [
|
||||
c.strip() for c in os.environ.get(
|
||||
"DEFAULT_CATEGORIES",
|
||||
"Spożywcze,Budowlane,Zabawki,Chemia,Inne,Elektronika,Odzież i obuwie,"
|
||||
"Artykuły biurowe,Kosmetyki i higiena,Motoryzacja,Ogród i rośliny,"
|
||||
"Zwierzęta,Sprzęt sportowy,Książki i prasa,Narzędzia i majsterkowanie,"
|
||||
"RTV / AGD,Apteka i suplementy,Artykuły dekoracyjne,Gry i hobby,Usługi,Pieczywo"
|
||||
).split(",") if c.strip()
|
||||
]
|
@@ -1,6 +1,7 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
let expensesChart = null;
|
||||
let selectedCategoryId = "";
|
||||
let categorySplit = false; // <-- nowy tryb
|
||||
const rangeLabel = document.getElementById("chartRangeLabel");
|
||||
|
||||
function loadExpenses(range = "monthly", startDate = null, endDate = null) {
|
||||
@@ -15,6 +16,9 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
if (selectedCategoryId) {
|
||||
url += `&category_id=${selectedCategoryId}`;
|
||||
}
|
||||
if (categorySplit) {
|
||||
url += '&by_category=true';
|
||||
}
|
||||
|
||||
fetch(url, { cache: "no-store" })
|
||||
.then(response => response.json())
|
||||
@@ -25,24 +29,44 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
expensesChart.destroy();
|
||||
}
|
||||
|
||||
expensesChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [{
|
||||
label: 'Suma wydatków [PLN]',
|
||||
data: data.expenses,
|
||||
backgroundColor: '#0d6efd'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
if (categorySplit) {
|
||||
// Tryb z podziałem na kategorie
|
||||
expensesChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: data.datasets // <-- gotowe z backendu
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
tooltip: { mode: 'index', intersect: false },
|
||||
legend: { position: 'top' }
|
||||
},
|
||||
scales: {
|
||||
x: { stacked: true },
|
||||
y: { stacked: true, beginAtZero: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Tryb zwykły
|
||||
expensesChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [{
|
||||
label: 'Suma wydatków [PLN]',
|
||||
data: data.expenses,
|
||||
backgroundColor: '#0d6efd'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: { y: { beginAtZero: true } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (startDate && endDate) {
|
||||
rangeLabel.textContent = `Widok: własny zakres (${startDate} → ${endDate})`;
|
||||
@@ -54,13 +78,28 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
else if (range === "yearly") labelText = "Widok: roczne";
|
||||
rangeLabel.textContent = labelText;
|
||||
}
|
||||
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Błąd pobierania danych:", error);
|
||||
});
|
||||
}
|
||||
|
||||
// Obsługa przycisku przełączania trybu
|
||||
document.getElementById("toggleCategorySplit").addEventListener("click", function () {
|
||||
categorySplit = !categorySplit;
|
||||
if (categorySplit) {
|
||||
this.textContent = "🔵 Pokaż całościowo";
|
||||
this.classList.remove("btn-outline-warning");
|
||||
this.classList.add("btn-outline-info");
|
||||
} else {
|
||||
this.textContent = "🎨 Pokaż podział na kategorie";
|
||||
this.classList.remove("btn-outline-info");
|
||||
this.classList.add("btn-outline-warning");
|
||||
}
|
||||
loadExpenses(); // przeładuj wykres
|
||||
});
|
||||
|
||||
// Reszta Twojego kodu bez zmian...
|
||||
const startDateInput = document.getElementById("startDate");
|
||||
const endDateInput = document.getElementById("endDate");
|
||||
const today = new Date();
|
||||
@@ -97,7 +136,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
document.querySelectorAll('.category-filter').forEach(b => b.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
selectedCategoryId = this.dataset.categoryId || "";
|
||||
loadExpenses(); // odśwież wykres z nowym filtrem
|
||||
loadExpenses();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -7,70 +7,117 @@
|
||||
<a href="/" class="btn btn-outline-secondary">← Powrót do strony głównej</a>
|
||||
</div>
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark rounded mb-4">
|
||||
<div class="container-fluid p-0">
|
||||
<a class="navbar-brand" href="#">Funkcje:</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#adminNavbar"
|
||||
aria-controls="adminNavbar" aria-expanded="false" aria-label="Przełącz nawigację">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="adminNavbar">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('list_users') }}">👥 Zarządzanie użytkownikami</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin_receipts', id='all') }}">📸 Wszystkie paragony</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('list_products') }}">🛍️ Produkty i sugestie</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin_mass_edit_categories') }}">🗂 Masowa edycja kategorii</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
<div class="card bg-dark text-white mb-4">
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a href="{{ url_for('list_users') }}" class="btn btn-outline-light btn-sm">👥 Użytkownicy</a>
|
||||
<a href="{{ url_for('admin_receipts', id='all') }}" class="btn btn-outline-light btn-sm">📸 Paragony</a>
|
||||
<a href="{{ url_for('list_products') }}" class="btn btn-outline-light btn-sm">🛍️ Produkty</a>
|
||||
<a href="{{ url_for('admin_mass_edit_categories') }}" class="btn btn-outline-light btn-sm">🗂 Kategorie</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<!-- Statystyki liczbowe -->
|
||||
<div class="col-md-4">
|
||||
<div class="card bg-dark text-white h-100">
|
||||
<div class="card-body">
|
||||
<p><strong>👤 Liczba użytkowników:</strong> {{ user_count }}</p>
|
||||
<p><strong>📝 Liczba list zakupowych:</strong> {{ list_count }}</p>
|
||||
<p><strong>🛒 Liczba produktów:</strong> {{ item_count }}</p>
|
||||
<p><strong>✅ Zakupionych produktów:</strong> {{ purchased_items_count }}</p>
|
||||
<h5 class="mb-3">📊 Statystyki ogólne</h5>
|
||||
<table class="table table-dark table-sm mb-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>👤 Użytkownicy</td>
|
||||
<td class="text-end fw-bold">{{ user_count }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>📝 Listy zakupowe</td>
|
||||
<td class="text-end fw-bold">{{ list_count }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🛒 Produkty</td>
|
||||
<td class="text-end fw-bold">{{ item_count }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>✅ Zakupione</td>
|
||||
<td class="text-end fw-bold">{{ purchased_items_count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if top_products %}
|
||||
<!-- Najczęściej kupowane -->
|
||||
<div class="col-md-4">
|
||||
<div class="card bg-dark text-white h-100">
|
||||
<div class="card-body">
|
||||
<h5>🔥 Najczęściej kupowane produkty:</h5>
|
||||
<ul class="mb-0">
|
||||
{% for name, count in top_products %}
|
||||
<li>{{ name }} — {{ count }}×</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<h5 class="mb-3">🔥 Najczęściej kupowane produkty</h5>
|
||||
{% if top_products %}
|
||||
{% set max_count = top_products[0][1] %}
|
||||
{% for name, count in top_products %}
|
||||
<div class="mb-2">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span>{{ name }}</span>
|
||||
<span class="text-muted">{{ count }}×</span>
|
||||
</div>
|
||||
<div class="progress" style="height: 6px;">
|
||||
<div class="progress-bar bg-success" role="progressbar" style="width: {{ (count / max_count) * 100 }}%"
|
||||
aria-valuenow="{{ count }}" aria-valuemin="0" aria-valuemax="{{ max_count }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Brak danych</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Podsumowanie wydatków -->
|
||||
<div class="col-md-4">
|
||||
<div class="card bg-dark text-white h-100">
|
||||
<div class="card-body">
|
||||
<h5>💸 Podsumowanie wydatków:</h5>
|
||||
<ul class="mb-3">
|
||||
<li><strong>Obecny miesiąc:</strong> {{ '%.2f'|format(month_expense_sum) }} PLN</li>
|
||||
<li><strong>Obecny rok:</strong> {{ '%.2f'|format(year_expense_sum) }} PLN</li>
|
||||
<li><strong>Całkowite:</strong> {{ '%.2f'|format(total_expense_sum) }} PLN</li>
|
||||
</ul>
|
||||
|
||||
<table class="table table-dark table-sm mb-3">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Typ listy</th>
|
||||
<th>Miesiąc</th>
|
||||
<th>Rok</th>
|
||||
<th>Całkowite</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Wszystkie</td>
|
||||
<td>{{ '%.2f'|format(expense_summary.all.month) }} PLN</td>
|
||||
<td>{{ '%.2f'|format(expense_summary.all.year) }} PLN</td>
|
||||
<td>{{ '%.2f'|format(expense_summary.all.total) }} PLN</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Aktywne</td>
|
||||
<td>{{ '%.2f'|format(expense_summary.active.month) }} PLN</td>
|
||||
<td>{{ '%.2f'|format(expense_summary.active.year) }} PLN</td>
|
||||
<td>{{ '%.2f'|format(expense_summary.active.total) }} PLN</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Archiwalne</td>
|
||||
<td>{{ '%.2f'|format(expense_summary.archived.month) }} PLN</td>
|
||||
<td>{{ '%.2f'|format(expense_summary.archived.year) }} PLN</td>
|
||||
<td>{{ '%.2f'|format(expense_summary.archived.total) }} PLN</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Wygasłe</td>
|
||||
<td>{{ '%.2f'|format(expense_summary.expired.month) }} PLN</td>
|
||||
<td>{{ '%.2f'|format(expense_summary.expired.year) }} PLN</td>
|
||||
<td>{{ '%.2f'|format(expense_summary.expired.total) }} PLN</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<button type="button" class="btn btn-outline-primary w-100 mt-3" data-bs-toggle="modal"
|
||||
data-bs-target="#expensesChartModal" id="loadExpensesBtn">
|
||||
📊 Pokaż wykres wydatków
|
||||
@@ -78,134 +125,152 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<h3 class="mt-4">📄 Wszystkie listy zakupowe</h3>
|
||||
<form method="post" action="{{ url_for('delete_selected_lists') }}">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-striped align-middle sortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" id="select-all"></th>
|
||||
<th>ID</th>
|
||||
<th>Tytuł</th>
|
||||
<th>Status</th>
|
||||
<th>Utworzono</th>
|
||||
<th>Właściciel</th>
|
||||
<th>Produkty</th>
|
||||
<th>Wypełnienie</th>
|
||||
<th>Komentarze</th>
|
||||
<th>Paragony</th>
|
||||
<th>Wydatki</th>
|
||||
<th>Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in enriched_lists %}
|
||||
{% set l = e.list %}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="list_ids" value="{{ l.id }}"></td>
|
||||
<td>{{ l.id }}</td>
|
||||
<td class="fw-bold">
|
||||
<a href="{{ url_for('view_list', list_id=l.id) }}" class="text-white">{{ l.title }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if l.is_archived %}
|
||||
<span class="badge bg-secondary">Archiwalna</span>
|
||||
{% elif e.expired %}
|
||||
<span class="badge bg-warning text-dark">Wygasła</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">Aktywna</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }}</td>
|
||||
<td>
|
||||
{% if l.owner_id %}
|
||||
{{ l.owner_id }} / {{ l.owner.username if l.owner else 'Brak użytkownika' }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ e.total_count }}</td>
|
||||
<td>{{ e.purchased_count }}/{{ e.total_count }} ({{ e.percent }}%)</td>
|
||||
<td>{{ e.comments_count }}</td>
|
||||
<td>{{ e.receipts_count }}</td>
|
||||
<td>
|
||||
{% if e.total_expense > 0 %}
|
||||
{{ '%.2f'|format(e.total_expense) }} PLN
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="d-flex flex-wrap gap-1">
|
||||
<a href="{{ url_for('edit_list', list_id=l.id) }}" class="btn btn-sm btn-outline-primary">✏️
|
||||
Edytuj</a>
|
||||
<a href="{{ url_for('delete_list', list_id=l.id) }}" class="btn btn-sm btn-outline-danger">🗑️
|
||||
Usuń</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<h3 class="mt-4">📄 Wszystkie listy zakupowe</h3>
|
||||
<form method="post" action="{{ url_for('delete_selected_lists') }}">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-striped align-middle sortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" id="select-all"></th>
|
||||
<th>ID</th>
|
||||
<th>Tytuł</th>
|
||||
<th>Status</th>
|
||||
<th>Utworzono</th>
|
||||
<th>Właściciel</th>
|
||||
<th>Produkty</th>
|
||||
<th>Wypełnienie</th>
|
||||
<th>Komentarze</th>
|
||||
<th>Paragony</th>
|
||||
<th>Wydatki</th>
|
||||
<th>Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in enriched_lists %}
|
||||
{% set l = e.list %}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="list_ids" value="{{ l.id }}"></td>
|
||||
<td>{{ l.id }}</td>
|
||||
<td class="fw-bold align-middle">
|
||||
<a href="{{ url_for('view_list', list_id=l.id) }}" class="text-white">{{ l.title }}</a>
|
||||
{% if l.categories %}
|
||||
<span class="ms-1 text-info" data-bs-toggle="tooltip"
|
||||
title="{{ l.categories | map(attribute='name') | join(', ') }}">
|
||||
🏷
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
</table>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger mt-2">🗑️ Usuń zaznaczone listy</button>
|
||||
</form>
|
||||
</div>
|
||||
<td>
|
||||
{% if l.is_archived %}
|
||||
<span class="badge bg-secondary">Archiwalna</span>
|
||||
{% elif e.expired %}
|
||||
<span class="badge bg-warning text-dark">Wygasła</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">Aktywna</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }}</td>
|
||||
<td>
|
||||
{% if l.owner %}
|
||||
👤 {{ l.owner.username }} ({{ l.owner.id }})
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ e.total_count }}</td>
|
||||
<td>
|
||||
<div class="progress" style="height: 14px;">
|
||||
<div class="progress-bar
|
||||
{% if e.percent >= 80 %}bg-success
|
||||
{% elif e.percent >= 40 %}bg-warning
|
||||
{% else %}bg-danger{% endif %}" role="progressbar" style="width: {{ e.percent }}%">
|
||||
{{ e.purchased_count }}/{{ e.total_count }}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="badge bg-primary">{{ e.comments_count }}</span></td>
|
||||
<td><span class="badge bg-secondary">{{ e.receipts_count }}</span></td>
|
||||
<td class="fw-bold
|
||||
{% if e.total_expense >= 500 %}text-danger
|
||||
{% elif e.total_expense > 0 %}text-success{% endif %}">
|
||||
{% if e.total_expense > 0 %}
|
||||
{{ '%.2f'|format(e.total_expense) }} PLN
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="d-flex flex-wrap gap-1">
|
||||
<a href="{{ url_for('edit_list', list_id=l.id) }}" class="btn btn-sm btn-outline-primary">✏️ Edytuj</a>
|
||||
<a href="{{ url_for('delete_list', list_id=l.id) }}" class="btn btn-sm btn-outline-danger"
|
||||
onclick="return confirm('Na pewno usunąć tę listę?')">🗑️ Usuń</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger mt-2">🗑️ Usuń zaznaczone listy</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="expensesChartModal" tabindex="-1" aria-labelledby="expensesChartModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content bg-dark text-white rounded">
|
||||
<div class="modal-header border-0">
|
||||
<div>
|
||||
<h5 class="modal-title m-0" id="expensesChartModalLabel">📊 Wydatki</h5>
|
||||
<small id="chartRangeLabel" class="text-muted">Widok: miesięczne</small>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
|
||||
|
||||
<div class="modal fade" id="expensesChartModal" tabindex="-1" aria-labelledby="expensesChartModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content bg-dark text-white rounded">
|
||||
<div class="modal-header border-0">
|
||||
<div>
|
||||
<h5 class="modal-title m-0" id="expensesChartModalLabel">📊 Wydatki</h5>
|
||||
<small id="chartRangeLabel" class="text-muted">Widok: miesięczne</small>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
|
||||
</div>
|
||||
<div class="modal-body pt-0">
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<button class="btn btn-outline-light btn-sm range-btn active" data-range="monthly">📅 Miesięczne</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="quarterly">📊 Kwartalne</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="halfyearly">🗓️ Półroczne</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="yearly">📆 Roczne</button>
|
||||
</div>
|
||||
<div class="modal-body pt-0">
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<button class="btn btn-outline-light btn-sm range-btn active" data-range="monthly">📅 Miesięczne</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="quarterly">📊 Kwartalne</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="halfyearly">🗓️ Półroczne</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="yearly">📆 Roczne</button>
|
||||
</div>
|
||||
|
||||
<div class="input-group input-group-sm mb-3 w-100" style="max-width: 570px;">
|
||||
<span class="input-group-text bg-secondary text-white border-secondary">Od</span>
|
||||
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="startDate">
|
||||
<span class="input-group-text bg-secondary text-white border-secondary">Do</span>
|
||||
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="endDate">
|
||||
<button class="btn btn-outline-success" id="customRangeBtn">Pokaż dane z zakresu 📅</button>
|
||||
</div>
|
||||
<div class="input-group input-group-sm mb-3 w-100" style="max-width: 570px;">
|
||||
<span class="input-group-text bg-secondary text-white border-secondary">Od</span>
|
||||
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="startDate">
|
||||
<span class="input-group-text bg-secondary text-white border-secondary">Do</span>
|
||||
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="endDate">
|
||||
<button class="btn btn-outline-success" id="customRangeBtn">Pokaż dane z zakresu 📅</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-dark rounded p-2">
|
||||
<canvas id="expensesChart" height="100"></canvas>
|
||||
</div>
|
||||
<div class="bg-dark rounded p-2">
|
||||
<canvas id="expensesChart" height="100"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='chart.js') }}"></script>
|
||||
<script>
|
||||
document.getElementById('select-all').addEventListener('click', function () {
|
||||
const checkboxes = document.querySelectorAll('input[name="list_ids"]');
|
||||
checkboxes.forEach(cb => cb.checked = this.checked);
|
||||
});
|
||||
</script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='expenses.js') }}"></script>
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='chart.js') }}"></script>
|
||||
<script>
|
||||
document.getElementById('select-all').addEventListener('click', function () {
|
||||
const checkboxes = document.querySelectorAll('input[name="list_ids"]');
|
||||
checkboxes.forEach(cb => cb.checked = this.checked);
|
||||
});
|
||||
</script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='expenses.js') }}"></script>
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
<div class="info-bar-fixed">
|
||||
Python: {{ python_version.split()[0] }} | {{ system_info }} | RAM app: {{ app_memory }} |
|
||||
DB: {{ db_info.engine|upper }}{% if db_info.version %} v{{ db_info.version[0] }}{% endif %} |
|
||||
Tabele: {{ table_count }} | Rekordy: {{ record_total }} |
|
||||
Uptime: {{ uptime_minutes }} min
|
||||
</div>
|
||||
{% endblock %}
|
||||
<div class="info-bar-fixed">
|
||||
Python: {{ python_version.split()[0] }} | {{ system_info }} | RAM app: {{ app_memory }} |
|
||||
DB: {{ db_info.engine|upper }}{% if db_info.version %} v{{ db_info.version[0] }}{% endif %} |
|
||||
Tabele: {{ table_count }} | Rekordy: {{ record_total }} |
|
||||
Uptime: {{ uptime_minutes }} min
|
||||
</div>
|
||||
{% endblock %}
|
@@ -6,15 +6,22 @@
|
||||
<h2 class="mb-2">Statystyki wydatków</h2>
|
||||
<a href="{{ url_for('main_page') }}" class="btn btn-outline-secondary">← Powrót</a>
|
||||
</div>
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="showAllLists" {% if show_all %}checked{% endif %}>
|
||||
<label class="form-check-label ms-2 text-white" for="showAllLists">Pokaż wszystkie publiczne listy
|
||||
innych</label>
|
||||
|
||||
|
||||
<div class="d-flex justify-content-center mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="showAllLists" {% if show_all %}checked{% endif %}>
|
||||
<label class="form-check-label ms-2 text-white" for="showAllLists">
|
||||
Pokaż wszystkie publiczne listy innych
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<a href="{{ url_for('user_expenses', show_all='true') }}"
|
||||
class="btn btn-sm {% if not selected_category %}btn-success{% else %}btn-outline-light{% endif %}">🌐 Wszystkie</a>
|
||||
<div class="d-flex flex-wrap gap-2 mb-3 justify-content-center">
|
||||
<a href="{{ url_for('user_expenses') }}"
|
||||
class="btn btn-sm {% if not selected_category %}btn-success{% else %}btn-outline-light{% endif %}">
|
||||
🌐 Wszystkie
|
||||
</a>
|
||||
{% for cat in categories %}
|
||||
<a href="{{ url_for('user_expenses', category_id=cat.id) }}"
|
||||
class="btn btn-sm {% if selected_category == cat.id %}btn-success{% else %}btn-outline-light{% endif %}">
|
||||
@@ -23,6 +30,7 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<ul class="nav nav-tabs mb-3" id="expenseTabs" role="tablist">
|
||||
@@ -49,7 +57,7 @@
|
||||
<div class="card bg-dark text-white mb-4">
|
||||
<div class="card-body">
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<div class="d-flex flex-wrap gap-2 mb-3 justify-content-center">
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="day">🗓️ Dzień</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="week">📆 Tydzień</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn active" data-range="month">📅 Miesiąc</button>
|
||||
@@ -57,19 +65,25 @@
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="all">🌐 Wszystko</button>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="onlyWithExpenses">
|
||||
<label class="form-check-label ms-2 text-white" for="onlyWithExpenses">Pokaż tylko listy z
|
||||
wydatkami</label>
|
||||
|
||||
|
||||
<div class="d-flex justify-content-center mb-3">
|
||||
<div class="input-group input-group-sm w-100" style="max-width: 570px;">
|
||||
<span class="input-group-text bg-secondary text-white border-secondary">Od</span>
|
||||
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1"
|
||||
id="customStart">
|
||||
<span class="input-group-text bg-secondary text-white border-secondary">Do</span>
|
||||
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="customEnd">
|
||||
<button class="btn btn-outline-success" id="applyCustomRange">📊 Zastosuj zakres</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="input-group input-group-sm mb-3 w-100" style="max-width: 570px;">
|
||||
<span class="input-group-text bg-secondary text-white border-secondary">Od</span>
|
||||
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="customStart">
|
||||
<span class="input-group-text bg-secondary text-white border-secondary">Do</span>
|
||||
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="customEnd">
|
||||
<button class="btn btn-outline-success" id="applyCustomRange">📊 Zastosuj zakres</button>
|
||||
<div class="d-flex justify-content-center mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="onlyWithExpenses">
|
||||
<label class="form-check-label ms-2 text-white" for="onlyWithExpenses">
|
||||
Pokaż tylko listy z wydatkami
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end mb-2">
|
||||
@@ -123,13 +137,19 @@
|
||||
<div class="tab-pane fade" id="chartTab" role="tabpanel">
|
||||
<div class="card bg-dark text-white mb-4">
|
||||
<div class="card-body">
|
||||
|
||||
<button class="btn btn-outline-light w-100 py-2 mb-2 d-flex align-items-center justify-content-center gap-2"
|
||||
id="toggleCategorySplit">
|
||||
🎨 Pokaż podział na kategorie
|
||||
</button>
|
||||
|
||||
<p id="chartRangeLabel" class="fw-bold mb-3">Widok: miesięczne</p>
|
||||
<canvas id="expensesChart" height="120"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<div class="d-flex flex-wrap gap-2 mb-3 justify-content-center">
|
||||
<button class="btn btn-outline-light btn-sm range-btn active" data-range="monthly">📅 Miesięczne</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="quarterly">📊 Kwartalne</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="halfyearly">🗓️ Półroczne</button>
|
||||
@@ -137,14 +157,17 @@
|
||||
</div>
|
||||
|
||||
<!-- Picker daty w formie input-group -->
|
||||
<div class="input-group input-group-sm mb-4 w-100" style="max-width: 570px;">
|
||||
<span class="input-group-text bg-secondary text-white border-secondary">Od</span>
|
||||
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="startDate">
|
||||
<span class="input-group-text bg-secondary text-white border-secondary">Do</span>
|
||||
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="endDate">
|
||||
<button class="btn btn-outline-success" id="customRangeBtn">📊 Pokaż dane z zakresu</button>
|
||||
<div class="d-flex justify-content-center mb-4">
|
||||
<div class="input-group input-group-sm w-100" style="max-width: 570px;">
|
||||
<span class="input-group-text bg-secondary text-white border-secondary">Od</span>
|
||||
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="startDate">
|
||||
<span class="input-group-text bg-secondary text-white border-secondary">Do</span>
|
||||
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="endDate">
|
||||
<button class="btn btn-outline-success" id="customRangeBtn">📊 Pokaż dane z zakresu</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
Reference in New Issue
Block a user