przebudowa systemu

This commit is contained in:
Mateusz Gruszczyński
2025-08-28 10:27:06 +02:00
parent d71d33cfe0
commit e9db945bb4
36 changed files with 2307 additions and 809 deletions

34
.env.example Normal file
View File

@@ -0,0 +1,34 @@
# === Podstawowe ===
APP_PORT=8080
# SQLAlchemy URI bazy (np. SQLite, Postgres, MySQL).
# Przykłady:
# - SQLite w katalogu instance: sqlite:///instance/baza.db
# - SQLite w bieżącym katalogu: sqlite:///baza.db
# - Postgres: postgresql+psycopg2://user:pass@host:5432/dbname
# - MySQL: mysql+pymysql://user:pass@host:3306/dbname
DATABASE_URL=sqlite:///instance/baza.db
# Klucz sesji Flask (USTAW własną silną wartość w produkcji!)
SECRET_KEY=change_me_strong_secret
# === Rejestracja i admin ===
# Czy pozwalać na rejestrację przez formularz (True/False)
ALLOW_REGISTRATION=False
# Dane głównego admina (tworzonego automatycznie, jeśli brak w bazie)
MAIN_ADMIN_USERNAME=admin
MAIN_ADMIN_PASSWORD=admin
# === Indeksowanie / cache ===
# Blokuj boty (ustawia także X-Robots-Tag) (True/False)
BLOCK_BOTS=True
# Wartość nagłówka Cache-Control dla stron publicznych
CACHE_CONTROL_HEADER=max-age=600
# Dodatkowe PRAGMA (opcjonalnie, jeśli chcesz dokładać własne)
PRAGMA_HEADER=
# Wartość nagłówka X-Robots-Tag, gdy BLOCK_BOTS=True
ROBOTS_TAG=noindex, nofollow, nosnippet, noarchive

2
.gitignore vendored
View File

@@ -2,4 +2,4 @@ __pycache__
data/ data/
instance/ instance/
venv/ venv/
config.py .env

View File

@@ -1,3 +1,48 @@
ALTER TABLE global_settings ADD COLUMN allowed_login_hosts TEXT; -- WŁĄCZ/wyłącz FK zależnie od etapu migracji
PRAGMA foreign_keys = OFF;
ALTER TABLE zbiorka ADD COLUMN zrealizowana BOOLEAN DEFAULT 0; BEGIN TRANSACTION;
-- 1) Nowa tabela z właściwym FK (ON DELETE CASCADE)
CREATE TABLE wplata_new (
id INTEGER PRIMARY KEY,
zbiorka_id INTEGER NOT NULL,
kwota REAL NOT NULL,
data DATETIME,
opis TEXT,
FOREIGN KEY(zbiorka_id) REFERENCES zbiorka(id) ON DELETE CASCADE
);
-- 2) (opcjonalnie) upewnij się, że nie ma „sierotek”
-- SELECT w.* FROM wplata w LEFT JOIN zbiorka z ON z.id = w.zbiorka_id WHERE z.id IS NULL;
-- 3) Kopiowanie danych
INSERT INTO wplata_new (id, zbiorka_id, kwota, data, opis)
SELECT id, zbiorka_id, kwota, data, opis
FROM wplata;
-- 4) Usunięcie starej tabeli
DROP TABLE wplata;
-- 5) Zmiana nazwy nowej tabeli na właściwą
ALTER TABLE wplata_new RENAME TO wplata;
-- 6) Odtwórz indeksy/trigger-y jeśli jakieś były (przykład indeksu po FK)
-- CREATE INDEX idx_wplata_zbiorka_id ON wplata(zbiorka_id);
COMMIT;
PRAGMA foreign_keys = ON;
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
ALTER TABLE global_settings ADD COLUMN logo_url TEXT DEFAULT '';
ALTER TABLE global_settings ADD COLUMN site_title TEXT DEFAULT '';
ALTER TABLE global_settings ADD COLUMN show_logo_in_navbar BOOLEAN DEFAULT 0;
COMMIT;
PRAGMA foreign_keys=ON;

411
app.py
View File

@@ -1,9 +1,19 @@
from flask import Flask, render_template, request, redirect, url_for, flash from flask import Flask, render_template, request, redirect, url_for, flash
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager, login_user, login_required, logout_user, current_user, UserMixin from flask_login import (
LoginManager,
login_user,
login_required,
logout_user,
current_user,
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
from markupsafe import Markup from markupsafe import Markup
from sqlalchemy import event
from sqlalchemy.engine import Engine
import markdown as md import markdown as md
from flask import request, flash, abort from flask import request, flash, abort
import os import os
@@ -12,14 +22,15 @@ import socket
app = Flask(__name__) app = Flask(__name__)
# Ładujemy konfigurację z pliku config.py # Ładujemy konfigurację z pliku config.py
app.config.from_object('config.Config') app.config.from_object("config.Config")
db = SQLAlchemy(app) db = SQLAlchemy(app)
login_manager = LoginManager(app) login_manager = LoginManager(app)
login_manager.login_view = 'login' login_manager.login_view = "login"
# 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)
@@ -32,6 +43,7 @@ class User(UserMixin, db.Model):
def check_password(self, password): def check_password(self, password):
return check_password_hash(self.password_hash, password) return check_password_hash(self.password_hash, password)
class Zbiorka(db.Model): class Zbiorka(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
nazwa = db.Column(db.String(100), nullable=False) nazwa = db.Column(db.String(100), nullable=False)
@@ -42,26 +54,57 @@ class Zbiorka(db.Model):
stan = db.Column(db.Float, default=0.0) stan = db.Column(db.Float, default=0.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)
wplaty = db.relationship('Wplata', backref='zbiorka', lazy=True, order_by='Wplata.data.desc()')
zrealizowana = db.Column(db.Boolean, default=False) zrealizowana = db.Column(db.Boolean, default=False)
wplaty = db.relationship(
"Wplata",
back_populates="zbiorka",
lazy=True,
order_by="Wplata.data.desc()",
cascade="all, delete-orphan",
passive_deletes=True,
)
class Wplata(db.Model): class Wplata(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
zbiorka_id = db.Column(db.Integer, db.ForeignKey('zbiorka.id'), nullable=False) zbiorka_id = db.Column(
db.Integer,
db.ForeignKey("zbiorka.id", ondelete="CASCADE"),
nullable=False,
)
kwota = db.Column(db.Float, nullable=False) kwota = db.Column(db.Float, 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 wpłaty opis = db.Column(db.Text, nullable=True)
zbiorka = db.relationship("Zbiorka", back_populates="wplaty")
class GlobalSettings(db.Model): class GlobalSettings(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
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)
allowed_login_hosts = db.Column(db.Text, nullable=True) allowed_login_hosts = db.Column(db.Text, nullable=True)
logo_url = db.Column(db.String(255), nullable=True)
site_title = db.Column(db.String(120), nullable=True)
show_logo_in_navbar = db.Column(db.Boolean, default=False)
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id): def load_user(user_id):
return User.query.get(int(user_id)) return User.query.get(int(user_id))
@event.listens_for(Engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
try:
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
except Exception:
pass
def get_real_ip(): def get_real_ip():
headers = request.headers headers = request.headers
cf_ip = headers.get("CF-Connecting-IP") cf_ip = headers.get("CF-Connecting-IP")
@@ -83,7 +126,7 @@ def is_allowed_ip(remote_ip, allowed_hosts_str):
if os.path.exists("emergency_access.txt"): if os.path.exists("emergency_access.txt"):
return True return True
allowed_hosts = re.split(r'[\n,]+', allowed_hosts_str.strip()) allowed_hosts = re.split(r"[\n,]+", allowed_hosts_str.strip())
allowed_ips = set() allowed_ips = set()
for host in allowed_hosts: for host in allowed_hosts:
host = host.strip() host = host.strip()
@@ -105,45 +148,58 @@ def is_allowed_ip(remote_ip, allowed_hosts_str):
# Dodaj filtr Markdown pozwala na zagnieżdżanie linków i obrazków w opisie # Dodaj filtr Markdown pozwala na zagnieżdżanie linków i obrazków w opisie
@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))
@app.context_processor @app.context_processor
def inject_ip_allowed(): def inject_globals():
settings = GlobalSettings.query.first() settings = GlobalSettings.query.first()
allowed_hosts_str = settings.allowed_login_hosts if settings and settings.allowed_login_hosts else "" allowed_hosts_str = (
settings.allowed_login_hosts
if settings and settings.allowed_login_hosts
else ""
)
client_ip = get_real_ip() client_ip = get_real_ip()
return {'is_ip_allowed': is_allowed_ip(client_ip, allowed_hosts_str)} return {
"is_ip_allowed": is_allowed_ip(client_ip, allowed_hosts_str),
"global_settings": settings,
}
# TRASY PUBLICZNE # TRASY PUBLICZNE
@app.route("/")
@app.route('/')
def index(): def index():
zbiorki = Zbiorka.query.filter_by(ukryta=False, zrealizowana=False).all() zbiorki = Zbiorka.query.filter_by(ukryta=False, zrealizowana=False).all()
return render_template('index.html', zbiorki=zbiorki) return render_template("index.html", zbiorki=zbiorki)
@app.route('/zbiorki_zrealizowane')
@app.route("/zbiorki_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)
@app.errorhandler(404) @app.errorhandler(404)
def page_not_found(e): def page_not_found(e):
return redirect(url_for('index')) return redirect(url_for("index"))
@app.route('/zbiorka/<int:zbiorka_id>')
@app.route("/zbiorka/<int:zbiorka_id>")
def zbiorka(zbiorka_id): def zbiorka(zbiorka_id):
zb = Zbiorka.query.get_or_404(zbiorka_id) zb = Zbiorka.query.get_or_404(zbiorka_id)
# Jeżeli zbiórka jest ukryta i użytkownik nie jest administratorem, zwróć 404 # Jeżeli zbiórka jest ukryta i użytkownik nie jest administratorem, zwróć 404
if zb.ukryta and (not current_user.is_authenticated or not current_user.is_admin): if zb.ukryta and (not current_user.is_authenticated or not current_user.is_admin):
abort(404) abort(404)
return render_template('zbiorka.html', zbiorka=zb) return render_template("zbiorka.html", zbiorka=zb)
# TRASY LOGOWANIA I REJESTRACJI # TRASY LOGOWANIA I REJESTRACJI
@app.route('/login', methods=['GET', 'POST'])
def login(): @app.route("/zaloguj", methods=["GET", "POST"])
def zaloguj():
# Pobierz ustawienia globalne, w tym dozwolone hosty # Pobierz ustawienia globalne, w tym dozwolone hosty
settings = GlobalSettings.query.first() settings = GlobalSettings.query.first()
allowed_hosts_str = "" allowed_hosts_str = ""
@@ -153,79 +209,93 @@ def login():
# Sprawdzenie, czy adres IP klienta jest dozwolony # Sprawdzenie, czy adres IP klienta jest dozwolony
client_ip = get_real_ip() client_ip = get_real_ip()
if not is_allowed_ip(client_ip, allowed_hosts_str): if not is_allowed_ip(client_ip, allowed_hosts_str):
flash('Dostęp do endpointu /login jest zablokowany dla Twojego adresu IP', 'danger') flash(
return redirect(url_for('index')) "Dostęp do endpointu /login jest zablokowany dla Twojego adresu IP",
"danger",
)
return redirect(url_for("index"))
if request.method == 'POST': if request.method == "POST":
username = request.form['username'] username = request.form["username"]
password = request.form['password'] password = request.form["password"]
user = User.query.filter_by(username=username).first() user = User.query.filter_by(username=username).first()
if user and user.check_password(password): if user and user.check_password(password):
login_user(user) login_user(user)
flash('Zalogowano pomyślnie', 'success') flash("Zalogowano pomyślnie", "success")
next_page = request.args.get('next') next_page = request.args.get("next")
return redirect(next_page) if next_page else redirect(url_for('admin_dashboard')) return (
redirect(next_page)
if next_page
else redirect(url_for("admin_dashboard"))
)
else: else:
flash('Nieprawidłowe dane logowania', 'danger') flash("Nieprawidłowe dane logowania", "danger")
return render_template('login.html') return render_template("login.html")
@app.route('/logout') @app.route("/wyloguj")
@login_required @login_required
def logout(): def wyloguj():
logout_user() logout_user()
flash('Wylogowano', 'success') flash("Wylogowano", "success")
return redirect(url_for('login')) return redirect(url_for("zaloguj"))
@app.route('/register', methods=['GET', 'POST'])
def register(): @app.route("/zarejestruj", methods=["GET", "POST"])
if not app.config.get('ALLOW_REGISTRATION', False): def zarejestruj():
flash('Rejestracja została wyłączona przez administratora', 'danger') if not app.config.get("ALLOW_REGISTRATION", False):
return redirect(url_for('login')) flash("Rejestracja została wyłączona przez administratora", "danger")
if request.method == 'POST': return redirect(url_for("zaloguj"))
username = request.form['username'] if request.method == "POST":
password = request.form['password'] username = request.form["username"]
password = request.form["password"]
if User.query.filter_by(username=username).first(): if User.query.filter_by(username=username).first():
flash('Użytkownik już istnieje', 'danger') flash("Użytkownik już istnieje", "danger")
return redirect(url_for('register')) return redirect(url_for("register"))
new_user = User(username=username) new_user = User(username=username)
new_user.set_password(password) new_user.set_password(password)
db.session.add(new_user) db.session.add(new_user)
db.session.commit() db.session.commit()
flash('Konto utworzone, możesz się zalogować', 'success') flash("Konto utworzone, możesz się zalogować", "success")
return redirect(url_for('login')) return redirect(url_for("zaloguj"))
return render_template('register.html') return render_template("register.html")
# PANEL ADMINISTRACYJNY # PANEL ADMINISTRACYJNY
@app.route('/admin')
@app.route("/admin")
@login_required @login_required
def admin_dashboard(): def admin_dashboard():
if not current_user.is_admin: if not current_user.is_admin:
flash('Brak uprawnień do panelu administracyjnego', 'danger') flash("Brak uprawnień do panelu administracyjnego", "danger")
return redirect(url_for('index')) return redirect(url_for("index"))
active_zbiorki = Zbiorka.query.filter_by(zrealizowana=False).all() active_zbiorki = Zbiorka.query.filter_by(zrealizowana=False).all()
completed_zbiorki = Zbiorka.query.filter_by(zrealizowana=True).all() completed_zbiorki = Zbiorka.query.filter_by(zrealizowana=True).all()
return render_template('admin/dashboard.html', active_zbiorki=active_zbiorki, return render_template(
completed_zbiorki=completed_zbiorki) "admin/dashboard.html",
active_zbiorki=active_zbiorki,
completed_zbiorki=completed_zbiorki,
)
@app.route('/admin/zbiorka/dodaj', methods=['GET', 'POST'])
@app.route("/admin/zbiorka/dodaj", methods=["GET", "POST"])
@login_required @login_required
def dodaj_zbiorka(): def dodaj_zbiorke():
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"))
global_settings = GlobalSettings.query.first() # Pobieramy globalne ustawienia global_settings = GlobalSettings.query.first() # Pobieramy globalne ustawienia
if request.method == 'POST': if request.method == "POST":
nazwa = request.form['nazwa'] nazwa = request.form["nazwa"]
opis = request.form['opis'] opis = request.form["opis"]
# Pozyskujemy numer konta i telefon z formularza (mogą być nadpisane ręcznie) # Pozyskujemy numer konta i telefon z formularza (mogą być nadpisane ręcznie)
numer_konta = request.form['numer_konta'] numer_konta = request.form["numer_konta"]
numer_telefonu_blik = request.form['numer_telefonu_blik'] numer_telefonu_blik = request.form["numer_telefonu_blik"]
cel = float(request.form['cel']) cel = float(request.form["cel"])
ukryj_kwote = 'ukryj_kwote' in request.form ukryj_kwote = "ukryj_kwote" in request.form
nowa_zbiorka = Zbiorka( nowa_zbiorka = Zbiorka(
nazwa=nazwa, nazwa=nazwa,
@@ -233,107 +303,117 @@ def dodaj_zbiorka():
numer_konta=numer_konta, numer_konta=numer_konta,
numer_telefonu_blik=numer_telefonu_blik, numer_telefonu_blik=numer_telefonu_blik,
cel=cel, cel=cel,
ukryj_kwote=ukryj_kwote ukryj_kwote=ukryj_kwote,
) )
db.session.add(nowa_zbiorka) db.session.add(nowa_zbiorka)
db.session.commit() db.session.commit()
flash('Zbiórka została dodana', 'success') flash("Zbiórka została dodana", "success")
return redirect(url_for('admin_dashboard')) return redirect(url_for("admin_dashboard"))
return render_template('admin/add_zbiorka.html', global_settings=global_settings) return render_template("admin/dodaj_zbiorke.html", global_settings=global_settings)
@app.route('/admin/zbiorka/edytuj/<int:zbiorka_id>', methods=['GET', 'POST'])
@app.route("/admin/zbiorka/edytuj/<int:zbiorka_id>", methods=["GET", "POST"])
@login_required @login_required
def edytuj_zbiorka(zbiorka_id): def edytuj_zbiorka(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)
global_settings = GlobalSettings.query.first() # Pobieramy globalne ustawienia global_settings = GlobalSettings.query.first() # Pobieramy globalne ustawienia
if request.method == 'POST': if request.method == "POST":
zb.nazwa = request.form['nazwa'] zb.nazwa = request.form["nazwa"]
zb.opis = request.form['opis'] zb.opis = request.form["opis"]
zb.numer_konta = request.form['numer_konta'] zb.numer_konta = request.form["numer_konta"]
zb.numer_telefonu_blik = request.form['numer_telefonu_blik'] zb.numer_telefonu_blik = request.form["numer_telefonu_blik"]
try: try:
zb.cel = float(request.form['cel']) zb.cel = float(request.form["cel"])
except ValueError: except ValueError:
flash('Podano nieprawidłową wartość dla celu zbiórki', 'danger') flash("Podano nieprawidłową wartość dla celu zbiórki", "danger")
return render_template('admin/edit_zbiorka.html', zbiorka=zb, global_settings=global_settings) return render_template(
zb.ukryj_kwote = 'ukryj_kwote' in request.form "admin/edytuj_zbiorke.html", zbiorka=zb, global_settings=global_settings
)
zb.ukryj_kwote = "ukryj_kwote" in request.form
db.session.commit() db.session.commit()
flash('Zbiórka została zaktualizowana', 'success') flash("Zbiórka została zaktualizowana", "success")
return redirect(url_for('admin_dashboard')) return redirect(url_for("admin_dashboard"))
return render_template('admin/edit_zbiorka.html', zbiorka=zb, global_settings=global_settings) return render_template(
"admin/edytuj_zbiorke.html", zbiorka=zb, global_settings=global_settings
)
# TRASA DODAWANIA WPŁATY Z OPISEM # TRASA DODAWANIA WPŁATY Z OPISEM
# TRASA DODAWANIA WPŁATY W PANELU ADMINA # 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 admin_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']) kwota = float(request.form["kwota"])
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 += kwota # Aktualizacja stanu zbiórki
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')) return redirect(url_for("admin_dashboard"))
return render_template('admin/add_wplata.html', zbiorka=zb) return render_template("admin/dodaj_wplate.html", zbiorka=zb)
@app.route('/admin/zbiorka/usun/<int:zbiorka_id>', methods=['POST'])
@app.route("/admin/zbiorka/usun/<int:zbiorka_id>", methods=["POST"])
@login_required @login_required
def usun_zbiorka(zbiorka_id): def usun_zbiorka(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)
db.session.delete(zb) db.session.delete(zb)
db.session.commit() db.session.commit()
flash('Zbiórka została usunięta', 'success') flash("Zbiórka została usunięta", "success")
return redirect(url_for('admin_dashboard')) return redirect(url_for("admin_dashboard"))
@app.route('/admin/zbiorka/edytuj_stan/<int:zbiorka_id>', methods=['GET', 'POST'])
@app.route("/admin/zbiorka/edytuj_stan/<int:zbiorka_id>", methods=["GET", "POST"])
@login_required @login_required
def edytuj_stan(zbiorka_id): def edytuj_stan(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:
nowy_stan = float(request.form['stan']) nowy_stan = float(request.form["stan"])
except ValueError: except 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
db.session.commit() db.session.commit()
flash('Stan zbiórki został zaktualizowany', 'success') flash("Stan zbiórki został zaktualizowany", "success")
return redirect(url_for('admin_dashboard')) return redirect(url_for("admin_dashboard"))
return render_template('admin/edytuj_stan.html', zbiorka=zb) return render_template("admin/edytuj_stan.html", zbiorka=zb)
@app.route('/admin/zbiorka/toggle_visibility/<int:zbiorka_id>', methods=['POST'])
@app.route("/admin/zbiorka/zmien_widzialnosc/<int:zbiorka_id>", methods=["POST"])
@login_required @login_required
def toggle_visibility(zbiorka_id): def zmien_widzialnosc(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)
zb.ukryta = not zb.ukryta zb.ukryta = not zb.ukryta
db.session.commit() db.session.commit()
flash('Zbiórka została ' + ('ukryta' if zb.ukryta else 'przywrócona'), 'success') flash("Zbiórka została " + ("ukryta" if zb.ukryta else "przywrócona"), "success")
return redirect(url_for('admin_dashboard')) return redirect(url_for("admin_dashboard"))
def create_admin_account(): def create_admin_account():
admin = User.query.filter_by(is_admin=True).first() admin = User.query.filter_by(is_admin=True).first()
if not admin: if not admin:
main_admin = User(username=app.config['MAIN_ADMIN_USERNAME'], is_admin=True) main_admin = User(username=app.config["MAIN_ADMIN_USERNAME"], is_admin=True)
main_admin.set_password(app.config['MAIN_ADMIN_PASSWORD']) main_admin.set_password(app.config["MAIN_ADMIN_PASSWORD"])
db.session.add(main_admin) db.session.add(main_admin)
db.session.commit() db.session.commit()
@@ -362,7 +442,9 @@ def apply_headers(response):
response.headers.pop("Vary", None) response.headers.pop("Vary", None)
elif request.path.startswith("/admin"): elif request.path.startswith("/admin"):
response.headers.pop("Vary", None) response.headers.pop("Vary", None)
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" response.headers["Cache-Control"] = (
"no-store, no-cache, must-revalidate, max-age=0"
)
else: else:
response.headers["Vary"] = "Cookie, Accept-Encoding" response.headers["Vary"] = "Cookie, Accept-Encoding"
default_cache = app.config.get("CACHE_CONTROL_HEADER") or "private, max-age=0" default_cache = app.config.get("CACHE_CONTROL_HEADER") or "private, max-age=0"
@@ -370,75 +452,96 @@ def apply_headers(response):
# Blokowanie botów # Blokowanie botów
if app.config.get("BLOCK_BOTS", False): if app.config.get("BLOCK_BOTS", False):
cc = app.config.get("CACHE_CONTROL_HEADER") or "no-store, no-cache, must-revalidate, max-age=0" cc = (
app.config.get("CACHE_CONTROL_HEADER")
or "no-store, no-cache, must-revalidate, max-age=0"
)
response.headers["Cache-Control"] = cc response.headers["Cache-Control"] = cc
response.headers["X-Robots-Tag"] = app.config.get("ROBOTS_TAG") or "noindex, nofollow, nosnippet, noarchive" response.headers["X-Robots-Tag"] = (
app.config.get("ROBOTS_TAG") or "noindex, nofollow, nosnippet, noarchive"
)
return response return response
@app.route('/admin/settings', methods=['GET', 'POST']) @app.route("/admin/ustawienia", methods=["GET", "POST"])
@login_required @login_required
def admin_settings(): def admin_ustawienia():
if not current_user.is_admin: if not current_user.is_admin:
flash('Brak uprawnień do panelu administracyjnego', 'danger') flash("Brak uprawnień do panelu administracyjnego", "danger")
return redirect(url_for('index')) return redirect(url_for("index"))
client_ip = get_real_ip() client_ip = get_real_ip()
settings = GlobalSettings.query.first() settings = GlobalSettings.query.first()
if request.method == 'POST': if request.method == "POST":
numer_konta = request.form.get('numer_konta') numer_konta = request.form.get("numer_konta")
numer_telefonu_blik = request.form.get('numer_telefonu_blik') numer_telefonu_blik = request.form.get("numer_telefonu_blik")
allowed_login_hosts = request.form.get('allowed_login_hosts') allowed_login_hosts = request.form.get("allowed_login_hosts")
logo_url = request.form.get("logo_url")
site_title = request.form.get("site_title")
show_logo_in_navbar = "show_logo_in_navbar" in request.form
if settings is None: if settings is None:
settings = GlobalSettings( settings = GlobalSettings(
numer_konta=numer_konta, numer_konta=numer_konta,
numer_telefonu_blik=numer_telefonu_blik, numer_telefonu_blik=numer_telefonu_blik,
allowed_login_hosts=allowed_login_hosts allowed_login_hosts=allowed_login_hosts,
) logo_url=logo_url,
db.session.add(settings) site_title=site_title,
else: show_logo_in_navbar=show_logo_in_navbar,
settings.numer_konta = numer_konta )
settings.numer_telefonu_blik = numer_telefonu_blik db.session.add(settings)
settings.allowed_login_hosts = allowed_login_hosts else:
settings.numer_konta = numer_konta
settings.numer_telefonu_blik = numer_telefonu_blik
settings.allowed_login_hosts = allowed_login_hosts
settings.logo_url = logo_url
settings.site_title = site_title
settings.show_logo_in_navbar = show_logo_in_navbar
db.session.commit() db.session.commit()
flash('Ustawienia globalne zostały zaktualizowane', 'success') flash("Ustawienia globalne zostały zaktualizowane", "success")
return redirect(url_for('admin_dashboard')) return redirect(url_for("admin_dashboard"))
return render_template('admin/settings.html', settings=settings, client_ip=client_ip) return render_template(
"admin/ustawienia.html", settings=settings, client_ip=client_ip
)
@app.route('/admin/zbiorka/oznacz/<int:zbiorka_id>', methods=['POST'])
@app.route("/admin/zbiorka/oznacz/<int:zbiorka_id>", methods=["POST"])
@login_required @login_required
def oznacz_zbiorka(zbiorka_id): def oznacz_zbiorka(zbiorka_id):
if not current_user.is_admin: if not current_user.is_admin:
flash('Brak uprawnień do wykonania tej operacji', 'danger') flash("Brak uprawnień do wykonania tej operacji", "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)
zb.zrealizowana = True zb.zrealizowana = True
db.session.commit() db.session.commit()
flash('Zbiórka została oznaczona jako zrealizowana', 'success') flash("Zbiórka została oznaczona jako zrealizowana", "success")
return redirect(url_for('admin_dashboard')) return redirect(url_for("admin_dashboard"))
@app.route('/robots.txt')
@app.route("/robots.txt")
def robots(): def robots():
if app.config.get("BLOCK_BOTS", False): if app.config.get("BLOCK_BOTS", False):
# Instrukcje dla robotów blokujemy indeksowanie całej witryny
robots_txt = "User-agent: *\nDisallow: /" robots_txt = "User-agent: *\nDisallow: /"
else: else:
# Jeśli blokowanie botów wyłączone, można zwrócić pusty plik lub inne ustawienia
robots_txt = "User-agent: *\nAllow: /" robots_txt = "User-agent: *\nAllow: /"
return robots_txt, 200, {'Content-Type': 'text/plain'} return robots_txt, 200, {"Content-Type": "text/plain"}
if __name__ == '__main__': @app.route("/favicon.ico")
def favicon():
return "", 204
if __name__ == "__main__":
with app.app_context(): with app.app_context():
db.create_all() db.create_all()
# Tworzenie konta głównego admina, jeśli nie istnieje # Tworzenie konta głównego admina, jeśli nie istnieje
if not User.query.filter_by(is_admin=True).first(): if not User.query.filter_by(is_admin=True).first():
main_admin = User(username=app.config['MAIN_ADMIN_USERNAME'], is_admin=True) main_admin = User(username=app.config["MAIN_ADMIN_USERNAME"], is_admin=True)
main_admin.set_password(app.config['MAIN_ADMIN_PASSWORD']) main_admin.set_password(app.config["MAIN_ADMIN_PASSWORD"])
db.session.add(main_admin) db.session.add(main_admin)
db.session.commit() db.session.commit()
app.run(debug=True) app.run(debug=True)

View File

@@ -1,14 +0,0 @@
# config.py
class Config:
SQLALCHEMY_DATABASE_URI = 'sqlite:///baza.db'
SECRET_KEY = 'tajny_klucz'
# Konfiguracja rejestracji i admina
ALLOW_REGISTRATION = False
MAIN_ADMIN_USERNAME = 'admin'
MAIN_ADMIN_PASSWORD = 'admin'
# Konfiguracja ochrony przed indeksowaniem
BLOCK_BOTS = True
CACHE_CONTROL_HEADER = "max-age=10"
ROBOTS_TAG = "noindex, nofollow, nosnippet, noarchive"

45
config.py Normal file
View File

@@ -0,0 +1,45 @@
import os
def _get_bool(name: str, default: bool) -> bool:
val = os.environ.get(name)
if val is None:
return default
return str(val).strip().lower() in {"1", "true", "t", "yes", "y", "on"}
def _get_str(name: str, default: str) -> str:
return os.environ.get(name, default)
class Config:
"""
Konfiguracja aplikacji pobierana z ENV (z sensownymi domyślnymi wartościami).
Zmiennych szukamy pod nazwami:
- DATABASE_URL
- SECRET_KEY
- ALLOW_REGISTRATION
- MAIN_ADMIN_USERNAME
- MAIN_ADMIN_PASSWORD
- BLOCK_BOTS
- CACHE_CONTROL_HEADER
- PRAGMA_HEADER
- ROBOTS_TAG
"""
# Baza danych
SQLALCHEMY_DATABASE_URI = _get_str("DATABASE_URL", "sqlite:///baza.db")
# Flask
SECRET_KEY = _get_str("SECRET_KEY", "tajny_klucz")
# Rejestracja i konto admina
ALLOW_REGISTRATION = _get_bool("ALLOW_REGISTRATION", False)
MAIN_ADMIN_USERNAME = _get_str("MAIN_ADMIN_USERNAME", "admin")
MAIN_ADMIN_PASSWORD = _get_str("MAIN_ADMIN_PASSWORD", "admin")
# Indeksowanie / cache / robots
BLOCK_BOTS = _get_bool("BLOCK_BOTS", True)
CACHE_CONTROL_HEADER = _get_str("CACHE_CONTROL_HEADER", "max-age=600")
PRAGMA_HEADER = _get_str("PRAGMA_HEADER", "")
ROBOTS_TAG = _get_str("ROBOTS_TAG", "noindex, nofollow, nosnippet, noarchive")
# (opcjonalnie) wyłącz warningi track_modifications
SQLALCHEMY_TRACK_MODIFICATIONS = False

View File

@@ -1,12 +1,10 @@
version: '3.8'
services: services:
app: app:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
ports: ports:
- "8080:8080" - "${APP_PORT:-8080}:8080"
volumes: volumes:
- ./instance:/app/instance - ./instance:/app/instance
restart: unless-stopped restart: unless-stopped

3
emergency_access.txt Normal file
View File

@@ -0,0 +1,3 @@
Jeśli ten plik istwnieje w katalogu apliakcji, to wylacza zebzpieczenie logowania do panelu admina z ograniczeniem IP.
Musi miec rozszerzenie .txt

View File

@@ -1,3 +1,4 @@
import os
from app import app, db, create_admin_account from app import app, db, create_admin_account
from waitress import serve from waitress import serve
@@ -5,4 +6,6 @@ if __name__ == '__main__':
with app.app_context(): with app.app_context():
db.create_all() db.create_all()
create_admin_account() create_admin_account()
serve(app, host='0.0.0.0', port=8080)
port = int(os.environ.get("APP_PORT", 8080))
serve(app, host="0.0.0.0", port=port)

View File

@@ -1,168 +1,295 @@
/* Import czcionki Roboto */
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap');
/* Globalne */ /* ========= TOKENS ========= */
:root {
color-scheme: dark;
--bg: #121212;
/* główne tło */
--surface-0: #1a1a1a;
/* navbar, header */
--surface-1: #202020;
/* karty */
--surface-2: #2a2a2a;
/* nagłówki kart, ciemniejsze sekcje */
--border: #3a3a3a;
--text: #e4e4e4;
--text-muted: #a8a8a8;
--accent: #f5c84c;
/* żółty/amber akcent */
--accent-600: #e3b23f;
--accent-700: #cfa033;
--accent-300: #ffe083;
--radius: 10px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, .5);
--shadow-md: 0 4px 12px rgba(0, 0, 0, .45);
--trans: 220ms cubic-bezier(.2, .8, .2, 1);
}
/* ========= BASE ========= */
body { body {
font-family: 'Roboto', sans-serif; font-family: 'Roboto', system-ui, -apple-system, Segoe UI, Arial, sans-serif;
background-color: #121212; background: var(--bg);
color: #dcdcdc; color: var(--text);
padding-top: 1vh;
margin: 0; margin: 0;
padding-top: 1vh;
} }
/* Nawigacja */
.navbar {
background-color: #1c1c1c;
border-bottom: 1px solid #444;
transition: background-color 0.3s ease;
}
.navbar-brand {
color: #f5f5f5;
font-weight: bold;
transition: color 0.3s ease;
}
.nav-link {
color: #cccccc;
transition: color 0.3s ease;
}
.nav-link:hover {
color: #ffc107;
}
/* Karty */
.card {
background-color: #444242;
border: none;
border-radius: 0.5rem;
box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.6);
margin-bottom: 20px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0px 3px 16px rgba(0, 0, 0, 0.8);
}
.card-header {
background-color: #272727;
border-bottom: 1px solid #444;
font-weight: bold;
}
.card-body {
background-color: #444242;
}
/* Przyciski */
.btn {
text-transform: uppercase;
font-weight: bold;
transition: background-color 0.3s ease, transform 0.2s ease;
}
.btn:hover {
transform: translateY(-2px);
}
.btn-primary {
background-color: #2d2c2c;
border-color: #ffeb3b;
color: #ffffff;
}
.btn-primary:hover {
background-color: #1e1e1e;
border-color: #ffc107;
}
/* Linki */
a { a {
color: #ffc107; color: var(--accent);
transition: color 0.3s ease; text-decoration: none;
transition: color var(--trans);
} }
a:hover { a:hover {
color: #ffeb3b; color: var(--accent-300);
} }
/* Progress Bar */ /* ========= NAVBAR ========= */
.progress { .navbar {
background-color: #2a2a2a; background: var(--surface-0);
border-radius: 0.5rem; border-bottom: 1px solid var(--border);
height: 35px;
font-size: 1.2rem;
} }
.progress-bar { .navbar-brand {
background: linear-gradient(90deg, #ffc107, #ffeb3b); color: var(--text);
font-weight: bold; font-weight: 700;
transition: width 0.3s ease; transition: color var(--trans);
animation: progressAnimation 1s ease-in-out forwards;
} }
@keyframes progressAnimation { .navbar-brand:hover {
from { width: 0%; } color: var(--accent);
to { width: var(--progress-width); }
} }
/* Alerty (flash messages) */ .nav-link {
.alert { color: var(--text-muted);
opacity: 0; transition: color var(--trans);
animation: fadeIn 0.5s forwards;
margin-bottom: 1rem;
} }
@keyframes fadeIn { .nav-link:hover,
to { opacity: 1; } .nav-link:focus {
color: var(--accent);
} }
/* Dodatkowe marginesy */ /* ========= CARDS ========= */
.container { .card {
padding: 0 15px; background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
margin-bottom: 20px;
transition: transform 160ms ease, box-shadow 160ms ease, border-color var(--trans);
} }
/* Responsywność */ .card:hover {
@media (max-width: 767px) { transform: translateY(-2px);
.card { box-shadow: var(--shadow-md);
margin-bottom: 1rem; border-color: color-mix(in srgb, var(--accent) 20%, var(--border));
}
.card-title {
font-size: 1.25rem;
}
.btn {
font-size: 0.9rem;
}
} }
h1 { font-size: 2rem; margin-bottom: 1rem; } .card-header {
h2 { font-size: 1.7rem; margin-bottom: 0.8rem; } background: var(--surface-2);
h3 { font-size: 0.9rem; margin-bottom: 0.6rem; } border-bottom: 1px solid var(--border);
font-weight: 700;
}
/* Wspomóż */ .card-body {
background: transparent;
}
/* Wyróżniona karta */
.card.wspomoz-card { .card.wspomoz-card {
border: 1px solid #ffc107 !important; border: 1px solid var(--accent) !important;
border-radius: 0.2rem !important; border-radius: var(--radius) !important;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
} }
.card.wspomoz-card .card-body, .card.wspomoz-card .card-body,
.card.wspomoz-card .card-title, .card.wspomoz-card .card-title,
.card.wspomoz-card .card-text { .card.wspomoz-card .card-text {
color: #ffffff !important; color: var(--text) !important;
font-weight: bold; font-weight: 700;
font-size: 1.25rem; font-size: 1.15rem;
} }
.btn-primary:focus, /* ========= BUTTONS ========= */
.btn-primary:active, .btn {
.btn-primary.focus, font-weight: 700;
.btn-primary.active { border-radius: 8px;
background-color: #2d2c2c !important; transition: transform 120ms ease, background-color var(--trans), border-color var(--trans), color var(--trans);
border-color: #ffeb3b !important; }
color: #ffffff !important;
box-shadow: none; .btn:hover {
transform: translateY(-1px);
}
.btn:focus-visible {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 40%, transparent);
outline: none; outline: none;
} }
/* Primary = żółty */
.btn-primary {
background-color: var(--accent);
border-color: var(--accent-600);
color: #111;
}
.btn-primary:hover {
background-color: var(--accent-600);
border-color: var(--accent-700);
color: #111;
}
.btn-primary:active,
.btn-primary:focus {
background-color: var(--accent-700) !important;
border-color: var(--accent-700) !important;
color: #111 !important;
}
/* Secondary = ciemna szarość */
.btn-secondary,
.btn-outline-primary {
background: var(--surface-1);
border: 1px solid var(--border);
color: var(--text);
}
.btn-secondary:hover,
.btn-outline-primary:hover {
border-color: var(--accent-600);
color: var(--accent);
}
/* ========= PROGRESS ========= */
.progress {
background: var(--surface-2);
border-radius: 999px;
height: 14px;
overflow: hidden;
border: 1px solid var(--border);
}
.progress-bar {
--progress-width: 0%;
width: var(--progress-width);
background: linear-gradient(90deg, var(--accent-600), var(--accent));
transition: width var(--trans);
}
/* ========= ALERTS ========= */
/* ALERT VARIANTS */
.alert-success {
background: rgba(40, 167, 69, 0.15);
/* lekka zieleń */
border-color: #28a745;
color: #28a745;
}
.alert-danger {
background: rgba(220, 53, 69, 0.15);
/* lekka czerwień */
border-color: #dc3545;
color: #dc3545;
}
.alert-warning {
background: rgba(255, 193, 7, 0.15);
/* lekki bursztyn */
border-color: #ffc107;
color: #ffc107;
}
.alert-info {
background: rgba(23, 162, 184, 0.15);
/* lekki cyjan */
border-color: #17a2b8;
color: #17a2b8;
}
@keyframes fadeIn {
to {
opacity: 1;
}
}
/* ========= TYPO ========= */
h1 {
font-size: 2rem;
margin-bottom: .75rem;
}
h2 {
font-size: 1.6rem;
margin-bottom: .6rem;
}
h3 {
font-size: 1.1rem;
margin-bottom: .5rem;
color: var(--text-muted);
}
small,
.text-muted {
color: var(--text-muted) !important;
}
/* ========= RESPONSIVE ========= */
.container {
padding: 0 15px;
}
@media (max-width: 767px) {
.card {
margin-bottom: 1rem;
}
.card-title {
font-size: 1.1rem;
}
.btn {
font-size: .95rem;
}
}
.table-responsive {
overflow: visible;
}
.dropdown-menu {
z-index: 1080;
}
/* ponad kartą/tabelą */
@media (max-width: 576px) {
.table-responsive {
overflow-x: auto;
}
}
/* ========= FORMS ========= */
input.form-control,
textarea.form-control,
select.form-select {
background-color: var(--surface-1);
border: 1px solid var(--border);
color: var(--text);
}
input.form-control:focus,
textarea.form-control:focus,
select.form-select:focus {
background-color: var(--surface-1);
border-color: var(--accent-600);
color: var(--text);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 50%, transparent);
}

View File

21
static/js/dodaj_wplate.js Normal file
View File

@@ -0,0 +1,21 @@
(function () {
const kwota = document.getElementById('kwota');
const opis = document.getElementById('opis');
const opisCount = document.getElementById('opisCount');
document.querySelectorAll('.btn-kwota').forEach(btn => {
btn.addEventListener('click', () => {
const val = btn.getAttribute('data-amount');
if (val && kwota) {
kwota.value = Number(val).toFixed(2);
kwota.focus();
}
});
});
if (opis && opisCount) {
const updateCount = () => opisCount.textContent = opis.value.length.toString();
opis.addEventListener('input', updateCount);
updateCount();
}
})();

View File

@@ -0,0 +1,37 @@
(function () {
const opis = document.getElementById('opis');
const opisCount = document.getElementById('opisCount');
if (opis && opisCount) {
const updateCount = () => opisCount.textContent = opis.value.length.toString();
opis.addEventListener('input', updateCount);
updateCount();
}
const iban = document.getElementById('numer_konta');
if (iban) {
iban.addEventListener('input', () => {
const digits = iban.value.replace(/\D/g, '').slice(0, 26);
const chunked = digits.replace(/(.{4})/g, '$1 ').trim();
iban.value = chunked;
});
}
const tel = document.getElementById('numer_telefonu_blik');
if (tel) {
tel.addEventListener('input', () => {
const digits = tel.value.replace(/\D/g, '').slice(0, 9);
const parts = [];
if (digits.length > 0) parts.push(digits.substring(0, 3));
if (digits.length > 3) parts.push(digits.substring(3, 6));
if (digits.length > 6) parts.push(digits.substring(6, 9));
tel.value = parts.join(' ');
});
}
const cel = document.getElementById('cel');
if (cel) {
cel.addEventListener('change', () => {
if (cel.value && Number(cel.value) < 0.01) cel.value = '0.01';
});
}
})();

82
static/js/edytuj_stan.js Normal file
View File

@@ -0,0 +1,82 @@
(() => {
// Root kontenera z danymi (dataset.cel)
const root = document.querySelector('[data-module="edit-stan"]');
if (!root) return;
const input = root.querySelector('#stan');
const previewPct = root.querySelector('#previewPct');
const previewBar = root.querySelector('#previewBar');
const previewNote = root.querySelector('#previewNote');
// Cel przekazany jako data atrybut
const cel = Number(root.dataset.cel || 0);
function clamp(n) {
if (Number.isNaN(n)) return 0;
return n < 0 ? 0 : n;
}
function pct(val) {
if (!cel || cel <= 0) return 0;
return (val / cel) * 100;
}
function updatePreview() {
if (!input) return;
const val = clamp(Number(input.value));
const p = Math.max(0, Math.min(100, pct(val)));
if (previewPct) previewPct.textContent = pct(val).toFixed(1);
if (previewBar) previewBar.style.setProperty('--progress-width', p + '%');
if (previewNote) {
if (cel > 0) {
const diff = cel - val;
if (diff > 0) {
previewNote.textContent = 'Do celu brakuje: ' + diff.toFixed(2) + ' PLN';
} else if (diff === 0) {
previewNote.textContent = 'Cel osiągnięty.';
} else {
previewNote.textContent = 'Przekroczono cel o: ' + Math.abs(diff).toFixed(2) + ' PLN';
}
} else {
previewNote.textContent = 'Brak zdefiniowanego celu — procent nie jest wyliczany.';
}
}
}
// Zmiana ręczna
if (input) {
input.addEventListener('input', updatePreview);
input.addEventListener('change', () => {
if (Number(input.value) < 0) input.value = '0.00';
updatePreview();
});
}
// Przyciski +/- delta
root.querySelectorAll('.btn-delta').forEach(btn => {
btn.addEventListener('click', () => {
const d = Number(btn.getAttribute('data-delta') || 0);
const cur = Number(input?.value || 0);
if (!input) return;
input.value = clamp(cur + d).toFixed(2);
updatePreview();
input.focus();
});
});
// Ustaw na konkretną wartość
root.querySelectorAll('.btn-set').forEach(btn => {
btn.addEventListener('click', () => {
const v = Number(btn.getAttribute('data-value') || 0);
if (!input) return;
input.value = clamp(v).toFixed(2);
updatePreview();
input.focus();
});
});
// Inicjalny podgląd
updatePreview();
})();

View File

@@ -0,0 +1,60 @@
(function () {
// Licznik znaków opisu
const opis = document.getElementById('opis');
const opisCount = document.getElementById('opisCount');
if (opis && opisCount) {
const updateCount = () => opisCount.textContent = opis.value.length.toString();
opis.addEventListener('input', updateCount);
updateCount();
}
// IBAN: tylko cyfry, auto-grupowanie co 4
const iban = document.getElementById('numer_konta');
if (iban) {
iban.addEventListener('input', () => {
const digits = iban.value.replace(/\D/g, '').slice(0, 26); // 26 cyfr po "PL"
const chunked = digits.replace(/(.{4})/g, '$1 ').trim();
iban.value = chunked;
});
}
// BLIK telefon: tylko cyfry, format 3-3-3
const tel = document.getElementById('numer_telefonu_blik');
if (tel) {
tel.addEventListener('input', () => {
const digits = tel.value.replace(/\D/g, '').slice(0, 9);
const parts = [];
if (digits.length > 0) parts.push(digits.substring(0, 3));
if (digits.length > 3) parts.push(digits.substring(3, 6));
if (digits.length > 6) parts.push(digits.substring(6, 9));
tel.value = parts.join(' ');
});
}
// „Ustaw globalne” z data-atrybutów (bez wstrzykiwania wartości w JS)
const setGlobalBtn = document.getElementById('ustaw-globalne');
if (setGlobalBtn && iban && tel) {
setGlobalBtn.addEventListener('click', () => {
const gIban = setGlobalBtn.dataset.iban || '';
const gBlik = setGlobalBtn.dataset.blik || '';
if (gIban) {
iban.value = gIban.replace(/\D/g, '').replace(/(.{4})/g, '$1 ').trim();
}
if (gBlik) {
const d = gBlik.replace(/\D/g, '').slice(0, 9);
const p = [d.slice(0, 3), d.slice(3, 6), d.slice(6, 9)].filter(Boolean).join(' ');
tel.value = p;
}
iban.dispatchEvent(new Event('input'));
tel.dispatchEvent(new Event('input'));
});
}
// Cel: minimalna wartość
const cel = document.getElementById('cel');
if (cel) {
cel.addEventListener('change', () => {
if (cel.value && Number(cel.value) < 0.01) cel.value = '0.01';
});
}
})();

4
static/js/mde_custom.js Normal file
View File

@@ -0,0 +1,4 @@
var simplemde = new SimpleMDE({
element: document.getElementById("opis"),
forceSync: true
});

13
static/js/progress.js Normal file
View File

@@ -0,0 +1,13 @@
function animateProgressBars() {
document.querySelectorAll('.progress-bar').forEach(bar => {
const progressValue = bar.getAttribute('aria-valuenow');
bar.style.setProperty('--progress-width', progressBarWidth(progressBarValue(progressBar)));
});
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.progress-bar').forEach(bar => {
const width = bar.getAttribute('aria-valuenow') + '%';
bar.style.setProperty('--progress-width', width);
});
});

92
static/js/ustawienia.js Normal file
View File

@@ -0,0 +1,92 @@
(function () {
// IBAN: tylko cyfry, auto-grupowanie co 4 (po prefiksie PL)
const iban = document.getElementById('numer_konta');
if (iban) {
iban.addEventListener('input', () => {
const digits = iban.value.replace(/\\D/g, '').slice(0, 26); // 26 cyfr po "PL"
const chunked = digits.replace(/(.{4})/g, '$1 ').trim();
iban.value = chunked;
});
}
// Telefon BLIK: tylko cyfry, format 3-3-3
const tel = document.getElementById('numer_telefonu_blik');
if (tel) {
tel.addEventListener('input', () => {
const digits = tel.value.replace(/\\D/g, '').slice(0, 9);
const parts = [];
if (digits.length > 0) parts.push(digits.substring(0, 3));
if (digits.length > 3) parts.push(digits.substring(3, 6));
if (digits.length > 6) parts.push(digits.substring(6, 9));
tel.value = parts.join(' ');
});
}
// Biała lista IP/hostów — helpery
const ta = document.getElementById('allowed_login_hosts');
const count = document.getElementById('hostsCount');
const addBtn = document.getElementById('btn-add-host');
const addMyBtn = document.getElementById('btn-add-my-ip');
const input = document.getElementById('host_input');
function parseList(text) {
// akceptuj przecinki, średniki i nowe linie; trimuj; usuń puste
return text
.split(/[\\n,;]+/)
.map(s => s.trim())
.filter(Boolean);
}
function formatList(arr) {
return arr.join('\\n');
}
function dedupe(arr) {
const seen = new Set();
const out = [];
for (const v of arr) {
const k = v.toLowerCase();
if (!seen.has(k)) { seen.add(k); out.push(v); }
}
return out;
}
function updateCount() {
if (!ta || !count) return;
count.textContent = parseList(ta.value).length.toString();
}
function addEntry(val) {
if (!ta || !val) return;
const list = dedupe([...parseList(ta.value), val]);
ta.value = formatList(list);
updateCount();
}
if (ta) {
ta.addEventListener('input', updateCount);
// inicjalny przelicznik
updateCount();
}
if (addBtn && input) {
addBtn.addEventListener('click', () => {
const val = (input.value || '').trim();
if (!val) return;
addEntry(val);
input.value = '';
input.focus();
});
}
if (addMyBtn) {
addMyBtn.addEventListener('click', () => {
const ip = addMyBtn.dataset.myIp || '';
if (ip) addEntry(ip);
});
}
const dedupeBtn = document.getElementById('btn-dedupe');
if (dedupeBtn && ta) {
dedupeBtn.addEventListener('click', () => {
ta.value = formatList(dedupe(parseList(ta.value)));
updateCount();
});
}
})();

View File

@@ -0,0 +1,27 @@
(function () {
const form = document.querySelector('form.needs-validation');
form.addEventListener('submit', function (e) {
if (!form.checkValidity()) {
e.preventDefault();
e.stopPropagation();
}
form.classList.add('was-validated');
}, false);
})();
const pw = document.getElementById('password');
const toggle = document.getElementById('togglePw');
toggle.addEventListener('click', () => {
const isText = pw.type === 'text';
pw.type = isText ? 'password' : 'text';
toggle.textContent = isText ? 'Pokaż' : 'Ukryj';
toggle.setAttribute('aria-pressed', (!isText).toString());
pw.focus();
});
const caps = document.getElementById('capsWarning');
function handleCaps(e) {
const capsOn = e.getModifierState && e.getModifierState('CapsLock');
caps.style.display = capsOn ? 'inline' : 'none';
}
pw.addEventListener('keyup', handleCaps);
pw.addEventListener('keydown', handleCaps);

View File

@@ -0,0 +1,37 @@
(function () {
const form = document.querySelector('form.needs-validation');
form.addEventListener('submit', function (e) {
if (!form.checkValidity()) {
e.preventDefault();
e.stopPropagation();
}
const pw1 = document.getElementById('password');
const pw2 = document.getElementById('password2');
if (pw1.value !== pw2.value) {
e.preventDefault();
e.stopPropagation();
pw2.setCustomValidity("Hasła muszą być identyczne.");
pw2.reportValidity();
} else {
pw2.setCustomValidity("");
}
form.classList.add('was-validated');
}, false);
})();
const pw = document.getElementById('password');
const toggle = document.getElementById('togglePw');
toggle.addEventListener('click', () => {
const isText = pw.type === 'text';
pw.type = isText ? 'password' : 'text';
toggle.textContent = isText ? 'Pokaż' : 'Ukryj';
pw.focus();
});
const caps = document.getElementById('capsWarning');
function handleCaps(e) {
const capsOn = e.getModifierState && e.getModifierState('CapsLock');
caps.style.display = capsOn ? 'inline' : 'none';
}
pw.addEventListener('keyup', handleCaps);
pw.addEventListener('keydown', handleCaps);

38
static/js/zbiorka.js Normal file
View File

@@ -0,0 +1,38 @@
(function () {
const ibanEl = document.getElementById('ibanDisplay');
if (ibanEl) {
const digits = (ibanEl.textContent || '').replace(/\s+/g, '').replace(/^PL/i, '').replace(/\D/g, '').slice(0, 26);
if (digits) ibanEl.textContent = 'PL ' + digits.replace(/(.{4})/g, '$1 ').trim();
}
const blikEl = document.getElementById('blikDisplay');
if (blikEl) {
const d = (blikEl.textContent || '').replace(/\D/g, '').slice(0, 9);
const parts = [d.slice(0, 3), d.slice(3, 6), d.slice(6, 9)].filter(Boolean).join(' ');
if (parts) blikEl.textContent = parts;
}
document.querySelectorAll('[data-copy-target]').forEach(btn => {
btn.addEventListener('click', async () => {
const sel = btn.getAttribute('data-copy-target');
const el = sel ? document.querySelector(sel) : null;
if (!el) return;
const raw = el.textContent.replace(/\u00A0/g, ' ').trim();
try {
await navigator.clipboard.writeText(raw);
const original = btn.textContent;
btn.textContent = 'Skopiowano!';
btn.disabled = true;
setTimeout(() => { btn.textContent = original; btn.disabled = false; }, 1200);
} catch {
// fallback
const r = document.createRange();
r.selectNodeContents(el);
const selObj = window.getSelection();
selObj.removeAllRanges();
selObj.addRange(r);
try { document.execCommand('copy'); } catch { }
selObj.removeAllRanges();
}
});
});
})();

View File

@@ -1,24 +0,0 @@
{% extends 'base.html' %}
{% block title %}Dodaj wpłatę{% endblock %}
{% block content %}
<div class="container my-4">
<div class="card shadow-sm">
<div class="card-header bg-warning text-dark">
<h3 class="card-title mb-0">Dodaj wpłatę do zbiórki: {{ zbiorka.nazwa }}</h3>
</div>
<div class="card-body">
<form method="post">
<div class="mb-3">
<label for="kwota" class="form-label">Kwota wpłaty (PLN)</label>
<input type="number" step="0.01" class="form-control" id="kwota" name="kwota" required>
</div>
<div class="mb-3">
<label for="opis" class="form-label">Opis wpłaty (opcjonalnie)</label>
<textarea class="form-control" id="opis" name="opis" rows="3"></textarea>
</div>
<button type="submit" class="btn btn-success">Dodaj wpłatę</button>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,51 +0,0 @@
{% extends 'base.html' %}
{% block title %}Dodaj zbiórkę{% endblock %}
{% block content %}
<div class="container my-4">
<div class="card shadow-sm">
<div class="card-header bg-success text-white">
<h3 class="card-title mb-0">Dodaj nową zbiórkę</h3>
</div>
<div class="card-body">
<form method="post">
<div class="mb-3">
<label for="nazwa" class="form-label">Nazwa zbiórki</label>
<input type="text" class="form-control" id="nazwa" name="nazwa" required>
</div>
<div class="mb-3">
<label for="opis" class="form-label">Opis</label>
<textarea class="form-control" id="opis" name="opis" rows="6" required></textarea>
</div>
<div class="mb-3">
<label for="numer_konta" class="form-label">Numer konta</label>
<input type="text" class="form-control" id="numer_konta" name="numer_konta" value="{{ global_settings.numer_konta if global_settings else '' }}" required>
</div>
<div class="mb-3">
<label for="numer_telefonu_blik" class="form-label">Numer telefonu BLIK</label>
<input type="text" class="form-control" id="numer_telefonu_blik" name="numer_telefonu_blik" value="{{ global_settings.numer_telefonu_blik if global_settings else '' }}" required>
</div>
<div class="mb-3">
<label for="cel" class="form-label">Cel zbiórki (PLN)</label>
<input type="number" step="0.01" class="form-control" id="cel" name="cel" required>
</div>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="ukryj_kwote" name="ukryj_kwote">
<label class="form-check-label" for="ukryj_kwote">Ukryj kwoty (cel i stan)</label>
</div>
<button type="submit" class="btn btn-success">Dodaj zbiórkę</button>
</form>
</div>
</div>
</div>
<!-- Inicjalizacja edytora Markdown (SimpleMDE) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css">
<script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script>
<script>
var simplemde = new SimpleMDE({
element: document.getElementById("opis"),
forceSync: true
});
</script>
{% endblock %}

View File

@@ -1,110 +1,247 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}Panel Admina{% endblock %} {% block title %}Panel Admina{% endblock %}
{% block content %} {% block content %}
<div class="container my-4"> <div class="container my-4">
<h2 class="mb-4">Panel Admina</h2>
<div class="mb-3"> <!-- Nagłówek + akcje globalne -->
<a href="{{ url_for('dodaj_zbiorka') }}" class="btn btn-success">Dodaj zbiórkę</a> <div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-4">
<a href="{{ url_for('admin_settings') }}" class="btn btn-primary">Ustawienia</a> <h2 class="mb-0">Panel Admina</h2>
</div> <div class="d-flex flex-wrap gap-2">
<a href="{{ url_for('dodaj_zbiorke') }}" class="btn btn-primary">
Dodaj zbiórkę
</a>
<a href="{{ url_for('admin_ustawienia') }}" class="btn btn-outline-light border">
⚙️ Ustawienia
</a>
</div>
</div>
<!-- Tabela zbiórek aktywnych --> <!-- Pigułki: Aktywne / Zrealizowane (zakładki w obrębie panelu) -->
<h4>Aktywne zbiórki</h4> <ul class="nav nav-pills mb-3" id="adminTabs" role="tablist">
<div class="table-responsive mb-5"> <li class="nav-item" role="presentation">
<table class="table table-dark table-striped table-hover"> <button class="nav-link active" id="tab-aktywne" data-bs-toggle="tab" data-bs-target="#pane-aktywne"
<thead> type="button" role="tab" aria-controls="pane-aktywne" aria-selected="true">
<tr> Aktywne zbiórki
<th>ID</th> </button>
<th>Nazwa</th> </li>
<th>Widoczność</th> <li class="nav-item" role="presentation">
<th>Opcje</th> <button class="nav-link" id="tab-zrealizowane" data-bs-toggle="tab" data-bs-target="#pane-zrealizowane"
</tr> type="button" role="tab" aria-controls="pane-zrealizowane" aria-selected="false">
</thead> Zrealizowane
<tbody> </button>
{% for z in active_zbiorki %} </li>
<tr> </ul>
<td>{{ z.id }}</td>
<td>{{ z.nazwa }}</td> <div class="tab-content">
<td>
{% if z.ukryta %} <!-- PANE: Aktywne -->
<span class="badge bg-secondary">Ukryta</span> <div class="tab-pane fade show active" id="pane-aktywne" role="tabpanel" aria-labelledby="tab-aktywne"
{% else %} tabindex="0">
<span class="badge bg-success">Widoczna</span>
{% endif %} {% if active_zbiorki and active_zbiorki|length > 0 %}
</td> <div class="table-responsive mb-5">
<td> <table class="table table-dark table-striped table-hover align-middle">
<a href="{{ url_for('edytuj_zbiorka', zbiorka_id=z.id) }}" class="btn btn-primary btn-sm">Edytuj</a> <thead>
<a href="{{ url_for('admin_dodaj_wplate', zbiorka_id=z.id) }}" class="btn btn-warning btn-sm">Dodaj wpłatę</a> <tr>
<a href="{{ url_for('edytuj_stan', zbiorka_id=z.id) }}" class="btn btn-info btn-sm">Edytuj stan</a> <th style="width:72px;">ID</th>
<!-- Przycisk do oznaczenia jako zrealizowana --> <th>Nazwa</th>
<form action="{{ url_for('oznacz_zbiorka', zbiorka_id=z.id) }}" method="post" style="display: inline;"> <th style="width:140px;">Widoczność</th>
<button type="submit" class="btn btn-warning btn-sm">Oznacz jako zrealizowana</button> <th style="width:1%;">Opcje</th>
</form> </tr>
<form action="{{ url_for('toggle_visibility', zbiorka_id=z.id) }}" method="post" style="display: inline;"> </thead>
<button type="submit" class="btn btn-secondary btn-sm"> <tbody>
{% if z.ukryta %} Pokaż {% else %} Ukryj {% endif %} {% for z in active_zbiorki %}
</button> <tr>
</form> <td class="text-muted">{{ z.id }}</td>
<form action="{{ url_for('usun_zbiorka', zbiorka_id=z.id) }}" method="post" style="display: inline;"> <td>
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Czy na pewno chcesz usunąć tę zbiórkę?');">Usuń</button> <div class="d-flex flex-column">
</form> <span class="fw-semibold">{{ z.nazwa }}</span>
</td> {# opcjonalnie: mini-meta z celem/stanem jeśli masz te pola #}
</tr> {% if z.cel is defined or z.stan is defined %}
<small class="text-muted">
{% if z.cel is defined %} Cel: {{ z.cel|round(2) }} PLN {% endif %}
{% if z.stan is defined %} · Stan: {{ z.stan|round(2) }} PLN {% endif %}
</small>
{% endif %}
</div>
</td>
<td>
{% if z.ukryta %}
<span class="badge bg-secondary border"
style="border-color: var(--border);">Ukryta</span>
{% else %}
<span class="badge rounded-pill"
style="background: var(--accent); color:#111;">Widoczna</span>
{% endif %}
</td>
<td class="text-end">
<!-- Grupa akcji: główne + rozwijane -->
<div class="btn-group">
<a href="{{ url_for('edytuj_zbiorka', zbiorka_id=z.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('dodaj_wplate', zbiorka_id=z.id) }}">Dodaj
wpłatę</a>
</li>
<li>
<a class="dropdown-item"
href="{{ url_for('edytuj_stan', zbiorka_id=z.id) }}">Edytuj stan</a>
</li>
<li>
<hr class="dropdown-divider">
</li>
<li>
<form action="{{ url_for('oznacz_zbiorka', zbiorka_id=z.id) }}"
method="post" class="m-0">
<button type="submit" class="dropdown-item">Oznacz jako
zrealizowana</button>
</form>
</li>
<li>
<form action="{{ url_for('zmien_widzialnosc', zbiorka_id=z.id) }}"
method="post" class="m-0">
<button type="submit" class="dropdown-item">
{% if z.ukryta %}Pokaż{% else %}Ukryj{% endif %}
</button>
</form>
</li>
<li>
<hr class="dropdown-divider">
</li>
<li>
<form action="{{ url_for('usun_zbiorka', zbiorka_id=z.id) }}" method="post"
class="m-0"
onsubmit="return confirm('Czy na pewno chcesz usunąć tę zbiórkę?');">
<button type="submit" class="dropdown-item text-danger">Usuń</button>
</form>
</li>
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %} {% else %}
<tr> <!-- Empty state -->
<td colspan="4" class="text-center">Brak aktywnych zbiórek</td> <div class="card">
</tr> <div class="card-body text-center py-5">
{% endfor %} <h5 class="mb-2">Brak aktywnych zbiórek</h5>
</tbody> <p class="text-muted mb-4">Wygląda na to, że teraz nic nie zbieramy.</p>
</table> <a href="{{ url_for('dodaj_zbiorka') }}" class="btn btn-primary">Utwórz nową zbiórkę</a>
</div> </div>
</div>
{% endif %}
</div>
<!-- Tabela zbiórek zrealizowanych --> <!-- PANE: Zrealizowane -->
<h4>Zrealizowane zbiórki</h4> <div class="tab-pane fade" id="pane-zrealizowane" role="tabpanel" aria-labelledby="tab-zrealizowane"
<div class="table-responsive"> tabindex="0">
<table class="table table-dark table-striped table-hover">
<thead> {% if completed_zbiorki and completed_zbiorki|length > 0 %}
<tr> <div class="table-responsive">
<th>ID</th> <table class="table table-dark table-striped table-hover align-middle">
<th>Nazwa</th> <thead>
<th>Widoczność</th> <tr>
<th>Opcje</th> <th style="width:72px;">ID</th>
</tr> <th>Nazwa</th>
</thead> <th style="width:180px;">Status</th>
<tbody> <th style="width:1%;">Opcje</th>
{% for z in completed_zbiorki %} </tr>
<tr> </thead>
<td>{{ z.id }}</td> <tbody>
<td>{{ z.nazwa }}</td> {% for z in completed_zbiorki %}
<td> <tr>
{% if z.ukryta %} <td class="text-muted">{{ z.id }}</td>
<span class="badge bg-secondary">Ukryta</span> <td>
{% else %} <div class="d-flex flex-column">
<span class="badge bg-success">Widoczna</span> <span class="fw-semibold">{{ z.nazwa }}</span>
{% endif %} {% if z.cel is defined or z.stan is defined %}
</td> <small class="text-muted">
<td> {% if z.cel is defined %} Cel: {{ z.cel|round(2) }} PLN {% endif %}
<a href="{{ url_for('edytuj_zbiorka', zbiorka_id=z.id) }}" class="btn btn-primary btn-sm">Edytuj</a> {% if z.stan is defined %} · Zebrano: {{ z.stan|round(2) }} PLN {% endif %}
<a href="{{ url_for('admin_dodaj_wplate', zbiorka_id=z.id) }}" class="btn btn-warning btn-sm">Dodaj wpłatę</a> </small>
<a href="{{ url_for('edytuj_stan', zbiorka_id=z.id) }}" class="btn btn-info btn-sm">Edytuj stan</a> {% endif %}
<form action="{{ url_for('toggle_visibility', zbiorka_id=z.id) }}" method="post" style="display: inline;"> </div>
<button type="submit" class="btn btn-secondary btn-sm"> </td>
{% if z.ukryta %} Pokaż {% else %} Ukryj {% endif %} <td>
</button> <div class="d-flex align-items-center gap-2 flex-wrap">
</form> <span class="badge rounded-pill"
<form action="{{ url_for('usun_zbiorka', zbiorka_id=z.id) }}" method="post" style="display: inline;"> style="background: var(--accent); color:#111;">Zrealizowana</span>
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Czy na pewno chcesz usunąć tę zbiórkę?');">Usuń</button> {% if z.ukryta %}
</form> <span class="badge bg-secondary border"
</td> style="border-color: var(--border);">Ukryta</span>
</tr> {% else %}
<span class="badge bg-success">Widoczna</span>
{% endif %}
</div>
</td>
<td class="text-end">
<div class="btn-group">
<a href="{{ url_for('edytuj_zbiorka', zbiorka_id=z.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('dodaj_wplate', zbiorka_id=z.id) }}">Dodaj
wpłatę</a>
</li>
<li>
<a class="dropdown-item"
href="{{ url_for('edytuj_stan', zbiorka_id=z.id) }}">Edytuj stan</a>
</li>
<li>
<hr class="dropdown-divider">
</li>
<li>
<form action="{{ url_for('zmien_widzialnosc', zbiorka_id=z.id) }}"
method="post" class="m-0">
<button type="submit" class="dropdown-item">
{% if z.ukryta %}Pokaż{% else %}Ukryj{% endif %}
</button>
</form>
</li>
<li>
<hr class="dropdown-divider">
</li>
<li>
<form action="{{ url_for('usun_zbiorka', zbiorka_id=z.id) }}" method="post"
class="m-0"
onsubmit="return confirm('Czy na pewno chcesz usunąć tę zbiórkę?');">
<button type="submit" class="dropdown-item text-danger">Usuń</button>
</form>
</li>
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %} {% else %}
<tr> <div class="card">
<td colspan="4" class="text-center">Brak zbiórek zrealizowanych</td> <div class="card-body text-center py-5">
</tr> <h5 class="mb-2">Brak zbiórek zrealizowanych</h5>
{% endfor %} <p class="text-muted mb-3">Gdy jakaś zbiórka osiągnie 100%, pojawi się tutaj.</p>
</tbody> <a href="{{ url_for('dodaj_zbiorka') }}" class="btn btn-outline-light border">Utwórz nową
</table> zbiórkę</a>
</div> </div>
</div>
{% endif %}
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,86 @@
{% extends 'base.html' %}
{% block title %}Dodaj wpłatę{% endblock %}
{% block content %}
<div class="container my-4">
<div class="d-flex align-items-center gap-2 mb-3">
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-sm btn-outline-light border">← Powrót do
zbiórki</a>
</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">Dodaj wpłatę: <span class="fw-semibold">{{ zbiorka.nazwa }}</span></h3>
<div class="d-flex align-items-center gap-2">
{% if zbiorka.cel %}
<span class="badge bg-dark border" style="border-color: var(--border);">Cel: {{ zbiorka.cel|round(2) }}
PLN</span>
{% endif %}
<span class="badge bg-dark border" style="border-color: var(--border);">Stan: {{ zbiorka.stan|round(2) }}
PLN</span>
</div>
</div>
{% set progress = (zbiorka.stan / zbiorka.cel * 100) if zbiorka.cel and zbiorka.cel > 0 else 0 %}
{% set progress_clamped = 100 if progress > 100 else (0 if progress < 0 else progress) %} <div class="px-3 pt-3">
<div class="progress" 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 d-block mt-1 mb-2">{{ progress|round(1) }}%</small>
</div>
<div class="card-body pt-0">
<form method="post" novalidate>
<div class="mb-3">
<label for="kwota" class="form-label">Kwota wpłaty</label>
<div class="input-group">
<span class="input-group-text">PLN</span>
<input type="number" step="0.01" min="0.01" inputmode="decimal" class="form-control" id="kwota" name="kwota"
placeholder="0,00" required aria-describedby="kwotaHelp">
</div>
<div id="kwotaHelp" class="form-text">Podaj kwotę w złotówkach (min. 0,01).</div>
<div class="d-flex flex-wrap gap-2 mt-2">
{% for preset in [10,25,50,100,200] %}
<button type="button" class="btn btn-sm btn-outline-light border btn-kwota" data-amount="{{ preset }}">
{{ preset }} PLN
</button>
{% endfor %}
{% if zbiorka.cel and zbiorka.cel > 0 %}
{% set brakujace = (zbiorka.cel - zbiorka.stan) if (zbiorka.cel - zbiorka.stan) > 0 else 0 %}
<button type="button" class="btn btn-sm btn-outline-light border btn-kwota"
data-amount="{{ brakujace|round(2) }}">
Do celu: {{ brakujace|round(2) }} PLN
</button>
{% endif %}
</div>
</div>
<div class="mb-3">
<label for="opis" class="form-label">Opis (opcjonalnie)</label>
<textarea class="form-control" id="opis" name="opis" rows="3" maxlength="300"
aria-describedby="opisHelp"></textarea>
<div class="d-flex justify-content-between">
<small id="opisHelp" class="form-text text-muted">Krótka notatka do wpłaty (widoczna w systemie).</small>
<small class="text-muted"><span id="opisCount">0</span>/300</small>
</div>
</div>
<div class="d-flex flex-wrap gap-2">
<button type="submit" class="btn btn-success">Dodaj wpłatę</button>
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-outline-light border">Anuluj</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='js/dodaj_wplate.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,125 @@
{% extends 'base.html' %}
{% block title %}Dodaj zbiórkę{% endblock %}
{% block extra_head %}
{{ super() }}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css">
{% endblock %}
{% block content %}
<div class="container my-4">
<!-- Powrót -->
<div class="d-flex align-items-center gap-2 mb-3">
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-sm btn-outline-light border">← Panel Admina</a>
</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">Dodaj nową zbiórkę</h3>
<small class="opacity-75">Uzupełnij podstawowe dane i dane płatności</small>
</div>
<div class="card-body">
<form method="post" novalidate>
{# {{ form.csrf_token }} jeśli używasz Flask-WTF #}
<!-- SEKCJA: Podstawowe -->
<div class="mb-4">
<h6 class="text-muted mb-2">Podstawowe</h6>
<div class="row g-3">
<div class="col-12">
<label for="nazwa" class="form-label">Nazwa zbiórki</label>
<input type="text" class="form-control" id="nazwa" name="nazwa" maxlength="120"
placeholder="Np. Wsparcie dla schroniska 'Azor'" required aria-describedby="nazwaHelp">
<div id="nazwaHelp" class="form-text">Krótki, zrozumiały tytuł. Max 120 znaków.</div>
</div>
<div class="col-12">
<label for="opis" class="form-label">Opis (Markdown)</label>
<textarea class="form-control" id="opis" name="opis" rows="8" required
aria-describedby="opisHelp"></textarea>
<div class="d-flex justify-content-between">
<small id="opisHelp" class="form-text text-muted">
Możesz używać **Markdown** (nagłówki, listy, linki). W edytorze włącz podgląd 👁️.
</small>
<small class="text-muted"><span id="opisCount">0</span> znaków</small>
</div>
</div>
</div>
</div>
<hr class="my-4" />
<!-- SEKCJA: Płatności -->
<div class="mb-4">
<h6 class="text-muted mb-2">Dane płatności</h6>
<div class="row g-3">
<div class="col-12">
<label for="numer_konta" class="form-label">Numer konta (IBAN)</label>
<div class="input-group">
<span class="input-group-text">PL</span>
<input type="text" class="form-control" id="numer_konta" name="numer_konta"
value="{{ global_settings.numer_konta if global_settings else '' }}" inputmode="numeric"
autocomplete="off" placeholder="12 3456 7890 1234 5678 9012 3456" required
aria-describedby="ibanHelp">
</div>
<div id="ibanHelp" class="form-text">Wpisz ciąg cyfr; spacje dodadzą się automatycznie dla czytelności.
</div>
</div>
<div class="col-12 col-md-6">
<label for="numer_telefonu_blik" class="form-label">Numer telefonu BLIK</label>
<div class="input-group">
<span class="input-group-text">+48</span>
<input type="tel" class="form-control" id="numer_telefonu_blik" name="numer_telefonu_blik"
value="{{ global_settings.numer_telefonu_blik if global_settings else '' }}" inputmode="tel"
pattern="[0-9 ]{9,13}" placeholder="123 456 789" required aria-describedby="blikHelp">
</div>
<div id="blikHelp" class="form-text">Dziewięć cyfr telefonu powiązanego z BLIK. Spacje opcjonalne.</div>
</div>
</div>
</div>
<hr class="my-4" />
<!-- SEKCJA: Cel i widoczność -->
<div class="mb-4">
<h6 class="text-muted mb-2">Cel i widoczność</h6>
<div class="row g-3">
<div class="col-12 col-md-6">
<label for="cel" class="form-label">Cel zbiórki</label>
<div class="input-group">
<span class="input-group-text">PLN</span>
<input type="number" class="form-control" id="cel" name="cel" step="0.01" min="0.01" placeholder="0,00"
required aria-describedby="celHelp">
</div>
<div id="celHelp" class="form-text">Minimalnie 0,01 PLN. Możesz to później edytować.</div>
</div>
<div class="col-12 col-md-6 d-flex align-items-end">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="ukryj_kwote" name="ukryj_kwote">
<label class="form-check-label" for="ukryj_kwote">Ukryj kwoty (cel i stan)</label>
</div>
</div>
</div>
</div>
<!-- CTA -->
<div class="d-flex flex-wrap gap-2">
<button type="submit" class="btn btn-success">Dodaj zbiórkę</button>
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-light border">Anuluj</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
{{ super() }}
<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/dodaj_zbiorke.js') }}"></script>
{% endblock %}

View File

@@ -1,67 +0,0 @@
{% extends 'base.html' %}
{% block title %}Edytuj zbiórkę{% endblock %}
{% block content %}
<div class="container my-4">
<div class="card shadow-sm">
<div class="card-header bg-success text-white">
<h3 class="card-title mb-0">Edytuj zbiórkę</h3>
</div>
<div class="card-body">
<form method="post">
<div class="mb-3">
<label for="nazwa" class="form-label">Nazwa zbiórki</label>
<input type="text" class="form-control" id="nazwa" name="nazwa" value="{{ zbiorka.nazwa }}" required>
</div>
<div class="mb-3">
<label for="opis" class="form-label">Opis</label>
<textarea class="form-control" id="opis" name="opis" rows="6" required>{{ zbiorka.opis }}</textarea>
</div>
<div class="mb-3">
<label for="numer_konta" class="form-label">Numer konta</label>
<input type="text" class="form-control" id="numer_konta" name="numer_konta"
value="{{ zbiorka.numer_konta if zbiorka.numer_konta else (global_settings.numer_konta if global_settings else '') }}" required>
</div>
<div class="mb-3">
<label for="numer_telefonu_blik" class="form-label">Numer telefonu BLIK</label>
<input type="text" class="form-control" id="numer_telefonu_blik" name="numer_telefonu_blik"
value="{{ zbiorka.numer_telefonu_blik if zbiorka.numer_telefonu_blik else (global_settings.numer_telefonu_blik if global_settings else '') }}" required>
</div>
<div class="mb-3">
<label for="cel" class="form-label">Cel zbiórki (PLN)</label>
<input type="number" step="0.01" class="form-control" id="cel" name="cel" value="{{ zbiorka.cel }}" required>
</div>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="ukryj_kwote" name="ukryj_kwote" {% if zbiorka.ukryj_kwote %}checked{% endif %}>
<label class="form-check-label" for="ukryj_kwote">Ukryj kwoty (cel i stan)</label>
</div>
<button type="submit" class="btn btn-primary">Zaktualizuj zbiórkę</button>
<button type="button" class="btn btn-primary" id="ustaw-globalne">Ustaw globalne</button>
<form action="{{ url_for('oznacz_zbiorka', zbiorka_id=zbiorka.id) }}" method="post" style="display:inline;">
<button type="submit" class="btn btn-warning">Oznacz jako zrealizowana</button>
</form>
</form>
</div>
</div>
</div>
<!-- Inicjalizacja edytora Markdown (SimpleMDE) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css">
<script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script>
<script>
var simplemde = new SimpleMDE({
element: document.getElementById("opis"),
forceSync: true
});
</script>
<script>
document.getElementById('ustaw-globalne').addEventListener('click', function() {
{% if global_settings %}
document.getElementById('numer_konta').value = "{{ global_settings.numer_konta }}";
document.getElementById('numer_telefonu_blik').value = "{{ global_settings.numer_telefonu_blik }}";
{% endif %}
});
</script>
{% endblock %}

View File

@@ -1,24 +1,128 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}Edytuj stan zbiórki{% endblock %} {% block title %}Edytuj stan zbiórki{% endblock %}
{% block content %} {% block content %}
<div class="container my-4"> <div class="container my-4">
<div class="card shadow-sm">
<div class="card-header bg-info text-white"> <!-- Nawigacja -->
<h3 class="card-title mb-0">Edytuj stan zbiórki: {{ zbiorka.nazwa }}</h3> <div class="d-flex align-items-center gap-2 mb-3">
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-sm btn-outline-light border">← Szczegóły
zbiórki</a>
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-sm btn-outline-light border">← Panel Admina</a>
</div>
{# Obliczenia wstępne (do inicjalnego podglądu) #}
{% 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) %} <div class="card shadow-sm"
data-module="edit-stan" data-cel="{{ (zbiorka.cel|round(2)) if has_cel else 0 }}">
<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 stan: <span class="fw-semibold">{{ zbiorka.nazwa }}</span></h3>
<div class="d-flex align-items-center flex-wrap gap-2">
{% if has_cel %}
<span class="badge bg-dark border" style="border-color: var(--border);">Cel: {{ zbiorka.cel|round(2) }}
PLN</span>
{% endif %}
<span class="badge bg-dark border" style="border-color: var(--border);">Obecnie: {{ zbiorka.stan|round(2) }}
PLN</span>
</div>
</div> </div>
<div class="card-body">
<form method="post"> <!-- Mini progress (aktualny) -->
<div class="mb-3"> <div class="px-3 pt-3">
<label for="stan" class="form-label">Nowy stan zbiórki (PLN)</label> <div class="progress" role="progressbar" aria-valuenow="{{ progress_clamped|round(2) }}" aria-valuemin="0"
<div class="input-group"> aria-valuemax="100" aria-label="Postęp zbiórki {{ progress_clamped|round(0) }} procent">
<span class="input-group-text">PLN</span> <div class="progress-bar" style="width: {{ progress_clamped }}%;"></div>
<input type="number" step="0.01" class="form-control" id="stan" name="stan" value="{{ zbiorka.stan|round(2) }}" required>
</div>
<small class="text-muted d-block mt-1 mb-2">Aktualnie: {{ progress|round(1) }}%</small>
</div>
<div class="card-body pt-0">
<form method="post" novalidate>
{# {{ form.csrf_token }} #}
<!-- Nowy stan -->
<div class="mb-3">
<label for="stan" class="form-label">Nowy stan zbiórki</label>
<div class="input-group">
<span class="input-group-text">PLN</span>
<input type="number" step="0.01" min="0" class="form-control" id="stan" name="stan"
value="{{ zbiorka.stan|round(2) }}" required aria-describedby="stanHelp">
</div>
<div id="stanHelp" class="form-text">
Wpisz łączną zebraną kwotę po zmianie (nie przyrost). Skorzystaj z szybkich korekt poniżej.
</div>
<!-- Szybkie korekty -->
<div class="d-flex flex-wrap gap-2 mt-2">
{% for delta in [10,50,100,200] %}
<button type="button" class="btn btn-sm btn-outline-light border btn-delta" data-delta="{{ delta }}">+{{
delta }} PLN</button>
<button type="button" class="btn btn-sm btn-outline-light border btn-delta" data-delta="-{{ delta }}">-{{
delta }} PLN</button>
{% endfor %}
{% if has_cel %}
<button type="button" class="btn btn-sm btn-outline-light border btn-set"
data-value="{{ zbiorka.cel|round(2) }}">Ustaw: do celu</button>
{% set brakujace = (zbiorka.cel - zbiorka.stan) if (zbiorka.cel - zbiorka.stan) > 0 else 0 %}
{% if brakujace > 0 %}
<span class="badge bg-dark border" style="border-color: var(--border);">Brakuje: {{ brakujace|round(2) }}
PLN</span>
{% endif %}
{% endif %}
<button type="button" class="btn btn-sm btn-outline-light border btn-set" data-value="0">Ustaw: 0</button>
</div>
</div>
<!-- Podgląd po zmianie -->
<div class="mb-3">
<div class="card bg-dark border" style="border-color: var(--border);">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<div class="small text-muted">Podgląd po zapisaniu</div>
<div class="fw-semibold">
Procent realizacji:
<span id="previewPct">{{ progress|round(1) }}</span>%
</div>
</div>
</div>
<div class="mt-2">
<div class="progress" aria-hidden="true">
<div class="progress-bar" style="width: {{ progress_clamped }}%;"></div>
</div>
<small class="text-muted d-block mt-1" id="previewNote">
{% if has_cel %}
{% if brakujace > 0 %}
Do celu brakuje: {{ brakujace|round(2) }} PLN
{% elif brakujace == 0 %}
Cel osiągnięty.
{% else %}
Przekroczono cel o: {{ (brakujace * -1)|round(2) }} PLN
{% endif %}
{% else %}
Brak zdefiniowanego celu — procent nie jest wyliczany.
{% endif %}
</small>
</div>
</div> </div>
</div> </div>
<button type="submit" class="btn btn-info">Aktualizuj stan</button> </div>
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-secondary">Powrót</a>
<!-- CTA -->
<div class="d-flex flex-wrap gap-2">
<button type="submit" class="btn btn-success">Aktualizuj stan</button>
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-outline-light border">Anuluj</a>
</div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='js/edytuj_stan.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,154 @@
{% extends 'base.html' %}
{% block title %}Edytuj zbiórkę{% endblock %}
{% block extra_head %}
{{ super() }}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css">
{% endblock %}
{% block content %}
<div class="container my-4">
<!-- Nawigacja -->
<div class="d-flex align-items-center gap-2 mb-3">
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-sm btn-outline-light border">← Szczegóły
zbiórki</a>
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-sm btn-outline-light border">← Panel Admina</a>
</div>
<!-- Nagłówek + meta -->
<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 zbiórkę</h3>
<div class="d-flex flex-wrap align-items-center gap-2">
{% if zbiorka.cel %}
<span class="badge bg-dark border" style="border-color: var(--border);">Cel: {{ zbiorka.cel|round(2) }}
PLN</span>
{% endif %}
{% if zbiorka.ukryj_kwote %}
<span class="badge bg-secondary">Kwoty ukryte</span>
{% else %}
<span class="badge bg-success">Kwoty widoczne</span>
{% endif %}
</div>
</div>
<div class="card-body">
<!-- GŁÓWNY FORMULARZ -->
<form method="post" novalidate id="form-edit-zbiorka">
{# {{ form.csrf_token }} jeśli używasz Flask-WTF #}
<!-- SEKCJA: Podstawowe -->
<div class="mb-4">
<h6 class="text-muted mb-2">Podstawowe</h6>
<div class="row g-3">
<div class="col-12">
<label for="nazwa" class="form-label">Nazwa zbiórki</label>
<input type="text" class="form-control" id="nazwa" name="nazwa" value="{{ zbiorka.nazwa }}"
maxlength="120" placeholder="Krótki, zrozumiały tytuł" required aria-describedby="nazwaHelp">
<div id="nazwaHelp" class="form-text">Max 120 znaków. Użyj konkretów.</div>
</div>
<div class="col-12">
<label for="opis" class="form-label">Opis (Markdown)</label>
<textarea class="form-control" id="opis" name="opis" rows="8" required
aria-describedby="opisHelp">{{ zbiorka.opis }}</textarea>
<div class="d-flex justify-content-between">
<small id="opisHelp" class="form-text text-muted">Wspieramy **Markdown** — użyj nagłówków, list, linków.
Włącz podgląd w edytorze 👁️.</small>
<small class="text-muted"><span id="opisCount">0</span> znaków</small>
</div>
</div>
</div>
</div>
<hr class="my-4" />
<!-- SEKCJA: Dane płatności -->
<div class="mb-4">
<h6 class="text-muted mb-2">Dane płatności</h6>
<div class="row g-3">
<div class="col-12">
<label for="numer_konta" class="form-label">Numer konta (IBAN)</label>
<div class="input-group">
<span class="input-group-text">PL</span>
<input type="text" class="form-control" id="numer_konta" name="numer_konta"
value="{{ zbiorka.numer_konta if zbiorka.numer_konta else (global_settings.numer_konta if global_settings else '') }}"
inputmode="numeric" autocomplete="off" placeholder="12 3456 7890 1234 5678 9012 3456" required
aria-describedby="ibanHelp">
</div>
<div id="ibanHelp" class="form-text">Wpisz same cyfry — spacje dodadzą się automatycznie co 4 znaki.</div>
</div>
<div class="col-12 col-md-6">
<label for="numer_telefonu_blik" class="form-label">Numer telefonu BLIK</label>
<div class="input-group">
<span class="input-group-text">+48</span>
<input type="tel" class="form-control" id="numer_telefonu_blik" name="numer_telefonu_blik"
value="{{ zbiorka.numer_telefonu_blik if zbiorka.numer_telefonu_blik else (global_settings.numer_telefonu_blik if global_settings else '') }}"
inputmode="tel" pattern="[0-9 ]{9,13}" placeholder="123 456 789" required aria-describedby="blikHelp">
</div>
<div id="blikHelp" class="form-text">9 cyfr. Spacje dodadzą się automatycznie (format 3-3-3).</div>
</div>
<div class="col-12 col-md-6 d-flex align-items-end">
<button type="button" class="btn btn-sm btn-outline-light border" id="ustaw-globalne"
title="Wstaw wartości z ustawień globalnych" {% if global_settings %}
data-iban="{{ global_settings.numer_konta }}" data-blik="{{ global_settings.numer_telefonu_blik }}" {%
endif %}>Ustaw globalne</button>
</div>
</div>
</div>
<hr class="my-4" />
<!-- SEKCJA: Cel i widoczność -->
<div class="mb-4">
<h6 class="text-muted mb-2">Cel i widoczność</h6>
<div class="row g-3">
<div class="col-12 col-md-6">
<label for="cel" class="form-label">Cel zbiórki</label>
<div class="input-group">
<span class="input-group-text">PLN</span>
<input type="number" class="form-control" id="cel" name="cel" step="0.01" min="0.01"
value="{{ zbiorka.cel }}" placeholder="0,00" required aria-describedby="celHelp">
</div>
<div id="celHelp" class="form-text">Minimalnie 0,01 PLN. W razie potrzeby zmienisz to później.</div>
</div>
<div class="col-12 col-md-6 d-flex align-items-end">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="ukryj_kwote" name="ukryj_kwote" {% if
zbiorka.ukryj_kwote %}checked{% endif %}>
<label class="form-check-label" for="ukryj_kwote">Ukryj kwoty (cel i stan)</label>
</div>
</div>
</div>
</div>
<!-- CTA: zapisz/anuluj + osobna akcja „zrealizowana” -->
<div class="d-flex flex-wrap gap-2">
<button type="submit" class="btn btn-success">Zaktualizuj zbiórkę</button>
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-outline-light border">Anuluj</a>
<!-- Osobny formularz dla oznaczenia „zrealizowana” -->
<form action="{{ url_for('oznacz_zbiorka', zbiorka_id=zbiorka.id) }}" method="post" class="ms-auto">
<button type="submit" class="btn btn-outline-light"
onclick="return confirm('Oznaczyć zbiórkę jako zrealizowaną?');">
Oznacz jako zrealizowana
</button>
</form>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
{{ super() }}
<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/edytuj_zbiorke.js') }}"></script>
{% endblock %}

View File

@@ -1,55 +0,0 @@
{% extends 'base.html' %}
{% block title %}Ustawienia globalne{% endblock %}
{% block content %}
<div class="container my-4">
<form method="post">
<!-- Blok ustawień konta -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-primary text-white">
<h3 class="card-title mb-0">Ustawienia konta</h3>
</div>
<div class="card-body">
<div class="mb-3">
<label for="numer_konta" class="form-label">Globalny numer konta</label>
<input type="text" class="form-control" id="numer_konta" name="numer_konta" value="{{ settings.numer_konta if settings else '' }}" required>
</div>
<div class="mb-3">
<label for="numer_telefonu_blik" class="form-label">Globalny numer telefonu BLIK</label>
<input type="text" class="form-control" id="numer_telefonu_blik" name="numer_telefonu_blik" value="{{ settings.numer_telefonu_blik if settings else '' }}" required>
</div>
</div>
</div>
<!-- Blok dozwolonych adresów IP -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center">
<h3 class="card-title mb-0">Dozwolone adresy IP</h3>
</div>
<div class="card-body">
<div class="mb-3">
<label for="allowed_login_hosts" class="form-label">Dozwolone hosty logowania</label>
<textarea class="form-control" id="allowed_login_hosts" name="allowed_login_hosts" rows="4" placeholder="Podaj adresy IP lub nazwy domen oddzielone przecinkami lub nowymi liniami">{{ settings.allowed_login_hosts if settings and settings.allowed_login_hosts else '' }}</textarea>
</div>
<p class="text-muted">Twój aktualny adres IP: <strong>{{ client_ip }}</strong></p>
<button type="button" class="btn btn-sm btn-light text-dark" onclick="dodajMojeIP()">Dodaj moje IP</button>
</div>
</div>
<div class="d-flex justify-content-between">
<button type="submit" class="btn btn-primary">Zapisz ustawienia</button>
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-secondary">Powrót</a>
</div>
</form>
</div>
<script>
function dodajMojeIP() {
const mojeIP = "{{ client_ip }}";
const textarea = document.getElementById("allowed_login_hosts");
if (!textarea.value.includes(mojeIP)) {
const separator = textarea.value.trim() === "" ? "" : "\n";
textarea.value += separator + mojeIP;
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,125 @@
{% extends 'base.html' %}
{% block title %}Ustawienia globalne{% endblock %}
{% block content %}
<div class="container my-4">
<form method="post" novalidate id="form-global-settings">
{# {{ form.csrf_token }} jeśli używasz Flask-WTF #}
<!-- SEKCJA: Dane płatności -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-secondary text-white d-flex align-items-center justify-content-between gap-2">
<h3 class="card-title mb-0">Dane płatności</h3>
<small class="opacity-75">Używane jako wartości domyślne przy dodawaniu/edycji zbiórek</small>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12">
<label for="numer_konta" class="form-label">Globalny numer konta (IBAN)</label>
<div class="input-group">
<span class="input-group-text">PL</span>
<input type="text" class="form-control" id="numer_konta" name="numer_konta"
value="{{ settings.numer_konta if settings else '' }}" inputmode="numeric" autocomplete="off"
placeholder="12 3456 7890 1234 5678 9012 3456" required aria-describedby="ibanHelp">
</div>
<div id="ibanHelp" class="form-text">Wpisz ciąg cyfr — spacje dodadzą się automatycznie co 4 znaki.</div>
</div>
<div class="col-12 col-md-6">
<label for="numer_telefonu_blik" class="form-label">Globalny numer telefonu BLIK</label>
<div class="input-group">
<span class="input-group-text">+48</span>
<input type="tel" class="form-control" id="numer_telefonu_blik" name="numer_telefonu_blik"
value="{{ settings.numer_telefonu_blik if settings else '' }}" inputmode="tel" pattern="[0-9 ]{9,13}"
placeholder="123 456 789" required aria-describedby="blikHelp">
</div>
<div id="blikHelp" class="form-text">9 cyfr. Spacje i format 3-3-3 dodajemy dla czytelności.</div>
</div>
</div>
</div>
</div>
<!-- SEKCJA: Dostępy / biała lista IP -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-secondary text-white d-flex align-items-center justify-content-between gap-2">
<h3 class="card-title mb-0">Dostęp — dozwolone adresy IP / hosty</h3>
<small class="opacity-75">Zależnie od konfiguracji, logowanie może wymagać dopasowania do białej listy</small>
</div>
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-12 col-md-6">
<label for="host_input" class="form-label">Dodaj pojedynczy IP/host</label>
<input type="text" class="form-control" id="host_input" placeholder="np. 203.0.113.42 lub corp.example.com"
aria-describedby="hostAddHelp">
<div id="hostAddHelp" class="form-text">Po wpisaniu kliknij „Dodaj do listy”. Duplikaty są pomijane.</div>
</div>
<div class="col-12 col-md-6 d-flex gap-2">
<button type="button" class="btn btn-outline-light border" id="btn-add-host">Dodaj do listy</button>
<button type="button" class="btn btn-light text-dark" id="btn-add-my-ip" data-my-ip="{{ client_ip }}">Dodaj
moje IP ({{ client_ip }})</button>
<button type="button" class="btn btn-outline-light border" id="btn-dedupe">Usuń duplikaty</button>
</div>
</div>
<div class="mt-3">
<label for="allowed_login_hosts" class="form-label">Dozwolone hosty logowania (jeden na linię lub rozdzielone
przecinkami)</label>
<textarea class="form-control" id="allowed_login_hosts" name="allowed_login_hosts" rows="6"
placeholder="Adresy IP lub nazwy domen — każdy w osobnej linii lub rozdzielony przecinkiem">{{ settings.allowed_login_hosts if settings and settings.allowed_login_hosts else '' }}</textarea>
<div class="d-flex justify-content-between mt-1">
<small class="text-muted">Akceptowane separatory: przecinek (`,`), średnik (`;`) i nowa linia.</small>
<small class="text-muted">Pozycji na liście: <span id="hostsCount">0</span></small>
</div>
</div>
</div>
</div>
<div class="mb-4">
<h6 class="text-muted mb-2">Branding</h6>
<div class="row g-3 align-items-end">
<!-- Logo -->
<div class="col-md-6">
<label for="logo_url" class="form-label">Logo (adres URL PNG/SVG)</label>
<input type="text" class="form-control" id="logo_url" name="logo_url"
value="{{ settings.logo_url if settings else '' }}" placeholder="https://example.com/logo.png">
<div class="form-text">Najlepiej transparentne, do 60px wysokości.</div>
</div>
<!-- Tekst -->
<div class="col-md-6">
<label for="site_title" class="form-label">Tytuł w navbarze</label>
<input type="text" class="form-control" id="site_title" name="site_title"
value="{{ settings.site_title if settings else '' }}" placeholder="Np. Zbiórki unitraklub.pl">
</div>
</div>
<!-- Checkbox: logo w navbarze -->
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" id="show_logo_in_navbar" name="show_logo_in_navbar" {% if
settings and settings.show_logo_in_navbar %}checked{% endif %}>
<label class="form-check-label" for="show_logo_in_navbar">Wyświetlaj logo w navbarze</label>
</div>
{% if settings and settings.logo_url %}
<div class="mt-3">
<span class="form-text d-block mb-1">Podgląd logo:</span>
<img src="{{ settings.logo_url }}" alt="Logo preview" style="max-height:50px;">
</div>
{% endif %}
</div>
<!-- CTA -->
<div class="d-flex justify-content-between">
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-light border">Powrót</a>
<button type="submit" class="btn btn-primary">Zapisz ustawienia</button>
</div>
</form>
</div>
{% endblock %}
{% block extra_scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='js/ustawienia.js') }}"></script>
{% endblock %}

View File

@@ -1,41 +1,51 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="pl"> <html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>{% block title %}Aplikacja Zbiórek{% endblock %}</title>
<!-- Bootswatch Darkly - atrakcyjny ciemny motyw -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootswatch@5.3.0/dist/darkly/bootstrap.min.css">
<!-- Opcjonalny plik custom.css dla drobnych modyfikacji -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/custom.css') }}">
</head>
<body class="bg-dark text-light">
<nav class="navbar navbar-expand-lg navbar-dark bg-secondary">
<div class="container">
<a class="navbar-brand" href="{{ url_for('index') }}">Zbiórki unitraklub.pl</a>
<!-- Przycisk rozwijania dla urządzeń mobilnych --> <head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<title>{% block title %}Aplikacja Zbiórek{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootswatch@5.3.0/dist/darkly/bootstrap.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/custom.css') }}" />
{% block extra_head %}{% endblock %}
</head>
<body class="d-flex flex-column min-vh-100">
<nav class="navbar navbar-expand-lg">
<div class="container">
<a class="navbar-brand" href="{{ url_for('index') }}">
{% if global_settings and global_settings.show_logo_in_navbar and global_settings.logo_url %}
<img src="{{ global_settings.logo_url }}" alt="Logo" style="max-height:40px; vertical-align:middle;">
{% endif %}
{{ global_settings.site_title if global_settings and global_settings.site_title else "Zbiórki" }}
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNavbar" <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNavbar"
aria-controls="mainNavbar" aria-expanded="false" aria-label="Toggle navigation"> aria-controls="mainNavbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<!-- Nawigacja ukrywana na małych ekranach -->
<div class="collapse navbar-collapse" id="mainNavbar"> <div class="collapse navbar-collapse" id="mainNavbar">
<ul class="navbar-nav ms-auto"> <ul class="navbar-nav ms-auto">
<li class="nav-item"><a class="nav-link" href="{{ url_for('index') }}">Aktualne zbiórki</a></li> {% set hide_links = request.path == url_for('index') or request.path ==
<li class="nav-item"><a class="nav-link" href="{{ url_for('zbiorki_zrealizowane') }}">Zrealizowane zbiórki</a></li> url_for('zbiorki_zrealizowane') %}
{% if current_user.is_authenticated %} {% if not hide_links %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin_dashboard') }}">Panel Admina</a></li> <li class="nav-item">
<li class="nav-item"><a class="nav-link" href="{{ url_for('logout') }}">Wyloguj</a></li> <a class="nav-link" href="{{ url_for('index') }}">Aktualne zbiórki</a>
</li>
{% else %} <li class="nav-item">
{% if is_ip_allowed %} <a class="nav-link" href="{{ url_for('zbiorki_zrealizowane') }}">Zrealizowane zbiórki</a>
<li class="nav-item"><a class="nav-link" href="{{ url_for('login') }}">Zaloguj</a></li> </li>
{% endif %} {% endif %}
{% if current_user.is_authenticated %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin_dashboard') }}">Panel Admina</a>
</li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('wyloguj') }}">Wyloguj</a></li>
{% else %}
{% if is_ip_allowed %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('zaloguj') }}">Zaloguj</a></li>
{% endif %}
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
</div> </div>
@@ -43,32 +53,31 @@
<div class="container mt-4"> <div class="container mt-4">
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
{% for category, message in messages %} {% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div> <div class="alert alert-{{ category|default('secondary') }}">{{ message }}</div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% block content %}{% endblock %}
<main class="flex-grow-1">
{% block content %}{% endblock %}
</main>
</div> </div>
<!-- stopka -->
<footer class="mt-auto text-center py-3 border-top" style="background: var(--surface-0);">
{% if global_settings and global_settings.logo_url %}
<img src="{{ global_settings.logo_url }}" alt="Logo" style="max-height:60px; opacity:0.85;">
{% else %}
<small class="text-muted">© linuxiarz.pl</small>
{% endif %}
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script> <script src="{{ url_for('static', filename='js/progress.js') }}"></script>
// Funkcja aktualizująca animację paska postępu
function animateProgressBars() {
document.querySelectorAll('.progress-bar').forEach(bar => {
const progressValue = bar.getAttribute('aria-valuenow');
bar.style.setProperty('--progress-width', progressBarWidth(progressBarValue(progressBar)));
});
}
// Funkcja wywoływana przy ładowaniu strony
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.progress-bar').forEach(bar => {
const width = bar.getAttribute('aria-valuenow') + '%';
bar.style.setProperty('--progress-width', width);
});
});
</script>
{% block extra_scripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -1,29 +1,88 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}{% if request.path == url_for('zbiorki_zrealizowane') %}Zrealizowane zbiórki{% else %}Aktualnie aktywne zbiórki{% endif %}{% endblock %} {% block title %}{% if request.path == url_for('zbiorki_zrealizowane') %}Zrealizowane zbiórki{% else %}Aktualnie aktywne
zbiórki{% endif %}{% endblock %}
{% block content %} {% block content %}
{% if request.path == url_for('zbiorki_zrealizowane') %} {# Ustal kontekst listy #}
<h2 class="mb-4">Zrealizowane zbiórki</h2> {% set is_completed_view = (request.path == url_for('zbiorki_zrealizowane')) %}
{% else %}
<h2 class="mb-4">Aktualnie aktywne zbiórki</h2> <div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-4">
{% endif %} <h2 class="mb-0">
<div class="row"> {% if is_completed_view %}Zrealizowane zbiórki{% else %}Aktualnie aktywne zbiórki{% endif %}
</h2>
<ul class="nav nav-pills">
<li class="nav-item">
<a class="nav-link {% if not is_completed_view %}active{% endif %}"
href="{{ url_for('index') }}">Aktywne</a>
</li>
<li class="nav-item">
<a class="nav-link {% if is_completed_view %}active{% endif %}"
href="{{ url_for('zbiorki_zrealizowane') }}">Zrealizowane</a>
</li>
</ul>
</div>
{% if zbiorki and zbiorki|length > 0 %}
<div class="row g-4">
{% for z in zbiorki %} {% for z in zbiorki %}
<div class="col-sm-12 col-md-6 col-lg-4 mb-4"> {% set progress = (z.stan / z.cel * 100) if z.cel > 0 else 0 %}
<div class="card"> {% set progress_clamped = 100 if progress > 100 else (0 if progress < 0 else progress) %} <div
<div class="card-body"> class="col-sm-12 col-md-6 col-lg-4">
<h5 class="card-title">{{ z.nazwa }}</h5> <div class="card h-100 position-relative">
{% set progress = (z.stan / z.cel * 100) if z.cel > 0 else 0 %} <div class="card-body d-flex flex-column">
<div class="progress mb-3"> <div class="d-flex align-items-start justify-content-between gap-2 mb-2">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: {{ progress if progress < 100 else 100 }}%;" aria-valuenow="{{ progress }}" aria-valuemin="0" aria-valuemax="100"> <h5 class="card-title mb-0">{{ z.nazwa }}</h5>
{{ progress|round(2) }}%
</div> {# Spójny badge zrealizowania: w zakładce „Zrealizowane” lub gdy >=100% #}
{% if is_completed_view or progress_clamped >= 100 %}
<span class="badge rounded-pill" style="background: var(--accent); color:#111;">Zrealizowana</span>
{% endif %}
</div>
<div class="mb-2 d-flex flex-wrap gap-2">
{% if z.cel > 0 %}
<span class="badge bg-dark border" style="border-color: var(--border);">Cel: {{ z.cel|round(2) }}
PLN</span>
{% endif %}
<span class="badge bg-dark border" style="border-color: var(--border);">Stan: {{ z.stan|round(2) }}
PLN</span>
</div>
<div class="mb-1">
<div class="progress" 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">{{ progress|round(1) }}%</small>
</div>
<div class="mt-auto pt-2">
<a href="{{ url_for('zbiorka', zbiorka_id=z.id) }}" class="stretched-link"></a>
<a href="{{ url_for('zbiorka', zbiorka_id=z.id) }}" class="btn btn-primary btn-sm">Szczegóły</a>
</div> </div>
<a href="{{ url_for('zbiorka', zbiorka_id=z.id) }}" class="btn btn-primary btn-sm">Szczegóły</a>
</div> </div>
</div> </div>
</div>
{% else %}
<p>Brak zbiórek</p>
{% endfor %}
</div> </div>
{% endfor %}
</div>
{% else %}
<div class="card">
<div class="card-body text-center py-5">
{% if is_completed_view %}
<h5 class="mb-2">Brak zrealizowanych zbiórek</h5>
<p class="text-muted mb-4">Gdy jakaś zbiórka osiągnie 100%, pojawi się tutaj.</p>
<a href="{{ url_for('index') }}" class="btn btn-primary">Zobacz aktywne</a>
{% else %}
<h5 class="mb-2">Brak aktywnych zbiórek</h5>
<p class="text-muted mb-4">Wygląda na to, że teraz nic nie zbieramy.</p>
{% if current_user.is_authenticated and current_user.is_admin %}
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-primary">Utwórz nową zbiórkę</a>
{% else %}
<a href="{{ url_for('zbiorki_zrealizowane') }}" class="btn btn-primary">Zobacz zrealizowane</a>
{% endif %}
{% endif %}
</div>
</div>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -1,19 +1,55 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}Logowanie{% endblock %} {% block title %}Logowanie{% endblock %}
{% block content %} {% block content %}
<div class="container my-4"> <div class="container my-5">
<h3 class="mb-4">Logowanie</h3> <div class="row justify-content-center">
<form method="post"> <div class="col-sm-10 col-md-8 col-lg-5">
<div class="mb-3"> <div class="card shadow-sm">
<label for="username" class="form-label">Nazwa użytkownika</label> <div class="card-header">
<input type="text" class="form-control" id="username" name="username" required> <h3 class="card-title mb-0">Logowanie</h3>
</div> </div>
<div class="mb-3">
<label for="password" class="form-label">Hasło</label> <div class="card-body">
<input type="password" class="form-control" id="password" name="password" required> <form method="post" class="needs-validation" novalidate>
</div> {% set next_url = request.args.get('next') %}
<button type="submit" class="btn btn-primary">Zaloguj</button> {% if next_url %}
</form> <input type="hidden" name="next" value="{{ next_url }}">
<p class="mt-3">Nie masz konta? <a href="{{ url_for('register') }}">Zarejestruj się</a></p> {% endif %}
<div class="mb-3">
<label for="username" class="form-label">Nazwa użytkownika</label>
<input type="text" class="form-control" id="username" name="username"
autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus>
<div class="invalid-feedback">Podaj nazwę użytkownika.</div>
</div>
<div class="mb-2">
<label for="password" class="form-label d-flex justify-content-between align-items-center">
<span>Hasło</span>
<small id="capsWarning" class="text-muted" style="display:none;">CAPS LOCK
włączony</small>
</label>
<div class="input-group">
<input type="password" class="form-control" id="password" name="password"
autocomplete="current-password" required minlength="5">
<button type="button" class="btn btn-secondary rounded-end" id="togglePw"
aria-label="Pokaż/ukryj hasło">Pokaż</button>
<div class="invalid-feedback">Wpisz hasło (min. 5 znaków).</div>
</div>
</div>
<button type="submit" class="btn btn-primary w-100">Zaloguj</button>
</form>
</div>
</div>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', filename='js/walidacja_logowanie.js') }}"></script>
{% endblock %}

View File

@@ -1,18 +1,61 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}Rejestracja{% endblock %} {% block title %}Rejestracja{% endblock %}
{% block content %} {% block content %}
<div class="container my-4"> <div class="container my-5">
<h3 class="mb-4">Rejestracja</h3> <div class="row justify-content-center">
<form method="post"> <div class="col-sm-10 col-md-8 col-lg-5">
<div class="mb-3"> <div class="card shadow-sm">
<label for="username" class="form-label">Nazwa użytkownika</label> <div class="card-header">
<input type="text" class="form-control" id="username" name="username" required> <h3 class="card-title mb-0">Rejestracja</h3>
</div> </div>
<div class="mb-3">
<label for="password" class="form-label">Hasło</label> <div class="card-body">
<input type="password" class="form-control" id="password" name="password" required> <form method="post" class="needs-validation" novalidate>
</div>
<button type="submit" class="btn btn-primary">Zarejestruj się</button> <div class="mb-3">
</form> <label for="username" class="form-label">Nazwa użytkownika</label>
<input type="text" class="form-control" id="username" name="username"
autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus>
<div class="invalid-feedback">Podaj nazwę użytkownika.</div>
</div>
<div class="mb-3">
<label for="password" class="form-label d-flex justify-content-between align-items-center">
<span>Hasło</span>
<small id="capsWarning" class="text-muted" style="display:none;">CAPS LOCK
włączony</small>
</label>
<div class="input-group">
<input type="password" class="form-control" id="password" name="password"
autocomplete="new-password" required minlength="6">
<button type="button" class="btn btn-secondary" id="togglePw"
aria-label="Pokaż/ukryj hasło">Pokaż</button>
<div class="invalid-feedback">Hasło musi mieć min. 6 znaków.</div>
</div>
</div>
<div class="mb-4">
<label for="password2" class="form-label">Powtórz hasło</label>
<input type="password" class="form-control" id="password2" name="password2"
autocomplete="new-password" required minlength="6">
<div class="invalid-feedback">Powtórz hasło.</div>
</div>
<button type="submit" class="btn btn-primary w-100">Zarejestruj się</button>
</form>
<p class="mt-3 mb-0 text-center">
Masz już konto?
<a href="{{ url_for('zaloguj') }}">Zaloguj się</a>
</p>
</div>
</div>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', filename='js/walidacja_rejestracja.js') }}"></script>
{% endblock %}

View File

@@ -1,94 +1,156 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}{{ zbiorka.nazwa }}{% endblock %} {% block title %}{{ zbiorka.nazwa }}{% endblock %}
{% block content %} {% block content %}
<div class="container my-4"> <div class="container my-4">
<!-- Główna karta zbiórki -->
<div class="card mb-4 shadow-sm">
<div class="card-header bg-secondary text-white">
<h3 class="card-title mb-0"></h3>{{ zbiorka.nazwa }}</h3>
</div>
<div class="card-body">
<div class="row">
<!-- Lewa kolumna: opis i postęp -->
<div class="col-md-8">
<h5>Opis:</h5>
<div class="mb-3">
{{ zbiorka.opis | markdown }}
</div>
{% set progress = (zbiorka.stan / zbiorka.cel * 100) if zbiorka.cel > 0 else 0 %}
<h5>Postęp:</h5>
<div class="progress mb-3">
<div class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar"
style="width: {{ progress if progress < 100 else 100 }}%;"
aria-valuenow="{{ progress }}"
aria-valuemin="0"
aria-valuemax="100">
{{ progress|round(2) }}%
</div>
</div>
</div> {# Postęp 0100 #}
<!-- Prawa kolumna: sekcja "Wspomóż" --> {% set has_cel = (zbiorka.cel is defined and zbiorka.cel and zbiorka.cel > 0) %}
<div class="col-md-4"> {% set progress = (zbiorka.stan / zbiorka.cel * 100) if has_cel else 0 %}
<div class="card wspomoz-card mb-3"> {% set progress_clamped = 100 if progress > 100 else (0 if progress < 0 else progress) %} {% set
<div class="card-body"> is_done=(progress_clamped>= 100) %}
<p class="card-text"> <!-- Nagłówek -->
<strong>Numer konta:</strong> <div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<span class="fs-4">{{ zbiorka.numer_konta }}</span> <h2 class="mb-0">{{ zbiorka.nazwa }}</h2>
</p> <div class="d-flex flex-wrap align-items-center gap-2">
<p class="card-text"> {% if is_done %}
<strong>Telefon BLIK:</strong> <span class="badge rounded-pill" style="background: var(--accent); color:#111;">Zrealizowana</span>
<span class="fs-4">{{ zbiorka.numer_telefonu_blik }}</span> {% endif %}
</p> {% if zbiorka.ukryj_kwote %}
{% if not zbiorka.ukryj_kwote %} <span class="badge bg-secondary">Kwoty ukryte</span>
<hr> {% else %}
<p class="card-text"> <span class="badge bg-success">Kwoty widoczne</span>
<strong>Cel zbiórki:</strong>
<span class="fs-4">{{ zbiorka.cel|round(2) }} PLN</span>
</p>
<p class="card-text">
<strong>Stan zbiórki:</strong>
<span class="fs-4">{{ zbiorka.stan|round(2) }} PLN</span>
</p>
{% endif %}
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-between">
{% if current_user.is_authenticated and current_user.is_admin %}
<a href="{{ url_for('admin_dodaj_wplate', zbiorka_id=zbiorka.id) }}" class="btn btn-primary">Dodaj wpłatę</a>
{% endif %} {% endif %}
<a href="{{ url_for('index') }}" class="btn btn-primary">Powrót do listy</a>
</div> </div>
</div> </div>
</div>
<!-- Karta z historią wpłat --> <div class="row g-4">
<div class="card shadow-sm"> <!-- Kolumna: opis + progress -->
<div class="card-header bg-secondary text-white"> <div class="col-md-8">
<h3 class="card-title mb-0">Historia wpłat</h3> <div class="card shadow-sm h-100">
</div> <div class="card-body">
<div class="card-body"> <h5 class="mb-2">Opis</h5>
{% if zbiorka.wplaty|length > 0 %} <div class="mb-4">
<ul class="list-group"> {{ zbiorka.opis | markdown }}
{% for w in zbiorka.wplaty %} </div>
<li class="list-group-item">
<strong>{{ w.data.strftime('%Y-%m-%d %H:%M:%S') }}</strong> {{ w.kwota|round(2) }} PLN <h5 class="mb-2">Postęp</h5>
{% if w.opis %} <div class="progress mb-2" role="progressbar" aria-valuenow="{{ progress_clamped|round(2) }}"
<em class="text-muted">({{ w.opis }})</em> 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 %} {% endif %}
</li> </small>
</div>
</div>
</div>
<!-- Kolumna: płatności (sticky) -->
<div class="col-md-4">
<div class="card shadow-sm wspomoz-card position-sticky" style="top: 1rem;">
<div class="card-body">
<div class="mb-3">
<div class="d-flex align-items-center justify-content-between">
<strong>Numer konta</strong>
<button class="btn btn-sm btn-outline-light border" type="button"
data-copy-target="#ibanDisplay">Kopiuj</button>
</div>
<div class="fs-5" id="ibanDisplay">{{ zbiorka.numer_konta }}</div>
</div>
<div class="mb-3">
<div class="d-flex align-items-center justify-content-between">
<strong>Telefon BLIK</strong>
<button class="btn btn-sm btn-outline-light border" type="button"
data-copy-target="#blikDisplay">Kopiuj</button>
</div>
<div class="fs-5" id="blikDisplay">{{ zbiorka.numer_telefonu_blik }}</div>
</div>
{% if not zbiorka.ukryj_kwote %}
<hr class="my-3">
<div class="d-flex flex-column gap-1">
{% if has_cel %}
<div><strong>Cel:</strong> <span class="fs-6">{{ zbiorka.cel|round(2) }} PLN</span></div>
{% endif %}
<div><strong>Stan:</strong> <span class="fs-6">{{ zbiorka.stan|round(2) }} PLN</span></div>
{% if has_cel %}
{% set brak = (zbiorka.cel - zbiorka.stan) %}
<small class="text-muted">
{% if brak > 0 %}
Do celu brakuje: {{ brak|round(2) }} PLN
{% elif brak == 0 %}
Cel osiągnięty.
{% else %}
Przekroczono cel o: {{ (brak * -1)|round(2) }} PLN
{% endif %}
</small>
{% endif %}
</div>
{% endif %}
{% if current_user.is_authenticated and current_user.is_admin %}
<div class="d-grid mt-3">
<a href="{{ url_for('dodaj_wplate', zbiorka_id=zbiorka.id) }}" class="btn btn-primary">Dodaj
wpłatę</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Historia wpłat -->
<div class="card shadow-sm mt-4">
<div class="card-header d-flex align-items-center justify-content-between">
<h5 class="card-title mb-0">Historia wpłat</h5>
{% if zbiorka.wplaty|length > 0 %}
<small class="text-muted">Łącznie pozycji: {{ zbiorka.wplaty|length }}</small>
{% endif %}
</div>
<div class="card-body">
{% if zbiorka.wplaty and zbiorka.wplaty|length > 0 %}
<ul class="list-group list-group-flush">
{% for w in zbiorka.wplaty %}
<li class="list-group-item bg-transparent d-flex flex-wrap justify-content-between align-items-center">
<div class="me-3">
<strong>{{ w.data.strftime('%Y-%m-%d %H:%M:%S') }}</strong>
{% if w.opis %}
<span class="text-muted">— {{ w.opis }}</span>
{% endif %}
</div>
<span class="badge bg-dark border ms-auto" style="border-color: var(--border);">
{{ w.kwota|round(2) }} PLN
</span>
</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<p class="text-center">Aktualnie brak wpłat..</p> <div class="text-center py-4">
{% endif %} <h6 class="mb-1">Brak wpłat</h6>
<p class="text-muted mb-0">Gdy pojawią się pierwsze wpłaty, zobaczysz je tutaj.</p>
</div>
{% endif %}
</div>
</div> </div>
</div>
<!-- Akcje dolne -->
<div class="d-flex gap-2 justify-content-between mt-3">
<div></div>
<a href="{{ url_for('index') }}" class="btn btn-outline-light border">Powrót do listy</a>
</div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='js/zbiorka.js') }}"></script>
<script src="{{ url_for('static', filename='js/progress.js') }}"></script>
{% endblock %}