funkcje rezerw i przesuniec

This commit is contained in:
Mateusz Gruszczyński
2025-12-11 13:47:57 +01:00
parent 3e4d1ba78c
commit 5220b8cf2c
10 changed files with 956 additions and 50 deletions

View File

@@ -1,17 +1,2 @@
ALTER TABLE zbiorka ALTER COLUMN numer_konta DROP NOT NULL;
ALTER TABLE zbiorka ALTER COLUMN numer_telefonu_blik DROP NOT NULL;
_______________________________
PGSQL
ALTER TABLE wplata ADD COLUMN ukryta boolean NOT NULL DEFAULT false;
ALTER TABLE wydatek ADD COLUMN ukryta boolean NOT NULL DEFAULT false;
-- po migracji można zdjąć DEFAULT (opcjonalnie)
ALTER TABLE wplata ALTER COLUMN ukryta DROP DEFAULT;
ALTER TABLE wydatek ALTER COLUMN ukryta DROP DEFAULT;
SQLite
ALTER TABLE wplata ADD COLUMN ukryta INTEGER NOT NULL DEFAULT 0;
ALTER TABLE wydatek ADD COLUMN ukryta INTEGER NOT NULL DEFAULT 0;
ALTER TABLE zbiorka ADD COLUMN typ_zbiorki VARCHAR(20) NOT NULL DEFAULT 'standardowa';
CREATE INDEX idx_zbiorka_typ ON zbiorka(typ_zbiorki);

336
app.py
View File

@@ -94,6 +94,8 @@ class Zbiorka(db.Model):
pokaz_postep_kwotowo = db.Column(db.Boolean, default=True, nullable=False)
uzyj_konta = db.Column(db.Boolean, default=True, nullable=False)
uzyj_blik = db.Column(db.Boolean, default=True, nullable=False)
typ_zbiorki = db.Column(db.String(20), default="standardowa", nullable=False)
nazwa_typu_rezerwy = db.Column(db.String(100), nullable=True)
wplaty = db.relationship(
"Wplata",
@@ -159,6 +161,33 @@ class Wydatek(db.Model):
opis = db.Column(db.Text, nullable=True)
ukryta = db.Column(db.Boolean, nullable=False, default=False)
class Przesuniecie(db.Model):
id = db.Column(db.Integer, primary_key=True)
zbiorka_zrodlo_id = db.Column(
db.Integer,
db.ForeignKey("zbiorka.id", ondelete="CASCADE"),
nullable=False,
)
zbiorka_cel_id = db.Column(
db.Integer,
db.ForeignKey("zbiorka.id", ondelete="CASCADE"),
nullable=False,
)
kwota = db.Column(Numeric(12, 2), nullable=False)
data = db.Column(db.DateTime, default=datetime.utcnow)
opis = db.Column(db.Text, nullable=True)
ukryta = db.Column(db.Boolean, nullable=False, default=False)
wplata_id = db.Column(
db.Integer,
db.ForeignKey("wplata.id", ondelete="SET NULL"),
nullable=True,
)
zbiorka_zrodlo = db.relationship("Zbiorka", foreign_keys=[zbiorka_zrodlo_id], backref="przesuniecia_wychodzace")
zbiorka_cel = db.relationship("Zbiorka", foreign_keys=[zbiorka_cel_id], backref="przesuniecia_przychodzace")
wplata = db.relationship("Wplata", foreign_keys=[wplata_id], backref="przesuniecia")
class UstawieniaGlobalne(db.Model):
__tablename__ = "ustawienia_globalne"
@@ -327,27 +356,59 @@ def zbiorka(zbiorka_id):
zb = db.session.get(Zbiorka, zbiorka_id)
if zb is None:
abort(404)
if zb.ukryta and (not current_user.is_authenticated or not current_user.czy_admin):
abort(404)
is_admin = current_user.is_authenticated and current_user.czy_admin
show_hidden = is_admin and (request.args.get("show_hidden") in ("1", "true", "yes"))
# wpłaty / wydatki z filtrem ukrycia
wplaty = [
{"typ": "wpłata", "kwota": w.kwota, "opis": w.opis, "data": w.data, "ukryta": getattr(w, "ukryta", False)}
for w in zb.wplaty
if show_hidden or not getattr(w, "ukryta", False)
]
wydatki = [
{"typ": "wydatek", "kwota": x.kwota, "opis": x.opis, "data": x.data, "ukryta": getattr(x, "ukryta", False)}
for x in zb.wydatki
if show_hidden or not getattr(x, "ukryta", False)
]
aktywnosci = wplaty + wydatki
# Przesunięcia przychodzące
przesuniecia_przych = [
{
"typ": "przesunięcie_przych",
"kwota": p.kwota,
"opis": p.opis or f"Przesunięcie z: {p.zbiorka_zrodlo.nazwa}",
"data": p.data,
"zbiorka_id": p.zbiorka_zrodlo_id,
"zbiorka_nazwa": p.zbiorka_zrodlo.nazwa,
"ukryta": getattr(p, "ukryta", False)
}
for p in zb.przesuniecia_przychodzace
if show_hidden or not getattr(p, "ukryta", False)
]
# Przesunięcia wychodzące
przesuniecia_wych = [
{
"typ": "przesunięcie_wych",
"kwota": p.kwota,
"opis": p.opis or f"Przesunięcie do: {p.zbiorka_cel.nazwa}",
"data": p.data,
"zbiorka_id": p.zbiorka_cel_id,
"zbiorka_nazwa": p.zbiorka_cel.nazwa,
"ukryta": getattr(p, "ukryta", False)
}
for p in zb.przesuniecia_wychodzace
if show_hidden or not getattr(p, "ukryta", False)
]
aktywnosci = wplaty + wydatki + przesuniecia_przych + przesuniecia_wych
aktywnosci.sort(key=lambda a: a["data"], reverse=True)
return render_template("zbiorka.html", zbiorka=zb, aktywnosci=aktywnosci, show_hidden=show_hidden)
@@ -428,15 +489,20 @@ def admin_dashboard():
if not current_user.czy_admin:
flash("Brak uprawnień do panelu administracyjnego", "danger")
return redirect(url_for("index"))
active_zbiorki = Zbiorka.query.filter_by(zrealizowana=False).all()
completed_zbiorki = Zbiorka.query.filter_by(zrealizowana=True).all()
active_zbiorki = Zbiorka.query.filter_by(zrealizowana=False).filter(
Zbiorka.typ_zbiorki != 'rezerwa'
).all()
completed_zbiorki = Zbiorka.query.filter_by(zrealizowana=True).filter(
Zbiorka.typ_zbiorki != 'rezerwa'
).all()
return render_template(
"admin/dashboard.html",
active_zbiorki=active_zbiorki,
completed_zbiorki=completed_zbiorki,
)
@app.route("/admin/zbiorka/dodaj", methods=["GET", "POST"])
@app.route("/admin/zbiorka/edytuj/<int:zbiorka_id>", methods=["GET", "POST"])
@login_required
@@ -661,6 +727,75 @@ def dodaj_wplate(zbiorka_id):
return redirect(next_url or url_for("transakcje_zbiorki", zbiorka_id=zb.id))
return render_template("admin/dodaj_wplate.html", zbiorka=zb)
@app.route("/admin/zbiorka/<int:zbiorka_id>/wplata/<int:wplata_id>/przesun", methods=["GET", "POST"])
@login_required
def przesun_wplate(zbiorka_id, wplata_id):
if not current_user.czy_admin:
flash("Brak uprawnień", "danger")
return redirect(url_for("index"))
zb_zrodlo = db.session.get(Zbiorka, zbiorka_id)
if zb_zrodlo is None:
abort(404)
wplata = db.session.get(Wplata, wplata_id)
if wplata is None or wplata.zbiorka_id != zbiorka_id:
abort(404)
if request.method == "POST":
zbiorka_cel_id = request.form.get("zbiorka_cel_id")
if not zbiorka_cel_id:
flash("Wybierz docelową zbiórkę", "danger")
return redirect(url_for("przesun_wplate", zbiorka_id=zbiorka_id, wplata_id=wplata_id))
zb_cel = db.session.get(Zbiorka, int(zbiorka_cel_id))
if zb_cel is None:
flash("Docelowa zbiórka nie istnieje", "danger")
return redirect(url_for("przesun_wplate", zbiorka_id=zbiorka_id, wplata_id=wplata_id))
if zb_zrodlo.stan < wplata.kwota:
flash("Niewystarczające środki w źródłowej zbiórce", "danger")
return redirect(url_for("przesun_wplate", zbiorka_id=zbiorka_id, wplata_id=wplata_id))
opis_dodatkowy = request.form.get("opis", "").strip()
# Opis przesunięcia
if opis_dodatkowy:
opis_przesuniecia = f"Przesunięcie wpłaty: {wplata.opis or 'bez opisu'} - {opis_dodatkowy}"
else:
opis_przesuniecia = f"Przesunięcie wpłaty: {wplata.opis or 'bez opisu'}"
# Utwórz przesunięcie
nowe_przesuniecie = Przesuniecie(
zbiorka_zrodlo_id=zb_zrodlo.id,
zbiorka_cel_id=zb_cel.id,
kwota=wplata.kwota,
opis=opis_przesuniecia,
wplata_id=wplata.id
)
# Zaktualizuj stany
zb_zrodlo.stan = (zb_zrodlo.stan or Decimal("0")) - wplata.kwota
zb_cel.stan = (zb_cel.stan or Decimal("0")) + wplata.kwota
# Przenieś wpłatę do nowej zbiórki
wplata.zbiorka_id = zb_cel.id
db.session.add(nowe_przesuniecie)
db.session.commit()
flash(f"Przesunięto wpłatę {wplata.kwota} PLN do zbiórki '{zb_cel.nazwa}'", "success")
next_url = request.args.get("next")
return redirect(next_url or url_for("transakcje_zbiorki", zbiorka_id=zb_zrodlo.id))
# GET - wyświetl formularz
dostepne_zbiorki = Zbiorka.query.filter(Zbiorka.id != zbiorka_id).all()
return render_template("admin/przesun_wplate.html",
zbiorka=zb_zrodlo,
wplata=wplata,
dostepne_zbiorki=dostepne_zbiorki)
@app.route("/admin/zbiorka/usun/<int:zbiorka_id>", methods=["POST"])
@login_required
@@ -1053,7 +1188,6 @@ def zapisz_wydatek(wydatek_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")
@@ -1076,6 +1210,190 @@ def usun_wydatek(wydatek_id):
return redirect(url_for("transakcje_zbiorki", zbiorka_id=zb.id))
@app.route("/admin/zbiorka/<int:zbiorka_id>/przesuniecie/dodaj", methods=["GET", "POST"])
@login_required
def dodaj_przesuniecie(zbiorka_id):
if not current_user.czy_admin:
flash("Brak uprawnień", "danger")
return redirect(url_for("index"))
zb_zrodlo = db.session.get(Zbiorka, zbiorka_id)
if zb_zrodlo is None:
abort(404)
if request.method == "POST":
try:
kwota = parse_amount(request.form.get("kwota"))
if kwota <= 0:
raise InvalidOperation
except (InvalidOperation, ValueError):
flash("Nieprawidłowa kwota (musi być > 0)", "danger")
return redirect(url_for("dodaj_przesuniecie", zbiorka_id=zbiorka_id))
zbiorka_cel_id = request.form.get("zbiorka_cel_id")
if not zbiorka_cel_id:
flash("Wybierz docelową zbiórkę", "danger")
return redirect(url_for("dodaj_przesuniecie", zbiorka_id=zbiorka_id))
zb_cel = db.session.get(Zbiorka, int(zbiorka_cel_id))
if zb_cel is None:
flash("Docelowa zbiórka nie istnieje", "danger")
return redirect(url_for("dodaj_przesuniecie", zbiorka_id=zbiorka_id))
if zb_zrodlo.stan < kwota:
flash("Niewystarczające środki w źródłowej zbiórce", "danger")
return redirect(url_for("dodaj_przesuniecie", zbiorka_id=zbiorka_id))
opis = request.form.get("opis", "")
nowe_przesuniecie = Przesuniecie(
zbiorka_zrodlo_id=zb_zrodlo.id,
zbiorka_cel_id=zb_cel.id,
kwota=kwota,
opis=opis
)
zb_zrodlo.stan = (zb_zrodlo.stan or Decimal("0")) - kwota
zb_cel.stan = (zb_cel.stan or Decimal("0")) + kwota
db.session.add(nowe_przesuniecie)
db.session.commit()
flash(f"Przesunięto {kwota} PLN do zbiórki '{zb_cel.nazwa}'", "success")
next_url = request.args.get("next")
return redirect(next_url or url_for("transakcje_zbiorki", zbiorka_id=zb_zrodlo.id))
dostepne_zbiorki = Zbiorka.query.filter(Zbiorka.id != zbiorka_id).all()
return render_template("admin/dodaj_przesuniecie.html", zbiorka=zb_zrodlo, dostepne_zbiorki=dostepne_zbiorki)
@app.route("/admin/rezerwy")
@login_required
def lista_rezerwowych():
if not current_user.czy_admin:
flash("Brak uprawnień", "danger")
return redirect(url_for("index"))
rezerwy = Zbiorka.query.filter_by(typ_zbiorki="rezerwa").all()
return render_template("admin/lista_rezerwowych.html", rezerwy=rezerwy)
@app.route("/admin/rezerwa/dodaj", methods=["GET", "POST"])
@login_required
def dodaj_rezerwe():
if not current_user.czy_admin:
flash("Brak uprawnień", "danger")
return redirect(url_for("index"))
if request.method == "POST":
nazwa = request.form.get("nazwa", "").strip()
if not nazwa:
flash("Nazwa jest wymagana", "danger")
global_settings = UstawieniaGlobalne.query.first()
return render_template("admin/formularz_rezerwy.html", zbiorka=None, global_settings=global_settings)
opis = request.form.get("opis", "").strip()
global_settings = UstawieniaGlobalne.query.first()
uzyj_konta = "uzyj_konta" in request.form
uzyj_blik = "uzyj_blik" in request.form
numer_konta = request.form.get("numer_konta", "").strip()
numer_telefonu_blik = request.form.get("numer_telefonu_blik", "").strip()
if uzyj_konta and not numer_konta:
if global_settings and global_settings.numer_konta:
numer_konta = global_settings.numer_konta
if uzyj_blik and not numer_telefonu_blik:
if global_settings and global_settings.numer_telefonu_blik:
numer_telefonu_blik = global_settings.numer_telefonu_blik
nowa_rezerwa = Zbiorka(
nazwa=nazwa,
opis=opis,
cel=Decimal("0"),
stan=Decimal("0"),
typ_zbiorki="rezerwa",
ukryta=True,
ukryj_kwote=False,
pokaz_postep_finanse=False,
pokaz_postep_pozycje=False,
pokaz_postep_kwotowo=False,
uzyj_konta=uzyj_konta,
uzyj_blik=uzyj_blik,
numer_konta=numer_konta if uzyj_konta else "",
numer_telefonu_blik=numer_telefonu_blik if uzyj_blik else ""
)
db.session.add(nowa_rezerwa)
db.session.commit()
flash(f"Lista rezerwowa '{nazwa}' została utworzona", "success")
return redirect(url_for("lista_rezerwowych"))
global_settings = UstawieniaGlobalne.query.first()
return render_template("admin/formularz_rezerwy.html", zbiorka=None, global_settings=global_settings)
@app.route("/admin/rezerwa/edytuj/<int:rezerwa_id>", methods=["GET", "POST"])
@login_required
def edytuj_rezerwe(rezerwa_id):
if not current_user.czy_admin:
flash("Brak uprawnień", "danger")
return redirect(url_for("index"))
zb = db.session.get(Zbiorka, rezerwa_id)
if zb is None or zb.typ_zbiorki != "rezerwa":
abort(404)
if request.method == "POST":
nazwa = request.form.get("nazwa", "").strip()
if not nazwa:
flash("Nazwa jest wymagana", "danger")
global_settings = UstawieniaGlobalne.query.first()
return render_template("admin/formularz_rezerwy.html", zbiorka=zb, global_settings=global_settings)
opis = request.form.get("opis", "").strip()
uzyj_konta = "uzyj_konta" in request.form
uzyj_blik = "uzyj_blik" in request.form
numer_konta = request.form.get("numer_konta", "").strip()
numer_telefonu_blik = request.form.get("numer_telefonu_blik", "").strip()
zb.nazwa = nazwa
zb.opis = opis
zb.uzyj_konta = uzyj_konta
zb.uzyj_blik = uzyj_blik
zb.numer_konta = numer_konta if uzyj_konta else ""
zb.numer_telefonu_blik = numer_telefonu_blik if uzyj_blik else ""
db.session.commit()
flash(f"Lista rezerwowa '{nazwa}' została zaktualizowana", "success")
return redirect(url_for("lista_rezerwowych"))
global_settings = UstawieniaGlobalne.query.first()
return render_template("admin/formularz_rezerwy.html", zbiorka=zb, global_settings=global_settings)
@app.route("/admin/rezerwa/usun/<int:rezerwa_id>", methods=["POST"])
@login_required
def usun_rezerwe(rezerwa_id):
if not current_user.czy_admin:
flash("Brak uprawnień", "danger")
return redirect(url_for("index"))
zb = db.session.get(Zbiorka, rezerwa_id)
if zb is None or zb.typ_zbiorki != "rezerwa":
abort(404)
nazwa = zb.nazwa
db.session.delete(zb)
db.session.commit()
flash(f"Lista rezerwowa '{nazwa}' została usunięta", "success")
return redirect(url_for("lista_rezerwowych"))
@app.route("/favicon.ico")
def favicon():
return "", 204

View File

@@ -0,0 +1,19 @@
document.addEventListener('DOMContentLoaded', function() {
const uzyjKonta = document.getElementById('uzyj_konta');
const kontoField = document.getElementById('konto-field');
const uzyjBlik = document.getElementById('uzyj_blik');
const blikField = document.getElementById('blik-field');
if (uzyjKonta && kontoField) {
uzyjKonta.addEventListener('change', function() {
kontoField.style.display = this.checked ? 'block' : 'none';
});
}
if (uzyjBlik && blikField) {
uzyjBlik.addEventListener('change', function() {
blikField.style.display = this.checked ? 'block' : 'none';
});
}
});

View File

@@ -11,6 +11,9 @@
<a href="{{ url_for('formularz_zbiorek') }}" class="btn btn-primary">
Dodaj zbiórkę
</a>
<a href="{{ url_for('lista_rezerwowych') }}" class="btn btn-outline-light">
Listy rezerwowe
</a>
<a href="{{ url_for('admin_ustawienia') }}" class="btn btn-outline-light">
Ustawienia główne
</a>

View File

@@ -0,0 +1,85 @@
{% extends 'base.html' %}
{% block title %}Przesuń środki - {{ zbiorka.nazwa }}{% endblock %}
{% block content %}
<div class="container my-5">
<!-- Nagłówek -->
<div class="mb-4">
<h1 class="mb-2">
<i class="bi bi-arrow-left-right text-primary"></i> Przesuń środki z: {{ zbiorka.nazwa }}
</h1>
<p class="text-muted">Dostępne środki: <strong class="text-success">{{ zbiorka.stan|round(2) }} PLN</strong></p>
</div>
<!-- Formularz -->
<form method="POST">
<div class="card shadow-sm mb-3">
<div class="card-header bg-transparent">
<h5 class="mb-0">
<i class="bi bi-box-arrow-in-down text-success"></i> Cel przesunięcia
</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label for="zbiorka_cel_id" class="form-label fw-semibold">
Docelowa zbiórka <span class="text-danger">*</span>
</label>
<select class="form-select" id="zbiorka_cel_id" name="zbiorka_cel_id" required>
<option value="">-- Wybierz zbiórkę docelową --</option>
{% for zb in dostepne_zbiorki %}
<option value="{{ zb.id }}">
{% if zb.typ_zbiorki == 'rezerwa' %}
{{ zb.nazwa }} (Rezerwa)
{% else %}
{{ zb.nazwa }}
{% endif %}
· Stan: {{ zb.stan|round(2) }} PLN
</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label for="kwota" class="form-label fw-semibold">
Kwota (PLN) <span class="text-danger">*</span>
</label>
<div class="input-group">
<input type="text" class="form-control" id="kwota" name="kwota"
placeholder="np. 100.00" required>
<span class="input-group-text">PLN</span>
</div>
<small class="text-muted">
<i class="bi bi-info-circle"></i> Można użyć przecinka lub kropki
</small>
</div>
<div class="mb-0">
<label for="opis" class="form-label fw-semibold">
Opis <span class="text-muted">(opcjonalny)</span>
</label>
<textarea class="form-control" id="opis" name="opis" rows="3"
placeholder="Dodatkowe informacje o przesunięciu"></textarea>
</div>
</div>
</div>
<!-- Alert informacyjny -->
<div class="alert alert-info d-flex align-items-start mb-3">
<i class="bi bi-lightbulb fs-5 me-2"></i>
<div class="small">
<strong>Jak to działa:</strong> Kwota zostanie odjęta ze źródłowej zbiórki i dodana do docelowej.
W obu zbiórkach pojawi się wpis o przesunięciu w historii transakcji.
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-arrow-left-right"></i> Przesuń środki
</button>
<a href="{{ url_for('transakcje_zbiorki', zbiorka_id=zbiorka.id) }}"
class="btn btn-secondary">Anuluj</a>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,170 @@
{# templates/admin/formularz_rezerwy.html #}
{% extends 'base.html' %}
{% set has_obj = zbiorka is not none %}
{% set is_edit = has_obj and zbiorka.id is not none %}
{% block title %}{{ 'Edytuj listę rezerwową' if is_edit else 'Dodaj listę rezerwową' }}{% endblock %}
{% block content %}
<div class="container my-4">
<!-- Nawigacja / powrót -->
<div class="d-flex align-items-center gap-2 mb-3">
{% if is_edit and zbiorka and zbiorka.id %}
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-sm btn-outline-light">← Szczegóły
listy</a>
{% else %}
<a href="{{ url_for('lista_rezerwowych') }}" class="btn btn-sm btn-outline-light">← Listy rezerwowe</a>
{% endif %}
</div>
<div class="card shadow-sm">
<div
class="card-header bg-secondary text-white d-flex flex-wrap align-items-center justify-content-between gap-2">
<h3 class="card-title mb-0">
{{ 'Edytuj listę rezerwową' if is_edit else 'Dodaj nową listę rezerwową' }}
</h3>
{% if is_edit %}
<div class="d-flex flex-wrap align-items-center gap-2">
<span class="badge bg-dark border" style="border-color: var(--border);">
Stan: {{ (zbiorka.stan or 0)|round(2) }} PLN
</span>
<span class="badge bg-info">Lista rezerwowa</span>
</div>
{% else %}
<small class="opacity-75">Utwórz dedykowaną listę do zarządzania środkami</small>
{% endif %}
</div>
<div class="card-body">
<form method="post" novalidate id="{{ 'form-edit-rezerwa' if is_edit else 'form-add-rezerwa' }}">
<!-- ======================================== -->
<!-- PODSTAWOWE DANE -->
<!-- ======================================== -->
<h5 class="mb-3">Podstawowe dane</h5>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label for="nazwa" class="form-label">Nazwa listy <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="nazwa" name="nazwa" required
value="{{ zbiorka.nazwa if is_edit else '' }}"
placeholder="np. Nadpłaty, Środki rezerwowe">
<small class="form-text text-muted">Unikalny identyfikator tej listy rezerwowej</small>
</div>
<div class="col-md-6">
<label for="opis" class="form-label">Opis (opcjonalny)</label>
<textarea class="form-control" id="opis" name="opis" rows="3"
placeholder="Krótki opis przeznaczenia tej listy">{{ zbiorka.opis if is_edit else '' }}</textarea>
<small class="form-text text-muted">Krótki opis, który pomoże w identyfikacji</small>
</div>
</div>
<hr class="my-4">
<!-- ======================================== -->
<!-- KANAŁY PŁATNOŚCI -->
<!-- ======================================== -->
<h5 class="mb-3">Kanały płatności</h5>
<p class="text-muted small mb-3">
Określ, czy ta lista ma akceptować bezpośrednie wpłaty od użytkowników.
</p>
<div class="row g-3 mb-4">
<!-- Przelew -->
<div class="col-md-6">
<div class="border rounded p-3 h-100">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="uzyj_konta" name="uzyj_konta"
{% if is_edit and zbiorka.uzyj_konta %}checked{% endif %}>
<label class="form-check-label fw-bold" for="uzyj_konta">
Włącz wpłaty przelewem
</label>
</div>
<div id="konto-field" style="display: {% if is_edit and zbiorka.uzyj_konta %}block{% else %}none{% endif %};">
<label for="numer_konta" class="form-label small">Numer konta</label>
<input type="text" class="form-control form-control-sm" id="numer_konta"
name="numer_konta"
value="{{ zbiorka.numer_konta if is_edit else (global_settings.numer_konta if global_settings else '') }}"
placeholder="26 cyfr numeru konta">
<small class="form-text text-muted">
{% if global_settings and global_settings.numer_konta %}
Domyślnie: {{ global_settings.numer_konta }}
{% else %}
Zostaw puste dla globalnego numeru
{% endif %}
</small>
</div>
</div>
</div>
<!-- BLIK -->
<div class="col-md-6">
<div class="border rounded p-3 h-100">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="uzyj_blik" name="uzyj_blik"
{% if is_edit and zbiorka.uzyj_blik %}checked{% endif %}>
<label class="form-check-label fw-bold" for="uzyj_blik">
Włącz wpłaty przez BLIK
</label>
</div>
<div id="blik-field" style="display: {% if is_edit and zbiorka.uzyj_blik %}block{% else %}none{% endif %};">
<label for="numer_telefonu_blik" class="form-label small">Numer telefonu BLIK</label>
<input type="text" class="form-control form-control-sm" id="numer_telefonu_blik"
name="numer_telefonu_blik"
value="{{ zbiorka.numer_telefonu_blik if is_edit else (global_settings.numer_telefonu_blik if global_settings else '') }}"
placeholder="9 cyfr numeru telefonu">
<small class="form-text text-muted">
{% if global_settings and global_settings.numer_telefonu_blik %}
Domyślnie: {{ global_settings.numer_telefonu_blik }}
{% else %}
Zostaw puste dla globalnego numeru
{% endif %}
</small>
</div>
</div>
</div>
</div>
{% if not is_edit %}
<!-- Alert informacyjny tylko przy tworzeniu -->
<div class="alert alert-info mb-4">
<strong>Wskazówka:</strong> Lista rezerwowa to pomocnicze miejsce do gromadzenia środków,
które mogą być później przesuwane do konkretnych zbiórek. Jest ukryta dla użytkowników
nieadministracyjnych i nie pojawia się na stronie głównej.
</div>
{% endif %}
<hr class="my-4">
<!-- Przyciski akcji -->
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-center">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
{{ 'Zapisz zmiany' if is_edit else 'Utwórz listę rezerwową' }}
</button>
<a href="{{ url_for('lista_rezerwowych') }}" class="btn btn-outline-light">Anuluj</a>
</div>
{% if is_edit %}
<div>
<a href="{{ url_for('transakcje_zbiorki', zbiorka_id=zbiorka.id) }}"
class="btn btn-outline-light btn-sm">Zobacz transakcje</a>
</div>
{% endif %}
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='js/formularz_rezerwy.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %}

View File

@@ -0,0 +1,146 @@
{% extends 'base.html' %}
{% block title %}Listy rezerwowe{% endblock %}
{% block content %}
<div class="container my-4">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-4">
<h2 class="mb-0">Listy rezerwowe</h2>
<div class="d-flex flex-wrap gap-2">
<a href="{{ url_for('dodaj_rezerwe') }}" class="btn btn-primary">
Dodaj listę rezerwową
</a>
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-light">
Panel Admina
</a>
</div>
</div>
{% if rezerwy %}
<div class="table-responsive mb-5">
<table class="table table-dark table-striped table-hover align-middle">
<thead>
<tr>
<th style="width:72px;">ID</th>
<th>Nazwa</th>
<th style="width:140px;">Widoczność</th>
<th style="width:1%;">Opcje</th>
</tr>
</thead>
<tbody>
{% for r in rezerwy %}
<tr>
<td class="text-muted">{{ r.id }}</td>
<td>
<div class="d-flex flex-column">
<span class="fw-semibold">
<i class="bi bi-wallet2"></i> {{ r.nazwa }}
</span>
<small class="text-muted">
Stan: {{ r.stan|round(2) }} PLN
{% if r.opis %}
· {{ r.opis[:50] }}{% if r.opis|length > 50 %}...{% endif %}
{% endif %}
</small>
</div>
</td>
<td>
{% if r.ukryta %}
<span class="badge bg-secondary border" style="border-color: var(--border);">Ukryta</span>
{% else %}
<span class="badge bg-success">Widoczna</span>
{% endif %}
</td>
<td class="text-end">
<!-- Grupa akcji: główne + rozwijane -->
<div class="btn-group">
<a href="{{ url_for('edytuj_rezerwe', rezerwa_id=r.id) }}"
class="btn btn-sm btn-outline-light">Edytuj</a>
<button class="btn btn-sm btn-outline-light dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown" aria-expanded="false" aria-label="Więcej opcji">
<span class="visually-hidden">Więcej</span>
</button>
<ul class="dropdown-menu dropdown-menu-dark dropdown-menu-end shadow">
<li>
<a class="dropdown-item"
href="{{ url_for('zbiorka', zbiorka_id=r.id) }}">
<i class="bi bi-eye"></i> Otwórz
</a>
</li>
<li>
<hr class="dropdown-divider">
</li>
<li>
<a class="dropdown-item"
href="{{ url_for('dodaj_wplate', zbiorka_id=r.id, next=url_for('zbiorka', zbiorka_id=r.id)) }}">
<i class="bi bi-plus-circle"></i> Dodaj wpłatę
</a>
</li>
<li>
<a class="dropdown-item"
href="{{ url_for('dodaj_wydatek', zbiorka_id=r.id, next=url_for('zbiorka', zbiorka_id=r.id)) }}">
<i class="bi bi-dash-circle"></i> Dodaj wydatek
</a>
</li>
<li>
<a class="dropdown-item"
href="{{ url_for('dodaj_przesuniecie', zbiorka_id=r.id, next=url_for('zbiorka', zbiorka_id=r.id)) }}">
<i class="bi bi-arrow-left-right"></i> Przesuń środki
</a>
</li>
<li>
<a class="dropdown-item"
href="{{ url_for('transakcje_zbiorki', zbiorka_id=r.id) }}">
<i class="bi bi-list-ul"></i> Transakcje
</a>
</li>
<li>
<a class="dropdown-item"
href="{{ url_for('edytuj_stan', zbiorka_id=r.id) }}">
<i class="bi bi-pencil-square"></i> Edytuj stan
</a>
</li>
<li>
<hr class="dropdown-divider">
</li>
<li>
<form action="{{ url_for('zmien_widzialnosc', zbiorka_id=r.id) }}"
method="post" class="m-0">
<button type="submit" class="dropdown-item">
<i class="bi bi-eye{% if r.ukryta %}-slash{% endif %}"></i>
{% if r.ukryta %}Pokaż{% else %}Ukryj{% endif %}
</button>
</form>
</li>
<li>
<hr class="dropdown-divider">
</li>
<li>
<form action="{{ url_for('usun_rezerwe', rezerwa_id=r.id) }}" method="post"
class="m-0"
onsubmit="return confirm('Czy na pewno usunąć listę \'{{ r.nazwa }}\'?\n\nUWAGA: Zostaną usunięte wszystkie transakcje powiązane z tą listą!');">
<button type="submit" class="dropdown-item text-danger">
<i class="bi bi-trash"></i> Usuń
</button>
</form>
</li>
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<!-- Empty state -->
<div class="card">
<div class="card-body text-center py-5">
<h5 class="mb-2">Brak list rezerwowych</h5>
<p class="text-muted mb-4">Nie masz jeszcze żadnych list rezerwowych. Utwórz pierwszą, aby zarządzać nadpłatami i środkami rezerwowymi.</p>
<a href="{{ url_for('dodaj_rezerwe') }}" class="btn btn-primary">Dodaj listę rezerwową</a>
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,119 @@
{% extends 'base.html' %}
{% block title %}Przesuń wpłatę - {{ zbiorka.nazwa }}{% endblock %}
{% block content %}
<div class="container my-5">
<!-- Nagłówek -->
<div class="mb-4">
<h1 class="mb-2">
<i class="bi bi-arrow-left-right text-primary"></i> Przesuń konkretną wpłatę
</h1>
<p class="text-muted">Przenieś wybraną wpłatę do innej zbiórki</p>
</div>
<!-- Szczegóły wpłaty -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-transparent">
<h5 class="mb-0">
<i class="bi bi-receipt text-success"></i> Szczegóły wpłaty
</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<div class="border-start border-primary border-3 ps-3">
<small class="text-muted d-block">Źródło</small>
<strong class="fs-6">{{ zbiorka.nazwa }}</strong>
{% if zbiorka.typ_zbiorki == 'rezerwa' %}
<span class="badge bg-info ms-2">Lista rezerwowa</span>
{% endif %}
</div>
</div>
<div class="col-md-6">
<div class="border-start border-success border-3 ps-3">
<small class="text-muted d-block">Kwota</small>
<strong class="fs-4 text-success">{{ wplata.kwota|round(2) }} PLN</strong>
</div>
</div>
<div class="col-md-6">
<div class="border-start border-secondary border-3 ps-3">
<small class="text-muted d-block">Data wpłaty</small>
<strong>{{ wplata.data|dt("%d.%m.%Y %H:%M") }}</strong>
</div>
</div>
{% if wplata.opis %}
<div class="col-md-6">
<div class="border-start border-secondary border-3 ps-3">
<small class="text-muted d-block">Opis oryginalny</small>
<strong>{{ wplata.opis }}</strong>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Formularz przesunięcia -->
<form method="POST">
<div class="card shadow-sm mb-3">
<div class="card-header bg-transparent">
<h5 class="mb-0">
<i class="bi bi-box-arrow-right text-warning"></i> Cel przesunięcia
</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label for="zbiorka_cel_id" class="form-label fw-semibold">
Docelowa zbiórka <span class="text-danger">*</span>
</label>
<select class="form-select" id="zbiorka_cel_id" name="zbiorka_cel_id" required>
<option value="">-- Wybierz zbiórkę docelową --</option>
{% for zb in dostepne_zbiorki %}
<option value="{{ zb.id }}">
{% if zb.typ_zbiorki == 'rezerwa' %}
[Rezerwa] {{ zb.nazwa }} · Stan: {{ zb.stan|round(2) }} PLN
{% else %}
{{ zb.nazwa }} · Stan: {{ zb.stan|round(2) }} PLN
{% endif %}
</option>
{% endfor %}
</select>
</div>
<div class="mb-0">
<label for="opis" class="form-label fw-semibold">
Dodatkowy opis przesunięcia <span class="text-muted">(opcjonalny)</span>
</label>
<textarea class="form-control" id="opis" name="opis" rows="3"
placeholder="np. Powód przesunięcia, notatki..."></textarea>
<small class="text-muted">
<i class="bi bi-info-circle"></i> Oryginalny opis wpłaty zostanie zachowany
</small>
</div>
</div>
</div>
<!-- Alert informacyjny -->
<div class="alert alert-info d-flex align-items-start mb-3">
<i class="bi bi-lightbulb fs-5 me-2"></i>
<div class="small">
<strong>Jak to działa:</strong>
<ul class="mb-0 mt-1">
<li>Wpłata zostanie przeniesiona do wybranej zbiórki wraz z całą historią</li>
<li>Zostanie utworzony wpis o przesunięciu w obu zbiórkach</li>
<li>Stany finansowe zostaną automatycznie zaktualizowane</li>
</ul>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-arrow-left-right"></i> Wykonaj przesunięcie
</button>
<a href="{{ url_for('transakcje_zbiorki', zbiorka_id=zbiorka.id) }}"
class="btn btn-secondary">Anuluj</a>
</div>
</form>
</div>
{% endblock %}

View File

@@ -14,6 +14,9 @@
<a class="btn btn-sm btn-outline-light" href="{{ url_for('dodaj_wydatek', zbiorka_id=zbiorka.id) }}">
Dodaj wydatek
</a>
<a class="btn btn-sm btn-outline-light" href="{{ url_for('dodaj_przesuniecie', zbiorka_id=zbiorka.id) }}">
Przesuń środki
</a>
<a class="btn btn-sm btn-outline-light" href="{{ url_for('edytuj_stan', zbiorka_id=zbiorka.id) }}">
Edytuj stan
</a>
@@ -61,6 +64,10 @@
<div class="d-inline-flex flex-nowrap align-items-center gap-2">
{% if a.typ == 'wpłata' %}
<a class="btn btn btn-sm btn-outline-light btn-edit-wplata"
href="{{ url_for('przesun_wplate', zbiorka_id=zbiorka.id, wplata_id=a.id) }}"
title="Przesuń tę wpłatę"> Przesuń
</a>
<button class="btn btn-sm btn-outline-light 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) }}">
@@ -114,7 +121,7 @@
</tr>
{% else %}
<tr>
<td colspan="5" class="text-center text-muted py-4">Brak transakcji.</td>
<td colspan="6" class="text-center text-muted py-4">Brak transakcji.</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -7,14 +7,22 @@
{# Wyliczenia postępu finansowego #}
{% 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_clamped = 100 if progress > 100 else (0 if progress < 0 else progress) %} {% set
is_done=(progress_clamped>= 100) %}
{% set progress_clamped = 100 if progress > 100 else (0 if progress < 0 else progress) %}
{% set is_done=(progress_clamped>= 100) %}
<!-- Nagłówek -->
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<h2 class="mb-0">{{ zbiorka.nazwa }}</h2>
<h2 class="mb-0">
{% if zbiorka.typ_zbiorki == 'rezerwa' %}
<i class="bi bi-wallet2"></i>
{% endif %}
{{ zbiorka.nazwa }}
</h2>
<div class="d-flex flex-wrap align-items-center gap-2">
{% if is_done %}
{% if zbiorka.typ_zbiorki == 'rezerwa' %}
<span class="badge bg-info">Lista rezerwowa</span>
{% endif %}
{% if is_done and zbiorka.typ_zbiorki != 'rezerwa' %}
<span class="badge rounded-pill" style="background: var(--accent); color:#111;">Zrealizowana</span>
{% endif %}
{% if zbiorka.ukryj_kwote %}
@@ -97,6 +105,8 @@
{% set suma_pct = (suma_kupione / suma_all * 100) if suma_all > 0 else 0 %}
{% endif %}
{# Pokazuj sekcję postępu TYLKO dla standardowych zbiórek, NIE dla rezerwowych #}
{% if zbiorka.typ_zbiorki != 'rezerwa' %}
<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">
@@ -162,6 +172,8 @@
{% endif %}
</div>
</div>
{% endif %}
{# Koniec warunku dla typu zbiórki #}
</div>
@@ -174,8 +186,14 @@
<div class="card-body d-flex flex-column gap-3">
<div class="d-flex align-items-center justify-content-between">
<h5 class="mb-0">Jak wspomóc?</h5>
{% if has_cel and not zbiorka.ukryj_kwote %}
<h5 class="mb-0">
{% if zbiorka.typ_zbiorki == 'rezerwa' %}
Wpłaty
{% else %}
Jak wspomóc?
{% endif %}
</h5>
{% if has_cel and not zbiorka.ukryj_kwote and zbiorka.typ_zbiorki != 'rezerwa' %}
{% set brak = (zbiorka.cel - zbiorka.stan) %}
{% if brak > 0 %}
<span class="badge bg-warning text-dark border border-warning">Brakuje: {{ brak|round(2) }} PLN</span>
@@ -223,17 +241,21 @@
{% if not zbiorka.ukryj_kwote %}
<ul class="list-group list-group-flush small">
{% if has_cel %}
<li class="list-group-item bg-transparent d-flex justify-content-between">
<span>Cel</span>
<span class="fw-semibold">{{ zbiorka.cel|round(2) }} PLN</span>
</li>
{% if zbiorka.typ_zbiorki != 'rezerwa' %}
{% if has_cel %}
<li class="list-group-item bg-transparent d-flex justify-content-between">
<span>Cel</span>
<span class="fw-semibold">{{ zbiorka.cel|round(2) }} PLN</span>
</li>
{% endif %}
{% endif %}
<li class="list-group-item bg-transparent d-flex justify-content-between">
<span>Stan</span>
<span class="fw-semibold text-success">{{ zbiorka.stan|round(2) }} PLN</span>
</li>
{% if has_cel %}
{% if zbiorka.typ_zbiorki != 'rezerwa' and has_cel %}
<li class="list-group-item bg-transparent d-flex justify-content-between">
<span>
{% if brak > 0 %}Brakuje{% elif brak == 0 %}Cel{% else %}Nadwyżka{% endif %}
@@ -259,10 +281,18 @@
wpłatę</a>
<a href="{{ url_for('dodaj_wydatek', zbiorka_id=zbiorka.id) }}" class="btn btn-outline-light btn-sm">Dodaj
wydatek</a>
<a href="{{ url_for('dodaj_przesuniecie', zbiorka_id=zbiorka.id) }}" class="btn btn-outline-light btn-sm">
<i class="bi bi-arrow-left-right"></i> Przesuń środki
</a>
<a href="{{ url_for('edytuj_stan', zbiorka_id=zbiorka.id) }}" class="btn btn-outline-light btn-sm">Edytuj
stan</a>
{% if zbiorka.typ_zbiorki != 'rezerwa' %}
<a href="{{ url_for('formularz_zbiorek', zbiorka_id=zbiorka.id) }}"
class="btn btn-outline-light btn-sm">Edytuj opis</a>
{% else %}
<a href="{{ url_for('edytuj_rezerwe', rezerwa_id=zbiorka.id) }}"
class="btn btn-outline-light btn-sm">Edytuj rezerwę</a>
{% endif %}
</div>
{% endif %}
@@ -293,16 +323,41 @@
<li class="list-group-item bg-transparent d-flex flex-wrap justify-content-between align-items-center">
<div class="me-3">
<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">
{{ a.typ|capitalize }}
</span>
{% if a.typ == 'wpłata' %}
<span class="badge bg-success ms-2">Wpłata</span>
{% elif a.typ == 'wydatek' %}
<span class="badge bg-danger ms-2">Wydatek</span>
{% elif a.typ == 'przesunięcie_przych' %}
<span class="badge bg-info ms-2">Przesunięcie (↓ przychód)</span>
{% elif a.typ == 'przesunięcie_wych' %}
<span class="badge bg-warning text-dark ms-2">Przesunięcie (↑ wychód)</span>
{% endif %}
{% if a.opis %}
<span class="text-muted">— {{ a.opis }}</span>
{% endif %}
{% if a.typ in ['przesunięcie_przych', 'przesunięcie_wych'] and a.zbiorka_id %}
<a href="{{ url_for('zbiorka', zbiorka_id=a.zbiorka_id) }}"
class="ms-2 text-decoration-none small">
<i class="bi bi-link-45deg"></i>
{% if a.typ == 'przesunięcie_przych' %}
z: {{ a.zbiorka_nazwa }}
{% else %}
do: {{ a.zbiorka_nazwa }}
{% endif %}
</a>
{% endif %}
</div>
{% if not zbiorka.ukryj_kwote %}
<span class="badge bg-dark border ms-auto" style="border-color: var(--border);">
{% if a.typ == 'wpłata' %}+{% else %}-{% endif %} {{ a.kwota|round(2) }} PLN
{% if a.typ == 'wpłata' or a.typ == 'przesunięcie_przych' %}
+{{ a.kwota|round(2) }} PLN
{% else %}
-{{ a.kwota|round(2) }} PLN
{% endif %}
</span>
{% endif %}
</li>
@@ -317,7 +372,6 @@
</div>
</div>
<!-- Akcje dolne -->
<div class="d-flex gap-2 justify-content-between mt-3">
<div></div>
@@ -325,10 +379,10 @@
</div>
</div>
{% endblock %}
{% endblock %}
{% block extra_scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='js/zbiorka.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static', filename='js/progress.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %}
{% block extra_scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='js/zbiorka.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static', filename='js/progress.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %}