listy, i inne funkcje

This commit is contained in:
Mateusz Gruszczyński
2025-09-22 14:01:57 +02:00
parent 0b221696d4
commit 1a423a8b92
8 changed files with 877 additions and 64 deletions

268
app.py
View File

@@ -9,9 +9,9 @@ from flask_login import (
UserMixin, UserMixin,
) )
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime from datetime import datetime, timezone
from markupsafe import Markup from markupsafe import Markup
from sqlalchemy import event from sqlalchemy import event, Numeric
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
import markdown as md import markdown as md
@@ -28,9 +28,16 @@ db = SQLAlchemy(app)
login_manager = LoginManager(app) login_manager = LoginManager(app)
login_manager.login_view = "zaloguj" login_manager.login_view = "zaloguj"
try:
from zoneinfo import ZoneInfo # Python 3.9+
except ImportError:
from backports.zoneinfo import ZoneInfo
LOCAL_TZ = ZoneInfo("Europe/Warsaw")
# MODELE # MODELE
class User(UserMixin, db.Model): class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False) username = db.Column(db.String(80), unique=True, nullable=False)
@@ -50,8 +57,8 @@ class Zbiorka(db.Model):
opis = db.Column(db.Text, nullable=False) opis = db.Column(db.Text, nullable=False)
numer_konta = db.Column(db.String(50), nullable=False) numer_konta = db.Column(db.String(50), nullable=False)
numer_telefonu_blik = db.Column(db.String(50), nullable=False) numer_telefonu_blik = db.Column(db.String(50), nullable=False)
cel = db.Column(db.Float, nullable=False, default=0.0) cel = db.Column(Numeric(12, 2), nullable=False, default=0)
stan = db.Column(db.Float, default=0.0) stan = db.Column(Numeric(12, 2), default=0)
ukryta = db.Column(db.Boolean, default=False) ukryta = db.Column(db.Boolean, default=False)
ukryj_kwote = db.Column(db.Boolean, default=False) ukryj_kwote = db.Column(db.Boolean, default=False)
zrealizowana = db.Column(db.Boolean, default=False) zrealizowana = db.Column(db.Boolean, default=False)
@@ -74,6 +81,26 @@ class Zbiorka(db.Model):
passive_deletes=True, passive_deletes=True,
) )
przedmioty = db.relationship(
"Przedmiot",
backref="zbiorka",
lazy=True,
order_by="Przedmiot.id.asc()",
cascade="all, delete-orphan",
passive_deletes=True,
)
class Przedmiot(db.Model):
id = db.Column(db.Integer, primary_key=True)
zbiorka_id = db.Column(
db.Integer,
db.ForeignKey("zbiorka.id", ondelete="CASCADE"),
nullable=False,
)
nazwa = db.Column(db.String(120), nullable=False)
link = db.Column(db.String(255), nullable=True)
cena = db.Column(Numeric(12, 2), nullable=True)
kupione = db.Column(db.Boolean, default=False)
class Wplata(db.Model): class Wplata(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@@ -82,7 +109,7 @@ class Wplata(db.Model):
db.ForeignKey("zbiorka.id", ondelete="CASCADE"), db.ForeignKey("zbiorka.id", ondelete="CASCADE"),
nullable=False, nullable=False,
) )
kwota = db.Column(db.Float, nullable=False) kwota = db.Column(Numeric(12, 2), nullable=False)
data = db.Column(db.DateTime, default=datetime.utcnow) data = db.Column(db.DateTime, default=datetime.utcnow)
opis = db.Column(db.Text, nullable=True) opis = db.Column(db.Text, nullable=True)
@@ -96,7 +123,7 @@ class Wydatek(db.Model):
db.ForeignKey("zbiorka.id", ondelete="CASCADE"), db.ForeignKey("zbiorka.id", ondelete="CASCADE"),
nullable=False, nullable=False,
) )
kwota = db.Column(db.Float, nullable=False) kwota = db.Column(Numeric(12, 2), nullable=False)
data = db.Column(db.DateTime, default=datetime.utcnow) data = db.Column(db.DateTime, default=datetime.utcnow)
opis = db.Column(db.Text, nullable=True) opis = db.Column(db.Text, nullable=True)
@@ -109,7 +136,6 @@ class GlobalSettings(db.Model):
logo_url = db.Column(db.String(255), nullable=True) logo_url = db.Column(db.String(255), nullable=True)
site_title = db.Column(db.String(120), nullable=True) site_title = db.Column(db.String(120), nullable=True)
show_logo_in_navbar = db.Column(db.Boolean, default=False) show_logo_in_navbar = db.Column(db.Boolean, default=False)
show_logo_in_navbar = db.Column(db.Boolean, default=False)
navbar_brand_mode = db.Column(db.String(10), default="text") navbar_brand_mode = db.Column(db.String(10), default="text")
footer_brand_mode = db.Column(db.String(10), default="text") footer_brand_mode = db.Column(db.String(10), default="text")
footer_text = db.Column(db.String(200), nullable=True) footer_text = db.Column(db.String(200), nullable=True)
@@ -172,7 +198,23 @@ def is_allowed_ip(remote_ip, allowed_hosts_str):
return remote_ip in allowed_ips return remote_ip in allowed_ips
# Dodaj filtr Markdown pozwala na zagnieżdżanie linków i obrazków w opisie def to_local(dt):
if dt is None:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(LOCAL_TZ)
@app.template_filter("dt")
def dt_filter(dt, fmt="%Y-%m-%d %H:%M"):
try:
ldt = to_local(dt)
return ldt.strftime(fmt) if ldt else ""
except Exception:
return ""
@app.template_filter("markdown") @app.template_filter("markdown")
def markdown_filter(text): def markdown_filter(text):
return Markup(md.markdown(text)) return Markup(md.markdown(text))
@@ -200,7 +242,7 @@ def index():
return render_template("index.html", zbiorki=zbiorki) return render_template("index.html", zbiorki=zbiorki)
@app.route("/zbiorki_zrealizowane") @app.route("/zrealizowane")
def zbiorki_zrealizowane(): def zbiorki_zrealizowane():
zbiorki = Zbiorka.query.filter_by(zrealizowana=True).all() zbiorki = Zbiorka.query.filter_by(zrealizowana=True).all()
return render_template("index.html", zbiorki=zbiorki) return render_template("index.html", zbiorki=zbiorki)
@@ -297,8 +339,6 @@ def zarejestruj():
# PANEL ADMINISTRACYJNY # PANEL ADMINISTRACYJNY
@app.route("/admin") @app.route("/admin")
@login_required @login_required
def admin_dashboard(): def admin_dashboard():
@@ -336,7 +376,7 @@ def formularz_zbiorek(zbiorka_id=None):
numer_konta = request.form.get("numer_konta", "").strip() numer_konta = request.form.get("numer_konta", "").strip()
numer_telefonu_blik = request.form.get("numer_telefonu_blik", "").strip() numer_telefonu_blik = request.form.get("numer_telefonu_blik", "").strip()
# Cel — walidacja liczby # Cel — walidacja liczby (Decimal, nie float)
try: try:
cel_str = request.form.get("cel", "").replace(",", ".").strip() cel_str = request.form.get("cel", "").replace(",", ".").strip()
cel = Decimal(cel_str) cel = Decimal(cel_str)
@@ -361,27 +401,85 @@ def formularz_zbiorek(zbiorka_id=None):
ukryj_kwote = "ukryj_kwote" in request.form ukryj_kwote = "ukryj_kwote" in request.form
names = request.form.getlist("item_nazwa[]")
links = request.form.getlist("item_link[]")
prices = request.form.getlist("item_cena[]")
def _read_price(val):
"""Zwraca Decimal(>=0) albo None; akceptuje przecinek jako separator dziesiętny."""
if not val or not val.strip():
return None
try:
d = Decimal(val.replace(",", "."))
if d < 0:
return None
return d
except Exception:
return None
# --- ZAPIS ZBIÓRKI + PRODUKTÓW ---
if is_edit: if is_edit:
# Aktualizacja istniejącej # Aktualizacja istniejącej zbiórki
zb.nazwa = nazwa zb.nazwa = nazwa
zb.opis = opis zb.opis = opis
zb.numer_konta = numer_konta zb.numer_konta = numer_konta
zb.numer_telefonu_blik = numer_telefonu_blik zb.numer_telefonu_blik = numer_telefonu_blik
zb.cel = float(cel) # jeśli masz Decimal w modelu, przypisz bez konwersji zb.cel = cel # ❗ bez float(cel) — zostaje Decimal
zb.ukryj_kwote = ukryj_kwote zb.ukryj_kwote = ukryj_kwote
db.session.commit() # najpierw zapisz bazowe pola
# Nadpisz listę produktów (czyść i dodaj od nowa dla prostoty)
zb.przedmioty.clear()
for i, raw_name in enumerate(names):
name = (raw_name or "").strip()
if not name:
continue
link = (links[i] if i < len(links) else "").strip() or None
cena_val = _read_price(prices[i] if i < len(prices) else "")
kupione_val = request.form.get(f"item_kupione_val_{i}") == "1"
db.session.add(Przedmiot(
zbiorka_id=zb.id,
nazwa=name,
link=link,
cena=cena_val, # Decimal albo None
kupione=kupione_val
))
db.session.commit() db.session.commit()
flash("Zbiórka została zaktualizowana", "success") flash("Zbiórka została zaktualizowana", "success")
else: else:
# Utworzenie nowej # Utworzenie nowej zbiórki
nowa = Zbiorka( nowa = Zbiorka(
nazwa=nazwa, nazwa=nazwa,
opis=opis, opis=opis,
numer_konta=numer_konta, numer_konta=numer_konta,
numer_telefonu_blik=numer_telefonu_blik, numer_telefonu_blik=numer_telefonu_blik,
cel=float(cel), cel=cel, # ❗ Decimal
ukryj_kwote=ukryj_kwote, ukryj_kwote=ukryj_kwote,
) )
db.session.add(nowa) db.session.add(nowa)
db.session.commit() # potrzebujemy ID nowej zbiórki
# Dodaj produkty do nowej zbiórki
for i, raw_name in enumerate(names):
name = (raw_name or "").strip()
if not name:
continue
link = (links[i] if i < len(links) else "").strip() or None
cena_val = _read_price(prices[i] if i < len(prices) else "")
kupione_val = request.form.get(f"item_kupione_val_{i}") == "1"
db.session.add(Przedmiot(
zbiorka_id=nowa.id,
nazwa=name,
link=link,
cena=cena_val, # Decimal albo None
kupione=kupione_val
))
db.session.commit() db.session.commit()
flash("Zbiórka została dodana", "success") flash("Zbiórka została dodana", "success")
@@ -393,24 +491,33 @@ def formularz_zbiorek(zbiorka_id=None):
) )
# TRASA DODAWANIA WPŁATY Z OPISEM
# TRASA DODAWANIA WPŁATY W PANELU ADMINA
@app.route("/admin/zbiorka/<int:zbiorka_id>/wplata/dodaj", methods=["GET", "POST"]) @app.route("/admin/zbiorka/<int:zbiorka_id>/wplata/dodaj", methods=["GET", "POST"])
@login_required @login_required
def dodaj_wplate(zbiorka_id): def dodaj_wplate(zbiorka_id):
if not current_user.is_admin: if not current_user.is_admin:
flash("Brak uprawnień", "danger") flash("Brak uprawnień", "danger")
return redirect(url_for("index")) return redirect(url_for("index"))
zb = Zbiorka.query.get_or_404(zbiorka_id) zb = Zbiorka.query.get_or_404(zbiorka_id)
if request.method == "POST": if request.method == "POST":
kwota = float(request.form["kwota"]) try:
kwota = Decimal(request.form.get("kwota", "").replace(",", "."))
if kwota <= 0:
raise InvalidOperation
except (InvalidOperation, ValueError):
flash("Nieprawidłowa kwota (musi być > 0)", "danger")
return redirect(url_for("dodaj_wplate", zbiorka_id=zbiorka_id))
opis = request.form.get("opis", "") opis = request.form.get("opis", "")
nowa_wplata = Wplata(zbiorka_id=zb.id, kwota=kwota, opis=opis) nowa_wplata = Wplata(zbiorka_id=zb.id, kwota=kwota, opis=opis)
zb.stan += kwota # Aktualizacja stanu zbiórki zb.stan = (zb.stan or Decimal("0")) + kwota
db.session.add(nowa_wplata) db.session.add(nowa_wplata)
db.session.commit() db.session.commit()
flash("Wpłata została dodana", "success") flash("Wpłata została dodana", "success")
return redirect(url_for("admin_dashboard"))
next_url = request.args.get("next")
return redirect(next_url or url_for("transakcje_zbiorki", zbiorka_id=zb.id))
return render_template("admin/dodaj_wplate.html", zbiorka=zb) return render_template("admin/dodaj_wplate.html", zbiorka=zb)
@@ -436,8 +543,8 @@ def edytuj_stan(zbiorka_id):
zb = Zbiorka.query.get_or_404(zbiorka_id) zb = Zbiorka.query.get_or_404(zbiorka_id)
if request.method == "POST": if request.method == "POST":
try: try:
nowy_stan = float(request.form["stan"]) nowy_stan = Decimal(request.form.get("stan", "").replace(",", "."))
except ValueError: except (InvalidOperation, ValueError):
flash("Nieprawidłowa wartość kwoty", "danger") flash("Nieprawidłowa wartość kwoty", "danger")
return redirect(url_for("edytuj_stan", zbiorka_id=zbiorka_id)) return redirect(url_for("edytuj_stan", zbiorka_id=zbiorka_id))
zb.stan = nowy_stan zb.stan = nowy_stan
@@ -469,8 +576,6 @@ def create_admin_account():
db.session.commit() db.session.commit()
from flask import request
@app.after_request @app.after_request
def apply_headers(response): def apply_headers(response):
@@ -588,22 +693,28 @@ def dodaj_wydatek(zbiorka_id):
if not current_user.is_admin: if not current_user.is_admin:
flash("Brak uprawnień", "danger") flash("Brak uprawnień", "danger")
return redirect(url_for("index")) return redirect(url_for("index"))
zb = Zbiorka.query.get_or_404(zbiorka_id) zb = Zbiorka.query.get_or_404(zbiorka_id)
if request.method == "POST": if request.method == "POST":
try: try:
kwota = float(request.form["kwota"]) kwota = Decimal(request.form.get("kwota", "").replace(",", "."))
if kwota <= 0: if kwota <= 0:
raise ValueError raise InvalidOperation
except (KeyError, ValueError): except (InvalidOperation, ValueError):
flash("Nieprawidłowa kwota", "danger") flash("Nieprawidłowa kwota (musi być > 0)", "danger")
return redirect(url_for("dodaj_wydatek", zbiorka_id=zbiorka_id)) return redirect(url_for("dodaj_wydatek", zbiorka_id=zbiorka_id))
opis = request.form.get("opis", "") opis = request.form.get("opis", "")
nowy_wydatek = Wydatek(zbiorka_id=zb.id, kwota=kwota, opis=opis) nowy_wydatek = Wydatek(zbiorka_id=zb.id, kwota=kwota, opis=opis)
zb.stan -= kwota zb.stan = (zb.stan or Decimal("0")) - kwota
db.session.add(nowy_wydatek) db.session.add(nowy_wydatek)
db.session.commit() db.session.commit()
flash("Wydatek został dodany", "success") flash("Wydatek został dodany", "success")
return redirect(url_for("admin_dashboard"))
next_url = request.args.get("next")
return redirect(next_url or url_for("transakcje_zbiorki", zbiorka_id=zb.id))
return render_template("admin/dodaj_wydatek.html", zbiorka=zb) return render_template("admin/dodaj_wydatek.html", zbiorka=zb)
@@ -646,6 +757,97 @@ def robots():
return robots_txt, 200, {"Content-Type": "text/plain"} return robots_txt, 200, {"Content-Type": "text/plain"}
@app.route("/admin/zbiorka/<int:zbiorka_id>/transakcje")
@login_required
def transakcje_zbiorki(zbiorka_id):
if not current_user.is_admin:
flash("Brak uprawnień", "danger"); return redirect(url_for("index"))
zb = Zbiorka.query.get_or_404(zbiorka_id)
aktywnosci = (
[{"typ": "wpłata", "id": w.id, "kwota": w.kwota, "opis": w.opis, "data": w.data} for w in zb.wplaty] +
[{"typ": "wydatek","id": x.id, "kwota": x.kwota,"opis": x.opis,"data": x.data} for x in zb.wydatki]
)
aktywnosci.sort(key=lambda a: a["data"], reverse=True)
return render_template("admin/transakcje.html", zbiorka=zb, aktywnosci=aktywnosci)
@app.route("/admin/wplata/<int:wplata_id>/zapisz", methods=["POST"])
@login_required
def zapisz_wplate(wplata_id):
if not current_user.is_admin:
flash("Brak uprawnień", "danger"); return redirect(url_for("index"))
w = Wplata.query.get_or_404(wplata_id)
zb = w.zbiorka
try:
nowa_kwota = Decimal(request.form.get("kwota", "").replace(",", "."))
if nowa_kwota <= 0:
raise InvalidOperation
except (InvalidOperation, ValueError):
flash("Nieprawidłowa kwota (musi być > 0)", "danger")
return redirect(url_for("transakcje_zbiorki", zbiorka_id=zb.id))
delta = nowa_kwota - (w.kwota or Decimal("0"))
w.kwota = nowa_kwota
w.opis = request.form.get("opis", "")
zb.stan = (zb.stan or Decimal("0")) + delta
db.session.commit()
flash("Wpłata zaktualizowana", "success")
return redirect(url_for("transakcje_zbiorki", zbiorka_id=zb.id))
@app.route("/admin/wplata/<int:wplata_id>/usun", methods=["POST"])
@login_required
def usun_wplate(wplata_id):
if not current_user.is_admin:
flash("Brak uprawnień", "danger"); return redirect(url_for("index"))
w = Wplata.query.get_or_404(wplata_id)
zb = w.zbiorka
zb.stan -= w.kwota
db.session.delete(w)
db.session.commit()
flash("Wpłata usunięta", "success")
return redirect(url_for("transakcje_zbiorki", zbiorka_id=zb.id))
@app.route("/admin/wydatek/<int:wydatek_id>/zapisz", methods=["POST"])
@login_required
def zapisz_wydatek(wydatek_id):
if not current_user.is_admin:
flash("Brak uprawnień", "danger"); return redirect(url_for("index"))
x = Wydatek.query.get_or_404(wydatek_id)
zb = x.zbiorka
try:
nowa_kwota = Decimal(request.form.get("kwota", "").replace(",", "."))
if nowa_kwota <= 0:
raise InvalidOperation
except (InvalidOperation, ValueError):
flash("Nieprawidłowa kwota (musi być > 0)", "danger")
return redirect(url_for("transakcje_zbiorki", zbiorka_id=zb.id))
delta = nowa_kwota - (x.kwota or Decimal("0"))
x.kwota = nowa_kwota
x.opis = request.form.get("opis", "")
# wydatki zmniejszają stan; jeżeli delta>0, stan spada bardziej
zb.stan = (zb.stan or Decimal("0")) - delta
db.session.commit()
flash("Wydatek zaktualizowany", "success")
return redirect(url_for("transakcje_zbiorki", zbiorka_id=zb.id))
@app.route("/admin/wydatek/<int:wydatek_id>/usun", methods=["POST"])
@login_required
def usun_wydatek(wydatek_id):
if not current_user.is_admin:
flash("Brak uprawnień", "danger"); return redirect(url_for("index"))
x = Wydatek.query.get_or_404(wydatek_id)
zb = x.zbiorka
zb.stan += x.kwota
db.session.delete(x)
db.session.commit()
flash("Wydatek usunięty", "success")
return redirect(url_for("transakcje_zbiorki", zbiorka_id=zb.id))
@app.route("/favicon.ico") @app.route("/favicon.ico")
def favicon(): def favicon():
return "", 204 return "", 204

View File

@@ -0,0 +1,153 @@
(function () {
const tbody = document.querySelector('#produkty-body');
const celInput = document.querySelector('#cel');
const box = document.querySelector('#celSyncBox');
const msg = document.querySelector('#celSyncMsg');
const btn = document.querySelector('#btnApplyCelFromSum');
if (!tbody || !celInput || !box || !msg || !btn) return;
const EPS = 0.01; // tolerancja porównania
function parsePrice(raw) {
if (!raw) return NaN;
const s = String(raw).trim().replace(/\s+/g, '').replace(',', '.');
const n = Number(s);
return Number.isFinite(n) && n >= 0 ? n : NaN;
}
function getRows() {
return Array.from(tbody.querySelectorAll('tr'));
}
function computeSum() {
const rows = getRows();
let hasNamed = false;
let sumAll = 0; // suma ze wszystkich wierszy z nazwą i poprawną ceną
let sumToBuy = 0; // suma tylko z wierszy NIE oznaczonych jako "Kupione"
for (const tr of rows) {
const nameInput = tr.querySelector('input[name="item_nazwa[]"]');
const priceInput = tr.querySelector('input[name="item_cena[]"]');
const kupioneSwitch = tr.querySelector('.kupione-switch');
const name = nameInput ? nameInput.value.trim() : '';
if (!name) continue; // ignoruj puste wiersze bez nazwy
hasNamed = true;
const priceVal = priceInput ? parsePrice(priceInput.value) : NaN;
if (Number.isNaN(priceVal)) continue;
// zawsze dolicz do sumy wszystkich
sumAll += priceVal;
// do sumy do-kupienia tylko jeśli nie jest oznaczone jako kupione
if (!(kupioneSwitch && kupioneSwitch.checked)) {
sumToBuy += priceVal;
}
}
return { hasNamed, sumAll, sumToBuy };
}
function readCel() {
const v = parsePrice(celInput.value);
return Number.isNaN(v) ? null : v;
}
function formatPln(n) {
// Nie narzucamy locale prosto 2 miejsca
return n.toFixed(2);
}
function updateUI() {
const { hasNamed, sumAll, sumToBuy } = computeSum();
// Brak produktów (brak nazw) lub obie sumy = 0 → nic nie pokazuj
if (!hasNamed || (sumAll <= 0 && sumToBuy <= 0)) {
box.classList.add('d-none');
btn.classList.add('d-none');
box.classList.remove('alert-success', 'alert-info');
msg.textContent = '';
return;
}
const cel = readCel();
const target = sumToBuy; // porównujemy do kwoty POZOSTAŁE DO KUPIENIA
// Jeśli cel nie ustawiony lub NaN → zaproponuj ustawienie celu = sumToBuy
if (cel === null) {
box.classList.remove('d-none');
box.classList.remove('alert-success');
box.classList.add('alert-info');
// pokazujemy obie sumy w komunikacie
msg.innerHTML = `
<div>Wszystkie: <strong>${formatPln(sumAll)} PLN</strong> ·
Do kupienia: <strong>${formatPln(sumToBuy)} PLN</strong></div>
<div class="mt-1">Możesz ustawić <strong>cel</strong> na kwotę do kupienia.</div>
`;
btn.textContent = `Ustaw cel = ${formatPln(target)} PLN`;
btn.classList.remove('d-none');
return;
}
// Mamy cel — porównanie do sumy do-kupienia
if (Math.abs(cel - target) <= EPS) {
box.classList.remove('d-none');
box.classList.remove('alert-info');
box.classList.add('alert-success');
msg.innerHTML = `
Suma <em>do kupienia</em> (<strong>${formatPln(target)} PLN</strong>) jest równa celowi.
<div class="small text-muted mt-1">Wszystkie: ${formatPln(sumAll)} PLN · Do kupienia: ${formatPln(sumToBuy)} PLN</div>
`;
btn.classList.add('d-none');
} else {
box.classList.remove('d-none');
box.classList.remove('alert-success');
box.classList.add('alert-info');
msg.innerHTML = `
<div>Wszystkie: <strong>${formatPln(sumAll)} PLN</strong> ·
Do kupienia: <strong>${formatPln(sumToBuy)} PLN</strong></div>
<div class="mt-1">Cel: <strong>${formatPln(cel)} PLN</strong></div>
`;
btn.textContent = `Zaktualizuj cel do ${formatPln(target)} PLN`;
btn.classList.remove('d-none');
}
}
btn.addEventListener('click', (e) => {
e.preventDefault();
const { sumToBuy } = computeSum();
if (sumToBuy > 0) {
celInput.value = formatPln(sumToBuy);
celInput.dispatchEvent(new Event('input', { bubbles: true }));
celInput.dispatchEvent(new Event('change', { bubbles: true }));
updateUI();
}
});
// Reaguj na zmiany cen/nazw
tbody.addEventListener('input', (e) => {
const name = e.target.getAttribute('name');
if (name === 'item_nazwa[]' || name === 'item_cena[]') {
updateUI();
}
});
// Reaguj na zmiany celu
celInput.addEventListener('input', updateUI);
celInput.addEventListener('change', updateUI);
// Obserwuj dodawanie/usuwanie wierszy przez inne skrypty
const mo = new MutationObserver(() => updateUI());
mo.observe(tbody, { childList: true, subtree: true });
// Init po załadowaniu
document.addEventListener('DOMContentLoaded', updateUI);
// i jedno wywołanie na starcie (gdy DOMContentLoaded już był)
updateUI();
})();

View File

@@ -0,0 +1,73 @@
(function () {
const body = document.querySelector('#produkty-body');
const addBtn = document.querySelector('#add-row');
const clearBtn = document.querySelector('#clear-empty');
if (!body) return;
function reindexHidden() {
const rows = [...body.querySelectorAll('tr')];
rows.forEach((tr, idx) => {
const hidden = tr.querySelector('input[type="hidden"][name^="item_kupione_val_"]');
if (hidden) hidden.name = `item_kupione_val_${idx}`;
});
}
function makeRow() {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><input type="text" class="form-control" name="item_nazwa[]" placeholder="np. Karma Brit 10kg" required></td>
<td><input type="url" class="form-control" name="item_link[]" placeholder="https://..."></td>
<td><input type="text" inputmode="decimal" class="form-control text-end" name="item_cena[]" placeholder="0,00"></td>
<td>
<div class="form-check form-switch">
<input class="form-check-input kupione-switch" type="checkbox">
<input type="hidden" name="item_kupione_val_TMP" value="0">
<label class="form-check-label small">Do kupienia</label>
</div>
</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-light border remove-row" title="Usuń wiersz">✕</button>
</td>`;
return tr;
}
body.addEventListener('change', (e) => {
if (e.target.classList.contains('kupione-switch')) {
const tr = e.target.closest('tr');
const hidden = tr.querySelector('input[type="hidden"][name^="item_kupione_val_"]');
const label = tr.querySelector('.form-check-label');
if (hidden) hidden.value = e.target.checked ? '1' : '0';
if (label) label.textContent = e.target.checked ? 'Kupione' : 'Do kupienia';
}
});
body.addEventListener('click', (e) => {
if (e.target.classList.contains('remove-row')) {
e.preventDefault();
const tr = e.target.closest('tr');
tr.remove();
reindexHidden();
}
});
addBtn?.addEventListener('click', (e) => {
e.preventDefault();
body.appendChild(makeRow());
reindexHidden();
});
clearBtn?.addEventListener('click', (e) => {
e.preventDefault();
[...body.querySelectorAll('tr')].forEach(tr => {
const name = tr.querySelector('input[name="item_nazwa[]"]')?.value.trim();
const link = tr.querySelector('input[name="item_link[]"]')?.value.trim();
const cena = tr.querySelector('input[name="item_cena[]"]')?.value.trim();
if (!name && !link && !cena) tr.remove();
});
reindexHidden();
});
// startowa normalizacja nazw hiddenów (ważne w trybie edycji)
reindexHidden();
})();

26
static/js/transakcje.js Normal file
View File

@@ -0,0 +1,26 @@
document.addEventListener('DOMContentLoaded', function () {
const modalW = new bootstrap.Modal(document.getElementById('modalWplata'));
const modalX = new bootstrap.Modal(document.getElementById('modalWydatek'));
// WPŁATA
document.querySelectorAll('.btn-edit-wplata').forEach(btn => {
btn.addEventListener('click', () => {
const form = document.getElementById('formWplata');
form.action = btn.dataset.action;
document.getElementById('wplataKwota').value = btn.dataset.kwota || '';
document.getElementById('wplataOpis').value = btn.dataset.opis || '';
modalW.show();
});
});
// WYDATEK
document.querySelectorAll('.btn-edit-wydatek').forEach(btn => {
btn.addEventListener('click', () => {
const form = document.getElementById('formWydatek');
form.action = btn.dataset.action;
document.getElementById('wydatekKwota').value = btn.dataset.kwota || '';
document.getElementById('wydatekOpis').value = btn.dataset.opis || '';
modalX.show();
});
});
});

View File

@@ -95,6 +95,10 @@
href="{{ url_for('dodaj_wydatek', zbiorka_id=z.id) }}">Dodaj href="{{ url_for('dodaj_wydatek', zbiorka_id=z.id) }}">Dodaj
wydatek</a> wydatek</a>
</li> </li>
<li>
<a class="dropdown-item"
href="{{ url_for('transakcje_zbiorki', zbiorka_id=z.id) }}">Transakcje</a>
</li>
<li> <li>
<a class="dropdown-item" <a class="dropdown-item"
href="{{ url_for('edytuj_stan', zbiorka_id=z.id) }}">Edytuj stan</a> href="{{ url_for('edytuj_stan', zbiorka_id=z.id) }}">Edytuj stan</a>
@@ -147,7 +151,7 @@
{% endif %} {% endif %}
</div> </div>
<!-- PANE: Zrealizowane --> <!-- PANEL: Zrealizowane -->
<div class="tab-pane fade" id="pane-zrealizowane" role="tabpanel" aria-labelledby="tab-zrealizowane" <div class="tab-pane fade" id="pane-zrealizowane" role="tabpanel" aria-labelledby="tab-zrealizowane"
tabindex="0"> tabindex="0">
@@ -208,6 +212,10 @@
href="{{ url_for('dodaj_wydatek', zbiorka_id=z.id) }}">Dodaj href="{{ url_for('dodaj_wydatek', zbiorka_id=z.id) }}">Dodaj
wydatek</a> wydatek</a>
</li> </li>
<li>
<a class="dropdown-item"
href="{{ url_for('transakcje_zbiorki', zbiorka_id=z.id) }}">Transakcje</a>
</li>
<li> <li>
<a class="dropdown-item" <a class="dropdown-item"
href="{{ url_for('edytuj_stan', zbiorka_id=z.id) }}">Edytuj stan</a> href="{{ url_for('edytuj_stan', zbiorka_id=z.id) }}">Edytuj stan</a>

View File

@@ -80,6 +80,98 @@
<hr class="my-4" /> <hr class="my-4" />
<!-- SEKCJA: Lista produktów -->
<div class="mb-4">
<h6 class="text-muted mb-2">Lista produktów</h6>
<p class="text-muted small mb-3">
Wypunktuj dokładnie produkty do zakupu — podaj nazwę, opcjonalny link do sklepu i cenę.
Status domyślnie <em>Do kupienia</em>; przełącz na <em>Kupione</em> po realizacji.
</p>
<div class="table-responsive">
<table class="table align-middle" id="produkty-table">
<thead>
<tr>
<th style="min-width:220px;">Produkt</th>
<th style="min-width:240px;">Link do sklepu</th>
<th style="width:140px;">Cena [PLN]</th>
<th style="width:160px;">Status</th>
<th style="width:60px;"></th>
</tr>
</thead>
<tbody id="produkty-body">
{% set items = zbiorka.przedmioty if is_edit and zbiorka and zbiorka.przedmioty else []
%}
{% if items %}
{% for it in items %}
{% set i = loop.index0 %}
<tr>
<td>
<input type="text" class="form-control" name="item_nazwa[]"
value="{{ it.nazwa }}" placeholder="np. Karma Brit 10kg" required>
</td>
<td>
<input type="url" class="form-control" name="item_link[]"
value="{{ it.link or '' }}" placeholder="https://...">
</td>
<td>
<input type="text" inputmode="decimal" class="form-control text-end"
name="item_cena[]"
value="{{ (it.cena|round(2)) if it.cena is not none else '' }}"
placeholder="0,00">
</td>
<td>
<div class="form-check form-switch">
<input class="form-check-input kupione-switch" type="checkbox" {% if
it.kupione %}checked{% endif %}>
<input type="hidden" name="item_kupione_val_{{ i }}"
value="{{ 1 if it.kupione else 0 }}">
<label class="form-check-label small">{{ 'Kupione' if it.kupione else 'Do
kupienia' }}</label>
</div>
</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-light border remove-row"
title="Usuń wiersz"></button>
</td>
</tr>
{% endfor %}
{% else %}
<!-- pusty wiersz startowy -->
<tr>
<td><input type="text" class="form-control" name="item_nazwa[]"
placeholder="np. Karma Brit 10kg" required></td>
<td><input type="url" class="form-control" name="item_link[]"
placeholder="https://..."></td>
<td><input type="text" inputmode="decimal" class="form-control text-end"
name="item_cena[]" placeholder="0,00"></td>
<td>
<div class="form-check form-switch">
<input class="form-check-input kupione-switch" type="checkbox">
<input type="hidden" name="item_kupione_val_0" value="0">
<label class="form-check-label small">Do kupienia</label>
</div>
</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-light border remove-row"
title="Usuń wiersz"></button>
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-light border" id="add-row">+ Dodaj
pozycję</button>
<button type="button" class="btn btn-sm btn-outline-light border" id="clear-empty">Usuń puste
wiersze</button>
</div>
</div>
<hr class="my-4" />
<!-- SEKCJA: Dane płatności --> <!-- SEKCJA: Dane płatności -->
<div class="mb-4"> <div class="mb-4">
<h6 class="text-muted mb-2">Dane płatności</h6> <h6 class="text-muted mb-2">Dane płatności</h6>
@@ -93,9 +185,8 @@
placeholder="12 3456 7890 1234 5678 9012 3456" required aria-describedby="ibanHelp" placeholder="12 3456 7890 1234 5678 9012 3456" required aria-describedby="ibanHelp"
value="{% if is_edit and zbiorka.numer_konta %}{{ zbiorka.numer_konta }}{% elif global_settings %}{{ global_settings.numer_konta }}{% else %}{% endif %}"> value="{% if is_edit and zbiorka.numer_konta %}{{ zbiorka.numer_konta }}{% elif global_settings %}{{ global_settings.numer_konta }}{% else %}{% endif %}">
</div> </div>
<div id="ibanHelp" class="form-text"> <div id="ibanHelp" class="form-text">Wpisz ciąg cyfr; spacje dodadzą się automatycznie dla
Wpisz ciąg cyfr; spacje dodadzą się automatycznie dla czytelności. czytelności.</div>
</div>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
@@ -116,8 +207,9 @@
<button type="button" class="btn btn-sm btn-outline-light border" id="ustaw-globalne" <button type="button" class="btn btn-sm btn-outline-light border" id="ustaw-globalne"
title="Wstaw wartości z ustawień globalnych" {% if global_settings %} title="Wstaw wartości z ustawień globalnych" {% if global_settings %}
data-iban="{{ global_settings.numer_konta }}" data-iban="{{ global_settings.numer_konta }}"
data-blik="{{ global_settings.numer_telefonu_blik }}" {% endif %}>Wstaw data-blik="{{ global_settings.numer_telefonu_blik }}" {% endif %}>
globalne ustawienia</button> Wstaw globalne ustawienia
</button>
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -128,6 +220,16 @@
<!-- SEKCJA: Cel i widoczność --> <!-- SEKCJA: Cel i widoczność -->
<div class="mb-4"> <div class="mb-4">
<h6 class="text-muted mb-2">Cel i widoczność</h6> <h6 class="text-muted mb-2">Cel i widoczność</h6>
{# === BOX: zgodność sumy produktów z celem === #}
<div id="celSyncBox" class="alert d-none py-2 px-3 mb-3" role="alert">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2">
<div id="celSyncMsg" class="small"></div>
<button type="button" id="btnApplyCelFromSum"
class="btn btn-sm btn-outline-light border d-none"></button>
</div>
</div>
<div class="row g-3"> <div class="row g-3">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<label for="cel" class="form-label">Cel zbiórki</label> <label for="cel" class="form-label">Cel zbiórki</label>
@@ -153,10 +255,9 @@
<!-- CTA --> <!-- CTA -->
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
<button type="submit" class="btn btn-success"> <button type="submit" class="btn btn-success">
{{ ' Zaktualizuj zbiórkę' if is_edit else 'Dodaj zbiórkę' }} </button> {{ ' Zaktualizuj zbiórkę' if is_edit else 'Dodaj zbiórkę' }}
</button>
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-light border">Anuluj</a> <a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-light border">Anuluj</a>
</div> </div>
</form> </form>
</div> </div>
@@ -169,4 +270,6 @@
<script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script> <script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script>
<script src="{{ url_for('static', filename='js/mde_custom.js') }}"></script> <script src="{{ url_for('static', filename='js/mde_custom.js') }}"></script>
<script src="{{ url_for('static', filename='js/formularz_zbiorek.js') }}"></script> <script src="{{ url_for('static', filename='js/formularz_zbiorek.js') }}"></script>
<script src="{{ url_for('static', filename='js/produkty_formularz.js') }}"></script>
<script src="{{ url_for('static', filename='js/kwoty_formularz.js') }}"></script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,143 @@
{% extends 'base.html' %}
{% block title %}Transakcje {{ zbiorka.nazwa }}{% endblock %}
{% block content %}
<div class="container my-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="mb-0">Transakcje: {{ zbiorka.nazwa }}</h3>
<div class="btn-group">
<a class="btn btn-sm btn-outline-light border" href="{{ url_for('dodaj_wplate', zbiorka_id=zbiorka.id) }}">+
Wpłata</a>
<a class="btn btn-sm btn-outline-light border"
href="{{ url_for('dodaj_wydatek', zbiorka_id=zbiorka.id) }}">+ Wydatek</a>
<a class="btn btn-sm btn-outline-light border"
href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}">Szczegóły zbiórki</a>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body">
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>Data</th>
<th>Typ</th>
<th class="text-end">Kwota</th>
<th>Opis</th>
<th class="text-end"></th>
</tr>
</thead>
<tbody>
{% for a in aktywnosci %}
<tr>
<td>{{ a.data|dt("%d.%m.%Y %H:%M") }}</td>
<td>
<span class="badge {{ 'bg-success' if a.typ=='wpłata' else 'bg-danger' }}">{{ a.typ
}}</span>
</td>
<td class="text-end">{{ '%.2f'|format(a.kwota) }} PLN</td>
<td class="text-muted">{{ a.opis or '—' }}</td>
<td class="text-end">
{% if a.typ == 'wpłata' %}
<button class="btn btn-sm btn-outline-light border btn-edit-wplata" data-id="{{ a.id }}"
data-kwota="{{ '%.2f'|format(a.kwota) }}" data-opis="{{ a.opis|e if a.opis }}"
data-action="{{ url_for('zapisz_wplate', wplata_id=a.id) }}">
Edytuj
</button>
<form class="d-inline" method="post"
action="{{ url_for('usun_wplate', wplata_id=a.id) }}"
onsubmit="return confirm('Usunąć wpłatę? Cofnie to wpływ na stan.');">
<button class="btn btn-sm btn-outline-danger">Usuń</button>
</form>
{% else %}
<button class="btn btn-sm btn-outline-light border btn-edit-wydatek"
data-id="{{ a.id }}" data-kwota="{{ '%.2f'|format(a.kwota) }}"
data-opis="{{ a.opis|e if a.opis }}"
data-action="{{ url_for('zapisz_wydatek', wydatek_id=a.id) }}">
Edytuj
</button>
<form class="d-inline" method="post"
action="{{ url_for('usun_wydatek', wydatek_id=a.id) }}"
onsubmit="return confirm('Usunąć wydatek? Cofnie to wpływ na stan.');">
<button class="btn btn-sm btn-outline-danger">Usuń</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-center text-muted py-4">Brak transakcji.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="small text-muted">
Aktualny stan: <strong>{{ '%.2f'|format(zbiorka.stan or 0) }} PLN</strong>
</div>
</div>
</div>
</div>
{# === MODAL: Edycja wpłaty === #}
<div class="modal fade" id="modalWplata" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form class="modal-content" method="post" id="formWplata">
<div class="modal-header">
<h5 class="modal-title">Edytuj wpłatę</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Kwota (PLN)</label>
<input class="form-control text-end" name="kwota" step="0.01" min="0.01" id="wplataKwota"
inputmode="decimal" required>
</div>
<div class="mb-3">
<label class="form-label">Opis</label>
<textarea class="form-control" name="opis" id="wplataOpis" rows="3"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-success">Zapisz</button>
<button type="button" class="btn btn-outline-light border" data-bs-dismiss="modal">Anuluj</button>
</div>
</form>
</div>
</div>
{# === MODAL: Edycja wydatku === #}
<div class="modal fade" id="modalWydatek" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form class="modal-content" method="post" id="formWydatek">
<div class="modal-header">
<h5 class="modal-title">Edytuj wydatek</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Kwota (PLN)</label>
<input class="form-control text-end" name="kwota" step="0.01" min="0.01" id="wydatekKwota"
inputmode="decimal" required>
</div>
<div class="mb-3">
<label class="form-label">Opis</label>
<textarea class="form-control" name="opis" id="wydatekOpis" rows="3"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-success">Zapisz</button>
<button type="button" class="btn btn-outline-light border" data-bs-dismiss="modal">Anuluj</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='js/transakcje.js') }}"></script>
{% endblock %}

View File

@@ -4,7 +4,7 @@
{% block content %} {% block content %}
<div class="container my-4"> <div class="container my-4">
{# Postęp 0100 #} {# Wyliczenia postępu finansowego #}
{% set has_cel = (zbiorka.cel is defined and zbiorka.cel and zbiorka.cel > 0) %} {% set has_cel = (zbiorka.cel is defined and zbiorka.cel and zbiorka.cel > 0) %}
{% set progress = (zbiorka.stan / zbiorka.cel * 100) if has_cel else 0 %} {% set progress = (zbiorka.stan / zbiorka.cel * 100) if has_cel else 0 %}
{% set progress_clamped = 100 if progress > 100 else (0 if progress < 0 else progress) %} {% set {% set progress_clamped = 100 if progress > 100 else (0 if progress < 0 else progress) %} {% set
@@ -26,32 +26,138 @@
</div> </div>
<div class="row g-4"> <div class="row g-4">
<!-- Kolumna: opis + progress --> <!-- Kolumna lewa: Opis + (opcjonalnie) Lista zakupów + Postęp -->
<div class="col-md-8"> <div class="col-md-8">
<div class="card shadow-sm h-100">
<!-- Card: Opis -->
<div class="card shadow-sm mb-4">
<div class="card-body"> <div class="card-body">
<h5 class="mb-2">Opis</h5> <h5 class="mb-2">Opis</h5>
<div class="mb-4"> <div class="mb-0">
{{ zbiorka.opis | markdown }} {{ zbiorka.opis | markdown }}
</div> </div>
<h5 class="mb-2">Postęp</h5>
<div class="progress mb-2" role="progressbar" aria-valuenow="{{ progress_clamped|round(2) }}"
aria-valuemin="0" aria-valuemax="100" aria-label="Postęp zbiórki {{ progress_clamped|round(0) }} procent">
<div class="progress-bar" style="width: {{ progress_clamped }}%;"></div>
</div>
<small class="text-muted">
{% if zbiorka.ukryj_kwote %}
{% else %}
{{ progress|round(1) }}%
{% endif %}
</small>
</div> </div>
</div> </div>
{# Czy są produkty? #}
{% set items = zbiorka.przedmioty or [] %}
{% set has_items = (items|length > 0) %}
<!-- Card: Lista zakupów (tylko gdy są produkty) -->
{% if has_items %}
<div class="card shadow-sm mb-4">
<div class="card-body">
<h5 class="mb-2">Lista zakupów</h5>
{% set posortowane = items|sort(attribute='kupione') %}
<ul class="list-group list-group-flush">
{% for it in posortowane %}
<li class="list-group-item bg-transparent d-flex flex-wrap justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2">
{% if it.kupione %}
<span class="badge bg-success">Kupione</span>
{% else %}
<span class="badge bg-warning text-dark">Do kupienia</span>
{% endif %}
<span class="fw-semibold">{{ it.nazwa }}</span>
{% if it.link %}
<a href="{{ it.link }}" target="_blank" rel="noopener"
class="btn btn-sm btn-outline-light border ms-2">Sklep ↗</a>
{% endif %}
</div>
<div>
{% if not zbiorka.ukryj_kwote %}
{% if it.cena is not none %}
<span class="badge bg-dark border" style="border-color: var(--border);">
{{ it.cena|round(2) }} PLN
</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
{% else %}
<span class="text-muted"></span>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
<!-- Card: Postęp (POD listą zakupów) -->
{# Dodatkowe wyliczenia do postępu zakupów #}
{% set total_cnt = items|length %}
{% set kupione_cnt = (items|selectattr('kupione')|list|length) %}
{% set items_pct = (kupione_cnt / total_cnt * 100) if total_cnt > 0 else 0 %}
{% if not zbiorka.ukryj_kwote %}
{% set suma_all = (items|selectattr('cena')|map(attribute='cena')|sum) or 0 %}
{% set suma_kupione = (items|selectattr('kupione')|selectattr('cena')|map(attribute='cena')|sum) or 0 %}
{% set suma_pct = (suma_kupione / suma_all * 100) if suma_all > 0 else 0 %}
{% endif %}
<div class="card shadow-sm">
<div class="card-body">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
<h5 class="mb-0">Postęp</h5>
<div class="d-flex flex-wrap align-items-center gap-2">
{% if has_cel and not zbiorka.ukryj_kwote %}
<span class="badge bg-dark border" style="border-color: var(--border);">
Finanse: {{ zbiorka.stan|round(2) }} / {{ zbiorka.cel|round(2) }} PLN
</span>
{% endif %}
{% if has_items %}
<span class="badge bg-secondary">Pozycje: {{ kupione_cnt }}/{{ total_cnt }}</span>
{% if not zbiorka.ukryj_kwote and (suma_all or 0) > 0 %}
<span class="badge bg-secondary">Zakupy (kwotowo):
{{ (suma_kupione or 0)|round(2) }} / {{ (suma_all or 0)|round(2) }} PLN
</span>
{% endif %}
{% endif %}
</div>
</div>
<!-- Pasek: Finanse (zawsze) -->
<div class="mb-3">
<small class="text-muted">Finanse</small>
<div class="progress" role="progressbar" aria-valuenow="{{ progress_clamped|round(2) }}" aria-valuemin="0"
aria-valuemax="100">
<div class="progress-bar" style="width: {{ progress_clamped }}%;"></div>
</div>
<small class="text-muted">
{% if zbiorka.ukryj_kwote %}—{% else %}{{ progress|round(1) }}%{% endif %}
</small>
</div>
{% if has_items %}
<!-- Pasek: Zakupy sztukami -->
<div class="mb-3">
<small class="text-muted">Zakupy (liczba pozycji)</small>
<div class="progress" role="progressbar" aria-valuenow="{{ items_pct|round(2) }}" aria-valuemin="0"
aria-valuemax="100">
<div class="progress-bar" style="width: {{ items_pct }}%;"></div>
</div>
<small class="text-muted">{{ items_pct|round(1) }}%</small>
</div>
{% if not zbiorka.ukryj_kwote and (suma_all or 0) > 0 %}
<!-- Pasek: Zakupy kwotowo -->
<div>
<small class="text-muted">Zakupy (kwotowo)</small>
<div class="progress" role="progressbar" aria-valuenow="{{ suma_pct|round(2) }}" aria-valuemin="0"
aria-valuemax="100">
<div class="progress-bar" style="width: {{ suma_pct }}%;"></div>
</div>
<small class="text-muted">{{ suma_pct|round(1) }}%</small>
</div>
{% endif %}
{% endif %}
</div>
</div>
</div> </div>
<!-- Kolumna: płatności (sticky) --> <!-- Kolumna prawa: płatności (sticky) -->
<div class="col-md-4"> <div class="col-md-4">
<div class="card shadow-sm wspomoz-card position-sticky" style="top: 1rem;"> <div class="card shadow-sm wspomoz-card position-sticky" style="top: 1rem;">
<div class="card-body"> <div class="card-body">
@@ -63,7 +169,6 @@
</div> </div>
<div class="fs-5" id="ibanDisplay">{{ zbiorka.numer_konta }}</div> <div class="fs-5" id="ibanDisplay">{{ zbiorka.numer_konta }}</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<div class="d-flex align-items-center justify-content-between"> <div class="d-flex align-items-center justify-content-between">
<strong>Telefon BLIK</strong> <strong>Telefon BLIK</strong>
@@ -109,10 +214,10 @@
</div> </div>
</div> </div>
<!-- Aktywność (wpłaty + wydatki) --> <!-- Aktywność -->
<div class="card shadow-sm mt-4"> <div class="card shadow-sm mt-4">
<div class="card-header d-flex align-items-center justify-content-between"> <div class="card-header d-flex align-items-center justify-content-between">
<h5 class="card-title mb-0">Aktywność</h5> <h5 class="card-title mb-0">Aktywność / Transakcje</h5>
{% if aktywnosci and aktywnosci|length > 0 %} {% if aktywnosci and aktywnosci|length > 0 %}
<small class="text-muted">Łącznie pozycji: {{ aktywnosci|length }}</small> <small class="text-muted">Łącznie pozycji: {{ aktywnosci|length }}</small>
{% endif %} {% endif %}
@@ -123,7 +228,7 @@
{% for a in aktywnosci %} {% for a in aktywnosci %}
<li class="list-group-item bg-transparent d-flex flex-wrap justify-content-between align-items-center"> <li class="list-group-item bg-transparent d-flex flex-wrap justify-content-between align-items-center">
<div class="me-3"> <div class="me-3">
<strong>{{ a.data.strftime('%Y-%m-%d %H:%M:%S') }}</strong> <strong>{{ a.data|dt("%d.%m.%Y %H:%M") }}</strong>
<span class="badge {% if a.typ == 'wpłata' %}bg-success{% else %}bg-danger{% endif %} ms-2"> <span class="badge {% if a.typ == 'wpłata' %}bg-success{% else %}bg-danger{% endif %} ms-2">
{{ a.typ|capitalize }} {{ a.typ|capitalize }}
</span> </span>