Compare commits
135 Commits
1126730cbf
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5db242685b | ||
|
|
be8d4b5a27 | ||
|
|
29b07f6431 | ||
|
|
bbfb3e0887 | ||
|
|
c5295fa49c | ||
|
|
21434588fc | ||
|
|
b3c070fa85 | ||
|
|
50c4c56e72 | ||
|
|
a776e7c51e | ||
|
|
fe48f589f0 | ||
|
|
20910fa898 | ||
|
|
ab19453c73 | ||
|
|
ff9283fd5f | ||
|
|
276a0199f3 | ||
|
|
c75505a79c | ||
|
|
5db700f3b0 | ||
|
|
29ca46a5ef | ||
|
|
da2c2ca3bd | ||
|
|
19f0bbc438 | ||
|
|
2af3b7357c | ||
|
|
5220b8cf2c | ||
|
|
3e4d1ba78c | ||
|
|
8ee34d931d | ||
|
|
562d8117c3 | ||
|
|
f2d43e0b04 | ||
|
|
d4253dd4a5 | ||
|
|
cad4f35997 | ||
|
|
e4bd4d1819 | ||
|
|
05e3265e2c | ||
|
|
236dbc8b16 | ||
|
|
ad42e44933 | ||
|
|
7d6fdaf03a | ||
|
|
95ed3180b2 | ||
|
|
91d83d91f0 | ||
|
|
c48cf7d92e | ||
|
|
b88080e5ac | ||
| 2c98d710f3 | |||
|
|
3be89fe9d8 | ||
|
|
f6d2c9955c | ||
|
|
15dd9d1fe9 | ||
|
|
e1367f8bf2 | ||
|
|
35a8d5dd8e | ||
|
|
f9406a46cf | ||
|
|
1c69295b9b | ||
|
|
a4887f9c73 | ||
|
|
22b4c3d99a | ||
|
|
16ec180822 | ||
|
|
3bad07ce69 | ||
|
|
086784b4ef | ||
|
|
a4fa47e1a2 | ||
|
|
6709681dfd | ||
|
|
1e7f1e3990 | ||
|
|
d844d354fa | ||
|
|
4de192b865 | ||
|
|
90b9c8d462 | ||
| ee2f498744 | |||
| 4a357ffd57 | |||
| f78fe5369c | |||
| 69edf7844a | |||
| 1bedab4825 | |||
| 609a411824 | |||
|
|
a4fabcf4bd | ||
|
|
d44681ed0c | ||
|
|
1d8a87216d | ||
|
|
2f961db2ac | ||
|
|
e79eebed17 | ||
|
|
c0daa93fe9 | ||
|
|
0d09abc4eb | ||
|
|
413c80baa2 | ||
|
|
0390a59008 | ||
|
|
ee05bf74b5 | ||
|
|
052439b340 | ||
|
|
a673fe99f8 | ||
|
|
fd46242cf5 | ||
|
|
95b01665b9 | ||
|
|
f7a99df93d | ||
|
|
64ad6b4bbf | ||
|
|
d7abf96080 | ||
|
|
8887ed2c62 | ||
|
|
dfa3028dad | ||
|
|
b679700c6d | ||
|
|
eb9f11b2d6 | ||
|
|
6a1734024a | ||
|
|
fdcfaff80e | ||
|
|
abb8ee0ae7 | ||
|
|
37061442d2 | ||
|
|
113f9e385e | ||
|
|
34d3f3ae0c | ||
|
|
72b82ae40c | ||
|
|
9a7660fdf7 | ||
|
|
56c02e072d | ||
|
|
b45a259455 | ||
|
|
d0df1cdee9 | ||
|
|
71a2b33a7a | ||
|
|
16beaac932 | ||
|
|
eec3985c5a | ||
|
|
b80b92a5df | ||
|
|
9b2353c82a | ||
|
|
dec43f324e | ||
|
|
2b024e2b3a | ||
|
|
5c54ccfe83 | ||
|
|
1ff6eac9a2 | ||
|
|
7495a6baca | ||
|
|
f4ebcdb5b1 | ||
|
|
4515a1b391 | ||
|
|
8b6fb41a68 | ||
|
|
6238cf9e4b | ||
|
|
f2b7c8a64a | ||
|
|
bbe318f995 | ||
|
|
1a423a8b92 | ||
|
|
0b221696d4 | ||
|
|
b9e85ab5d4 | ||
|
|
b3904f25b9 | ||
|
|
31e65fe8a4 | ||
|
|
f30668d987 | ||
|
|
80038d6fb0 | ||
|
|
b122c2b040 | ||
|
|
ee877be74c | ||
|
|
6f85dbf91c | ||
|
|
62e40d5aee | ||
|
|
d42ce7fcc4 | ||
|
|
248d1dcd84 | ||
|
|
829142f1cc | ||
|
|
b7ab11d124 | ||
|
|
2186c81264 | ||
|
|
e9db945bb4 | ||
|
|
d71d33cfe0 | ||
|
|
8d29328103 | ||
|
|
748b4e47a2 | ||
|
|
6935cefaf7 | ||
|
|
1a62bbae2a | ||
|
|
3d54f95a01 | ||
|
|
9cc6730291 | ||
|
|
cb5666e9b9 | ||
|
|
214bd4f2c6 |
67
.env.example
Normal file
67
.env.example
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# === Podstawowe ===
|
||||||
|
APP_PORT=8080
|
||||||
|
|
||||||
|
# healthcheck na potrzeby dockera
|
||||||
|
HEALTHCHECK_TOKEN=Bu22SW455TPe92
|
||||||
|
|
||||||
|
# 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 i elementóœ statycznych
|
||||||
|
CACHE_CONTROL_HEADER=max-age=600
|
||||||
|
CACHE_CONTROL_HEADER_STATIC=max-age=72000
|
||||||
|
USE_ETAGS=True
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
|
||||||
|
# Rodzaj bazy: sqlite, pgsql, mysql
|
||||||
|
# Mozliwe wartosci: sqlite / pgsql / mysql
|
||||||
|
DB_ENGINE=sqlite
|
||||||
|
|
||||||
|
# --- Konfiguracja dla sqlite ---
|
||||||
|
# Plik bazy bedzie utworzony automatycznie w katalogu ./instance
|
||||||
|
# Pozostale zmienne sa ignorowane przy DB_ENGINE=sqlite
|
||||||
|
|
||||||
|
# --- Konfiguracja dla pgsql ---
|
||||||
|
# Ustaw DB_ENGINE=pgsql
|
||||||
|
# Domyslny port PostgreSQL to 5432
|
||||||
|
# Wymaga dzialajacego serwera PostgreSQL (np. kontener `postgres`)
|
||||||
|
|
||||||
|
# --- Konfiguracja dla mysql ---
|
||||||
|
# Ustaw DB_ENGINE=mysql
|
||||||
|
# Domyslny port MySQL to 3306
|
||||||
|
# Wymaga kontenera z MySQL i uzytkownika z dostepem do bazy
|
||||||
|
|
||||||
|
# Wspolne zmienne (dla pgsql, mysql)
|
||||||
|
# DB_HOST = pgsql lub mysql zgodnie z deployem (profil w docker-compose.yml)
|
||||||
|
|
||||||
|
DB_HOST=pgsql
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=myapp
|
||||||
|
DB_USER=user
|
||||||
|
DB_PASSWORD=pass
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -2,4 +2,8 @@ __pycache__
|
|||||||
data/
|
data/
|
||||||
instance/
|
instance/
|
||||||
venv/
|
venv/
|
||||||
config.py
|
.env
|
||||||
|
version.txt
|
||||||
|
deploy/varnish/default.vcl
|
||||||
|
*.tar.gz
|
||||||
|
db/*
|
||||||
15
Dockerfile
15
Dockerfile
@@ -1,13 +1,18 @@
|
|||||||
FROM python:3.13-slim
|
#FROM python:3.13-slim
|
||||||
|
FROM python:3.14-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
&& apt-get install -y build-essential \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt requirements.txt
|
COPY requirements.txt requirements.txt
|
||||||
RUN apt-get update && apt-get install -y build-essential && \
|
|
||||||
pip install --upgrade pip && pip install -r requirements.txt
|
RUN pip install --upgrade pip
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN mkdir -p /app/instance
|
RUN mkdir -p /app/instance
|
||||||
|
|
||||||
EXPOSE 8080
|
|
||||||
|
|
||||||
CMD ["python", "run_waitress.py"]
|
CMD ["python", "run_waitress.py"]
|
||||||
|
|||||||
38
_tools/db/migrate.txt
Normal file
38
_tools/db/migrate.txt
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
python3 -m venv venv_migrate
|
||||||
|
source venv_migrate/bin/activate
|
||||||
|
pip install sqlalchemy psycopg2-binary dotenv
|
||||||
|
docker compose --profile pgsql up -d --build
|
||||||
|
PYTHONPATH=. python3 _tools/db/migrate_sqlite_to_pgsql.py
|
||||||
|
rm -rf venv_migrate
|
||||||
|
|
||||||
|
# reset wszystkich sekwencji w pgsql
|
||||||
|
docker exec -it zbiorka-pgsql-db psql -U zbiorki -d zbiorki
|
||||||
|
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
r RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN
|
||||||
|
SELECT
|
||||||
|
c.relname AS seq_name,
|
||||||
|
t.relname AS table_name,
|
||||||
|
a.attname AS column_name
|
||||||
|
FROM
|
||||||
|
pg_class c
|
||||||
|
JOIN
|
||||||
|
pg_depend d ON d.objid = c.oid
|
||||||
|
JOIN
|
||||||
|
pg_class t ON d.refobjid = t.oid
|
||||||
|
JOIN
|
||||||
|
pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid
|
||||||
|
WHERE
|
||||||
|
c.relkind = 'S'
|
||||||
|
AND d.deptype = 'a'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format(
|
||||||
|
'SELECT setval(%L, COALESCE((SELECT MAX(%I) FROM %I), 1), true)',
|
||||||
|
r.seq_name, r.column_name, r.table_name
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END$$;
|
||||||
68
_tools/db/migrate_sqlite_to_pgsql.py
Normal file
68
_tools/db/migrate_sqlite_to_pgsql.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, MetaData
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from config import Config
|
||||||
|
|
||||||
|
|
||||||
|
# Źródło: SQLite
|
||||||
|
sqlite_engine = create_engine("sqlite:///instance/baza.db")
|
||||||
|
sqlite_meta = MetaData()
|
||||||
|
sqlite_meta.reflect(bind=sqlite_engine)
|
||||||
|
|
||||||
|
|
||||||
|
# Cel: PostgreSQL
|
||||||
|
pg_engine = create_engine(Config.SQLALCHEMY_DATABASE_URI)
|
||||||
|
pg_meta = MetaData()
|
||||||
|
pg_meta.reflect(bind=pg_engine)
|
||||||
|
|
||||||
|
|
||||||
|
# Sesje
|
||||||
|
SQLiteSession = sessionmaker(bind=sqlite_engine)
|
||||||
|
PGSession = sessionmaker(bind=pg_engine)
|
||||||
|
|
||||||
|
sqlite_session = SQLiteSession()
|
||||||
|
pg_session = PGSession()
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_table(table_name):
|
||||||
|
print("Używana baza docelowa:", Config.SQLALCHEMY_DATABASE_URI)
|
||||||
|
print(f"Migruję tabelę: {table_name}")
|
||||||
|
source_table = sqlite_meta.tables.get(table_name)
|
||||||
|
target_table = pg_meta.tables.get(table_name)
|
||||||
|
|
||||||
|
if source_table is None or target_table is None:
|
||||||
|
print(f"Pominięto: {table_name} (brak w jednej z baz)")
|
||||||
|
return
|
||||||
|
|
||||||
|
rows = sqlite_session.execute(source_table.select()).fetchall()
|
||||||
|
if not rows:
|
||||||
|
print("Brak danych do migracji.")
|
||||||
|
return
|
||||||
|
|
||||||
|
insert_data = [dict(row._mapping) for row in rows]
|
||||||
|
|
||||||
|
try:
|
||||||
|
with pg_engine.begin() as conn:
|
||||||
|
conn.execute(target_table.delete())
|
||||||
|
conn.execute(target_table.insert(), insert_data)
|
||||||
|
print(f"Przeniesiono: {len(rows)} rekordów")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Błąd przy migracji {table_name}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
tables = ["zbiorka", "przedmiot", "uzytkownik", "wydatek", "ustawienia_globalne", "wplata"]
|
||||||
|
for table in tables:
|
||||||
|
migrate_table(table)
|
||||||
|
print("\nMigracja zakończona pomyślnie.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
ALTER TABLE global_settings ADD COLUMN allowed_login_hosts TEXT;
|
ALTER TABLE zbiorka ADD COLUMN typ_zbiorki VARCHAR(20) NOT NULL DEFAULT 'standardowa';
|
||||||
|
CREATE INDEX idx_zbiorka_typ ON zbiorka(typ_zbiorki);
|
||||||
|
|
||||||
ALTER TABLE zbiorka ADD COLUMN zrealizowana BOOLEAN DEFAULT 0;
|
|
||||||
|
ALTER TABLE ustawienia_globalne
|
||||||
|
ADD COLUMN kolejnosc_rezerwowych VARCHAR(20) NOT NULL DEFAULT 'id';
|
||||||
404
app.py
404
app.py
@@ -1,401 +1,11 @@
|
|||||||
from flask import Flask, render_template, request, redirect, url_for, flash
|
from zbiorka_app import create_app
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from zbiorka_app.extensions import db
|
||||||
from flask_login import LoginManager, login_user, login_required, logout_user, current_user, UserMixin
|
from zbiorka_app.utils import create_admin_account, init_database_with_retry
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
|
||||||
from datetime import datetime
|
|
||||||
from markupsafe import Markup
|
|
||||||
import markdown as md
|
|
||||||
from flask import request, flash, abort
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import socket
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = create_app()
|
||||||
# Ładujemy konfigurację z pliku config.py
|
|
||||||
app.config.from_object('config.Config')
|
|
||||||
|
|
||||||
db = SQLAlchemy(app)
|
__all__ = ["app", "db", "create_admin_account", "init_database_with_retry"]
|
||||||
login_manager = LoginManager(app)
|
|
||||||
login_manager.login_view = 'login'
|
|
||||||
|
|
||||||
# MODELE
|
if __name__ == "__main__":
|
||||||
|
init_database_with_retry(app)
|
||||||
class User(UserMixin, db.Model):
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
username = db.Column(db.String(80), unique=True, nullable=False)
|
|
||||||
password_hash = db.Column(db.String(128), nullable=False)
|
|
||||||
is_admin = db.Column(db.Boolean, default=False) # Flaga głównego administratora
|
|
||||||
|
|
||||||
def set_password(self, password):
|
|
||||||
self.password_hash = generate_password_hash(password)
|
|
||||||
|
|
||||||
def check_password(self, password):
|
|
||||||
return check_password_hash(self.password_hash, password)
|
|
||||||
|
|
||||||
class Zbiorka(db.Model):
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
nazwa = db.Column(db.String(100), nullable=False)
|
|
||||||
opis = db.Column(db.Text, nullable=False)
|
|
||||||
numer_konta = db.Column(db.String(50), nullable=False)
|
|
||||||
numer_telefonu_blik = db.Column(db.String(50), nullable=False)
|
|
||||||
cel = db.Column(db.Float, nullable=False, default=0.0)
|
|
||||||
stan = db.Column(db.Float, default=0.0)
|
|
||||||
ukryta = 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)
|
|
||||||
|
|
||||||
class Wplata(db.Model):
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
zbiorka_id = db.Column(db.Integer, db.ForeignKey('zbiorka.id'), nullable=False)
|
|
||||||
kwota = db.Column(db.Float, nullable=False)
|
|
||||||
data = db.Column(db.DateTime, default=datetime.utcnow)
|
|
||||||
opis = db.Column(db.Text, nullable=True) # Opis wpłaty
|
|
||||||
|
|
||||||
class GlobalSettings(db.Model):
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
numer_konta = 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)
|
|
||||||
|
|
||||||
@login_manager.user_loader
|
|
||||||
def load_user(user_id):
|
|
||||||
return User.query.get(int(user_id))
|
|
||||||
|
|
||||||
def is_allowed_ip(remote_ip, allowed_hosts_str):
|
|
||||||
# Jeśli istnieje plik awaryjny, zawsze zezwalamy na dostęp
|
|
||||||
if os.path.exists("emergency_access.txt"):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Rozdzielamy wpisy – mogą być oddzielone przecinkami lub znakami nowej linii
|
|
||||||
allowed_hosts = re.split(r'[\n,]+', allowed_hosts_str.strip())
|
|
||||||
allowed_ips = set()
|
|
||||||
for host in allowed_hosts:
|
|
||||||
host = host.strip()
|
|
||||||
if not host:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
# Rozwiązywanie nazwy domeny do adresu IP.
|
|
||||||
resolved_ip = socket.gethostbyname(host)
|
|
||||||
allowed_ips.add(resolved_ip)
|
|
||||||
except Exception:
|
|
||||||
# Jeśli rozwiązywanie nazwy nie powiedzie się, pomijamy ten wpis.
|
|
||||||
continue
|
|
||||||
return remote_ip in allowed_ips
|
|
||||||
|
|
||||||
# Dodaj filtr Markdown – pozwala na zagnieżdżanie linków i obrazków w opisie
|
|
||||||
@app.template_filter('markdown')
|
|
||||||
def markdown_filter(text):
|
|
||||||
return Markup(md.markdown(text))
|
|
||||||
|
|
||||||
# TRASY PUBLICZNE
|
|
||||||
|
|
||||||
@app.route('/')
|
|
||||||
def index():
|
|
||||||
zbiorki = Zbiorka.query.filter_by(ukryta=False, zrealizowana=False).all()
|
|
||||||
return render_template('index.html', zbiorki=zbiorki)
|
|
||||||
|
|
||||||
@app.route('/zbiorki_zrealizowane')
|
|
||||||
def zbiorki_zrealizowane():
|
|
||||||
zbiorki = Zbiorka.query.filter_by(zrealizowana=True).all()
|
|
||||||
return render_template('index.html', zbiorki=zbiorki)
|
|
||||||
|
|
||||||
@app.errorhandler(404)
|
|
||||||
def page_not_found(e):
|
|
||||||
return redirect(url_for('index'))
|
|
||||||
|
|
||||||
@app.route('/zbiorka/<int:zbiorka_id>')
|
|
||||||
def zbiorka(zbiorka_id):
|
|
||||||
zb = Zbiorka.query.get_or_404(zbiorka_id)
|
|
||||||
# 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):
|
|
||||||
abort(404)
|
|
||||||
return render_template('zbiorka.html', zbiorka=zb)
|
|
||||||
|
|
||||||
def get_real_ip():
|
|
||||||
# Cloudflare
|
|
||||||
if "CF-Connecting-IP" in request.headers:
|
|
||||||
return request.headers.get("CF-Connecting-IP")
|
|
||||||
# Nginx proxy (Nginx Proxy Manager / standard reverse proxy)
|
|
||||||
elif "X-Real-IP" in request.headers:
|
|
||||||
return request.headers.get("X-Real-IP")
|
|
||||||
elif "X-Forwarded-For" in request.headers:
|
|
||||||
forwarded_for = request.headers.get("X-Forwarded-For").split(",")
|
|
||||||
return forwarded_for[0].strip()
|
|
||||||
# Fallback
|
|
||||||
return request.remote_addr
|
|
||||||
|
|
||||||
# TRASY LOGOWANIA I REJESTRACJI
|
|
||||||
|
|
||||||
@app.route('/login', methods=['GET', 'POST'])
|
|
||||||
def login():
|
|
||||||
# Pobierz ustawienia globalne, w tym dozwolone hosty
|
|
||||||
settings = GlobalSettings.query.first()
|
|
||||||
allowed_hosts_str = ""
|
|
||||||
if settings and settings.allowed_login_hosts:
|
|
||||||
allowed_hosts_str = settings.allowed_login_hosts
|
|
||||||
|
|
||||||
# Sprawdzenie, czy adres IP klienta jest dozwolony
|
|
||||||
client_ip = get_real_ip()
|
|
||||||
if not is_allowed_ip(client_ip, allowed_hosts_str):
|
|
||||||
flash('Dostęp do endpointu /login jest zablokowany dla Twojego adresu IP', 'danger')
|
|
||||||
return redirect(url_for('index'))
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
|
||||||
username = request.form['username']
|
|
||||||
password = request.form['password']
|
|
||||||
user = User.query.filter_by(username=username).first()
|
|
||||||
if user and user.check_password(password):
|
|
||||||
login_user(user)
|
|
||||||
flash('Zalogowano pomyślnie', 'success')
|
|
||||||
next_page = request.args.get('next')
|
|
||||||
return redirect(next_page) if next_page else redirect(url_for('admin_dashboard'))
|
|
||||||
else:
|
|
||||||
flash('Nieprawidłowe dane logowania', 'danger')
|
|
||||||
return render_template('login.html')
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/logout')
|
|
||||||
@login_required
|
|
||||||
def logout():
|
|
||||||
logout_user()
|
|
||||||
flash('Wylogowano', 'success')
|
|
||||||
return redirect(url_for('login'))
|
|
||||||
|
|
||||||
@app.route('/register', methods=['GET', 'POST'])
|
|
||||||
def register():
|
|
||||||
if not app.config.get('ALLOW_REGISTRATION', False):
|
|
||||||
flash('Rejestracja została wyłączona przez administratora', 'danger')
|
|
||||||
return redirect(url_for('login'))
|
|
||||||
if request.method == 'POST':
|
|
||||||
username = request.form['username']
|
|
||||||
password = request.form['password']
|
|
||||||
if User.query.filter_by(username=username).first():
|
|
||||||
flash('Użytkownik już istnieje', 'danger')
|
|
||||||
return redirect(url_for('register'))
|
|
||||||
new_user = User(username=username)
|
|
||||||
new_user.set_password(password)
|
|
||||||
db.session.add(new_user)
|
|
||||||
db.session.commit()
|
|
||||||
flash('Konto utworzone, możesz się zalogować', 'success')
|
|
||||||
return redirect(url_for('login'))
|
|
||||||
return render_template('register.html')
|
|
||||||
|
|
||||||
# PANEL ADMINISTRACYJNY
|
|
||||||
|
|
||||||
@app.route('/admin')
|
|
||||||
@login_required
|
|
||||||
def admin_dashboard():
|
|
||||||
if not current_user.is_admin:
|
|
||||||
flash('Brak uprawnień do panelu administracyjnego', 'danger')
|
|
||||||
return redirect(url_for('index'))
|
|
||||||
active_zbiorki = Zbiorka.query.filter_by(zrealizowana=False).all()
|
|
||||||
completed_zbiorki = Zbiorka.query.filter_by(zrealizowana=True).all()
|
|
||||||
return render_template('admin/dashboard.html', active_zbiorki=active_zbiorki,
|
|
||||||
completed_zbiorki=completed_zbiorki)
|
|
||||||
|
|
||||||
@app.route('/admin/zbiorka/dodaj', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def dodaj_zbiorka():
|
|
||||||
if not current_user.is_admin:
|
|
||||||
flash('Brak uprawnień', 'danger')
|
|
||||||
return redirect(url_for('index'))
|
|
||||||
|
|
||||||
global_settings = GlobalSettings.query.first() # Pobieramy globalne ustawienia
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
|
||||||
nazwa = request.form['nazwa']
|
|
||||||
opis = request.form['opis']
|
|
||||||
# Pozyskujemy numer konta i telefon z formularza (mogą być nadpisane ręcznie)
|
|
||||||
numer_konta = request.form['numer_konta']
|
|
||||||
numer_telefonu_blik = request.form['numer_telefonu_blik']
|
|
||||||
cel = float(request.form['cel'])
|
|
||||||
ukryj_kwote = 'ukryj_kwote' in request.form
|
|
||||||
|
|
||||||
nowa_zbiorka = Zbiorka(
|
|
||||||
nazwa=nazwa,
|
|
||||||
opis=opis,
|
|
||||||
numer_konta=numer_konta,
|
|
||||||
numer_telefonu_blik=numer_telefonu_blik,
|
|
||||||
cel=cel,
|
|
||||||
ukryj_kwote=ukryj_kwote
|
|
||||||
)
|
|
||||||
db.session.add(nowa_zbiorka)
|
|
||||||
db.session.commit()
|
|
||||||
flash('Zbiórka została dodana', 'success')
|
|
||||||
return redirect(url_for('admin_dashboard'))
|
|
||||||
|
|
||||||
return render_template('admin/add_zbiorka.html', global_settings=global_settings)
|
|
||||||
|
|
||||||
@app.route('/admin/zbiorka/edytuj/<int:zbiorka_id>', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def edytuj_zbiorka(zbiorka_id):
|
|
||||||
if not current_user.is_admin:
|
|
||||||
flash('Brak uprawnień', 'danger')
|
|
||||||
return redirect(url_for('index'))
|
|
||||||
zb = Zbiorka.query.get_or_404(zbiorka_id)
|
|
||||||
global_settings = GlobalSettings.query.first() # Pobieramy globalne ustawienia
|
|
||||||
if request.method == 'POST':
|
|
||||||
zb.nazwa = request.form['nazwa']
|
|
||||||
zb.opis = request.form['opis']
|
|
||||||
zb.numer_konta = request.form['numer_konta']
|
|
||||||
zb.numer_telefonu_blik = request.form['numer_telefonu_blik']
|
|
||||||
try:
|
|
||||||
zb.cel = float(request.form['cel'])
|
|
||||||
except ValueError:
|
|
||||||
flash('Podano nieprawidłową wartość dla celu zbiórki', 'danger')
|
|
||||||
return render_template('admin/edit_zbiorka.html', zbiorka=zb, global_settings=global_settings)
|
|
||||||
zb.ukryj_kwote = 'ukryj_kwote' in request.form
|
|
||||||
db.session.commit()
|
|
||||||
flash('Zbiórka została zaktualizowana', 'success')
|
|
||||||
return redirect(url_for('admin_dashboard'))
|
|
||||||
return render_template('admin/edit_zbiorka.html', zbiorka=zb, global_settings=global_settings)
|
|
||||||
|
|
||||||
# TRASA DODAWANIA WPŁATY Z OPISEM
|
|
||||||
# TRASA DODAWANIA WPŁATY W PANELU ADMINA
|
|
||||||
@app.route('/admin/zbiorka/<int:zbiorka_id>/wplata/dodaj', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def admin_dodaj_wplate(zbiorka_id):
|
|
||||||
if not current_user.is_admin:
|
|
||||||
flash('Brak uprawnień', 'danger')
|
|
||||||
return redirect(url_for('index'))
|
|
||||||
zb = Zbiorka.query.get_or_404(zbiorka_id)
|
|
||||||
if request.method == 'POST':
|
|
||||||
kwota = float(request.form['kwota'])
|
|
||||||
opis = request.form.get('opis', '')
|
|
||||||
nowa_wplata = Wplata(zbiorka_id=zb.id, kwota=kwota, opis=opis)
|
|
||||||
zb.stan += kwota # Aktualizacja stanu zbiórki
|
|
||||||
db.session.add(nowa_wplata)
|
|
||||||
db.session.commit()
|
|
||||||
flash('Wpłata została dodana', 'success')
|
|
||||||
return redirect(url_for('admin_dashboard'))
|
|
||||||
return render_template('admin/add_wplata.html', zbiorka=zb)
|
|
||||||
|
|
||||||
@app.route('/admin/zbiorka/usun/<int:zbiorka_id>', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def usun_zbiorka(zbiorka_id):
|
|
||||||
if not current_user.is_admin:
|
|
||||||
flash('Brak uprawnień', 'danger')
|
|
||||||
return redirect(url_for('index'))
|
|
||||||
zb = Zbiorka.query.get_or_404(zbiorka_id)
|
|
||||||
db.session.delete(zb)
|
|
||||||
db.session.commit()
|
|
||||||
flash('Zbiórka została usunięta', 'success')
|
|
||||||
return redirect(url_for('admin_dashboard'))
|
|
||||||
|
|
||||||
@app.route('/admin/zbiorka/edytuj_stan/<int:zbiorka_id>', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def edytuj_stan(zbiorka_id):
|
|
||||||
if not current_user.is_admin:
|
|
||||||
flash('Brak uprawnień', 'danger')
|
|
||||||
return redirect(url_for('index'))
|
|
||||||
zb = Zbiorka.query.get_or_404(zbiorka_id)
|
|
||||||
if request.method == 'POST':
|
|
||||||
try:
|
|
||||||
nowy_stan = float(request.form['stan'])
|
|
||||||
except ValueError:
|
|
||||||
flash('Nieprawidłowa wartość kwoty', 'danger')
|
|
||||||
return redirect(url_for('edytuj_stan', zbiorka_id=zbiorka_id))
|
|
||||||
zb.stan = nowy_stan
|
|
||||||
db.session.commit()
|
|
||||||
flash('Stan zbiórki został zaktualizowany', 'success')
|
|
||||||
return redirect(url_for('admin_dashboard'))
|
|
||||||
return render_template('admin/edytuj_stan.html', zbiorka=zb)
|
|
||||||
|
|
||||||
@app.route('/admin/zbiorka/toggle_visibility/<int:zbiorka_id>', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def toggle_visibility(zbiorka_id):
|
|
||||||
if not current_user.is_admin:
|
|
||||||
flash('Brak uprawnień', 'danger')
|
|
||||||
return redirect(url_for('index'))
|
|
||||||
zb = Zbiorka.query.get_or_404(zbiorka_id)
|
|
||||||
zb.ukryta = not zb.ukryta
|
|
||||||
db.session.commit()
|
|
||||||
flash('Zbiórka została ' + ('ukryta' if zb.ukryta else 'przywrócona'), 'success')
|
|
||||||
return redirect(url_for('admin_dashboard'))
|
|
||||||
|
|
||||||
def create_admin_account():
|
|
||||||
admin = User.query.filter_by(is_admin=True).first()
|
|
||||||
if not admin:
|
|
||||||
main_admin = User(username=app.config['MAIN_ADMIN_USERNAME'], is_admin=True)
|
|
||||||
main_admin.set_password(app.config['MAIN_ADMIN_PASSWORD'])
|
|
||||||
db.session.add(main_admin)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
@app.after_request
|
|
||||||
def add_security_headers(response):
|
|
||||||
if app.config.get("BLOCK_BOTS", False):
|
|
||||||
cache_control = app.config.get("CACHE_CONTROL_HEADER")
|
|
||||||
if cache_control:
|
|
||||||
response.headers["Cache-Control"] = cache_control
|
|
||||||
# Jeśli Cache-Control jest ustawiony, usuwamy Pragma
|
|
||||||
response.headers.pop("Pragma", None)
|
|
||||||
else:
|
|
||||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
|
||||||
response.headers["Pragma"] = app.config.get("PRAGMA_HEADER", "no-cache")
|
|
||||||
response.headers["X-Robots-Tag"] = app.config.get("ROBOTS_TAG", "noindex, nofollow, nosnippet, noarchive")
|
|
||||||
return response
|
|
||||||
|
|
||||||
@app.route('/admin/settings', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def admin_settings():
|
|
||||||
if not current_user.is_admin:
|
|
||||||
flash('Brak uprawnień do panelu administracyjnego', 'danger')
|
|
||||||
return redirect(url_for('index'))
|
|
||||||
|
|
||||||
settings = GlobalSettings.query.first()
|
|
||||||
if request.method == 'POST':
|
|
||||||
numer_konta = request.form.get('numer_konta')
|
|
||||||
numer_telefonu_blik = request.form.get('numer_telefonu_blik')
|
|
||||||
allowed_login_hosts = request.form.get('allowed_login_hosts')
|
|
||||||
|
|
||||||
if settings is None:
|
|
||||||
settings = GlobalSettings(
|
|
||||||
numer_konta=numer_konta,
|
|
||||||
numer_telefonu_blik=numer_telefonu_blik,
|
|
||||||
allowed_login_hosts=allowed_login_hosts
|
|
||||||
)
|
|
||||||
db.session.add(settings)
|
|
||||||
else:
|
|
||||||
settings.numer_konta = numer_konta
|
|
||||||
settings.numer_telefonu_blik = numer_telefonu_blik
|
|
||||||
settings.allowed_login_hosts = allowed_login_hosts
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
flash('Ustawienia globalne zostały zaktualizowane', 'success')
|
|
||||||
return redirect(url_for('admin_dashboard'))
|
|
||||||
|
|
||||||
return render_template('admin/settings.html', settings=settings)
|
|
||||||
|
|
||||||
@app.route('/admin/zbiorka/oznacz/<int:zbiorka_id>', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def oznacz_zbiorka(zbiorka_id):
|
|
||||||
if not current_user.is_admin:
|
|
||||||
flash('Brak uprawnień do wykonania tej operacji', 'danger')
|
|
||||||
return redirect(url_for('index'))
|
|
||||||
zb = Zbiorka.query.get_or_404(zbiorka_id)
|
|
||||||
zb.zrealizowana = True
|
|
||||||
db.session.commit()
|
|
||||||
flash('Zbiórka została oznaczona jako zrealizowana', 'success')
|
|
||||||
return redirect(url_for('admin_dashboard'))
|
|
||||||
|
|
||||||
@app.route('/robots.txt')
|
|
||||||
def robots():
|
|
||||||
if app.config.get("BLOCK_BOTS", False):
|
|
||||||
# Instrukcje dla robotów – blokujemy indeksowanie całej witryny
|
|
||||||
robots_txt = "User-agent: *\nDisallow: /"
|
|
||||||
else:
|
|
||||||
# Jeśli blokowanie botów wyłączone, można zwrócić pusty plik lub inne ustawienia
|
|
||||||
robots_txt = "User-agent: *\nAllow: /"
|
|
||||||
return robots_txt, 200, {'Content-Type': 'text/plain'}
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
with app.app_context():
|
|
||||||
db.create_all()
|
|
||||||
# Tworzenie konta głównego admina, jeśli nie istnieje
|
|
||||||
if not User.query.filter_by(is_admin=True).first():
|
|
||||||
main_admin = User(username=app.config['MAIN_ADMIN_USERNAME'], is_admin=True)
|
|
||||||
main_admin.set_password(app.config['MAIN_ADMIN_PASSWORD'])
|
|
||||||
db.session.add(main_admin)
|
|
||||||
db.session.commit()
|
|
||||||
app.run(debug=True)
|
app.run(debug=True)
|
||||||
|
|||||||
@@ -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"
|
|
||||||
91
config.py
Normal file
91
config.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_int(name: str, default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(os.environ.get(name, default))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 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
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
#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")
|
||||||
|
CACHE_CONTROL_HEADER_STATIC = _get_str("CACHE_CONTROL_HEADER_STATIC", "max-age=3600")
|
||||||
|
|
||||||
|
USE_ETAGS = _get_bool("USE_ETAGS", True)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||||
|
"pool_pre_ping": True,
|
||||||
|
"pool_recycle": _get_int("DB_POOL_RECYCLE", 300),
|
||||||
|
"pool_timeout": _get_int("DB_POOL_TIMEOUT", 30),
|
||||||
|
}
|
||||||
|
|
||||||
|
HEALTHCHECK_TOKEN = _get_str("HEALTHCHECK_TOKEN", "healthcheck")
|
||||||
|
|
||||||
|
# Baza danych
|
||||||
|
DB_ENGINE = os.environ.get("DB_ENGINE", "sqlite").lower()
|
||||||
|
|
||||||
|
if DB_ENGINE == "sqlite":
|
||||||
|
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(basedir, 'db', 'database.db')}"
|
||||||
|
elif DB_ENGINE == "pgsql":
|
||||||
|
SQLALCHEMY_DATABASE_URI = (
|
||||||
|
f"postgresql://{os.environ['DB_USER']}:{os.environ['DB_PASSWORD']}@"
|
||||||
|
f"{os.environ['DB_HOST']}:{os.environ.get('DB_PORT', 5432)}/{os.environ['DB_NAME']}"
|
||||||
|
)
|
||||||
|
SQLALCHEMY_ENGINE_OPTIONS["connect_args"] = {
|
||||||
|
"connect_timeout": _get_int("DB_CONNECT_TIMEOUT", 5),
|
||||||
|
"application_name": _get_str("DB_APPLICATION_NAME", "zbiorka-app"),
|
||||||
|
}
|
||||||
|
elif DB_ENGINE == "mysql":
|
||||||
|
SQLALCHEMY_DATABASE_URI = (
|
||||||
|
f"mysql+pymysql://{os.environ['DB_USER']}:{os.environ['DB_PASSWORD']}@"
|
||||||
|
f"{os.environ['DB_HOST']}:{os.environ.get('DB_PORT', 3306)}/{os.environ['DB_NAME']}"
|
||||||
|
)
|
||||||
|
SQLALCHEMY_ENGINE_OPTIONS["connect_args"] = {
|
||||||
|
"connect_timeout": _get_int("DB_CONNECT_TIMEOUT", 5),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
raise ValueError("Nieobsługiwany typ bazy danych.")
|
||||||
18
deploy/app/Dockerfile
Normal file
18
deploy/app/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
#FROM python:3.13-slim
|
||||||
|
FROM python:3.14-slim
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
&& apt-get install -y build-essential \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt requirements.txt
|
||||||
|
|
||||||
|
RUN pip install --upgrade pip
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN mkdir -p /app/instance
|
||||||
|
|
||||||
|
CMD ["python", "run_waitress.py"]
|
||||||
46
deploy/varnish/Dockerfile
Normal file
46
deploy/varnish/Dockerfile
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# --- Stage 1: build varnish + modules ---
|
||||||
|
FROM debian:trixie-slim AS builder
|
||||||
|
|
||||||
|
ARG VARNISH_VERSION=8.0.0
|
||||||
|
ARG VARNISH_MODULES_VERSION=0.27.0
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl build-essential automake autoconf libtool pkg-config python3-sphinx \
|
||||||
|
git ca-certificates \
|
||||||
|
libpcre2-dev libedit-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# build varnish
|
||||||
|
RUN curl -fsSL https://varnish-cache.org/_downloads/varnish-${VARNISH_VERSION}.tgz -o varnish.tar.gz \
|
||||||
|
&& tar xzf varnish.tar.gz \
|
||||||
|
&& cd varnish-${VARNISH_VERSION} \
|
||||||
|
&& ./configure && make -j$(nproc) && make install \
|
||||||
|
&& cd .. && rm -rf varnish-${VARNISH_VERSION} varnish.tar.gz
|
||||||
|
|
||||||
|
# build varnish-modules
|
||||||
|
RUN curl -fsSL https://github.com/varnish/varnish-modules/releases/download/${VARNISH_MODULES_VERSION}/varnish-modules-${VARNISH_MODULES_VERSION}.tar.gz -o modules.tar.gz \
|
||||||
|
&& tar xzf modules.tar.gz \
|
||||||
|
&& cd varnish-modules-${VARNISH_MODULES_VERSION} \
|
||||||
|
&& ./configure && make -j$(nproc) && make install \
|
||||||
|
&& cd .. && rm -rf varnish-modules-${VARNISH_MODULES_VERSION} modules.tar.gz
|
||||||
|
|
||||||
|
# --- Stage 2: runtime ---
|
||||||
|
FROM debian:trixie-slim AS runtime
|
||||||
|
|
||||||
|
# tylko to co potrzebne do uruchomienia varnish
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libpcre2-8-0 \
|
||||||
|
libedit2 \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# kopiujemy binaria i moduły z buildera
|
||||||
|
COPY --from=builder /usr/local /usr/local
|
||||||
|
|
||||||
|
WORKDIR /etc/varnish
|
||||||
|
COPY default.vcl /etc/varnish/
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
ENTRYPOINT ["varnishd", "-F", "-f", "/etc/varnish/default.vcl", "-s", "malloc,256m"]
|
||||||
264
deploy/varnish/default.vcl.template
Normal file
264
deploy/varnish/default.vcl.template
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
vcl 4.1;
|
||||||
|
|
||||||
|
import vsthrottle;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
# ===== Backend =====
|
||||||
|
backend app {
|
||||||
|
.host = "app";
|
||||||
|
.port = "${APP_PORT}";
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===== ACL =====
|
||||||
|
acl purge {
|
||||||
|
"127.0.0.1";
|
||||||
|
"::1";
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===== RECV =====
|
||||||
|
sub vcl_recv {
|
||||||
|
# RATE LIMIT: 200 żądań / 10s, blokada 60s
|
||||||
|
if (vsthrottle.is_denied(client.identity, 200, 10s, 60s)) {
|
||||||
|
return (synth(429, "Too Many Requests"));
|
||||||
|
}
|
||||||
|
|
||||||
|
# PURGE tylko lokalnie
|
||||||
|
if (req.method == "PURGE") {
|
||||||
|
if (!client.ip ~ purge) { return (synth(405, "Not allowed")); }
|
||||||
|
return (purge);
|
||||||
|
}
|
||||||
|
|
||||||
|
# omijamy cache dla healthchecków / wewnętrznych nagłówków
|
||||||
|
if (req.url == "/healthcheck" || req.http.X-Internal-Check) { return (pass); }
|
||||||
|
|
||||||
|
# Specjalna obsługa WebSocket i socket.io
|
||||||
|
if (req.http.Upgrade ~ "(?i)websocket" || req.url ~ "^/socket.io/") {
|
||||||
|
return (pipe);
|
||||||
|
}
|
||||||
|
|
||||||
|
# metody inne niż GET/HEAD bez cache
|
||||||
|
if (req.method != "GET" && req.method != "HEAD") { return (pass); }
|
||||||
|
|
||||||
|
# Żądania z Authorization nie są buforowane
|
||||||
|
if (req.http.Authorization) { return (pass); }
|
||||||
|
|
||||||
|
# ---- Normalizacja Accept-Encoding (kolejność: zstd > br > gzip) ----
|
||||||
|
if (req.http.Accept-Encoding) {
|
||||||
|
if (req.http.Accept-Encoding ~ "zstd") {
|
||||||
|
set req.http.Accept-Encoding = "zstd";
|
||||||
|
} else if (req.http.Accept-Encoding ~ "br") {
|
||||||
|
set req.http.Accept-Encoding = "br";
|
||||||
|
} else if (req.http.Accept-Encoding ~ "gzip") {
|
||||||
|
set req.http.Accept-Encoding = "gzip";
|
||||||
|
} else {
|
||||||
|
set req.http.Accept-Encoding = "identity";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- (Opcjonalnie) Normalizacja Accept dla obrazów generowanych wariantowo ----
|
||||||
|
# if (req.url ~ "\.(png|jpe?g|gif|bmp)$") {
|
||||||
|
# if (req.http.Accept ~ "image/webp") {
|
||||||
|
# set req.http.X-Accept-Image = "modern"; # webp
|
||||||
|
# } else {
|
||||||
|
# set req.http.X-Accept-Image = "legacy"; # jpg/png
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
|
||||||
|
# ---- STATYCZNE – agresywny cache + ignorujemy sesję ----
|
||||||
|
if (req.url ~ "^/static/" || req.url ~ "\.(css|js|png|jpe?g|webp|svg|ico|woff2?)$") {
|
||||||
|
unset req.http.Cookie;
|
||||||
|
unset req.http.Authorization;
|
||||||
|
return (hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.http.X-Forwarded-Proto) {
|
||||||
|
set req.http.X-Forwarded-Proto = "https";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.url == "/healthcheck" || req.http.X-Internal-Check) {
|
||||||
|
set req.http.X-Pass-Reason = "internal";
|
||||||
|
return (pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method != "GET" && req.method != "HEAD") {
|
||||||
|
set req.http.X-Pass-Reason = "method";
|
||||||
|
return (pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.http.Authorization) {
|
||||||
|
set req.http.X-Pass-Reason = "auth";
|
||||||
|
return (pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
# jeśli chcesz PASS przy cookie:
|
||||||
|
# if (req.http.Cookie) {
|
||||||
|
# set req.http.X-Pass-Reason = "cookie";
|
||||||
|
# return (pass);
|
||||||
|
# }
|
||||||
|
|
||||||
|
return (hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===== PIPE (WebSocket passthrough) =====
|
||||||
|
sub vcl_pipe {
|
||||||
|
if (req.http.Upgrade) {
|
||||||
|
set bereq.http.Upgrade = req.http.Upgrade;
|
||||||
|
set bereq.http.Connection = req.http.Connection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===== HASH =====
|
||||||
|
sub vcl_hash {
|
||||||
|
hash_data(req.url);
|
||||||
|
if (req.http.host) { hash_data(req.http.host); } else { hash_data(server.ip); }
|
||||||
|
|
||||||
|
# Cookie: zostają dla dynamicznych (dla statyków wyczyszczone wcześniej)
|
||||||
|
if (req.http.Cookie) { hash_data(req.http.Cookie); }
|
||||||
|
|
||||||
|
# Accept-Encoding: już znormalizowany do zstd/br/gzip/identity
|
||||||
|
if (req.http.Accept-Encoding) { hash_data(req.http.Accept-Encoding); }
|
||||||
|
|
||||||
|
# (Opcjonalnie) sygnał obrazów z negocjacją po Accept
|
||||||
|
if (req.http.X-Accept-Image) { hash_data(req.http.X-Accept-Image); }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===== BACKEND_RESPONSE =====
|
||||||
|
sub vcl_backend_response {
|
||||||
|
# Zakaz cache – respektujemy
|
||||||
|
if (beresp.http.Cache-Control ~ "(?i)no-store|private") {
|
||||||
|
set beresp.uncacheable = true;
|
||||||
|
set beresp.ttl = 0s;
|
||||||
|
set beresp.http.X-Pass-Reason = "no-store";
|
||||||
|
return (deliver);
|
||||||
|
}
|
||||||
|
|
||||||
|
# NIE cache'uj redirectów do loginu (HTML) z backendu
|
||||||
|
if (beresp.status >= 300 && beresp.status < 400) {
|
||||||
|
set beresp.uncacheable = true;
|
||||||
|
set beresp.ttl = 0s;
|
||||||
|
set beresp.http.X-Pass-Reason = "redirect";
|
||||||
|
return (deliver);
|
||||||
|
}
|
||||||
|
|
||||||
|
# Nie cache'uj statyków, jeśli status ≠ 200
|
||||||
|
if (bereq.url ~ "^/static/" ||
|
||||||
|
bereq.url ~ "\.(css|js|png|jpe?g|webp|svg|ico|woff2?)($|\?)") {
|
||||||
|
if (beresp.status != 200) {
|
||||||
|
set beresp.uncacheable = true;
|
||||||
|
set beresp.ttl = 0s;
|
||||||
|
return (deliver);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Jeśli pod .js przychodzi text/html — też nie cache'uj (to zwykle redirect/login)
|
||||||
|
if (bereq.url ~ "\.js(\?.*)?$" && beresp.http.Content-Type ~ "(?i)text/html") {
|
||||||
|
set beresp.uncacheable = true;
|
||||||
|
set beresp.ttl = 0s;
|
||||||
|
return (deliver);
|
||||||
|
}
|
||||||
|
|
||||||
|
# Wymuś poprawny Content-Type dla .js/.css, gdy backend zwróci HTML
|
||||||
|
if (bereq.url ~ "\.js(\?.*)?$") {
|
||||||
|
if (!beresp.http.Content-Type || beresp.http.Content-Type ~ "(?i)text/html") {
|
||||||
|
set beresp.http.Content-Type = "application/javascript; charset=utf-8";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bereq.url ~ "\.css(\?.*)?$") {
|
||||||
|
if (!beresp.http.Content-Type || beresp.http.Content-Type ~ "(?i)text/html") {
|
||||||
|
set beresp.http.Content-Type = "text/css; charset=utf-8";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- STATYCZNE: zdejmij Set-Cookie i Vary: Cookie, zapewnij TTL ----
|
||||||
|
if (bereq.url ~ "^/static/" || bereq.url ~ "\.(css|js|png|jpe?g|webp|svg|ico|woff2?)$") {
|
||||||
|
unset beresp.http.Set-Cookie;
|
||||||
|
|
||||||
|
# Jeśli backend dodał Vary: Cookie, usuńmy ten element (nie wpływa na statyki)
|
||||||
|
if (beresp.http.Vary) {
|
||||||
|
set beresp.http.Vary = regsuball(beresp.http.Vary, "(?i)(^|,)[[:space:]]*Cookie[[:space:]]*(,|$)", "\1");
|
||||||
|
set beresp.http.Vary = regsuball(beresp.http.Vary, ",[[:space:]]*,", ",");
|
||||||
|
set beresp.http.Vary = regsub(beresp.http.Vary, "^[[:space:]]*,[[:space:]]*", "");
|
||||||
|
set beresp.http.Vary = regsub(beresp.http.Vary, "[[:space:]]*,[[:space:]]*$", "");
|
||||||
|
if (beresp.http.Vary ~ "^[[:space:]]*$") { unset beresp.http.Vary; }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Jeśli brak kontroli czasu życia – ustawiamy twarde wartości
|
||||||
|
if (!(beresp.http.Cache-Control ~ "(?i)(s-maxage|max-age)")) {
|
||||||
|
set beresp.ttl = 24h;
|
||||||
|
set beresp.http.Cache-Control = "public, max-age=86400, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
set beresp.grace = 1h;
|
||||||
|
set beresp.keep = 24h;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Ogólne TTL z nagłówków ----
|
||||||
|
if (beresp.http.Cache-Control ~ "(?i)s-maxage=([0-9]+)") {
|
||||||
|
set beresp.ttl = std.duration(regsub(beresp.http.Cache-Control, "(?i).*s-maxage=([0-9]+).*", "\1") + "s", 0s);
|
||||||
|
} else if (beresp.http.Cache-Control ~ "(?i)max-age=([0-9]+)") {
|
||||||
|
set beresp.ttl = std.duration(regsub(beresp.http.Cache-Control, "(?i).*max-age=([0-9]+).*", "\1") + "s", 0s);
|
||||||
|
} else if (beresp.http.Expires) {
|
||||||
|
set beresp.ttl = std.time(beresp.http.Expires, now) - now;
|
||||||
|
if (beresp.ttl < 0s) { set beresp.ttl = 0s; }
|
||||||
|
} else {
|
||||||
|
if (beresp.ttl <= 0s) { set beresp.ttl = 60s; }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Immutable => dłuższe grace/keep
|
||||||
|
if (beresp.http.Cache-Control ~ "(?i)immutable") {
|
||||||
|
set beresp.grace = 1h;
|
||||||
|
set beresp.keep = 24h;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Kompresja po stronie Varnisha wyłącznie dla klientów akceptujących gzip
|
||||||
|
# i tylko jeśli backend nie dostarczył już Content-Encoding.
|
||||||
|
if (!beresp.http.Content-Encoding && bereq.http.Accept-Encoding ~ "gzip") {
|
||||||
|
# Kompresujemy tylko „tekstowe” typy; wykluczamy WASM
|
||||||
|
if (beresp.http.Content-Type ~ "(?i)text/|application/(javascript|json|xml)") {
|
||||||
|
set beresp.do_gzip = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Duże odpowiedzi streamujemy
|
||||||
|
if (beresp.http.Content-Length && std.integer(beresp.http.Content-Length, 0) > 1048576) {
|
||||||
|
set beresp.do_stream = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# (Opcjonalnie) Serwuj „stale” przy błędach backendu, jeśli jest obiekt w grace
|
||||||
|
sub vcl_backend_error {
|
||||||
|
return (deliver);
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===== DELIVER =====
|
||||||
|
sub vcl_deliver {
|
||||||
|
if (obj.uncacheable) {
|
||||||
|
if (req.http.X-Pass-Reason) {
|
||||||
|
set resp.http.X-Cache = "PASS:" + req.http.X-Pass-Reason;
|
||||||
|
} else if (resp.http.X-Pass-Reason) { # z backendu
|
||||||
|
set resp.http.X-Cache = "PASS:" + resp.http.X-Pass-Reason;
|
||||||
|
} else {
|
||||||
|
set resp.http.X-Cache = "PASS";
|
||||||
|
}
|
||||||
|
unset resp.http.X-Pass-Reason;
|
||||||
|
unset resp.http.Age;
|
||||||
|
} else if (obj.hits > 0) {
|
||||||
|
set resp.http.X-Cache = "HIT";
|
||||||
|
} else {
|
||||||
|
set resp.http.X-Cache = "MISS";
|
||||||
|
unset resp.http.Age;
|
||||||
|
}
|
||||||
|
|
||||||
|
unset resp.http.Via;
|
||||||
|
unset resp.http.X-Varnish;
|
||||||
|
unset resp.http.Server;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub vcl_synth {
|
||||||
|
set resp.http.X-Cache = "SYNTH";
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===== PURGE HANDLER =====
|
||||||
|
sub vcl_purge {
|
||||||
|
return (synth(200, "Purged"));
|
||||||
|
}
|
||||||
86
deploy_docker.sh
Executable file
86
deploy_docker.sh
Executable file
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# --- Wczytaj zmienne z .env ---
|
||||||
|
if [[ -f .env ]]; then
|
||||||
|
set -a
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
APP_PORT="${APP_PORT:-8080}"
|
||||||
|
|
||||||
|
REPO_DIR="${REPO_DIR:-$(pwd)}"
|
||||||
|
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
|
||||||
|
GIT_REMOTE="${GIT_REMOTE:-origin}"
|
||||||
|
GIT_BRANCH="${GIT_BRANCH:-$(git -C "$REPO_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)}"
|
||||||
|
|
||||||
|
# Domyślny profil
|
||||||
|
PROFILE="${1:-sqlite}"
|
||||||
|
|
||||||
|
if [[ "$PROFILE" != "pgsql" && "$PROFILE" != "mysql" && "$PROFILE" != "sqlite" ]]; then
|
||||||
|
echo "Użycie: $0 {pgsql|mysql|sqlite}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SERVICES=()
|
||||||
|
if [[ $# -gt 1 ]]; then
|
||||||
|
SERVICES=("${@:2}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
log() { printf "\n==> %s\n" "$*"; }
|
||||||
|
|
||||||
|
command -v git >/dev/null || { echo "Brak 'git' w PATH"; exit 1; }
|
||||||
|
command -v docker >/dev/null || { echo "Brak 'docker' w PATH"; exit 1; }
|
||||||
|
if ! docker compose version >/dev/null 2>&1; then
|
||||||
|
echo "Wymagany jest 'docker compose' (plugin), nie stary 'docker-compose'."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$REPO_DIR/$COMPOSE_FILE" ]]; then
|
||||||
|
if [[ -f "$REPO_DIR/compose.yaml" ]]; then
|
||||||
|
COMPOSE_FILE="compose.yaml"
|
||||||
|
else
|
||||||
|
echo "Nie znaleziono pliku Compose w: $REPO_DIR/$COMPOSE_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
|
||||||
|
log "Aktualizacja repo: git pull --ff-only ($GIT_REMOTE/$GIT_BRANCH)"
|
||||||
|
git fetch --prune "$GIT_REMOTE"
|
||||||
|
git checkout "$GIT_BRANCH" >/dev/null 2>&1 || true
|
||||||
|
git pull --ff-only "$GIT_REMOTE" "$GIT_BRANCH"
|
||||||
|
|
||||||
|
log "Zapisywanie hasha commita do version.txt"
|
||||||
|
git rev-parse --short HEAD > version.txt
|
||||||
|
|
||||||
|
log "Docker Compose DOWN"
|
||||||
|
docker compose --profile "$PROFILE" stop
|
||||||
|
|
||||||
|
log "Generowanie default.vcl z APP_PORT=$APP_PORT"
|
||||||
|
envsubst < deploy/varnish/default.vcl.template > deploy/varnish/default.vcl
|
||||||
|
|
||||||
|
# Tworzenie katalogów danych dla baz jeśli brak
|
||||||
|
if [[ "$PROFILE" == "pgsql" ]]; then
|
||||||
|
if [[ ! -d "./db/pgsql" ]]; then
|
||||||
|
log "Tworzę katalog ./db/pgsql dla danych PostgreSQL"
|
||||||
|
mkdir -p ./db/pgsql
|
||||||
|
fi
|
||||||
|
elif [[ "$PROFILE" == "mysql" ]]; then
|
||||||
|
if [[ ! -d "./db/mysql" ]]; then
|
||||||
|
log "Tworzę katalog ./db/mysql dla danych MySQL"
|
||||||
|
mkdir -p ./db/mysql
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Docker Compose UP (build bez deps) dla profilu: $PROFILE i serwisów: ${SERVICES[*]:-(wszystkie)}"
|
||||||
|
|
||||||
|
if [[ ${#SERVICES[@]} -gt 0 ]]; then
|
||||||
|
DB_ENGINE="$PROFILE" docker compose -f "$COMPOSE_FILE" --profile "$PROFILE" up -d --no-deps --build "${SERVICES[@]}"
|
||||||
|
else
|
||||||
|
DB_ENGINE="$PROFILE" docker compose -f "$COMPOSE_FILE" --profile "$PROFILE" up -d --no-deps --build
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Gotowe ✅ (wersja: $(cat version.txt))"
|
||||||
@@ -1,12 +1,80 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
build: .
|
||||||
context: .
|
container_name: zbiorka-app
|
||||||
dockerfile: Dockerfile
|
#ports:
|
||||||
ports:
|
# - "${APP_PORT:-8080}:${APP_PORT}"
|
||||||
- "8080:8080"
|
expose:
|
||||||
|
- "${APP_PORT}"
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"python",
|
||||||
|
"-c",
|
||||||
|
"import urllib.request; import sys; req = urllib.request.Request('http://localhost:${APP_PORT:-8080}/healthcheck', headers={'X-Internal-Check': '${HEALTHCHECK_TOKEN}'}); sys.exit(0) if urllib.request.urlopen(req).getcode() == 200 else sys.exit(1)",
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./instance:/app/instance
|
- ./instance:/app/instance
|
||||||
|
networks:
|
||||||
|
- zbiorki_app_network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
varnish:
|
||||||
|
#build: ./deploy/varnish
|
||||||
|
image: varnish:latest
|
||||||
|
container_name: zbiorka-varnish
|
||||||
|
depends_on:
|
||||||
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "${APP_PORT:-8080}:80"
|
||||||
|
volumes:
|
||||||
|
- ./deploy/varnish/default.vcl:/etc/varnish/default.vcl:ro
|
||||||
|
environment:
|
||||||
|
- VARNISH_SIZE=256m
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
networks:
|
||||||
|
- zbiorki_app_network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
mysql:
|
||||||
|
image: mysql:8
|
||||||
|
container_name: zbiorka-mysql-db
|
||||||
|
environment:
|
||||||
|
MYSQL_DATABASE: ${DB_NAME}
|
||||||
|
MYSQL_USER: ${DB_USER}
|
||||||
|
MYSQL_PASSWORD: ${DB_PASSWORD}
|
||||||
|
MYSQL_ROOT_PASSWORD: 89o38kUX5T4C
|
||||||
|
volumes:
|
||||||
|
- ./db/mysql:/var/lib/mysql
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- zbiorki_app_network
|
||||||
|
profiles: ["mysql"]
|
||||||
|
|
||||||
|
pgsql:
|
||||||
|
image: postgres:18
|
||||||
|
container_name: zbiorka-pgsql-db
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${DB_NAME}
|
||||||
|
POSTGRES_USER: ${DB_USER}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
PGDATA: /var/lib/postgresql/
|
||||||
|
volumes:
|
||||||
|
- ./db/pgsql:/var/lib/postgresql
|
||||||
|
networks:
|
||||||
|
- zbiorki_app_network
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: ["pgsql"]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
zbiorki_app_network:
|
||||||
|
driver: bridge
|
||||||
|
|||||||
3
emergency_access.txt
Normal file
3
emergency_access.txt
Normal 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
|
||||||
@@ -3,4 +3,7 @@ Flask-SQLAlchemy
|
|||||||
Flask-Login
|
Flask-Login
|
||||||
Werkzeug
|
Werkzeug
|
||||||
waitress
|
waitress
|
||||||
markdown
|
markdown
|
||||||
|
psycopg2-binary # pgsql
|
||||||
|
pymysql # mysql
|
||||||
|
cryptography # mysql8
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from app import app, db, create_admin_account
|
import os
|
||||||
|
|
||||||
from waitress import serve
|
from waitress import serve
|
||||||
|
|
||||||
|
from app import app, init_database_with_retry
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
with app.app_context():
|
init_database_with_retry(app, raise_on_failure=False)
|
||||||
db.create_all()
|
port = int(os.environ.get("APP_PORT", 8080))
|
||||||
create_admin_account()
|
serve(app, host="0.0.0.0", port=port)
|
||||||
serve(app, host='0.0.0.0', port=8080)
|
|
||||||
|
|||||||
@@ -1,168 +0,0 @@
|
|||||||
/* Import czcionki Roboto */
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap');
|
|
||||||
|
|
||||||
/* Globalne */
|
|
||||||
body {
|
|
||||||
font-family: 'Roboto', sans-serif;
|
|
||||||
background-color: #121212;
|
|
||||||
color: #dcdcdc;
|
|
||||||
padding-top: 1vh;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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 {
|
|
||||||
color: #ffc107;
|
|
||||||
transition: color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
color: #ffeb3b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Progress Bar */
|
|
||||||
.progress {
|
|
||||||
background-color: #2a2a2a;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
height: 35px;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar {
|
|
||||||
background: linear-gradient(90deg, #ffc107, #ffeb3b);
|
|
||||||
font-weight: bold;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
animation: progressAnimation 1s ease-in-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes progressAnimation {
|
|
||||||
from { width: 0%; }
|
|
||||||
to { width: var(--progress-width); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Alerty (flash messages) */
|
|
||||||
.alert {
|
|
||||||
opacity: 0;
|
|
||||||
animation: fadeIn 0.5s forwards;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dodatkowe marginesy */
|
|
||||||
.container {
|
|
||||||
padding: 0 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsywność */
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.card {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.card-title {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 { font-size: 2rem; margin-bottom: 1rem; }
|
|
||||||
h2 { font-size: 1.7rem; margin-bottom: 0.8rem; }
|
|
||||||
h3 { font-size: 0.9rem; margin-bottom: 0.6rem; }
|
|
||||||
|
|
||||||
/* Wspomóż */
|
|
||||||
.card.wspomoz-card {
|
|
||||||
border: 1px solid #ffc107 !important;
|
|
||||||
border-radius: 0.2rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card.wspomoz-card .card-body,
|
|
||||||
.card.wspomoz-card .card-title,
|
|
||||||
.card.wspomoz-card .card-text {
|
|
||||||
color: #ffffff !important;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:focus,
|
|
||||||
.btn-primary:active,
|
|
||||||
.btn-primary.focus,
|
|
||||||
.btn-primary.active {
|
|
||||||
background-color: #2d2c2c !important;
|
|
||||||
border-color: #ffeb3b !important;
|
|
||||||
color: #ffffff !important;
|
|
||||||
box-shadow: none;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% block title %}Panel Admina{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="container my-4">
|
|
||||||
<h2 class="mb-4">Panel Admina</h2>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<a href="{{ url_for('dodaj_zbiorka') }}" class="btn btn-success">Dodaj zbiórkę</a>
|
|
||||||
<a href="{{ url_for('admin_settings') }}" class="btn btn-primary">Ustawienia</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tabela zbiórek aktywnych -->
|
|
||||||
<h4>Aktywne zbiórki</h4>
|
|
||||||
<div class="table-responsive mb-5">
|
|
||||||
<table class="table table-dark table-striped table-hover">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>Nazwa</th>
|
|
||||||
<th>Widoczność</th>
|
|
||||||
<th>Opcje</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for z in active_zbiorki %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ z.id }}</td>
|
|
||||||
<td>{{ z.nazwa }}</td>
|
|
||||||
<td>
|
|
||||||
{% if z.ukryta %}
|
|
||||||
<span class="badge bg-secondary">Ukryta</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-success">Widoczna</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<a href="{{ url_for('edytuj_zbiorka', zbiorka_id=z.id) }}" class="btn btn-primary btn-sm">Edytuj</a>
|
|
||||||
<a href="{{ url_for('admin_dodaj_wplate', zbiorka_id=z.id) }}" class="btn btn-warning btn-sm">Dodaj wpłatę</a>
|
|
||||||
<a href="{{ url_for('edytuj_stan', zbiorka_id=z.id) }}" class="btn btn-info btn-sm">Edytuj stan</a>
|
|
||||||
<!-- Przycisk do oznaczenia jako zrealizowana -->
|
|
||||||
<form action="{{ url_for('oznacz_zbiorka', zbiorka_id=z.id) }}" method="post" style="display: inline;">
|
|
||||||
<button type="submit" class="btn btn-warning btn-sm">Oznacz jako zrealizowana</button>
|
|
||||||
</form>
|
|
||||||
<form action="{{ url_for('toggle_visibility', zbiorka_id=z.id) }}" method="post" style="display: inline;">
|
|
||||||
<button type="submit" class="btn btn-secondary btn-sm">
|
|
||||||
{% if z.ukryta %} Pokaż {% else %} Ukryj {% endif %}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<form action="{{ url_for('usun_zbiorka', zbiorka_id=z.id) }}" method="post" style="display: inline;">
|
|
||||||
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Czy na pewno chcesz usunąć tę zbiórkę?');">Usuń</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="4" class="text-center">Brak aktywnych zbiórek</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tabela zbiórek zrealizowanych -->
|
|
||||||
<h4>Zrealizowane zbiórki</h4>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-dark table-striped table-hover">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>Nazwa</th>
|
|
||||||
<th>Widoczność</th>
|
|
||||||
<th>Opcje</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for z in completed_zbiorki %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ z.id }}</td>
|
|
||||||
<td>{{ z.nazwa }}</td>
|
|
||||||
<td>
|
|
||||||
{% if z.ukryta %}
|
|
||||||
<span class="badge bg-secondary">Ukryta</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-success">Widoczna</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<a href="{{ url_for('edytuj_zbiorka', zbiorka_id=z.id) }}" class="btn btn-primary btn-sm">Edytuj</a>
|
|
||||||
<a href="{{ url_for('admin_dodaj_wplate', zbiorka_id=z.id) }}" class="btn btn-warning btn-sm">Dodaj wpłatę</a>
|
|
||||||
<a href="{{ url_for('edytuj_stan', zbiorka_id=z.id) }}" class="btn btn-info btn-sm">Edytuj stan</a>
|
|
||||||
<form action="{{ url_for('toggle_visibility', zbiorka_id=z.id) }}" method="post" style="display: inline;">
|
|
||||||
<button type="submit" class="btn btn-secondary btn-sm">
|
|
||||||
{% if z.ukryta %} Pokaż {% else %} Ukryj {% endif %}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<form action="{{ url_for('usun_zbiorka', zbiorka_id=z.id) }}" method="post" style="display: inline;">
|
|
||||||
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Czy na pewno chcesz usunąć tę zbiórkę?');">Usuń</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="4" class="text-center">Brak zbiórek zrealizowanych</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% block title %}Edytuj stan zbiórki{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="container my-4">
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-header bg-info text-white">
|
|
||||||
<h3 class="card-title mb-0">Edytuj stan zbiórki: {{ zbiorka.nazwa }}</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form method="post">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="stan" class="form-label">Nowy stan zbiórki (PLN)</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text">PLN</span>
|
|
||||||
<input type="number" step="0.01" class="form-control" id="stan" name="stan" value="{{ zbiorka.stan|round(2) }}" required>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-info">Aktualizuj stan</button>
|
|
||||||
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-secondary">Powrót</a>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,54 +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>
|
|
||||||
<button type="button" class="btn btn-sm btn-light text-dark" onclick="dodajMojeIP()">Dodaj moje IP</button>
|
|
||||||
</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>
|
|
||||||
</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 %}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<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>
|
|
||||||
<div class="collapse navbar-collapse">
|
|
||||||
<ul class="navbar-nav ms-auto">
|
|
||||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('index') }}">Aktualne zbiórki</a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('zbiorki_zrealizowane') }}">Zrealizowane zbiórki</a></li>
|
|
||||||
{% 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('logout') }}">Wyloguj</a></li>
|
|
||||||
{% else %}
|
|
||||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('login') }}">Zaloguj</a></li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
<div class="container mt-4">
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
||||||
{% if messages %}
|
|
||||||
{% for category, message in messages %}
|
|
||||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</div>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% block title %}{% if request.path == url_for('zbiorki_zrealizowane') %}Zrealizowane zbiórki{% else %}Aktualnie aktywne zbiórki{% endif %}{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
{% if request.path == url_for('zbiorki_zrealizowane') %}
|
|
||||||
<h2 class="mb-4">Zrealizowane zbiórki</h2>
|
|
||||||
{% else %}
|
|
||||||
<h2 class="mb-4">Aktualnie aktywne zbiórki</h2>
|
|
||||||
{% endif %}
|
|
||||||
<div class="row">
|
|
||||||
{% for z in zbiorki %}
|
|
||||||
<div class="col-sm-12 col-md-6 col-lg-4 mb-4">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">{{ z.nazwa }}</h5>
|
|
||||||
{% set progress = (z.stan / z.cel * 100) if z.cel > 0 else 0 %}
|
|
||||||
<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>
|
|
||||||
<a href="{{ url_for('zbiorka', zbiorka_id=z.id) }}" class="btn btn-primary btn-sm">Szczegóły</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p>Brak zbiórek</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% block title %}Logowanie{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="container my-4">
|
|
||||||
<h3 class="mb-4">Logowanie</h3>
|
|
||||||
<form method="post">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="username" class="form-label">Nazwa użytkownika</label>
|
|
||||||
<input type="text" class="form-control" id="username" name="username" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="password" class="form-label">Hasło</label>
|
|
||||||
<input type="password" class="form-control" id="password" name="password" required>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary">Zaloguj</button>
|
|
||||||
</form>
|
|
||||||
<p class="mt-3">Nie masz konta? <a href="{{ url_for('register') }}">Zarejestruj się</a></p>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% block title %}Rejestracja{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="container my-4">
|
|
||||||
<h3 class="mb-4">Rejestracja</h3>
|
|
||||||
<form method="post">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="username" class="form-label">Nazwa użytkownika</label>
|
|
||||||
<input type="text" class="form-control" id="username" name="username" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="password" class="form-label">Hasło</label>
|
|
||||||
<input type="password" class="form-control" id="password" name="password" required>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary">Zarejestruj się</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% block title %}{{ zbiorka.nazwa }}{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<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>
|
|
||||||
<!-- Prawa kolumna: sekcja "Wspomóż" -->
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card wspomoz-card mb-3">
|
|
||||||
<div class="card-body">
|
|
||||||
|
|
||||||
<p class="card-text">
|
|
||||||
<strong>Numer konta:</strong>
|
|
||||||
<span class="fs-4">{{ zbiorka.numer_konta }}</span>
|
|
||||||
</p>
|
|
||||||
<p class="card-text">
|
|
||||||
<strong>Telefon BLIK:</strong>
|
|
||||||
<span class="fs-4">{{ zbiorka.numer_telefonu_blik }}</span>
|
|
||||||
</p>
|
|
||||||
{% if not zbiorka.ukryj_kwote %}
|
|
||||||
<hr>
|
|
||||||
<p class="card-text">
|
|
||||||
<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 %}
|
|
||||||
<a href="{{ url_for('index') }}" class="btn btn-primary">Powrót do listy</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Karta z historią wpłat -->
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-header bg-secondary text-white">
|
|
||||||
<h3 class="card-title mb-0">Historia wpłat</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
{% if zbiorka.wplaty|length > 0 %}
|
|
||||||
<ul class="list-group">
|
|
||||||
{% for w in zbiorka.wplaty %}
|
|
||||||
<li class="list-group-item">
|
|
||||||
<strong>{{ w.data.strftime('%Y-%m-%d %H:%M:%S') }}</strong> – {{ w.kwota|round(2) }} PLN
|
|
||||||
{% if w.opis %}
|
|
||||||
<em class="text-muted">({{ w.opis }})</em>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-center">Aktualnie brak wpłat..</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
43
zbiorka_app/__init__.py
Normal file
43
zbiorka_app/__init__.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
|
||||||
|
from .cli import register_cli_commands
|
||||||
|
from .errors import register_error_handlers
|
||||||
|
from .extensions import db, login_manager
|
||||||
|
from .routes import register_routes
|
||||||
|
from .utils import asset_url, init_version, init_database_with_retry
|
||||||
|
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
app = Flask(
|
||||||
|
__name__,
|
||||||
|
template_folder="templates",
|
||||||
|
static_folder="static",
|
||||||
|
static_url_path="/static",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.config.from_object(Config)
|
||||||
|
|
||||||
|
db.init_app(app)
|
||||||
|
login_manager.init_app(app)
|
||||||
|
login_manager.login_view = "zaloguj"
|
||||||
|
|
||||||
|
init_version(app)
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_asset_helpers():
|
||||||
|
return {"asset_url": asset_url}
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def ensure_db_ready_before_request():
|
||||||
|
if app.extensions.get("database_ready") is True:
|
||||||
|
return None
|
||||||
|
init_database_with_retry(app, max_attempts=1, delay=0, raise_on_failure=False)
|
||||||
|
return None
|
||||||
|
|
||||||
|
register_routes(app)
|
||||||
|
register_error_handlers(app)
|
||||||
|
register_cli_commands(app)
|
||||||
|
|
||||||
|
return app
|
||||||
79
zbiorka_app/cli.py
Normal file
79
zbiorka_app/cli.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import click
|
||||||
|
from flask.cli import with_appcontext
|
||||||
|
|
||||||
|
from .extensions import db
|
||||||
|
from .utils import (
|
||||||
|
ensure_database_ready,
|
||||||
|
init_database_with_retry,
|
||||||
|
safe_db_rollback,
|
||||||
|
set_login_hosts,
|
||||||
|
set_user_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_cli_commands(app):
|
||||||
|
@app.cli.command("init-db")
|
||||||
|
@click.option("--attempts", default=20, type=int, show_default=True)
|
||||||
|
@click.option("--delay", default=3, type=int, show_default=True)
|
||||||
|
def init_db_command(attempts: int, delay: int):
|
||||||
|
"""Inicjalizuje baze i czeka az bedzie gotowa."""
|
||||||
|
ok = init_database_with_retry(app, max_attempts=attempts, delay=delay, raise_on_failure=False)
|
||||||
|
if not ok:
|
||||||
|
raise click.ClickException("Nie udalo sie zainicjalizowac bazy danych.")
|
||||||
|
click.echo("Baza danych gotowa.")
|
||||||
|
|
||||||
|
@app.cli.command("set-admin-password")
|
||||||
|
@click.option("--username", default="admin", show_default=True)
|
||||||
|
@click.option("--password", prompt=True, hide_input=True, confirmation_prompt=True)
|
||||||
|
@with_appcontext
|
||||||
|
def set_admin_password_command(username: str, password: str):
|
||||||
|
"""Ustawia haslo administratora."""
|
||||||
|
try:
|
||||||
|
ensure_database_ready(create_schema=True, create_admin=True)
|
||||||
|
user = set_user_password(username=username, password=password, is_admin=True)
|
||||||
|
click.echo(f"Haslo admina ustawione dla uzytkownika: {user.uzytkownik}")
|
||||||
|
except Exception as exc:
|
||||||
|
safe_db_rollback()
|
||||||
|
raise click.ClickException(str(exc))
|
||||||
|
|
||||||
|
@app.cli.command("set-user-password")
|
||||||
|
@click.argument("username")
|
||||||
|
@click.option("--password", prompt=True, hide_input=True, confirmation_prompt=True)
|
||||||
|
@click.option("--admin/--no-admin", default=None)
|
||||||
|
@with_appcontext
|
||||||
|
def set_user_password_command(username: str, password: str, admin: bool | None):
|
||||||
|
"""Tworzy uzytkownika lub zmienia jego haslo."""
|
||||||
|
try:
|
||||||
|
ensure_database_ready(create_schema=True, create_admin=True)
|
||||||
|
user = set_user_password(username=username, password=password, is_admin=admin)
|
||||||
|
click.echo(f"Haslo ustawione dla uzytkownika: {user.uzytkownik}")
|
||||||
|
except Exception as exc:
|
||||||
|
safe_db_rollback()
|
||||||
|
raise click.ClickException(str(exc))
|
||||||
|
|
||||||
|
@app.cli.command("set-login-hosts")
|
||||||
|
@click.argument("hosts", required=False, default="")
|
||||||
|
@with_appcontext
|
||||||
|
def set_login_hosts_command(hosts: str):
|
||||||
|
"""Ustawia dozwolone hosty lub IP do logowania."""
|
||||||
|
try:
|
||||||
|
ensure_database_ready(create_schema=True, create_admin=True)
|
||||||
|
settings = set_login_hosts(hosts)
|
||||||
|
click.echo(
|
||||||
|
"Dozwolone hosty logowania ustawione na: "
|
||||||
|
f"{settings.dozwolone_hosty_logowania or '(brak - logowanie zablokowane)'}"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
safe_db_rollback()
|
||||||
|
raise click.ClickException(str(exc))
|
||||||
|
|
||||||
|
@app.cli.command("db-ping")
|
||||||
|
@with_appcontext
|
||||||
|
def db_ping_command():
|
||||||
|
"""Sprawdza polaczenie z baza danych."""
|
||||||
|
try:
|
||||||
|
ensure_database_ready(create_schema=False, create_admin=False)
|
||||||
|
click.echo("OK")
|
||||||
|
except Exception as exc:
|
||||||
|
safe_db_rollback()
|
||||||
|
raise click.ClickException(f"DB ERROR: {exc}")
|
||||||
114
zbiorka_app/errors.py
Normal file
114
zbiorka_app/errors.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from flask import jsonify, render_template, request
|
||||||
|
from jinja2 import TemplateNotFound
|
||||||
|
from sqlalchemy.exc import OperationalError
|
||||||
|
from werkzeug.exceptions import HTTPException
|
||||||
|
|
||||||
|
from .extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_rollback() -> None:
|
||||||
|
try:
|
||||||
|
db.session.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _wants_json_response() -> bool:
|
||||||
|
if request.path.startswith("/api/"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
best = request.accept_mimetypes.best_match(["application/json", "text/html"])
|
||||||
|
if not best:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return (
|
||||||
|
best == "application/json"
|
||||||
|
and request.accept_mimetypes["application/json"]
|
||||||
|
>= request.accept_mimetypes["text/html"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_status_phrase(status_code: int) -> str:
|
||||||
|
try:
|
||||||
|
return HTTPStatus(status_code).phrase
|
||||||
|
except ValueError:
|
||||||
|
return "Blad"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_status_description(status_code: int) -> str:
|
||||||
|
try:
|
||||||
|
return HTTPStatus(status_code).description
|
||||||
|
except ValueError:
|
||||||
|
return "Wystapil blad podczas przetwarzania zadania."
|
||||||
|
|
||||||
|
|
||||||
|
def _error_headers(status_code: int) -> dict[str, str]:
|
||||||
|
headers = {}
|
||||||
|
|
||||||
|
if status_code >= 500:
|
||||||
|
headers.update(
|
||||||
|
{
|
||||||
|
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0, private",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Expires": "0",
|
||||||
|
"Surrogate-Control": "no-store",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _render_error(status_code: int, message: str | None = None):
|
||||||
|
phrase = _get_status_phrase(status_code)
|
||||||
|
description = message or _get_status_description(status_code)
|
||||||
|
headers = _error_headers(status_code)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"status": status_code,
|
||||||
|
"error": phrase,
|
||||||
|
"message": description,
|
||||||
|
}
|
||||||
|
|
||||||
|
if _wants_json_response():
|
||||||
|
return jsonify(payload), status_code, headers
|
||||||
|
|
||||||
|
try:
|
||||||
|
return (
|
||||||
|
render_template(
|
||||||
|
"error.html",
|
||||||
|
error_code=status_code,
|
||||||
|
error_name=phrase,
|
||||||
|
error_message=description,
|
||||||
|
),
|
||||||
|
status_code,
|
||||||
|
headers,
|
||||||
|
)
|
||||||
|
except TemplateNotFound:
|
||||||
|
return (
|
||||||
|
f"{status_code} {phrase}: {description}",
|
||||||
|
status_code,
|
||||||
|
headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_error_handlers(app):
|
||||||
|
@app.errorhandler(HTTPException)
|
||||||
|
def handle_http_exception(exc):
|
||||||
|
return _render_error(exc.code or 500, exc.description)
|
||||||
|
|
||||||
|
@app.errorhandler(OperationalError)
|
||||||
|
def handle_operational_error(exc):
|
||||||
|
_safe_rollback()
|
||||||
|
app.logger.exception("Blad polaczenia z baza danych: %s", exc)
|
||||||
|
return _render_error(
|
||||||
|
503,
|
||||||
|
"Baza danych jest chwilowo niedostepna. Sprobuj ponownie za chwile.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.errorhandler(Exception)
|
||||||
|
def handle_unexpected_error(exc):
|
||||||
|
_safe_rollback()
|
||||||
|
app.logger.exception("Nieobsluzony wyjatek: %s", exc)
|
||||||
|
return _render_error(500, "Wystapil nieoczekiwany blad serwera.")
|
||||||
5
zbiorka_app/extensions.py
Normal file
5
zbiorka_app/extensions.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from flask_login import LoginManager
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
login_manager = LoginManager()
|
||||||
146
zbiorka_app/models.py
Normal file
146
zbiorka_app/models.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from flask_login import UserMixin
|
||||||
|
from sqlalchemy import Numeric
|
||||||
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
|
from .extensions import db
|
||||||
|
|
||||||
|
class Uzytkownik(UserMixin, db.Model):
|
||||||
|
__tablename__ = "uzytkownik"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
uzytkownik = db.Column(db.String(80), unique=True, nullable=False)
|
||||||
|
haslo_hash = db.Column(db.String(128), nullable=False)
|
||||||
|
czy_admin = db.Column(db.Boolean, default=False)
|
||||||
|
|
||||||
|
def set_password(self, password):
|
||||||
|
self.haslo_hash = generate_password_hash(password)
|
||||||
|
def check_password(self, password):
|
||||||
|
return check_password_hash(self.haslo_hash, password)
|
||||||
|
|
||||||
|
class Zbiorka(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
nazwa = db.Column(db.String(100), nullable=False)
|
||||||
|
opis = db.Column(db.Text, nullable=False)
|
||||||
|
numer_konta = db.Column(db.String(50), nullable=True)
|
||||||
|
numer_telefonu_blik = db.Column(db.String(50), nullable=True)
|
||||||
|
cel = db.Column(Numeric(12, 2), nullable=False, default=0)
|
||||||
|
stan = db.Column(Numeric(12, 2), default=0)
|
||||||
|
ukryta = db.Column(db.Boolean, default=False)
|
||||||
|
ukryj_kwote = db.Column(db.Boolean, default=False)
|
||||||
|
zrealizowana = db.Column(db.Boolean, default=False)
|
||||||
|
pokaz_postep_finanse = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
pokaz_postep_pozycje = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
pokaz_postep_kwotowo = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
uzyj_konta = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
uzyj_blik = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
typ_zbiorki = db.Column(db.String(20), default="standardowa", nullable=False)
|
||||||
|
|
||||||
|
wplaty = db.relationship(
|
||||||
|
"Wplata",
|
||||||
|
back_populates="zbiorka",
|
||||||
|
lazy=True,
|
||||||
|
order_by="Wplata.data.desc()",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
passive_deletes=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
wydatki = db.relationship(
|
||||||
|
"Wydatek",
|
||||||
|
backref="zbiorka",
|
||||||
|
lazy=True,
|
||||||
|
order_by="Wydatek.data.desc()",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
passive_deletes=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
przedmioty = db.relationship(
|
||||||
|
"Przedmiot",
|
||||||
|
backref="zbiorka",
|
||||||
|
lazy=True,
|
||||||
|
order_by="Przedmiot.id.asc()",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
passive_deletes=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Przedmiot(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
zbiorka_id = db.Column(
|
||||||
|
db.Integer,
|
||||||
|
db.ForeignKey("zbiorka.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
nazwa = db.Column(db.String(120), nullable=False)
|
||||||
|
link = db.Column(db.String(255), nullable=True)
|
||||||
|
cena = db.Column(Numeric(12, 2), nullable=True)
|
||||||
|
kupione = db.Column(db.Boolean, default=False)
|
||||||
|
|
||||||
|
class Wplata(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
zbiorka_id = db.Column(
|
||||||
|
db.Integer,
|
||||||
|
db.ForeignKey("zbiorka.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
kwota = db.Column(Numeric(12, 2), nullable=False)
|
||||||
|
data = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
opis = db.Column(db.Text, nullable=True)
|
||||||
|
zbiorka = db.relationship("Zbiorka", back_populates="wplaty")
|
||||||
|
ukryta = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
class Wydatek(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
zbiorka_id = db.Column(
|
||||||
|
db.Integer,
|
||||||
|
db.ForeignKey("zbiorka.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
kwota = db.Column(Numeric(12, 2), nullable=False)
|
||||||
|
data = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
opis = db.Column(db.Text, nullable=True)
|
||||||
|
ukryta = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
class Przesuniecie(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
zbiorka_zrodlo_id = db.Column(
|
||||||
|
db.Integer,
|
||||||
|
db.ForeignKey("zbiorka.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
zbiorka_cel_id = db.Column(
|
||||||
|
db.Integer,
|
||||||
|
db.ForeignKey("zbiorka.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
kwota = db.Column(Numeric(12, 2), nullable=False)
|
||||||
|
data = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
opis = db.Column(db.Text, nullable=True)
|
||||||
|
ukryta = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
wplata_id = db.Column(
|
||||||
|
db.Integer,
|
||||||
|
db.ForeignKey("wplata.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
zbiorka_zrodlo = db.relationship("Zbiorka", foreign_keys=[zbiorka_zrodlo_id], backref="przesuniecia_wychodzace")
|
||||||
|
zbiorka_cel = db.relationship("Zbiorka", foreign_keys=[zbiorka_cel_id], backref="przesuniecia_przychodzace")
|
||||||
|
wplata = db.relationship("Wplata", foreign_keys=[wplata_id], backref="przesuniecia")
|
||||||
|
|
||||||
|
class UstawieniaGlobalne(db.Model):
|
||||||
|
__tablename__ = "ustawienia_globalne"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
numer_konta = db.Column(db.String(50), nullable=False)
|
||||||
|
numer_telefonu_blik = db.Column(db.String(50), nullable=False)
|
||||||
|
dozwolone_hosty_logowania = db.Column(db.Text, nullable=True)
|
||||||
|
logo_url = db.Column(db.String(255), nullable=True)
|
||||||
|
tytul_strony = db.Column(db.String(120), nullable=True)
|
||||||
|
pokaz_logo_w_navbar = db.Column(db.Boolean, default=False)
|
||||||
|
typ_navbar = db.Column(db.String(10), default="text")
|
||||||
|
typ_stopka = db.Column(db.String(10), default="text")
|
||||||
|
stopka_text = db.Column(db.String(200), nullable=True)
|
||||||
|
kolejnosc_rezerwowych = db.Column(db.String(20), default="id", nullable=False)
|
||||||
|
|
||||||
|
|
||||||
1371
zbiorka_app/routes.py
Normal file
1371
zbiorka_app/routes.py
Normal file
File diff suppressed because it is too large
Load Diff
440
zbiorka_app/static/css/custom.css
Normal file
440
zbiorka_app/static/css/custom.css
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap');
|
||||||
|
|
||||||
|
/* ========= 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 {
|
||||||
|
font-family: 'Roboto', system-ui, -apple-system, Segoe UI, Arial, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
padding-top: 1vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--trans);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--accent-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========= NAVBAR ========= */
|
||||||
|
.navbar {
|
||||||
|
background: var(--surface-0);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 700;
|
||||||
|
transition: color var(--trans);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: color var(--trans);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover,
|
||||||
|
.nav-link:focus {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========= CARDS ========= */
|
||||||
|
.card {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 20%, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wyróżniona karta */
|
||||||
|
.card.wspomoz-card {
|
||||||
|
border: 1px solid var(--accent) !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-title,
|
||||||
|
.card.wspomoz-card .card-text {
|
||||||
|
color: var(--text) !important;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========= BUTTONS ========= */
|
||||||
|
.btn {
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: transform 120ms ease, background-color var(--trans), border-color var(--trans), color var(--trans);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:focus-visible {
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 40%, transparent);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* pole edycji ciemne */
|
||||||
|
.CodeMirror {
|
||||||
|
background-color: #1e1e1e !important;
|
||||||
|
color: #e0e0e0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* kursor */
|
||||||
|
.CodeMirror-cursor {
|
||||||
|
border-left: 1px solid #e0e0e0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-pills .nav-link.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-pills .nav-link {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* sticky tylko od md wzwyż, by na mobile nie przyklejać */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.sticky-md {
|
||||||
|
position: sticky;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--sticky-offset: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rząd kopiowania: czytelny, łatwy klik w przycisk */
|
||||||
|
.copy-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
gap: .25rem .5rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
border: 1px solid var(--border, rgba(255, 255, 255, .15));
|
||||||
|
border-radius: .75rem;
|
||||||
|
background: rgba(255, 255, 255, .02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-row+.copy-row {
|
||||||
|
margin-top: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-row__label {
|
||||||
|
grid-column: 1 / 2;
|
||||||
|
grid-row: 1 / 2;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: .9rem;
|
||||||
|
opacity: .85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-row__value {
|
||||||
|
grid-column: 1 / 2;
|
||||||
|
grid-row: 2 / 3;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
user-select: text;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-row__btn {
|
||||||
|
grid-column: 2 / 3;
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
height: fit-content;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
|
||||||
|
/* na XS przycisk pod spodem – łatwiej trafić kciukiem */
|
||||||
|
.copy-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-row__btn {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 3 / 4;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-bw {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px dashed rgba(255, 255, 255, 0.2);
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tylko ten przycisk */
|
||||||
|
.btn.btn-outline-light.btn-opis {
|
||||||
|
color: #fff;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.btn-outline-light.btn-opis:hover,
|
||||||
|
.btn.btn-outline-light.btn-opis:focus {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #161616;
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 20%, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.btn-outline-light {
|
||||||
|
color: #fff;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.btn-outline-light:hover,
|
||||||
|
.btn.btn-outline-light:focus {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #161616;
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 20%, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.btn-outline-light:active {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #141414;
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 24%, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
#kanalyWarning,
|
||||||
|
#postepyWarning {
|
||||||
|
border: 1px solid #ffc107;
|
||||||
|
background-color: #2c2c2c;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:disabled,
|
||||||
|
textarea:disabled,
|
||||||
|
select:disabled {
|
||||||
|
background-color: #2b2b2b !important;
|
||||||
|
color: #bbb !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
0
zbiorka_app/static/js/admin_dashboard.js
Normal file
0
zbiorka_app/static/js/admin_dashboard.js
Normal file
21
zbiorka_app/static/js/dodaj_wplate.js
Normal file
21
zbiorka_app/static/js/dodaj_wplate.js
Normal 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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
5
zbiorka_app/static/js/dodaj_wydatek.js
Normal file
5
zbiorka_app/static/js/dodaj_wydatek.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
document.addEventListener('input', (e) => {
|
||||||
|
if (e.target && e.target.id === 'opis') {
|
||||||
|
document.getElementById('opisCount').textContent = e.target.value.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
88
zbiorka_app/static/js/edytuj_stan.js
Normal file
88
zbiorka_app/static/js/edytuj_stan.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
(() => {
|
||||||
|
// 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 = p.toFixed(1);
|
||||||
|
|
||||||
|
if (previewBar) {
|
||||||
|
previewBar.style.width = p + '%';
|
||||||
|
previewBar.setAttribute('aria-valuenow', p.toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previewNote) {
|
||||||
|
if (cel > 0) {
|
||||||
|
const diff = cel - val;
|
||||||
|
const isZero = Math.abs(diff) < 0.005; // float-safe
|
||||||
|
if (diff > 0 && !isZero) {
|
||||||
|
previewNote.textContent = 'Do celu brakuje: ' + diff.toFixed(2) + ' PLN';
|
||||||
|
} else if (isZero) {
|
||||||
|
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();
|
||||||
|
})();
|
||||||
19
zbiorka_app/static/js/formularz_rezerwy.js
Normal file
19
zbiorka_app/static/js/formularz_rezerwy.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const uzyjKonta = document.getElementById('uzyj_konta');
|
||||||
|
const kontoField = document.getElementById('konto-field');
|
||||||
|
const uzyjBlik = document.getElementById('uzyj_blik');
|
||||||
|
const blikField = document.getElementById('blik-field');
|
||||||
|
|
||||||
|
if (uzyjKonta && kontoField) {
|
||||||
|
uzyjKonta.addEventListener('change', function() {
|
||||||
|
kontoField.style.display = this.checked ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uzyjBlik && blikField) {
|
||||||
|
uzyjBlik.addEventListener('change', function() {
|
||||||
|
blikField.style.display = this.checked ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
63
zbiorka_app/static/js/formularz_zbiorek.js
Normal file
63
zbiorka_app/static/js/formularz_zbiorek.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
(function () {
|
||||||
|
// Licznik znaków opisu
|
||||||
|
const opis = document.getElementById('opis');
|
||||||
|
const opisCount = document.getElementById('opisCount');
|
||||||
|
if (opis && opisCount) {
|
||||||
|
const updateCount = () => (opisCount.textContent = String(opis.value.length));
|
||||||
|
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” – jest tylko w trybie edycji; odpalamy warunkowo
|
||||||
|
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();
|
||||||
|
iban.dispatchEvent(new Event('input'));
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
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';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
153
zbiorka_app/static/js/kwoty_formularz.js
Normal file
153
zbiorka_app/static/js/kwoty_formularz.js
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
(function () {
|
||||||
|
const tbody = document.querySelector('#produkty-body');
|
||||||
|
const celInput = document.querySelector('#cel');
|
||||||
|
const box = document.querySelector('#celSyncBox');
|
||||||
|
const msg = document.querySelector('#celSyncMsg');
|
||||||
|
const btn = document.querySelector('#btnApplyCelFromSum');
|
||||||
|
|
||||||
|
if (!tbody || !celInput || !box || !msg || !btn) return;
|
||||||
|
|
||||||
|
const EPS = 0.01; // tolerancja porównania
|
||||||
|
|
||||||
|
function parsePrice(raw) {
|
||||||
|
if (!raw) return NaN;
|
||||||
|
const s = String(raw).trim().replace(/\s+/g, '').replace(',', '.');
|
||||||
|
const n = Number(s);
|
||||||
|
return Number.isFinite(n) && n >= 0 ? n : NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRows() {
|
||||||
|
return Array.from(tbody.querySelectorAll('tr'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeSum() {
|
||||||
|
const rows = getRows();
|
||||||
|
|
||||||
|
let hasNamed = false;
|
||||||
|
let sumAll = 0; // suma ze wszystkich wierszy z nazwą i poprawną ceną
|
||||||
|
let sumToBuy = 0; // suma tylko z wierszy NIE oznaczonych jako "Kupione"
|
||||||
|
|
||||||
|
for (const tr of rows) {
|
||||||
|
const nameInput = tr.querySelector('input[name="item_nazwa[]"]');
|
||||||
|
const priceInput = tr.querySelector('input[name="item_cena[]"]');
|
||||||
|
const kupioneSwitch = tr.querySelector('.kupione-switch');
|
||||||
|
|
||||||
|
const name = nameInput ? nameInput.value.trim() : '';
|
||||||
|
if (!name) continue; // ignoruj puste wiersze bez nazwy
|
||||||
|
|
||||||
|
hasNamed = true;
|
||||||
|
|
||||||
|
const priceVal = priceInput ? parsePrice(priceInput.value) : NaN;
|
||||||
|
if (Number.isNaN(priceVal)) continue;
|
||||||
|
|
||||||
|
// zawsze dolicz do sumy wszystkich
|
||||||
|
sumAll += priceVal;
|
||||||
|
|
||||||
|
// do sumy do-kupienia tylko jeśli nie jest oznaczone jako kupione
|
||||||
|
if (!(kupioneSwitch && kupioneSwitch.checked)) {
|
||||||
|
sumToBuy += priceVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hasNamed, sumAll, sumToBuy };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function readCel() {
|
||||||
|
const v = parsePrice(celInput.value);
|
||||||
|
return Number.isNaN(v) ? null : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPln(n) {
|
||||||
|
// Nie narzucamy locale – prosto 2 miejsca
|
||||||
|
return n.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUI() {
|
||||||
|
const { hasNamed, sumAll, sumToBuy } = computeSum();
|
||||||
|
|
||||||
|
// Brak produktów (brak nazw) lub obie sumy = 0 → nic nie pokazuj
|
||||||
|
if (!hasNamed || (sumAll <= 0 && sumToBuy <= 0)) {
|
||||||
|
box.classList.add('d-none');
|
||||||
|
btn.classList.add('d-none');
|
||||||
|
box.classList.remove('alert-success', 'alert-info');
|
||||||
|
msg.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cel = readCel();
|
||||||
|
const target = sumToBuy; // porównujemy do kwoty POZOSTAŁE DO KUPIENIA
|
||||||
|
|
||||||
|
// Jeśli cel nie ustawiony lub NaN → zaproponuj ustawienie celu = sumToBuy
|
||||||
|
if (cel === null) {
|
||||||
|
box.classList.remove('d-none');
|
||||||
|
box.classList.remove('alert-success');
|
||||||
|
box.classList.add('alert-info');
|
||||||
|
|
||||||
|
// pokazujemy obie sumy w komunikacie
|
||||||
|
msg.innerHTML = `
|
||||||
|
<div>Wszystkie: <strong>${formatPln(sumAll)} PLN</strong> ·
|
||||||
|
Do kupienia: <strong>${formatPln(sumToBuy)} PLN</strong></div>
|
||||||
|
<div class="mt-1">Możesz ustawić <strong>cel</strong> na kwotę do kupienia.</div>
|
||||||
|
`;
|
||||||
|
btn.textContent = `Ustaw cel = ${formatPln(target)} PLN`;
|
||||||
|
btn.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mamy cel — porównanie do sumy do-kupienia
|
||||||
|
if (Math.abs(cel - target) <= EPS) {
|
||||||
|
box.classList.remove('d-none');
|
||||||
|
box.classList.remove('alert-info');
|
||||||
|
box.classList.add('alert-success');
|
||||||
|
msg.innerHTML = `
|
||||||
|
Suma <em>do kupienia</em> (<strong>${formatPln(target)} PLN</strong>) jest równa celowi.
|
||||||
|
<div class="small text-muted mt-1">Wszystkie: ${formatPln(sumAll)} PLN · Do kupienia: ${formatPln(sumToBuy)} PLN</div>
|
||||||
|
`;
|
||||||
|
btn.classList.add('d-none');
|
||||||
|
} else {
|
||||||
|
box.classList.remove('d-none');
|
||||||
|
box.classList.remove('alert-success');
|
||||||
|
box.classList.add('alert-info');
|
||||||
|
msg.innerHTML = `
|
||||||
|
<div>Wszystkie: <strong>${formatPln(sumAll)} PLN</strong> ·
|
||||||
|
Do kupienia: <strong>${formatPln(sumToBuy)} PLN</strong></div>
|
||||||
|
<div class="mt-1">Cel: <strong>${formatPln(cel)} PLN</strong></div>
|
||||||
|
`;
|
||||||
|
btn.textContent = `Zaktualizuj cel do ${formatPln(target)} PLN`;
|
||||||
|
btn.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const { sumToBuy } = computeSum();
|
||||||
|
if (sumToBuy > 0) {
|
||||||
|
celInput.value = formatPln(sumToBuy);
|
||||||
|
celInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
celInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reaguj na zmiany cen/nazw
|
||||||
|
tbody.addEventListener('input', (e) => {
|
||||||
|
const name = e.target.getAttribute('name');
|
||||||
|
if (name === 'item_nazwa[]' || name === 'item_cena[]') {
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reaguj na zmiany celu
|
||||||
|
celInput.addEventListener('input', updateUI);
|
||||||
|
celInput.addEventListener('change', updateUI);
|
||||||
|
|
||||||
|
// Obserwuj dodawanie/usuwanie wierszy przez inne skrypty
|
||||||
|
const mo = new MutationObserver(() => updateUI());
|
||||||
|
mo.observe(tbody, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
// Init po załadowaniu
|
||||||
|
document.addEventListener('DOMContentLoaded', updateUI);
|
||||||
|
// i jedno wywołanie na starcie (gdy DOMContentLoaded już był)
|
||||||
|
updateUI();
|
||||||
|
})();
|
||||||
4
zbiorka_app/static/js/mde_custom.js
Normal file
4
zbiorka_app/static/js/mde_custom.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
var simplemde = new SimpleMDE({
|
||||||
|
element: document.getElementById("opis"),
|
||||||
|
forceSync: true
|
||||||
|
});
|
||||||
73
zbiorka_app/static/js/produkty_formularz.js
Normal file
73
zbiorka_app/static/js/produkty_formularz.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
(function () {
|
||||||
|
const body = document.querySelector('#produkty-body');
|
||||||
|
const addBtn = document.querySelector('#add-row');
|
||||||
|
const clearBtn = document.querySelector('#clear-empty');
|
||||||
|
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
function reindexHidden() {
|
||||||
|
const rows = [...body.querySelectorAll('tr')];
|
||||||
|
rows.forEach((tr, idx) => {
|
||||||
|
const hidden = tr.querySelector('input[type="hidden"][name^="item_kupione_val_"]');
|
||||||
|
if (hidden) hidden.name = `item_kupione_val_${idx}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRow() {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td><input type="text" class="form-control" name="item_nazwa[]" placeholder="np. Karma Brit 10kg" required></td>
|
||||||
|
<td><input type="url" class="form-control" name="item_link[]" placeholder="https://..."></td>
|
||||||
|
<td><input type="text" inputmode="decimal" class="form-control text-end" name="item_cena[]" placeholder="0,00"></td>
|
||||||
|
<td>
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input kupione-switch" type="checkbox">
|
||||||
|
<input type="hidden" name="item_kupione_val_TMP" value="0">
|
||||||
|
<label class="form-check-label small">Do kupienia</label>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-light border remove-row" title="Usuń wiersz">✕</button>
|
||||||
|
</td>`;
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.addEventListener('change', (e) => {
|
||||||
|
if (e.target.classList.contains('kupione-switch')) {
|
||||||
|
const tr = e.target.closest('tr');
|
||||||
|
const hidden = tr.querySelector('input[type="hidden"][name^="item_kupione_val_"]');
|
||||||
|
const label = tr.querySelector('.form-check-label');
|
||||||
|
if (hidden) hidden.value = e.target.checked ? '1' : '0';
|
||||||
|
if (label) label.textContent = e.target.checked ? 'Kupione' : 'Do kupienia';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
body.addEventListener('click', (e) => {
|
||||||
|
if (e.target.classList.contains('remove-row')) {
|
||||||
|
e.preventDefault();
|
||||||
|
const tr = e.target.closest('tr');
|
||||||
|
tr.remove();
|
||||||
|
reindexHidden();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
addBtn?.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
body.appendChild(makeRow());
|
||||||
|
reindexHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
clearBtn?.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
[...body.querySelectorAll('tr')].forEach(tr => {
|
||||||
|
const name = tr.querySelector('input[name="item_nazwa[]"]')?.value.trim();
|
||||||
|
const link = tr.querySelector('input[name="item_link[]"]')?.value.trim();
|
||||||
|
const cena = tr.querySelector('input[name="item_cena[]"]')?.value.trim();
|
||||||
|
if (!name && !link && !cena) tr.remove();
|
||||||
|
});
|
||||||
|
reindexHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
// startowa normalizacja nazw hiddenów (ważne w trybie edycji)
|
||||||
|
reindexHidden();
|
||||||
|
})();
|
||||||
13
zbiorka_app/static/js/progress.js
Normal file
13
zbiorka_app/static/js/progress.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
74
zbiorka_app/static/js/przelaczniki_zabezpieczenie.js
Normal file
74
zbiorka_app/static/js/przelaczniki_zabezpieczenie.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// static/js/przelaczniki_zabezpieczenie.js
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function onReady(cb) {
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', cb);
|
||||||
|
} else {
|
||||||
|
cb();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onReady(function () {
|
||||||
|
var boxes = Array.prototype.slice.call(
|
||||||
|
document.querySelectorAll('input.form-check-input[type="checkbox"][data-group="postepy"]')
|
||||||
|
);
|
||||||
|
var warning = document.getElementById('postepyWarning');
|
||||||
|
|
||||||
|
if (!boxes.length || !warning) {
|
||||||
|
// Nic do zrobienia, brak elementów
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function atLeastOneChecked() {
|
||||||
|
return boxes.some(function (b) { return !!b.checked; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function showWarning(show) {
|
||||||
|
warning.classList.toggle('d-none', !show);
|
||||||
|
if (show) {
|
||||||
|
// dyskretny highlight
|
||||||
|
warning.classList.add('border', 'border-warning');
|
||||||
|
warning.style.transition = 'box-shadow 0.2s ease';
|
||||||
|
warning.style.boxShadow = '0 0 0.25rem rgba(255,193,7,.6)';
|
||||||
|
setTimeout(function () {
|
||||||
|
warning.style.boxShadow = '';
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforceAtLeastOne(e) {
|
||||||
|
// Jeżeli po zmianie byłaby 0/3, przywróć zaznaczenie klikniętego i pokaż ostrzeżenie
|
||||||
|
if (!atLeastOneChecked()) {
|
||||||
|
e.target.checked = true;
|
||||||
|
showWarning(true);
|
||||||
|
e.target.classList.add('is-invalid');
|
||||||
|
setTimeout(function () { e.target.classList.remove('is-invalid'); }, 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Jeśli >=1, ostrzeżenie ukryj
|
||||||
|
showWarning(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Podpinka zdarzeń
|
||||||
|
boxes.forEach(function (box) {
|
||||||
|
box.addEventListener('change', enforceAtLeastOne);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Walidacja przy submit (na wszelki wypadek)
|
||||||
|
var form = boxes[0].closest('form');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
if (!atLeastOneChecked()) {
|
||||||
|
e.preventDefault();
|
||||||
|
showWarning(true);
|
||||||
|
boxes[0].focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inicjalny stan (np. po rerenderze z błędem)
|
||||||
|
showWarning(!atLeastOneChecked());
|
||||||
|
});
|
||||||
|
})();
|
||||||
0
zbiorka_app/static/js/service-worker.js
Normal file
0
zbiorka_app/static/js/service-worker.js
Normal file
88
zbiorka_app/static/js/sposoby_wplat.js
Normal file
88
zbiorka_app/static/js/sposoby_wplat.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
(function () {
|
||||||
|
const form = document.getElementById('form-edit-zbiorka') || document.getElementById('form-add-zbiorka') || document.querySelector('form');
|
||||||
|
|
||||||
|
const map = [
|
||||||
|
['uzyj_konta', 'numer_konta'],
|
||||||
|
['uzyj_blik', 'numer_telefonu_blik']
|
||||||
|
];
|
||||||
|
|
||||||
|
const warnBox = document.getElementById('kanalyWarning');
|
||||||
|
|
||||||
|
function showWarn(show) {
|
||||||
|
if (!warnBox) return;
|
||||||
|
warnBox.classList.toggle('d-none', !show);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEl(id) { return document.getElementById(id); }
|
||||||
|
|
||||||
|
function toggleField(chkId, inputId) {
|
||||||
|
const chk = getEl(chkId);
|
||||||
|
const inp = getEl(inputId);
|
||||||
|
if (!inp || !chk) return;
|
||||||
|
const on = chk.checked;
|
||||||
|
inp.disabled = !on;
|
||||||
|
if (on) inp.setAttribute('required', '');
|
||||||
|
else inp.removeAttribute('required');
|
||||||
|
}
|
||||||
|
|
||||||
|
function atLeastOneOn() {
|
||||||
|
return map.some(([c]) => getEl(c)?.checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
function blinkInvalid(el) {
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.add('is-invalid');
|
||||||
|
setTimeout(() => el.classList.remove('is-invalid'), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function preventUncheckLast(e) {
|
||||||
|
const target = e.target;
|
||||||
|
if (target.checked) return;
|
||||||
|
const after = map.map(([c]) => c === target.id ? false : !!getEl(c)?.checked);
|
||||||
|
if (!after.some(Boolean)) {
|
||||||
|
e.preventDefault();
|
||||||
|
target.checked = true;
|
||||||
|
showWarn(true);
|
||||||
|
blinkInvalid(target);
|
||||||
|
} else {
|
||||||
|
showWarn(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onToggle(chkId, inputId) {
|
||||||
|
toggleField(chkId, inputId);
|
||||||
|
showWarn(!atLeastOneOn());
|
||||||
|
}
|
||||||
|
|
||||||
|
map.forEach(([chkId, inputId]) => {
|
||||||
|
const chk = getEl(chkId);
|
||||||
|
if (!chk) return;
|
||||||
|
chk.addEventListener('click', preventUncheckLast);
|
||||||
|
chk.addEventListener('change', () => onToggle(chkId, inputId));
|
||||||
|
toggleField(chkId, inputId);
|
||||||
|
});
|
||||||
|
showWarn(!atLeastOneOn());
|
||||||
|
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
if (!atLeastOneOn()) {
|
||||||
|
e.preventDefault();
|
||||||
|
showWarn(true);
|
||||||
|
blinkInvalid(getEl('uzyj_konta') || getEl('uzyj_blik'));
|
||||||
|
(getEl('uzyj_konta') || getEl('uzyj_blik'))?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [chkId, inputId] of map) {
|
||||||
|
const chk = getEl(chkId), inp = getEl(inputId);
|
||||||
|
if (chk?.checked && inp && !inp.value.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
showWarn(true);
|
||||||
|
blinkInvalid(inp);
|
||||||
|
inp.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
27
zbiorka_app/static/js/transakcje.js
Normal file
27
zbiorka_app/static/js/transakcje.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const modalW = new bootstrap.Modal(document.getElementById('modalWplata'));
|
||||||
|
const modalX = new bootstrap.Modal(document.getElementById('modalWydatek'));
|
||||||
|
|
||||||
|
// WPŁATA
|
||||||
|
document.querySelectorAll('.btn-edit-wplata').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const form = document.getElementById('formWplata');
|
||||||
|
form.action = btn.dataset.action;
|
||||||
|
document.getElementById('wplataKwota').value = btn.dataset.kwota || '';
|
||||||
|
document.getElementById('wplataOpis').value = btn.dataset.opis || '';
|
||||||
|
modalW.show();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// WYDATEK
|
||||||
|
document.querySelectorAll('.btn-edit-wydatek').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const form = document.getElementById('formWydatek');
|
||||||
|
form.action = btn.dataset.action;
|
||||||
|
document.getElementById('wydatekKwota').value = btn.dataset.kwota || '';
|
||||||
|
document.getElementById('wydatekOpis').value = btn.dataset.opis || '';
|
||||||
|
modalX.show();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
92
zbiorka_app/static/js/ustawienia.js
Normal file
92
zbiorka_app/static/js/ustawienia.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// static/js/ustawienia.js
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Formatowanie IBAN (PL)
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Telefon BLIK 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
|
||||||
|
const ta = document.getElementById('dozwolone_hosty_logowania');
|
||||||
|
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');
|
||||||
|
const dedupeBtn = document.getElementById('btn-dedupe');
|
||||||
|
|
||||||
|
const parseList = (text) =>
|
||||||
|
text
|
||||||
|
.split(/[\r\n,;]+/) // \r?\n, przecinek, średnik
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const formatList = (arr) => arr.join('\n');
|
||||||
|
|
||||||
|
const 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCount = () => {
|
||||||
|
if (!ta || !count) return;
|
||||||
|
count.textContent = String(parseList(ta.value).length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEntry = (val) => {
|
||||||
|
if (!ta || !val) return;
|
||||||
|
const list = dedupe([...parseList(ta.value), val]);
|
||||||
|
ta.value = formatList(list);
|
||||||
|
updateCount();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ta) {
|
||||||
|
ta.addEventListener('input', updateCount);
|
||||||
|
updateCount(); // inicjalne przeliczenie
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dedupeBtn && ta) {
|
||||||
|
dedupeBtn.addEventListener('click', () => {
|
||||||
|
ta.value = formatList(dedupe(parseList(ta.value)));
|
||||||
|
updateCount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
27
zbiorka_app/static/js/walidacja_logowanie.js
Normal file
27
zbiorka_app/static/js/walidacja_logowanie.js
Normal 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("haslo");
|
||||||
|
const toggle = document.getElementById('togglePw');
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
const isText = pw.type === 'text';
|
||||||
|
pw.type = isText ? "haslo" : '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);
|
||||||
37
zbiorka_app/static/js/walidacja_rejestracja.js
Normal file
37
zbiorka_app/static/js/walidacja_rejestracja.js
Normal 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("haslo");
|
||||||
|
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("haslo");
|
||||||
|
const toggle = document.getElementById('togglePw');
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
const isText = pw.type === 'text';
|
||||||
|
pw.type = isText ? "haslo" : '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);
|
||||||
66
zbiorka_app/static/js/zbiorka.js
Normal file
66
zbiorka_app/static/js/zbiorka.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
|
||||||
|
(function () {
|
||||||
|
// --- Formatowanie IBAN ---
|
||||||
|
const ibanEl = document.getElementById('ibanInput') || document.getElementById('ibanDisplay');
|
||||||
|
if (ibanEl) {
|
||||||
|
const raw = (('value' in ibanEl ? ibanEl.value : ibanEl.textContent) || '')
|
||||||
|
.toString().replace(/\s+/g, '').toUpperCase();
|
||||||
|
const digits = raw.replace(/^PL/, '').replace(/\D/g, '').slice(0, 26);
|
||||||
|
if (digits) {
|
||||||
|
const pretty = 'PL ' + digits.replace(/(.{4})/g, '$1 ').trim();
|
||||||
|
if ('value' in ibanEl) ibanEl.value = pretty; else ibanEl.textContent = pretty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Formatowanie BLIK ---
|
||||||
|
const blikEl = document.getElementById('blikInput') || document.getElementById('blikDisplay');
|
||||||
|
if (blikEl) {
|
||||||
|
const raw = (('value' in blikEl ? blikEl.value : blikEl.textContent) || '')
|
||||||
|
.toString().replace(/\D/g, '').slice(0, 9);
|
||||||
|
if (raw) {
|
||||||
|
const pretty = [raw.slice(0, 3), raw.slice(3, 6), raw.slice(6, 9)]
|
||||||
|
.filter(Boolean).join(' ');
|
||||||
|
if ('value' in blikEl) blikEl.value = pretty; else blikEl.textContent = pretty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Kopiowanie: wspiera data-copy-input i data-copy-target ---
|
||||||
|
const buttons = document.querySelectorAll('[data-copy-input], [data-copy-target]');
|
||||||
|
buttons.forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const sel = btn.getAttribute('data-copy-input') || btn.getAttribute('data-copy-target');
|
||||||
|
const el = sel ? document.querySelector(sel) : null;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const textRaw = ('value' in el ? el.value : el.textContent || '')
|
||||||
|
.toString().replace(/\u00A0/g, ' ').trim();
|
||||||
|
|
||||||
|
const copyWithFallback = async (text) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// Fallback: tymczasowy textarea
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.top = '-1000px';
|
||||||
|
ta.setAttribute('readonly', '');
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
try { document.execCommand('copy'); } catch { /* ignore */ }
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const original = btn.textContent;
|
||||||
|
const ok = await copyWithFallback(textRaw);
|
||||||
|
if (ok) {
|
||||||
|
btn.textContent = 'Skopiowano!';
|
||||||
|
btn.disabled = true;
|
||||||
|
setTimeout(() => { btn.textContent = original; btn.disabled = false; }, 1200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
373
zbiorka_app/templates/admin/dashboard.html
Normal file
373
zbiorka_app/templates/admin/dashboard.html
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Panel Admina{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container my-4">
|
||||||
|
|
||||||
|
<!-- Nagłówek + akcje globalne -->
|
||||||
|
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-1">Panel Admina</h2>
|
||||||
|
<p class="text-muted mb-0">Zarządzaj zbiórkami i monitoruj finanse</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<a href="{{ url_for('admin_statystyki') }}" class="btn btn-info">
|
||||||
|
<i class="bi bi-graph-up"></i> Statystyki
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('formularz_zbiorek') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> Dodaj zbiórkę
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('lista_rezerwowych') }}" class="btn btn-outline-light">
|
||||||
|
<i class="bi bi-wallet2"></i> Listy rezerwowe
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('admin_ustawienia') }}" class="btn btn-outline-light">
|
||||||
|
<i class="bi bi-gear"></i> Ustawienia
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Szybkie statystyki (cards) -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-primary">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="bi bi-list-check fs-1 text-primary mb-2"></i>
|
||||||
|
<h3 class="mb-0">{{ active_zbiorki|length }}</h3>
|
||||||
|
<small class="text-muted">Aktywnych zbiórek</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-success">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="bi bi-check-circle fs-1 text-success mb-2"></i>
|
||||||
|
<h3 class="mb-0">{{ completed_zbiorki|length }}</h3>
|
||||||
|
<small class="text-muted">Zrealizowanych</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-info">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="bi bi-bar-chart fs-1 text-info mb-2"></i>
|
||||||
|
<h3 class="mb-0">{{ active_zbiorki|length + completed_zbiorki|length }}</h3>
|
||||||
|
<small class="text-muted">Łącznie zbiórek</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-warning">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<a href="{{ url_for('admin_statystyki') }}" class="text-decoration-none">
|
||||||
|
<i class="bi bi-graph-up-arrow fs-1 text-warning mb-2"></i>
|
||||||
|
<h5 class="mb-0 text-white">Zobacz pełne</h5>
|
||||||
|
<small class="text-muted">statystyki</small>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pigułki: Aktywne / Zrealizowane -->
|
||||||
|
<ul class="nav nav-pills mb-3" id="adminTabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="tab-aktywne" data-bs-toggle="tab" data-bs-target="#pane-aktywne"
|
||||||
|
type="button" role="tab" aria-controls="pane-aktywne" aria-selected="true">
|
||||||
|
<i class="bi bi-lightning"></i> Aktywne zbiórki ({{ active_zbiorki|length }})
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="tab-zrealizowane" data-bs-toggle="tab" data-bs-target="#pane-zrealizowane"
|
||||||
|
type="button" role="tab" aria-controls="pane-zrealizowane" aria-selected="false">
|
||||||
|
<i class="bi bi-check-circle"></i> Zrealizowane ({{ completed_zbiorki|length }})
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content">
|
||||||
|
|
||||||
|
<!-- PANE: Aktywne -->
|
||||||
|
<div class="tab-pane fade show active" id="pane-aktywne" role="tabpanel" aria-labelledby="tab-aktywne"
|
||||||
|
tabindex="0">
|
||||||
|
|
||||||
|
{% if active_zbiorki and active_zbiorki|length > 0 %}
|
||||||
|
<div class="table-responsive mb-5">
|
||||||
|
<table class="table table-dark table-striped table-hover align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:72px;">ID</th>
|
||||||
|
<th>Nazwa</th>
|
||||||
|
<th style="width:140px;">Widoczność</th>
|
||||||
|
<th style="width:1%;">Opcje</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for z in active_zbiorki %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted">{{ z.id }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<a href="{{ url_for('zbiorka', zbiorka_id=z.id) }}" class="fw-semibold text-decoration-none">
|
||||||
|
{{ z.nazwa }}
|
||||||
|
</a>
|
||||||
|
{% if z.cel is defined or z.stan is defined %}
|
||||||
|
<small class="text-muted">
|
||||||
|
{% if z.cel is defined and z.cel > 0 %}
|
||||||
|
Cel: {{ z.cel|round(2) }} PLN
|
||||||
|
{% endif %}
|
||||||
|
{% if z.stan is defined %}
|
||||||
|
· Stan: {{ z.stan|round(2) }} PLN
|
||||||
|
{% endif %}
|
||||||
|
{% if z.cel is defined and z.cel > 0 and z.stan is defined %}
|
||||||
|
· {{ ((z.stan / z.cel) * 100)|round(1) }}%
|
||||||
|
{% endif %}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if z.ukryta %}
|
||||||
|
<span class="badge bg-secondary border"
|
||||||
|
style="border-color: var(--border);"><i class="bi bi-eye-slash"></i> Ukryta</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-eye"></i> Widoczna</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<!-- Grupa akcji: główne + rozwijane -->
|
||||||
|
<div class="btn-group">
|
||||||
|
<a href="{{ url_for('formularz_zbiorek', zbiorka_id=z.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-light">
|
||||||
|
<i class="bi bi-pencil"></i> Edytuj
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-sm btn-outline-light dropdown-toggle dropdown-toggle-split"
|
||||||
|
data-bs-toggle="dropdown" aria-expanded="false" aria-label="Więcej opcji">
|
||||||
|
<span class="visually-hidden">Więcej</span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-dark dropdown-menu-end shadow">
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{{ url_for('zbiorka', zbiorka_id=z.id) }}">
|
||||||
|
<i class="bi bi-eye"></i> Podgląd
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item"
|
||||||
|
href="{{ url_for('dodaj_wplate', zbiorka_id=z.id) }}">
|
||||||
|
<i class="bi bi-plus-circle text-success"></i> Dodaj wpłatę
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item"
|
||||||
|
href="{{ url_for('dodaj_wydatek', zbiorka_id=z.id) }}">
|
||||||
|
<i class="bi bi-dash-circle text-danger"></i> Dodaj wydatek
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item"
|
||||||
|
href="{{ url_for('transakcje_zbiorki', zbiorka_id=z.id) }}">
|
||||||
|
<i class="bi bi-list-ul"></i> Transakcje
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item"
|
||||||
|
href="{{ url_for('edytuj_stan', zbiorka_id=z.id) }}">
|
||||||
|
<i class="bi bi-currency-dollar"></i> Edytuj stan
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<form action="{{ url_for('oznacz_zrealizowana', zbiorka_id=z.id) }}"
|
||||||
|
method="post" class="m-0">
|
||||||
|
<button type="submit" class="dropdown-item">
|
||||||
|
<i class="bi bi-check-circle text-success"></i> Oznacz jako zrealizowaną
|
||||||
|
</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 %}
|
||||||
|
<i class="bi bi-eye"></i> Pokaż
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-eye-slash"></i> 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">
|
||||||
|
<i class="bi bi-trash"></i> Usuń
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<i class="bi bi-inbox fs-1 text-muted mb-3"></i>
|
||||||
|
<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>
|
||||||
|
<a href="{{ url_for('formularz_zbiorek') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> Utwórz nową zbiórkę
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PANEL: Zrealizowane -->
|
||||||
|
<div class="tab-pane fade" id="pane-zrealizowane" role="tabpanel" aria-labelledby="tab-zrealizowane"
|
||||||
|
tabindex="0">
|
||||||
|
|
||||||
|
{% if completed_zbiorki and completed_zbiorki|length > 0 %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-dark table-striped table-hover align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:72px;">ID</th>
|
||||||
|
<th>Nazwa</th>
|
||||||
|
<th style="width:180px;">Status</th>
|
||||||
|
<th style="width:1%;">Opcje</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for z in completed_zbiorki %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted">{{ z.id }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<a href="{{ url_for('zbiorka', zbiorka_id=z.id) }}" class="fw-semibold text-decoration-none">
|
||||||
|
{{ z.nazwa }}
|
||||||
|
</a>
|
||||||
|
{% 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 %} · Zebrano: {{ z.stan|round(2) }} PLN {% endif %}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<span class="badge rounded-pill"
|
||||||
|
style="background: var(--accent); color:#111;">
|
||||||
|
<i class="bi bi-check-circle"></i> Zrealizowana
|
||||||
|
</span>
|
||||||
|
{% if z.ukryta %}
|
||||||
|
<span class="badge bg-secondary border"
|
||||||
|
style="border-color: var(--border);"><i class="bi bi-eye-slash"></i> Ukryta</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-eye"></i> Widoczna</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="btn-group">
|
||||||
|
<a href="{{ url_for('formularz_zbiorek', zbiorka_id=z.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-light">
|
||||||
|
<i class="bi bi-pencil"></i> Edytuj
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-sm btn-outline-light dropdown-toggle dropdown-toggle-split"
|
||||||
|
data-bs-toggle="dropdown" aria-expanded="false" aria-label="Więcej opcji">
|
||||||
|
<span class="visually-hidden">Więcej</span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-dark dropdown-menu-end shadow">
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{{ url_for('zbiorka', zbiorka_id=z.id) }}">
|
||||||
|
<i class="bi bi-eye"></i> Podgląd
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item"
|
||||||
|
href="{{ url_for('dodaj_wplate', zbiorka_id=z.id) }}">
|
||||||
|
<i class="bi bi-plus-circle text-success"></i> Dodaj wpłatę
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item"
|
||||||
|
href="{{ url_for('dodaj_wydatek', zbiorka_id=z.id) }}">
|
||||||
|
<i class="bi bi-dash-circle text-danger"></i> Dodaj wydatek
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item"
|
||||||
|
href="{{ url_for('transakcje_zbiorki', zbiorka_id=z.id) }}">
|
||||||
|
<i class="bi bi-list-ul"></i> Transakcje
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item"
|
||||||
|
href="{{ url_for('edytuj_stan', zbiorka_id=z.id) }}">
|
||||||
|
<i class="bi bi-currency-dollar"></i> Edytuj stan
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<form action="{{ url_for('oznacz_niezrealizowana', zbiorka_id=z.id) }}"
|
||||||
|
method="post" class="m-0">
|
||||||
|
<button type="submit" class="dropdown-item">
|
||||||
|
<i class="bi bi-arrow-counterclockwise"></i> Oznacz jako niezrealizowaną
|
||||||
|
</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 %}
|
||||||
|
<i class="bi bi-eye"></i> Pokaż
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-eye-slash"></i> 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">
|
||||||
|
<i class="bi bi-trash"></i> Usuń
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<i class="bi bi-trophy fs-1 text-muted mb-3"></i>
|
||||||
|
<h5 class="mb-2">Brak zbiórek zrealizowanych</h5>
|
||||||
|
<p class="text-muted mb-3">Gdy jakaś zbiórka osiągnie 100%, pojawi się tutaj.</p>
|
||||||
|
<a href="{{ url_for('formularz_zbiorek') }}" class="btn btn-outline-light">
|
||||||
|
<i class="bi bi-plus-circle"></i> Utwórz nową zbiórkę
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
85
zbiorka_app/templates/admin/dodaj_przesuniecie.html
Normal file
85
zbiorka_app/templates/admin/dodaj_przesuniecie.html
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Przesuń środki - {{ zbiorka.nazwa }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="container my-5">
|
||||||
|
<!-- Nagłówek -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<h1 class="mb-2">
|
||||||
|
<i class="bi bi-arrow-left-right text-primary"></i> Przesuń środki z: {{ zbiorka.nazwa }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted">Dostępne środki: <strong class="text-success">{{ zbiorka.stan|round(2) }} PLN</strong></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formularz -->
|
||||||
|
<form method="POST">
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-transparent">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-box-arrow-in-down text-success"></i> Cel przesunięcia
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="zbiorka_cel_id" class="form-label fw-semibold">
|
||||||
|
Docelowa zbiórka <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="zbiorka_cel_id" name="zbiorka_cel_id" required>
|
||||||
|
<option value="">-- Wybierz zbiórkę docelową --</option>
|
||||||
|
{% for zb in dostepne_zbiorki %}
|
||||||
|
<option value="{{ zb.id }}">
|
||||||
|
{% if zb.typ_zbiorki == 'rezerwa' %}
|
||||||
|
{{ zb.nazwa }} (Rezerwa)
|
||||||
|
{% else %}
|
||||||
|
{{ zb.nazwa }}
|
||||||
|
{% endif %}
|
||||||
|
· Stan: {{ zb.stan|round(2) }} PLN
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="kwota" class="form-label fw-semibold">
|
||||||
|
Kwota (PLN) <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" id="kwota" name="kwota"
|
||||||
|
placeholder="np. 100.00" required>
|
||||||
|
<span class="input-group-text">PLN</span>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="bi bi-info-circle"></i> Można użyć przecinka lub kropki
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-0">
|
||||||
|
<label for="opis" class="form-label fw-semibold">
|
||||||
|
Opis <span class="text-muted">(opcjonalny)</span>
|
||||||
|
</label>
|
||||||
|
<textarea class="form-control" id="opis" name="opis" rows="3"
|
||||||
|
placeholder="Dodatkowe informacje o przesunięciu"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alert informacyjny -->
|
||||||
|
<div class="alert alert-info d-flex align-items-start mb-3">
|
||||||
|
<i class="bi bi-lightbulb fs-5 me-2"></i>
|
||||||
|
<div class="small">
|
||||||
|
<strong>Jak to działa:</strong> Kwota zostanie odjęta ze źródłowej zbiórki i dodana do docelowej.
|
||||||
|
W obu zbiórkach pojawi się wpis o przesunięciu w historii transakcji.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-arrow-left-right"></i> Przesuń środki
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('transakcje_zbiorki', zbiorka_id=zbiorka.id) }}"
|
||||||
|
class="btn btn-secondary">Anuluj</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
104
zbiorka_app/templates/admin/dodaj_wplate.html
Normal file
104
zbiorka_app/templates/admin/dodaj_wplate.html
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
{% 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">← 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 and zbiorka.typ_zbiorki != 'rezerwa' %}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{% if zbiorka.cel and zbiorka.cel > 0 and zbiorka.typ_zbiorki != 'rezerwa' %}
|
||||||
|
{% set delta = zbiorka.cel - zbiorka.stan %}
|
||||||
|
{% if delta > 0 %}
|
||||||
|
<span class="badge bg-dark border" style="border-color: var(--border);">
|
||||||
|
Brakuje: {{ delta|round(2) }} PLN
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-dark border" style="border-color: var(--border);">
|
||||||
|
Nadwyżka: {{ (-delta)|round(2) }} PLN
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if zbiorka.typ_zbiorki != 'rezerwa' %}
|
||||||
|
{% 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>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card-body {% if zbiorka.typ_zbiorki == 'rezerwa' %}pt-3{% else %}pt-0{% endif %}">
|
||||||
|
<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 [5,10,20,25,30,35,40,50,60,100,150,200] %}
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-light btn-kwota" data-amount="{{ preset }}">
|
||||||
|
{{ preset }} PLN
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
{% if zbiorka.cel and zbiorka.cel > 0 and zbiorka.typ_zbiorki != 'rezerwa' %}
|
||||||
|
{% set brakujace = (zbiorka.cel - zbiorka.stan) if (zbiorka.cel - zbiorka.stan) > 0 else 0 %}
|
||||||
|
{% if brakujace > 0 %}
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-light btn-kwota"
|
||||||
|
data-amount="{{ brakujace|round(2) }}">
|
||||||
|
Do celu: {{ brakujace|round(2) }} PLN
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% 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">Anuluj</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
{% block extra_scripts %}
|
||||||
|
{{ super() }}
|
||||||
|
<script src="{{ asset_url('js/dodaj_wplate.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
79
zbiorka_app/templates/admin/dodaj_wydatek.html
Normal file
79
zbiorka_app/templates/admin/dodaj_wydatek.html
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Dodaj wydatek{% 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">← 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 wydatek: <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>
|
||||||
|
|
||||||
|
{% if has_cel %}
|
||||||
|
{% set delta = zbiorka.cel - zbiorka.stan %}
|
||||||
|
{% if delta > 0 %}
|
||||||
|
<span class="badge bg-dark border" style="border-color: var(--border);">
|
||||||
|
Brakuje: {{ delta|round(2) }} PLN
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-dark border" style="border-color: var(--border);">
|
||||||
|
Nadwyżka: {{ (-delta)|round(2) }} PLN
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" novalidate>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="kwota" class="form-label">Kwota wydatku</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>
|
||||||
|
|
||||||
|
<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 wydatku (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 wydatek</button>
|
||||||
|
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}"
|
||||||
|
class="btn btn-outline-light">Anuluj</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
{{ super() }}
|
||||||
|
<script src="{{ asset_url('js/dodaj_wydatek.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
141
zbiorka_app/templates/admin/edytuj_stan.html
Normal file
141
zbiorka_app/templates/admin/edytuj_stan.html
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Edytuj stan zbiórki{% 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">← Szczegóły zbiórki</a>
|
||||||
|
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-sm btn-outline-light">← 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 and zbiorka.typ_zbiorki != 'rezerwa') %}
|
||||||
|
{% 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 }}"
|
||||||
|
data-typ-zbiorki="{{ zbiorka.typ_zbiorki }}">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{% if has_cel %}
|
||||||
|
{% set delta = zbiorka.cel - zbiorka.stan %}
|
||||||
|
{% if delta > 0 %}
|
||||||
|
<span class="badge bg-dark border" style="border-color: var(--border);">
|
||||||
|
Brakuje: {{ delta|round(2) }} PLN
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-dark border" style="border-color: var(--border);">
|
||||||
|
Nadwyżka: {{ (-delta)|round(2) }} PLN
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mini progress (aktualny) - tylko dla standardowych zbiórek -->
|
||||||
|
{% if zbiorka.typ_zbiorki != 'rezerwa' %}
|
||||||
|
<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">Aktualnie: {{ progress|round(1) }}%</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card-body {% if zbiorka.typ_zbiorki == 'rezerwa' %}pt-3{% else %}pt-0{% endif %}">
|
||||||
|
<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 btn-delta" data-delta="{{ delta }}">+{{ delta }} PLN</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-light btn-delta" data-delta="-{{ delta }}">-{{ delta }} PLN</button>
|
||||||
|
{% endfor %}
|
||||||
|
{% if has_cel %}
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-light btn-set"
|
||||||
|
data-value="{{ zbiorka.cel|round(2) }}">Ustaw: do celu</button>
|
||||||
|
{% endif %}
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-light btn-set" data-value="0">Ustaw: 0</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Podgląd po zmianie - tylko dla standardowych zbiórek -->
|
||||||
|
{% if zbiorka.typ_zbiorki != 'rezerwa' %}
|
||||||
|
<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 id="previewBar" class="progress-bar" style="width: {{ progress_clamped }}%;"></div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted d-block mt-1" id="previewNote">
|
||||||
|
{% if has_cel %}
|
||||||
|
{% set brakujace = (zbiorka.cel - zbiorka.stan) %}
|
||||||
|
{% 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>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- 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">Anuluj</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
{{ super() }}
|
||||||
|
<script src="{{ asset_url('js/edytuj_stan.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
170
zbiorka_app/templates/admin/formularz_rezerwy.html
Normal file
170
zbiorka_app/templates/admin/formularz_rezerwy.html
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
{# templates/admin/formularz_rezerwy.html #}
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% set has_obj = zbiorka is not none %}
|
||||||
|
{% set is_edit = has_obj and zbiorka.id is not none %}
|
||||||
|
|
||||||
|
{% block title %}{{ 'Edytuj listę rezerwową' if is_edit else 'Dodaj listę rezerwową' }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container my-4">
|
||||||
|
|
||||||
|
<!-- Nawigacja / powrót -->
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
{% if is_edit and zbiorka and zbiorka.id %}
|
||||||
|
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-sm btn-outline-light">← Szczegóły
|
||||||
|
listy</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('lista_rezerwowych') }}" class="btn btn-sm btn-outline-light">← Listy rezerwowe</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div
|
||||||
|
class="card-header bg-secondary text-white d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||||
|
<h3 class="card-title mb-0">
|
||||||
|
{{ 'Edytuj listę rezerwową' if is_edit else 'Dodaj nową listę rezerwową' }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{% if is_edit %}
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||||
|
<span class="badge bg-dark border" style="border-color: var(--border);">
|
||||||
|
Stan: {{ (zbiorka.stan or 0)|round(2) }} PLN
|
||||||
|
</span>
|
||||||
|
<span class="badge bg-info">Lista rezerwowa</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<small class="opacity-75">Utwórz dedykowaną listę do zarządzania środkami</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" novalidate id="{{ 'form-edit-rezerwa' if is_edit else 'form-add-rezerwa' }}">
|
||||||
|
|
||||||
|
<!-- ======================================== -->
|
||||||
|
<!-- PODSTAWOWE DANE -->
|
||||||
|
<!-- ======================================== -->
|
||||||
|
<h5 class="mb-3">Podstawowe dane</h5>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="nazwa" class="form-label">Nazwa listy <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" id="nazwa" name="nazwa" required
|
||||||
|
value="{{ zbiorka.nazwa if is_edit else '' }}"
|
||||||
|
placeholder="np. Nadpłaty, Środki rezerwowe">
|
||||||
|
<small class="form-text text-muted">Unikalny identyfikator tej listy rezerwowej</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="opis" class="form-label">Opis (opcjonalny)</label>
|
||||||
|
<textarea class="form-control" id="opis" name="opis" rows="3"
|
||||||
|
placeholder="Krótki opis przeznaczenia tej listy">{{ zbiorka.opis if is_edit else '' }}</textarea>
|
||||||
|
<small class="form-text text-muted">Krótki opis, który pomoże w identyfikacji</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<!-- ======================================== -->
|
||||||
|
<!-- KANAŁY PŁATNOŚCI -->
|
||||||
|
<!-- ======================================== -->
|
||||||
|
<h5 class="mb-3">Kanały płatności</h5>
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
Określ, czy ta lista ma akceptować bezpośrednie wpłaty od użytkowników.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<!-- Przelew -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="border rounded p-3 h-100">
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="uzyj_konta" name="uzyj_konta"
|
||||||
|
{% if is_edit and zbiorka.uzyj_konta %}checked{% endif %}>
|
||||||
|
<label class="form-check-label fw-bold" for="uzyj_konta">
|
||||||
|
Włącz wpłaty przelewem
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="konto-field" style="display: {% if is_edit and zbiorka.uzyj_konta %}block{% else %}none{% endif %};">
|
||||||
|
<label for="numer_konta" class="form-label small">Numer konta</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="numer_konta"
|
||||||
|
name="numer_konta"
|
||||||
|
value="{{ zbiorka.numer_konta if is_edit else (global_settings.numer_konta if global_settings else '') }}"
|
||||||
|
placeholder="26 cyfr numeru konta">
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
{% if global_settings and global_settings.numer_konta %}
|
||||||
|
Domyślnie: {{ global_settings.numer_konta }}
|
||||||
|
{% else %}
|
||||||
|
Zostaw puste dla globalnego numeru
|
||||||
|
{% endif %}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- BLIK -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="border rounded p-3 h-100">
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="uzyj_blik" name="uzyj_blik"
|
||||||
|
{% if is_edit and zbiorka.uzyj_blik %}checked{% endif %}>
|
||||||
|
<label class="form-check-label fw-bold" for="uzyj_blik">
|
||||||
|
Włącz wpłaty przez BLIK
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="blik-field" style="display: {% if is_edit and zbiorka.uzyj_blik %}block{% else %}none{% endif %};">
|
||||||
|
<label for="numer_telefonu_blik" class="form-label small">Numer telefonu BLIK</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="numer_telefonu_blik"
|
||||||
|
name="numer_telefonu_blik"
|
||||||
|
value="{{ zbiorka.numer_telefonu_blik if is_edit else (global_settings.numer_telefonu_blik if global_settings else '') }}"
|
||||||
|
placeholder="9 cyfr numeru telefonu">
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
{% if global_settings and global_settings.numer_telefonu_blik %}
|
||||||
|
Domyślnie: {{ global_settings.numer_telefonu_blik }}
|
||||||
|
{% else %}
|
||||||
|
Zostaw puste dla globalnego numeru
|
||||||
|
{% endif %}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not is_edit %}
|
||||||
|
<!-- Alert informacyjny tylko przy tworzeniu -->
|
||||||
|
<div class="alert alert-info mb-4">
|
||||||
|
<strong>Wskazówka:</strong> Lista rezerwowa to pomocnicze miejsce do gromadzenia środków,
|
||||||
|
które mogą być później przesuwane do konkretnych zbiórek. Jest ukryta dla użytkowników
|
||||||
|
nieadministracyjnych i nie pojawia się na stronie głównej.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<!-- Przyciski akcji -->
|
||||||
|
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
{{ 'Zapisz zmiany' if is_edit else 'Utwórz listę rezerwową' }}
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('lista_rezerwowych') }}" class="btn btn-outline-light">Anuluj</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if is_edit %}
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('transakcje_zbiorki', zbiorka_id=zbiorka.id) }}"
|
||||||
|
class="btn btn-outline-light btn-sm">Zobacz transakcje</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block extra_scripts %}
|
||||||
|
{{ super() }}
|
||||||
|
<script src="{{ asset_url('js/formularz_rezerwy.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
358
zbiorka_app/templates/admin/formularz_zbiorek.html
Normal file
358
zbiorka_app/templates/admin/formularz_zbiorek.html
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
{# templates/formularz_zbiorek.html #}
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% set has_obj = zbiorka is not none %}
|
||||||
|
{% set is_edit = has_obj and zbiorka.id is not none %}
|
||||||
|
|
||||||
|
{% block title %}{{ 'Edytuj zbiórkę' if is_edit else '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">
|
||||||
|
|
||||||
|
<!-- Nawigacja / powrót -->
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
{% if is_edit and zbiorka and zbiorka.id %}
|
||||||
|
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-sm btn-outline-light">← Szczegóły
|
||||||
|
zbiórki</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-sm btn-outline-light">← Panel Admina</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div
|
||||||
|
class="card-header bg-secondary text-white d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||||
|
<h3 class="card-title mb-0">
|
||||||
|
{{ 'Edytuj zbiórkę' if is_edit else 'Dodaj nową zbiórkę' }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{% if is_edit %}
|
||||||
|
<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 not zbiorka.ukryj_kwote %}
|
||||||
|
<span class="badge bg-dark border" style="border-color: var(--border);">
|
||||||
|
Stan: {{ (zbiorka.stan or 0)|round(2) }} PLN
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{% if zbiorka.cel %}
|
||||||
|
{% set delta = (zbiorka.cel or 0) - (zbiorka.stan or 0) %}
|
||||||
|
{% if delta > 0 %}
|
||||||
|
<span class="badge bg-dark border" style="border-color: var(--border);">
|
||||||
|
Brakuje: {{ delta|round(2) }} PLN
|
||||||
|
</span>
|
||||||
|
{% elif delta < 0 %} <span class="badge bg-dark border" style="border-color: var(--border);">
|
||||||
|
Nadwyżka: {{ (-delta)|round(2) }} PLN
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if zbiorka.ukryj_kwote %}
|
||||||
|
<span class="badge bg-secondary">Kwoty niepubliczne</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-success">Kwoty widoczne</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<small class="opacity-75">Uzupełnij podstawowe dane i dane płatności</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" novalidate id="{{ 'form-edit-zbiorka' if is_edit else 'form-add-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" maxlength="120"
|
||||||
|
placeholder="{{ 'Krótki, zrozumiały tytuł' if is_edit else 'Np. Wsparcie dla schroniska Azor' }}"
|
||||||
|
value="{{ (zbiorka.nazwa if zbiorka else request.form.get('nazwa','')) }}" 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">{{ (zbiorka.opis if zbiorka else request.form.get('opis','')) }}</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: Lista produktów -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<h6 class="text-muted mb-2">Lista produktów / Pozycje / Cele</h6>
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
Wypunktuj dokładnie produkty do zakupu — podaj nazwę, opcjonalny link do sklepu i cenę.
|
||||||
|
Status domyślnie <code>Do kupienia</code>; przełącz na <code>Kupione</code> po realizacji.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table align-middle" id="produkty-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="min-width:220px;">Produkt</th>
|
||||||
|
<th style="min-width:240px;">Link do sklepu</th>
|
||||||
|
<th style="width:140px;">Cena [PLN]</th>
|
||||||
|
<th style="width:160px;">Status</th>
|
||||||
|
<th style="width:60px;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="produkty-body">
|
||||||
|
{% set items = (zbiorka.przedmioty if zbiorka and zbiorka.przedmioty else []) %}
|
||||||
|
{% if items %}
|
||||||
|
{% for it in items %}
|
||||||
|
{% set i = loop.index0 %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input type="text" class="form-control" name="item_nazwa[]"
|
||||||
|
value="{{ it.nazwa }}" placeholder="np. Karma Brit 10kg" required>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="url" class="form-control" name="item_link[]"
|
||||||
|
value="{{ it.link or '' }}" placeholder="https://...">
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" inputmode="decimal" class="form-control text-end"
|
||||||
|
name="item_cena[]"
|
||||||
|
value="{{ (it.cena|round(2)) if it.cena is not none else '' }}"
|
||||||
|
placeholder="0,00">
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input kupione-switch" type="checkbox" {% if
|
||||||
|
it.kupione %}checked{% endif %}>
|
||||||
|
<input type="hidden" name="item_kupione_val_{{ i }}"
|
||||||
|
value="{{ 1 if it.kupione else 0 }}">
|
||||||
|
<label class="form-check-label small">{{ 'Kupione' if it.kupione else 'Do
|
||||||
|
kupienia' }}</label>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-light remove-row"
|
||||||
|
title="Usuń wiersz">✕</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<!-- pusty wiersz startowy -->
|
||||||
|
<tr>
|
||||||
|
<td><input type="text" class="form-control" name="item_nazwa[]"
|
||||||
|
placeholder="np. Karma Brit 10kg" required></td>
|
||||||
|
<td><input type="url" class="form-control" name="item_link[]"
|
||||||
|
placeholder="https://..."></td>
|
||||||
|
<td><input type="text" inputmode="decimal" class="form-control text-end"
|
||||||
|
name="item_cena[]" placeholder="0,00"></td>
|
||||||
|
<td>
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input kupione-switch" type="checkbox">
|
||||||
|
<input type="hidden" name="item_kupione_val_0" value="0">
|
||||||
|
<label class="form-check-label small">Do kupienia</label>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-light remove-row"
|
||||||
|
title="Usuń wiersz">✕</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-light" id="add-row">+ Dodaj pozycję</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-light" id="clear-empty">Usuń puste
|
||||||
|
wiersze</button>
|
||||||
|
</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">
|
||||||
|
<!-- Przełączniki kanałów -->
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="uzyj_konta" name="uzyj_konta" {% if
|
||||||
|
zbiorka is none or zbiorka.uzyj_konta %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="uzyj_konta">Przelew na konto</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="uzyj_blik" name="uzyj_blik" {% if
|
||||||
|
zbiorka is none or zbiorka.uzyj_blik %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="uzyj_blik">BLIK</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div id="kanalyWarning" class="alert alert-warning d-none mt-2" role="alert">
|
||||||
|
Musi być włączony co najmniej jeden kanał wpłat (konto lub BLIK).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- IBAN -->
|
||||||
|
<div class="col-12" id="pole_konto">
|
||||||
|
<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"
|
||||||
|
inputmode="numeric" autocomplete="off"
|
||||||
|
placeholder="12 3456 7890 1234 5678 9012 3456" {% if zbiorka and not
|
||||||
|
zbiorka.uzyj_konta %}disabled{% endif %} {% if zbiorka is none or zbiorka.uzyj_konta
|
||||||
|
%}required{% endif %} aria-describedby="ibanHelp"
|
||||||
|
value="{% if zbiorka and zbiorka.numer_konta %}{{ zbiorka.numer_konta }}{% elif global_settings %}{{ global_settings.numer_konta }}{% else %}{{ request.form.get('numer_konta','') }}{% endif %}">
|
||||||
|
</div>
|
||||||
|
<div id="ibanHelp" class="form-text">Wpisz ciąg cyfr; spacje dodadzą się automatycznie dla
|
||||||
|
czytelności.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- BLIK -->
|
||||||
|
<div class="col-12 col-md-6" id="pole_blik">
|
||||||
|
<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" inputmode="tel" pattern="[0-9 ]{9,13}"
|
||||||
|
placeholder="123 456 789" {% if zbiorka and not zbiorka.uzyj_blik %}disabled{% endif
|
||||||
|
%} {% if zbiorka is none or zbiorka.uzyj_blik %}required{% endif %}
|
||||||
|
aria-describedby="blikHelp"
|
||||||
|
value="{% if zbiorka and zbiorka.numer_telefonu_blik %}{{ zbiorka.numer_telefonu_blik }}{% elif global_settings %}{{ global_settings.numer_telefonu_blik }}{% else %}{{ request.form.get('numer_telefonu_blik','') }}{% endif %}">
|
||||||
|
</div>
|
||||||
|
<div id="blikHelp" class="form-text">Dziewięć cyfr telefonu powiązanego z BLIK. Spacje
|
||||||
|
opcjonalne.</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if is_edit %}
|
||||||
|
<div class="col-12 col-md-12 d-flex align-items-end">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-light" 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 %}>
|
||||||
|
Wstaw globalne ustawienia
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</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 id="celSyncBox" class="alert d-none py-2 px-3 mb-3" role="alert">
|
||||||
|
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||||
|
<div id="celSyncMsg" class="small"></div>
|
||||||
|
<button type="button" id="btnApplyCelFromSum"
|
||||||
|
class="btn btn-sm btn-outline-light d-none"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="text" inputmode="decimal" class="form-control" id="cel" name="cel"
|
||||||
|
placeholder="0,00" required aria-describedby="celHelp"
|
||||||
|
value="{% if zbiorka and zbiorka.cel is not none %}{{ zbiorka.cel }}{% else %}{{ request.form.get('cel','') }}{% endif %}">
|
||||||
|
</div>
|
||||||
|
<div id="celHelp" class="form-text">Minimalnie 0,01 PLN. Można później edytować.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-12 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 %}{% if zbiorka.ukryj_kwote %}checked{% endif %}{% endif %}>
|
||||||
|
<label class="form-check-label" for="ukryj_kwote">Ukryj kwoty (cel i stan)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mt-2">
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="pokaz_postep_finanse"
|
||||||
|
name="pokaz_postep_finanse" data-group="postepy" {% if zbiorka %}{% if
|
||||||
|
zbiorka.pokaz_postep_finanse %}checked{% endif %}{% else %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="pokaz_postep_finanse">Pokaż postęp: Finanse</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="pokaz_postep_pozycje"
|
||||||
|
name="pokaz_postep_pozycje" data-group="postepy" {% if zbiorka %}{% if
|
||||||
|
zbiorka.pokaz_postep_pozycje %}checked{% endif %}{% else %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="pokaz_postep_pozycje">Pokaż postęp: Zakupy
|
||||||
|
(liczba)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="pokaz_postep_kwotowo"
|
||||||
|
name="pokaz_postep_kwotowo" data-group="postepy" {% if zbiorka %}{% if
|
||||||
|
zbiorka.pokaz_postep_kwotowo %}checked{% endif %}{% else %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="pokaz_postep_kwotowo">Pokaż postęp: Zakupy
|
||||||
|
(kwotowo)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div><br>
|
||||||
|
<div id="postepyWarning" class="alert alert-warning d-none mt-2" role="alert">
|
||||||
|
Nie można wyłączyć wszystkich wskaźników postępu. Pozostaw przynajmniej jeden włączony.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<button type="submit" class="btn btn-success">
|
||||||
|
{{ ' Zaktualizuj zbiórkę' if is_edit else 'Dodaj zbiórkę' }}
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-light">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="{{ asset_url('js/mde_custom.js') }}"></script>
|
||||||
|
<script src="{{ asset_url('js/formularz_zbiorek.js') }}"></script>
|
||||||
|
<script src="{{ asset_url('js/produkty_formularz.js') }}"></script>
|
||||||
|
<script src="{{ asset_url('js/kwoty_formularz.js') }}"></script>
|
||||||
|
<script src="{{ asset_url('js/przelaczniki_zabezpieczenie.js') }}"></script>
|
||||||
|
<script src="{{ asset_url('js/sposoby_wplat.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
199
zbiorka_app/templates/admin/lista_rezerwowych.html
Normal file
199
zbiorka_app/templates/admin/lista_rezerwowych.html
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Listy rezerwowe{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="container my-4">
|
||||||
|
<!-- Nagłówek + akcje globalne -->
|
||||||
|
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-1">Listy rezerwowe</h2>
|
||||||
|
<p class="text-muted mb-0">Zarządzaj środkami rezerwowymi i nadpłatami</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<a href="{{ url_for('dodaj_rezerwe') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> Dodaj listę
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-light">
|
||||||
|
<i class="bi bi-arrow-left"></i> Panel Admina
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Szybkie statystyki dla rezerw -->
|
||||||
|
{% if rezerwy %}
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-info">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="bi bi-wallet2 fs-1 text-info mb-2"></i>
|
||||||
|
<h3 class="mb-0">{{ rezerwy|length }}</h3>
|
||||||
|
<small class="text-muted">Aktywnych list</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-success">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="bi bi-piggy-bank fs-1 text-success mb-2"></i>
|
||||||
|
<h3 class="mb-0">{{ "%.2f"|format(rezerwy|sum(attribute='stan')) }} zł</h3>
|
||||||
|
<small class="text-muted">Łączna rezerwa</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-warning">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="bi bi-eye fs-1 text-warning mb-2"></i>
|
||||||
|
<h3 class="mb-0">{{ rezerwy|selectattr('ukryta', 'equalto', False)|list|length }}</h3>
|
||||||
|
<small class="text-muted">Widocznych publicznie</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if rezerwy %}
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-dark table-striped table-hover align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:72px;">ID</th>
|
||||||
|
<th>Nazwa</th>
|
||||||
|
<th class="text-end" style="width:150px;">Stan</th>
|
||||||
|
<th style="width:140px;">Widoczność</th>
|
||||||
|
<th style="width:1%;">Opcje</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in rezerwy %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted">{{ r.id }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<a href="{{ url_for('zbiorka', zbiorka_id=r.id) }}" class="fw-semibold text-decoration-none">
|
||||||
|
<i class="bi bi-wallet2"></i> {{ r.nazwa }}
|
||||||
|
</a>
|
||||||
|
{% if r.opis %}
|
||||||
|
<small class="text-muted">
|
||||||
|
{{ r.opis[:60] }}{% if r.opis|length > 60 %}...{% endif %}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<strong class="text-success">{{ "%.2f"|format(r.stan) }} zł</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if r.ukryta %}
|
||||||
|
<span class="badge bg-secondary border" style="border-color: var(--border);">
|
||||||
|
<i class="bi bi-eye-slash"></i> Ukryta
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-success">
|
||||||
|
<i class="bi bi-eye"></i> Widoczna
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<!-- Grupa akcji: główne + rozwijane -->
|
||||||
|
<div class="btn-group">
|
||||||
|
<a href="{{ url_for('edytuj_rezerwe', rezerwa_id=r.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-light">
|
||||||
|
<i class="bi bi-pencil"></i> Edytuj
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-sm btn-outline-light dropdown-toggle dropdown-toggle-split"
|
||||||
|
data-bs-toggle="dropdown" aria-expanded="false" aria-label="Więcej opcji">
|
||||||
|
<span class="visually-hidden">Więcej</span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-dark dropdown-menu-end shadow">
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item"
|
||||||
|
href="{{ url_for('zbiorka', zbiorka_id=r.id) }}">
|
||||||
|
<i class="bi bi-eye"></i> Podgląd
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item"
|
||||||
|
href="{{ url_for('dodaj_wplate', zbiorka_id=r.id) }}">
|
||||||
|
<i class="bi bi-plus-circle text-success"></i> Dodaj wpłatę
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item"
|
||||||
|
href="{{ url_for('dodaj_wydatek', zbiorka_id=r.id) }}">
|
||||||
|
<i class="bi bi-dash-circle text-danger"></i> Dodaj wydatek
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item"
|
||||||
|
href="{{ url_for('dodaj_przesuniecie', zbiorka_id=r.id) }}">
|
||||||
|
<i class="bi bi-arrow-left-right text-info"></i> Przesuń środki
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item"
|
||||||
|
href="{{ url_for('transakcje_zbiorki', zbiorka_id=r.id) }}">
|
||||||
|
<i class="bi bi-list-ul"></i> Transakcje
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item"
|
||||||
|
href="{{ url_for('edytuj_stan', zbiorka_id=r.id) }}">
|
||||||
|
<i class="bi bi-currency-dollar"></i> Edytuj stan
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<form action="{{ url_for('zmien_widzialnosc', zbiorka_id=r.id) }}"
|
||||||
|
method="post" class="m-0">
|
||||||
|
<button type="submit" class="dropdown-item">
|
||||||
|
{% if r.ukryta %}
|
||||||
|
<i class="bi bi-eye"></i> Pokaż
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-eye-slash"></i> Ukryj
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<form action="{{ url_for('usun_rezerwe', rezerwa_id=r.id) }}" method="post"
|
||||||
|
class="m-0"
|
||||||
|
onsubmit="return confirm('Czy na pewno usunąć listę \'{{ r.nazwa }}\'?\n\nUWAGA: Zostaną usunięte wszystkie transakcje powiązane z tą listą!');">
|
||||||
|
<button type="submit" class="dropdown-item text-danger">
|
||||||
|
<i class="bi bi-trash"></i> Usuń
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<i class="bi bi-wallet2 fs-1 text-muted mb-3"></i>
|
||||||
|
<h5 class="mb-2">Brak list rezerwowych</h5>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Nie masz jeszcze żadnych list rezerwowych.<br>
|
||||||
|
Utwórz pierwszą, aby zarządzać nadpłatami i środkami rezerwowymi.
|
||||||
|
</p>
|
||||||
|
<a href="{{ url_for('dodaj_rezerwe') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> Dodaj listę rezerwową
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
119
zbiorka_app/templates/admin/przesun_wplate.html
Normal file
119
zbiorka_app/templates/admin/przesun_wplate.html
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Przesuń wpłatę - {{ zbiorka.nazwa }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="container my-5">
|
||||||
|
<!-- Nagłówek -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<h1 class="mb-2">
|
||||||
|
<i class="bi bi-arrow-left-right text-primary"></i> Przesuń konkretną wpłatę
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted">Przenieś wybraną wpłatę do innej zbiórki</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Szczegóły wpłaty -->
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-transparent">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-receipt text-success"></i> Szczegóły wpłaty
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="border-start border-primary border-3 ps-3">
|
||||||
|
<small class="text-muted d-block">Źródło</small>
|
||||||
|
<strong class="fs-6">{{ zbiorka.nazwa }}</strong>
|
||||||
|
{% if zbiorka.typ_zbiorki == 'rezerwa' %}
|
||||||
|
<span class="badge bg-info ms-2">Lista rezerwowa</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="border-start border-success border-3 ps-3">
|
||||||
|
<small class="text-muted d-block">Kwota</small>
|
||||||
|
<strong class="fs-4 text-success">{{ wplata.kwota|round(2) }} PLN</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="border-start border-secondary border-3 ps-3">
|
||||||
|
<small class="text-muted d-block">Data wpłaty</small>
|
||||||
|
<strong>{{ wplata.data|dt("%d.%m.%Y %H:%M") }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if wplata.opis %}
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="border-start border-secondary border-3 ps-3">
|
||||||
|
<small class="text-muted d-block">Opis oryginalny</small>
|
||||||
|
<strong>{{ wplata.opis }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formularz przesunięcia -->
|
||||||
|
<form method="POST">
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-transparent">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-box-arrow-right text-warning"></i> Cel przesunięcia
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="zbiorka_cel_id" class="form-label fw-semibold">
|
||||||
|
Docelowa zbiórka <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="zbiorka_cel_id" name="zbiorka_cel_id" required>
|
||||||
|
<option value="">-- Wybierz zbiórkę docelową --</option>
|
||||||
|
{% for zb in dostepne_zbiorki %}
|
||||||
|
<option value="{{ zb.id }}">
|
||||||
|
{% if zb.typ_zbiorki == 'rezerwa' %}
|
||||||
|
[Rezerwa] {{ zb.nazwa }} · Stan: {{ zb.stan|round(2) }} PLN
|
||||||
|
{% else %}
|
||||||
|
{{ zb.nazwa }} · Stan: {{ zb.stan|round(2) }} PLN
|
||||||
|
{% endif %}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-0">
|
||||||
|
<label for="opis" class="form-label fw-semibold">
|
||||||
|
Dodatkowy opis przesunięcia <span class="text-muted">(opcjonalny)</span>
|
||||||
|
</label>
|
||||||
|
<textarea class="form-control" id="opis" name="opis" rows="3"
|
||||||
|
placeholder="np. Powód przesunięcia, notatki..."></textarea>
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="bi bi-info-circle"></i> Oryginalny opis wpłaty zostanie zachowany
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alert informacyjny -->
|
||||||
|
<div class="alert alert-info d-flex align-items-start mb-3">
|
||||||
|
<i class="bi bi-lightbulb fs-5 me-2"></i>
|
||||||
|
<div class="small">
|
||||||
|
<strong>Jak to działa:</strong>
|
||||||
|
<ul class="mb-0 mt-1">
|
||||||
|
<li>Wpłata zostanie przeniesiona do wybranej zbiórki wraz z całą historią</li>
|
||||||
|
<li>Zostanie utworzony wpis o przesunięciu w obu zbiórkach</li>
|
||||||
|
<li>Stany finansowe zostaną automatycznie zaktualizowane</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-arrow-left-right"></i> Wykonaj przesunięcie
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('transakcje_zbiorki', zbiorka_id=zbiorka.id) }}"
|
||||||
|
class="btn btn-secondary">Anuluj</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
461
zbiorka_app/templates/admin/statystyki.html
Normal file
461
zbiorka_app/templates/admin/statystyki.html
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Statystyki - Panel Admina{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container my-4">
|
||||||
|
|
||||||
|
<!-- Nagłówek + akcje globalne -->
|
||||||
|
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-4">
|
||||||
|
<h2 class="mb-0">Statystyki systemu</h2>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-light">
|
||||||
|
← Powrót do panelu
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('admin_ustawienia') }}" class="btn btn-outline-light">
|
||||||
|
Ustawienia główne
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KARTY PODSUMOWANIA -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-success">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h6 class="text-success mb-2">Suma wpłat</h6>
|
||||||
|
<h3 class="mb-1">{{ "%.2f"|format(total_wplaty) }} zł</h3>
|
||||||
|
<small class="text-muted">{{ liczba_wplat }} wpłat</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-danger">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h6 class="text-danger mb-2">Suma wydatków</h6>
|
||||||
|
<h3 class="mb-1">{{ "%.2f"|format(total_wydatki) }} zł</h3>
|
||||||
|
<small class="text-muted">{{ liczba_wydatkow }} wydatków</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-info">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h6 class="text-info mb-2">Suma przesunięć</h6>
|
||||||
|
<h3 class="mb-1">{{ "%.2f"|format(total_przesuniec) }} zł</h3>
|
||||||
|
<small class="text-muted">{{ liczba_przesuniec }} operacji</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card {% if bilans >= 0 %}border-success{% else %}border-danger{% endif %}">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h6 class="{% if bilans >= 0 %}text-success{% else %}text-danger{% endif %} mb-2">Bilans</h6>
|
||||||
|
<h3 class="mb-1 {% if bilans >= 0 %}text-success{% else %}text-danger{% endif %}">
|
||||||
|
{{ "%.2f"|format(bilans) }} zł
|
||||||
|
</h3>
|
||||||
|
<small class="text-muted">{{ liczba_zbiorek }} zbiórek</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pigułki: Podsumowanie / Aktywność / Miesięczne / Roczne -->
|
||||||
|
<ul class="nav nav-pills mb-3" id="statsTabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="tab-podsumowanie" data-bs-toggle="tab" data-bs-target="#pane-podsumowanie"
|
||||||
|
type="button" role="tab" aria-controls="pane-podsumowanie" aria-selected="true">
|
||||||
|
Podsumowanie
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="tab-aktywnosc" data-bs-toggle="tab" data-bs-target="#pane-aktywnosc"
|
||||||
|
type="button" role="tab" aria-controls="pane-aktywnosc" aria-selected="false">
|
||||||
|
Aktywność 7/30 dni
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="tab-miesieczne" data-bs-toggle="tab" data-bs-target="#pane-miesieczne"
|
||||||
|
type="button" role="tab" aria-controls="pane-miesieczne" aria-selected="false">
|
||||||
|
Miesięczne
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="tab-roczne" data-bs-toggle="tab" data-bs-target="#pane-roczne"
|
||||||
|
type="button" role="tab" aria-controls="pane-roczne" aria-selected="false">
|
||||||
|
Roczne
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content">
|
||||||
|
|
||||||
|
<!-- PANE: Podsumowanie -->
|
||||||
|
<div class="tab-pane fade show active" id="pane-podsumowanie" role="tabpanel" aria-labelledby="tab-podsumowanie" tabindex="0">
|
||||||
|
|
||||||
|
<!-- Rekordy -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-success text-white">
|
||||||
|
<h6 class="mb-0">Najwyższa wpłata</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if najwyzsza_wplata %}
|
||||||
|
<h4 class="mb-2">{{ "%.2f"|format(najwyzsza_wplata.kwota) }} zł</h4>
|
||||||
|
<p class="mb-1"><strong>Opis:</strong> {{ najwyzsza_wplata.opis or "Brak opisu" }}</p>
|
||||||
|
<p class="mb-1"><strong>Data:</strong> {{ najwyzsza_wplata.data.strftime('%d.%m.%Y') }}</p>
|
||||||
|
<p class="mb-0"><strong>Zbiórka:</strong>
|
||||||
|
<a href="{{ url_for('zbiorka', zbiorka_id=najwyzsza_wplata.zbiorka_id) }}" class="text-decoration-none">
|
||||||
|
{{ najwyzsza_wplata.zbiorka.nazwa }}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mb-0">Brak wpłat</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-danger text-white">
|
||||||
|
<h6 class="mb-0">Najwyższy wydatek</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if najwyzszy_wydatek %}
|
||||||
|
<h4 class="mb-2">{{ "%.2f"|format(najwyzszy_wydatek.kwota) }} zł</h4>
|
||||||
|
<p class="mb-1"><strong>Opis:</strong> {{ najwyzszy_wydatek.opis or "Brak opisu" }}</p>
|
||||||
|
<p class="mb-1"><strong>Data:</strong> {{ najwyzszy_wydatek.data.strftime('%d.%m.%Y') }}</p>
|
||||||
|
<p class="mb-0"><strong>Zbiórka:</strong>
|
||||||
|
<a href="{{ url_for('zbiorka', zbiorka_id=najwyzszy_wydatek.zbiorka_id) }}" class="text-decoration-none">
|
||||||
|
{{ najwyzszy_wydatek.zbiorka.nazwa }}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mb-0">Brak wydatków</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-info text-white">
|
||||||
|
<h6 class="mb-0">Średnie wartości</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="mb-2"><strong>Średnia wpłata:</strong> {{ "%.2f"|format(srednia_wplata) }} zł</p>
|
||||||
|
<p class="mb-0"><strong>Średni wydatek:</strong> {{ "%.2f"|format(sredni_wydatek) }} zł</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top 10 wpłat -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header" style="background: var(--accent); color: #111;">
|
||||||
|
<h6 class="mb-0">Top 10 najwyższych wpłat</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if top_10_wplat %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-dark table-striped table-hover align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:60px;">#</th>
|
||||||
|
<th>Kwota</th>
|
||||||
|
<th>Opis</th>
|
||||||
|
<th>Data</th>
|
||||||
|
<th>Zbiórka</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for wplata in top_10_wplat %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted">{{ loop.index }}</td>
|
||||||
|
<td><strong class="text-success">{{ "%.2f"|format(wplata.kwota) }} zł</strong></td>
|
||||||
|
<td>{{ wplata.opis or "Brak opisu" }}</td>
|
||||||
|
<td class="text-muted">{{ wplata.data.strftime('%d.%m.%Y') }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('zbiorka', zbiorka_id=wplata.zbiorka_id) }}" class="text-decoration-none">
|
||||||
|
{{ wplata.zbiorka.nazwa }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card-body text-center py-4">
|
||||||
|
<p class="text-muted mb-0">Brak danych</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top 5 zbiórek -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h6 class="mb-0">Top 5 zbiórek (największe wpłaty)</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if top_zbiorki %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-dark table-striped table-hover align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:60px;">#</th>
|
||||||
|
<th>Nazwa zbiórki</th>
|
||||||
|
<th class="text-end" style="width:180px;">Suma wpłat</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for zbiorka, suma in top_zbiorki %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted">{{ loop.index }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="text-decoration-none">
|
||||||
|
{{ zbiorka.nazwa }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="text-end"><strong>{{ "%.2f"|format(suma) }} zł</strong></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card-body text-center py-4">
|
||||||
|
<p class="text-muted mb-0">Brak danych</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top 5 źródeł przesunięć -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-info text-white">
|
||||||
|
<h6 class="mb-0">Top 5 źródeł przesunięć</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if top_zrodla_przesuniec %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-dark table-striped table-hover align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:60px;">#</th>
|
||||||
|
<th>Zbiórka źródłowa</th>
|
||||||
|
<th class="text-end">Liczba przesunięć</th>
|
||||||
|
<th class="text-end" style="width:180px;">Suma</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for nazwa, liczba, suma in top_zrodla_przesuniec %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted">{{ loop.index }}</td>
|
||||||
|
<td>{{ nazwa }}</td>
|
||||||
|
<td class="text-end text-muted">{{ liczba }}</td>
|
||||||
|
<td class="text-end"><strong>{{ "%.2f"|format(suma) }} zł</strong></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card-body text-center py-4">
|
||||||
|
<p class="text-muted mb-0">Brak przesunięć</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PANE: Aktywność 7/30 dni -->
|
||||||
|
<div class="tab-pane fade" id="pane-aktywnosc" role="tabpanel" aria-labelledby="tab-aktywnosc" tabindex="0">
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<!-- Ostatnie 7 dni -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-success text-white">
|
||||||
|
<h6 class="mb-0">Ostatnie 7 dni</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="mb-3">Wpłaty</h5>
|
||||||
|
<p class="mb-1"><strong>Liczba:</strong> {{ wplaty_7dni.liczba or 0 }}</p>
|
||||||
|
<p class="mb-3"><strong>Suma:</strong> {{ "%.2f"|format(wplaty_7dni.suma or 0) }} zł</p>
|
||||||
|
|
||||||
|
<h5 class="mb-3">Wydatki</h5>
|
||||||
|
<p class="mb-1"><strong>Liczba:</strong> {{ wydatki_7dni.liczba or 0 }}</p>
|
||||||
|
<p class="mb-0"><strong>Suma:</strong> {{ "%.2f"|format(wydatki_7dni.suma or 0) }} zł</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ostatnie 30 dni -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h6 class="mb-0">Ostatnie 30 dni</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="mb-3">Wpłaty</h5>
|
||||||
|
<p class="mb-1"><strong>Liczba:</strong> {{ wplaty_30dni.liczba or 0 }}</p>
|
||||||
|
<p class="mb-3"><strong>Suma:</strong> {{ "%.2f"|format(wplaty_30dni.suma or 0) }} zł</p>
|
||||||
|
|
||||||
|
<h5 class="mb-3">Wydatki</h5>
|
||||||
|
<p class="mb-1"><strong>Liczba:</strong> {{ wydatki_30dni.liczba or 0 }}</p>
|
||||||
|
<p class="mb-0"><strong>Suma:</strong> {{ "%.2f"|format(wydatki_30dni.suma or 0) }} zł</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PANE: Statystyki miesięczne -->
|
||||||
|
<div class="tab-pane fade" id="pane-miesieczne" role="tabpanel" aria-labelledby="tab-miesieczne" tabindex="0">
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header" style="background: var(--accent); color: #111;">
|
||||||
|
<h6 class="mb-0">Ostatnie 12 miesięcy</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if wplaty_miesieczne or wydatki_miesieczne or przesuniecia_miesieczne %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-dark table-striped table-hover align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Miesiąc</th>
|
||||||
|
<th class="text-end">Wpłaty (suma)</th>
|
||||||
|
<th class="text-end">Wpłaty (liczba)</th>
|
||||||
|
<th class="text-end">Wydatki (suma)</th>
|
||||||
|
<th class="text-end">Wydatki (liczba)</th>
|
||||||
|
<th class="text-end">Przesunięcia (suma)</th>
|
||||||
|
<th class="text-end">Bilans</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% set wplaty_dict = {} %}
|
||||||
|
{% set wydatki_dict = {} %}
|
||||||
|
{% set przesuniecia_dict = {} %}
|
||||||
|
|
||||||
|
{% for rok, miesiac, suma, liczba in wplaty_miesieczne %}
|
||||||
|
{% set klucz = "%d-%02d"|format(rok|int, miesiac|int) %}
|
||||||
|
{% set _ = wplaty_dict.update({klucz: {'suma': suma, 'liczba': liczba}}) %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for rok, miesiac, suma, liczba in wydatki_miesieczne %}
|
||||||
|
{% set klucz = "%d-%02d"|format(rok|int, miesiac|int) %}
|
||||||
|
{% set _ = wydatki_dict.update({klucz: {'suma': suma, 'liczba': liczba}}) %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for rok, miesiac, suma, liczba in przesuniecia_miesieczne %}
|
||||||
|
{% set klucz = "%d-%02d"|format(rok|int, miesiac|int) %}
|
||||||
|
{% set _ = przesuniecia_dict.update({klucz: {'suma': suma, 'liczba': liczba}}) %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% set miesiace = (wplaty_dict.keys() | list + wydatki_dict.keys() | list + przesuniecia_dict.keys() | list) | unique | sort | reverse %}
|
||||||
|
|
||||||
|
{% for miesiac_key in miesiace %}
|
||||||
|
{% set wp = wplaty_dict.get(miesiac_key, {'suma': 0, 'liczba': 0}) %}
|
||||||
|
{% set wy = wydatki_dict.get(miesiac_key, {'suma': 0, 'liczba': 0}) %}
|
||||||
|
{% set pr = przesuniecia_dict.get(miesiac_key, {'suma': 0, 'liczba': 0}) %}
|
||||||
|
{% set bilans_m = wp.suma - wy.suma %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ miesiac_key }}</strong></td>
|
||||||
|
<td class="text-end text-success">{{ "%.2f"|format(wp.suma) }} zł</td>
|
||||||
|
<td class="text-end text-muted">{{ wp.liczba }}</td>
|
||||||
|
<td class="text-end text-danger">{{ "%.2f"|format(wy.suma) }} zł</td>
|
||||||
|
<td class="text-end text-muted">{{ wy.liczba }}</td>
|
||||||
|
<td class="text-end text-info">{{ "%.2f"|format(pr.suma) }} zł</td>
|
||||||
|
<td class="text-end {% if bilans_m >= 0 %}text-success{% else %}text-danger{% endif %}">
|
||||||
|
<strong>{{ "%.2f"|format(bilans_m) }} zł</strong>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card-body text-center py-4">
|
||||||
|
<p class="text-muted mb-0">Brak danych</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PANE: Statystyki roczne -->
|
||||||
|
<div class="tab-pane fade" id="pane-roczne" role="tabpanel" aria-labelledby="tab-roczne" tabindex="0">
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-secondary text-white">
|
||||||
|
<h6 class="mb-0">Zestawienie roczne</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if wplaty_roczne or wydatki_roczne %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-dark table-striped table-hover align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Rok</th>
|
||||||
|
<th class="text-end">Wpłaty (suma)</th>
|
||||||
|
<th class="text-end">Wpłaty (liczba)</th>
|
||||||
|
<th class="text-end">Wydatki (suma)</th>
|
||||||
|
<th class="text-end">Wydatki (liczba)</th>
|
||||||
|
<th class="text-end">Bilans</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% set wplaty_dict = {} %}
|
||||||
|
{% set wydatki_dict = {} %}
|
||||||
|
|
||||||
|
{% for rok, suma, liczba in wplaty_roczne %}
|
||||||
|
{% set _ = wplaty_dict.update({rok|int: {'suma': suma, 'liczba': liczba}}) %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for rok, suma, liczba in wydatki_roczne %}
|
||||||
|
{% set _ = wydatki_dict.update({rok|int: {'suma': suma, 'liczba': liczba}}) %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% set lata = (wplaty_dict.keys() | list + wydatki_dict.keys() | list) | unique | sort | reverse %}
|
||||||
|
|
||||||
|
{% for rok in lata %}
|
||||||
|
{% set wp = wplaty_dict.get(rok, {'suma': 0, 'liczba': 0}) %}
|
||||||
|
{% set wy = wydatki_dict.get(rok, {'suma': 0, 'liczba': 0}) %}
|
||||||
|
{% set bilans_rok = wp.suma - wy.suma %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ rok|int }}</strong></td>
|
||||||
|
<td class="text-end text-success">{{ "%.2f"|format(wp.suma) }} zł</td>
|
||||||
|
<td class="text-end text-muted">{{ wp.liczba }}</td>
|
||||||
|
<td class="text-end text-danger">{{ "%.2f"|format(wy.suma) }} zł</td>
|
||||||
|
<td class="text-end text-muted">{{ wy.liczba }}</td>
|
||||||
|
<td class="text-end {% if bilans_rok >= 0 %}text-success{% else %}text-danger{% endif %}">
|
||||||
|
<strong>{{ "%.2f"|format(bilans_rok) }} zł</strong>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card-body text-center py-4">
|
||||||
|
<p class="text-muted mb-0">Brak danych</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
223
zbiorka_app/templates/admin/transakcje.html
Normal file
223
zbiorka_app/templates/admin/transakcje.html
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Transakcje – {{ zbiorka.nazwa }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container my-4">
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-1">Transakcje: {{ zbiorka.nazwa }}</h3>
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||||
|
{% if zbiorka.typ_zbiorki == 'rezerwa' %}
|
||||||
|
<span class="badge bg-info">Lista rezerwowa</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if zbiorka.cel and zbiorka.typ_zbiorki != 'rezerwa' %}
|
||||||
|
<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 border-success" style="border-color: var(--border);">
|
||||||
|
Stan: {{ zbiorka.stan|round(2) }} PLN
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{% if zbiorka.cel and zbiorka.cel > 0 and zbiorka.typ_zbiorki != 'rezerwa' %}
|
||||||
|
{% set delta = zbiorka.cel - zbiorka.stan %}
|
||||||
|
{% if delta > 0 %}
|
||||||
|
<span class="badge bg-dark border border-warning">
|
||||||
|
Brakuje: {{ delta|round(2) }} PLN
|
||||||
|
</span>
|
||||||
|
{% elif delta < 0 %}
|
||||||
|
<span class="badge bg-dark border" style="border-color: var(--border);">
|
||||||
|
Nadwyżka: {{ (-delta)|round(2) }} PLN
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge rounded-pill" style="background: var(--accent); color:#111;">Zrealizowana</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-group" role="group" aria-label="Akcje zbiórki">
|
||||||
|
<a class="btn btn-sm btn-outline-light" href="{{ url_for('dodaj_wplate', zbiorka_id=zbiorka.id) }}">
|
||||||
|
Dodaj wpłatę
|
||||||
|
</a>
|
||||||
|
<a class="btn btn-sm btn-outline-light" href="{{ url_for('dodaj_wydatek', zbiorka_id=zbiorka.id) }}">
|
||||||
|
Dodaj wydatek
|
||||||
|
</a>
|
||||||
|
<a class="btn btn-sm btn-outline-light" href="{{ url_for('dodaj_przesuniecie', zbiorka_id=zbiorka.id) }}">
|
||||||
|
Przesuń środki
|
||||||
|
</a>
|
||||||
|
<a class="btn btn-sm btn-outline-light" href="{{ url_for('edytuj_stan', zbiorka_id=zbiorka.id) }}">
|
||||||
|
Edytuj stan
|
||||||
|
</a>
|
||||||
|
<a class="btn btn-sm btn-outline-light" href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}">
|
||||||
|
Otwórz ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Data</th>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th>Widoczność</th>
|
||||||
|
<th class="text-end">Kwota</th>
|
||||||
|
<th>Opis</th>
|
||||||
|
<th class="text-end"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for a in aktywnosci %}
|
||||||
|
<tr data-tx-id="{{ a.id }}" data-tx-typ="{{ a.typ }}">
|
||||||
|
<td>{{ a.data|dt("%d.%m.%Y %H:%M") }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {{ 'bg-success' if a.typ=='wpłata' else 'bg-danger' }}">{{ a.typ }}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{% if a.ukryta %}
|
||||||
|
<span class="badge bg-warning ms-1">ukryta</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-success ms-1">widoczna</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">{{ '%.2f'|format(a.kwota) }} PLN</td>
|
||||||
|
<td class="text-muted">{{ a.opis or '—' }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
|
||||||
|
<div class="d-inline-flex flex-nowrap align-items-center gap-2">
|
||||||
|
{% if a.typ == 'wpłata' %}
|
||||||
|
<a class="btn btn btn-sm btn-outline-light btn-edit-wplata"
|
||||||
|
href="{{ url_for('przesun_wplate', zbiorka_id=zbiorka.id, wplata_id=a.id) }}"
|
||||||
|
title="Przesuń tę wpłatę"> Przesuń
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-sm btn-outline-light btn-edit-wplata" data-id="{{ a.id }}"
|
||||||
|
data-kwota="{{ '%.2f'|format(a.kwota) }}" data-opis="{{ a.opis|e if a.opis }}"
|
||||||
|
data-action="{{ url_for('zapisz_wplate', wplata_id=a.id) }}">
|
||||||
|
Edytuj
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{% if a.ukryta %}
|
||||||
|
<form class="d-inline" method="post"
|
||||||
|
action="{{ url_for('odkryj_wplate', wplata_id=a.id) }}">
|
||||||
|
<button class="btn btn-sm btn-outline-success">Odkryj</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form class="d-inline" method="post"
|
||||||
|
action="{{ url_for('ukryj_wplate', wplata_id=a.id) }}">
|
||||||
|
<button class="btn btn-sm btn-outline-warning">Ukryj</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form class="d-inline" method="post"
|
||||||
|
action="{{ url_for('usun_wplate', wplata_id=a.id) }}"
|
||||||
|
onsubmit="return confirm('Usunąć wpłatę? Cofnie to wpływ na stan.');">
|
||||||
|
<button class="btn btn-sm btn-outline-danger">Usuń</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<button class="btn btn-sm btn-outline-light btn-edit-wydatek" data-id="{{ a.id }}"
|
||||||
|
data-kwota="{{ '%.2f'|format(a.kwota) }}" data-opis="{{ a.opis|e if a.opis }}"
|
||||||
|
data-action="{{ url_for('zapisz_wydatek', wydatek_id=a.id) }}">
|
||||||
|
Edytuj
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{% if a.ukryta %}
|
||||||
|
<form class="d-inline" method="post"
|
||||||
|
action="{{ url_for('odkryj_wydatek', wydatek_id=a.id) }}">
|
||||||
|
<button class="btn btn-sm btn-outline-success">Odkryj</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form class="d-inline" method="post"
|
||||||
|
action="{{ url_for('ukryj_wydatek', wydatek_id=a.id) }}">
|
||||||
|
<button class="btn btn-sm btn-outline-warning">Ukryj</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form class="d-inline" method="post"
|
||||||
|
action="{{ url_for('usun_wydatek', wydatek_id=a.id) }}"
|
||||||
|
onsubmit="return confirm('Usunąć wydatek? Cofnie to wpływ na stan.');">
|
||||||
|
<button class="btn btn-sm btn-outline-danger">Usuń</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center text-muted py-4">Brak transakcji.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# === MODAL: Edycja wpłaty === #}
|
||||||
|
<div class="modal fade" id="modalWplata" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<form class="modal-content" method="post" id="formWplata">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Edytuj wpłatę</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Zamknij"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Kwota (PLN)</label>
|
||||||
|
<input class="form-control text-end" name="kwota" step="0.01" min="0.01" id="wplataKwota"
|
||||||
|
inputmode="decimal" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Opis</label>
|
||||||
|
<textarea class="form-control" name="opis" id="wplataOpis" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-success">Zapisz</button>
|
||||||
|
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Anuluj</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# === MODAL: Edycja wydatku === #}
|
||||||
|
<div class="modal fade" id="modalWydatek" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<form class="modal-content" method="post" id="formWydatek">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Edytuj wydatek</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Zamknij"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Kwota (PLN)</label>
|
||||||
|
<input class="form-control text-end" name="kwota" step="0.01" min="0.01" id="wydatekKwota"
|
||||||
|
inputmode="decimal" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Opis</label>
|
||||||
|
<textarea class="form-control" name="opis" id="wydatekOpis" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-success">Zapisz</button>
|
||||||
|
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Anuluj</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
{{ super() }}
|
||||||
|
<script src="{{ asset_url('js/transakcje.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
223
zbiorka_app/templates/admin/ustawienia.html
Normal file
223
zbiorka_app/templates/admin/ustawienia.html
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Ustawienia globalne{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container my-4">
|
||||||
|
|
||||||
|
<!-- Nagłówek -->
|
||||||
|
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-1">Ustawienia globalne</h2>
|
||||||
|
<p class="text-muted mb-0">Konfiguracja systemu, płatności i wyglądu</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<a href="{{ url_for('admin_statystyki') }}" class="btn btn-outline-light">
|
||||||
|
Statystyki
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-light">
|
||||||
|
← Panel Admina
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" novalidate id="form-global-settings">
|
||||||
|
|
||||||
|
<!-- 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">Wartości domyślne dla 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. Format 3-3-3 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">Kontrola dostępu</h3>
|
||||||
|
<small class="opacity-75">Biała lista IP/hostów dla logowania</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Wiersz z inputem i przyciskiem dodawania -->
|
||||||
|
<div class="row g-3 align-items-end">
|
||||||
|
<div class="col-12 col-lg-8">
|
||||||
|
<label for="host_input" class="form-label">Dodaj IP/host</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" id="host_input"
|
||||||
|
placeholder="np. 203.0.113.42 lub corp.example.com" aria-describedby="hostAddHelp">
|
||||||
|
<button type="button" class="btn btn-outline-light" id="btn-add-host">
|
||||||
|
➕ Dodaj
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="hostAddHelp" class="form-text">Po wpisaniu kliknij „Dodaj". Duplikaty są pomijane</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
<div class="d-flex flex-wrap gap-2 justify-content-lg-end">
|
||||||
|
<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" id="btn-dedupe">
|
||||||
|
Usuń duplikaty
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
|
<label for="dozwolone_hosty_logowania" class="form-label mb-0">
|
||||||
|
Dozwolone hosty logowania (jeden na linię lub rozdzielone przecinkami)
|
||||||
|
</label>
|
||||||
|
<span class="badge text-bg-secondary">Pozycji: <span id="hostsCount">0</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea class="form-control" id="dozwolone_hosty_logowania" name="dozwolone_hosty_logowania" rows="6"
|
||||||
|
placeholder="Adresy IP lub nazwy domen — każdy w osobnej linii lub rozdzielony przecinkiem">{{ settings.dozwolone_hosty_logowania if settings and settings.dozwolone_hosty_logowania else '' }}</textarea>
|
||||||
|
|
||||||
|
<small class="text-muted d-block mt-1">
|
||||||
|
Akceptowane separatory: przecinek (`,`), średnik (`;`) i nowa linia
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SEKCJA: Kolejność list rezerwowych -->
|
||||||
|
<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">Kolejność wyświetlania</h3>
|
||||||
|
<small class="opacity-75">Pozycja list rezerwowych na stronie głównej</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="kolejnosc_rezerwowych" class="form-label fw-semibold">Kolejność list rezerwowych</label>
|
||||||
|
<select class="form-select" id="kolejnosc_rezerwowych" name="kolejnosc_rezerwowych">
|
||||||
|
<option value="id" {% if settings and settings.kolejnosc_rezerwowych == 'id' %}selected{% endif %}>
|
||||||
|
Według ID (domyślnie)
|
||||||
|
</option>
|
||||||
|
<option value="first" {% if settings and settings.kolejnosc_rezerwowych == 'first' %}selected{% endif %}>
|
||||||
|
Jako pierwsze
|
||||||
|
</option>
|
||||||
|
<option value="last" {% if settings and settings.kolejnosc_rezerwowych == 'last' %}selected{% endif %}>
|
||||||
|
Jako ostatnie
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<small class="text-muted d-block mt-1">Określa, gdzie na stronie głównej będą wyświetlane listy rezerwowe względem standardowych zbiórek</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SEKCJA: Branding -->
|
||||||
|
<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">Branding</h3>
|
||||||
|
<small class="opacity-75">Logo i tytuły wyświetlane w menu i stopce</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Wspólne zasoby: logo + tytuł serwisu -->
|
||||||
|
<div class="row g-3 align-items-end">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="logo_url" class="form-label">Logo (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.svg">
|
||||||
|
<div class="form-text">Transparentne, do ~60px wysokości</div>
|
||||||
|
{% if settings and settings.logo_url %}
|
||||||
|
<div class="mt-2">
|
||||||
|
<img src="{{ settings.logo_url }}" alt="Logo preview" style="max-height:50px">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="tytul_strony" class="form-label">Tytuł serwisu</label>
|
||||||
|
<input type="text" class="form-control" id="tytul_strony" name="tytul_strony"
|
||||||
|
value="{{ settings.tytul_strony if settings else '' }}" placeholder="Np. Zbiórki unitraklub.pl">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<!-- NAVBAR -->
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="mb-2">Menu (navbar)</h6>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="typ_navbar" id="navbar_mode_logo" value="logo" {% if
|
||||||
|
settings and settings.typ_navbar=='logo' or (settings and settings.pokaz_logo_w_navbar) %}checked{%
|
||||||
|
endif %}>
|
||||||
|
<label class="form-check-label" for="navbar_mode_logo">Pokaż logo</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="typ_navbar" id="navbar_mode_text" value="text" {% if
|
||||||
|
not settings or (settings and settings.typ_navbar !='logo' and not settings.pokaz_logo_w_navbar)
|
||||||
|
%}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="navbar_mode_text">Pokaż tekst</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text mt-1">Jeśli wybierzesz logo, użyjemy adresu z pola "Logo URL"</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- STOPKA -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="mb-2">Stopka</h6>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="typ_stopka" id="footer_mode_logo" value="logo" {% if
|
||||||
|
settings and settings.typ_stopka=='logo' %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="footer_mode_logo">Logo</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="typ_stopka" id="footer_mode_text" value="text" {% if
|
||||||
|
not settings or (settings and settings.typ_stopka !='logo' ) %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="footer_mode_text">Tekst</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label for="stopka_text" class="form-label mt-2">Tekst w stopce (gdy wybrano „Tekst")</label>
|
||||||
|
<input type="text" class="form-control" id="stopka_text" name="stopka_text"
|
||||||
|
value="{{ settings.stopka_text if settings and settings.stopka_text else '' }}"
|
||||||
|
placeholder="Np. © {{ now().year if now else '2025' }} Zbiórki">
|
||||||
|
<div class="form-text">Pozostaw pusty, by użyć domyślnego</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-light">Powrót</a>
|
||||||
|
<button type="submit" class="btn btn-success">Zapisz ustawienia</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
{{ super() }}
|
||||||
|
<script src="{{ asset_url('js/ustawienia.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
93
zbiorka_app/templates/base.html
Normal file
93
zbiorka_app/templates/base.html
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<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>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootswatch@5.3.0/dist/darkly/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ asset_url('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 d-flex align-items-center gap-2" href="{{ url_for('index') }}">
|
||||||
|
{% set nav_mode = (global_settings.typ_navbar if global_settings and
|
||||||
|
global_settings.typ_navbar else ('logo' if global_settings and
|
||||||
|
global_settings.pokaz_logo_w_navbar else 'text')) %}
|
||||||
|
{% if nav_mode == 'logo' and global_settings and global_settings.logo_url %}
|
||||||
|
<img src="{{ global_settings.logo_url }}" alt="Logo" style="max-height:40px; vertical-align:middle;">
|
||||||
|
{% else %}
|
||||||
|
<span>{{ global_settings.tytul_strony if global_settings and global_settings.tytul_strony else "Zbiórki"
|
||||||
|
}}</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNavbar"
|
||||||
|
aria-controls="mainNavbar" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="collapse navbar-collapse" id="mainNavbar">
|
||||||
|
<ul class="navbar-nav ms-auto">
|
||||||
|
{% set hide_links = request.path == url_for('index') or request.path ==
|
||||||
|
url_for('zbiorki_zrealizowane') %}
|
||||||
|
{% if not hide_links %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('index') }}">Aktualne zbiórki</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('zbiorki_zrealizowane') }}">Zrealizowane zbiórki</a>
|
||||||
|
</li>
|
||||||
|
{% 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|default(false) %}
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{{ url_for('zaloguj') }}">Zaloguj</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category|default('secondary') }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<main class="flex-grow-1">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- stopka -->
|
||||||
|
<footer class="mt-auto text-center py-3 border-top" style="background: var(--surface-0);">
|
||||||
|
{% set footer_mode = global_settings.typ_stopka if global_settings and global_settings.typ_stopka
|
||||||
|
else 'text' %}
|
||||||
|
{% if footer_mode == 'logo' and global_settings and global_settings.logo_url %}
|
||||||
|
<img src="{{ global_settings.logo_url }}" alt="Logo" style="max-height:28px;">
|
||||||
|
{% else %}
|
||||||
|
{{ global_settings.stopka_text if global_settings and global_settings.stopka_text else "© " ~ (now().year if now
|
||||||
|
else '2025') ~ " linuxiarz.pl" }}
|
||||||
|
{% endif %}
|
||||||
|
<div class="small text-muted">v{{ APP_VERSION }}</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="{{ asset_url('js/progress.js') }}"></script>
|
||||||
|
|
||||||
|
{% block extra_scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
152
zbiorka_app/templates/error.html
Normal file
152
zbiorka_app/templates/error.html
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="pl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>{{ error_code }} {{ error_name }}</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
|
{% if asset_url is defined %}
|
||||||
|
<link rel="stylesheet" href="{{ asset_url('css/style.css') }}">
|
||||||
|
{% else %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
--bg: #121212;
|
||||||
|
--surface-0: #1a1a1a;
|
||||||
|
--surface-1: #202020;
|
||||||
|
--surface-2: #2a2a2a;
|
||||||
|
--border: #3a3a3a;
|
||||||
|
|
||||||
|
--text: #e4e4e4;
|
||||||
|
--text-muted: #a8a8a8;
|
||||||
|
|
||||||
|
--accent: #f5c84c;
|
||||||
|
--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);
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'Roboto', system-ui, -apple-system, Segoe UI, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--trans);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--accent-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-box {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-code {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-name {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
margin: 0 0 .75rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions a {
|
||||||
|
display: inline-block;
|
||||||
|
padding: .6rem 1.3rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
background-color: var(--accent);
|
||||||
|
border: 1px solid var(--accent-600);
|
||||||
|
color: #111;
|
||||||
|
transition: transform 120ms ease, background-color var(--trans), border-color var(--trans), color var(--trans);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions a:hover {
|
||||||
|
background-color: var(--accent-600);
|
||||||
|
border-color: var(--accent-700);
|
||||||
|
color: #111;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions a:active {
|
||||||
|
background-color: var(--accent-700);
|
||||||
|
border-color: var(--accent-700);
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.error-box {
|
||||||
|
padding: 2rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-code {
|
||||||
|
font-size: 2.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-name {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="error-page">
|
||||||
|
<section class="error-box">
|
||||||
|
<div class="error-code">{{ error_code }}</div>
|
||||||
|
<h1 class="error-name">{{ error_name }}</h1>
|
||||||
|
<p class="error-message">{{ error_message }}</p>
|
||||||
|
|
||||||
|
<div class="error-actions">
|
||||||
|
<a href="/">Powrot na strone glowna</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
143
zbiorka_app/templates/index.html
Normal file
143
zbiorka_app/templates/index.html
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}{% if request.path == url_for('zbiorki_zrealizowane') %}Zrealizowane zbiórki{% else %}Aktualnie aktywne zbiórki{% endif %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{# Ustal kontekst listy #}
|
||||||
|
{% set is_completed_view = (request.path == url_for('zbiorki_zrealizowane')) %}
|
||||||
|
|
||||||
|
<div class="container my-4">
|
||||||
|
<!-- Nagłówek z przełącznikiem -->
|
||||||
|
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-0">
|
||||||
|
{% if is_completed_view %}Zrealizowane zbiórki{% else %}Aktywne zbiórki{% endif %}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted mb-0 small">
|
||||||
|
{% if is_completed_view %}
|
||||||
|
Ukończone projekty i osiągnięte cele
|
||||||
|
{% else %}
|
||||||
|
Trwające zbiórki, które możesz wesprzeć
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<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
|
||||||
|
{% if not is_completed_view and zbiorki %}
|
||||||
|
<span class="badge bg-light text-dark ms-1">{{ zbiorki|length }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if is_completed_view %}active{% endif %}"
|
||||||
|
href="{{ url_for('zbiorki_zrealizowane') }}">
|
||||||
|
Zrealizowane
|
||||||
|
{% if is_completed_view and zbiorki %}
|
||||||
|
<span class="badge bg-light text-dark ms-1">{{ zbiorki|length }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if zbiorki and zbiorki|length > 0 %}
|
||||||
|
<div class="row g-3 pb-4">
|
||||||
|
{% for z in zbiorki %}
|
||||||
|
{% set progress = (z.stan / z.cel * 100) if z.cel > 0 else 0 %}
|
||||||
|
{% set progress_clamped = 100 if progress > 100 else (0 if progress < 0 else progress) %}
|
||||||
|
<div class="col-sm-12 col-md-6 col-lg-4">
|
||||||
|
<div class="card h-100 position-relative">
|
||||||
|
<div class="card-body d-flex flex-column">
|
||||||
|
<div class="d-flex align-items-start justify-content-between gap-2 mb-2">
|
||||||
|
<h5 class="card-title mb-0">{{ z.nazwa }}</h5>
|
||||||
|
|
||||||
|
{% if z.typ_zbiorki == 'rezerwa' %}
|
||||||
|
<span class="badge bg-info">Rezerwa</span>
|
||||||
|
{% elif is_completed_view or progress_clamped >= 100 %}
|
||||||
|
<span class="badge rounded-pill" style="background: var(--accent); color:#111;">Zrealizowana</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<hr class="hr-bw my-2">
|
||||||
|
|
||||||
|
<div class="mb-2 d-flex flex-wrap gap-2 justify-content-center">
|
||||||
|
{% if not z.ukryj_kwote %}
|
||||||
|
{% if z.cel > 0 and z.typ_zbiorki != 'rezerwa' %}
|
||||||
|
<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 border-success {% if z.typ_zbiorki == 'rezerwa' %}w-100{% endif %}" style="border-color: var(--border);">
|
||||||
|
Stan: {{ z.stan|round(2) }} PLN
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{% if z.cel > 0 and z.typ_zbiorki != 'rezerwa' %}
|
||||||
|
{% set delta = z.cel - z.stan %}
|
||||||
|
{% if delta > 0 %}
|
||||||
|
<span class="badge bg-dark border border-warning">
|
||||||
|
Brakuje: {{ delta|round(2) }} PLN
|
||||||
|
</span>
|
||||||
|
{% elif delta < 0 %}
|
||||||
|
<span class="badge bg-dark border" style="border-color: var(--border);">
|
||||||
|
Nadwyżka: {{ (-delta)|round(2) }} PLN
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Kwoty niepubliczne</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Progress bar TYLKO dla standardowych zbiórek (nie dla rezerwowych) #}
|
||||||
|
{% if z.typ_zbiorki != 'rezerwa' %}
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100"
|
||||||
|
aria-valuenow="{{ progress_clamped|round(2) if not z.ukryj_kwote else '' }}"
|
||||||
|
aria-label="{% if z.ukryj_kwote %}Postęp ukryty{% else %}Postęp zbiórki {{ progress_clamped|round(0) }} procent{% endif %}">
|
||||||
|
<div class="progress-bar" style="width: {{ progress_clamped }}%;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not z.ukryj_kwote %}
|
||||||
|
<small class="text-muted d-block text-center">{{ progress_clamped|round(1) }}%</small>
|
||||||
|
{% else %}
|
||||||
|
<small class="text-muted d-block text-center">Postęp ukryty</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-auto pt-1">
|
||||||
|
<div class="d-grid">
|
||||||
|
<a href="{{ url_for('zbiorka', zbiorka_id=z.id) }}"
|
||||||
|
class="btn btn-outline-light btn-sm w-100 btn-opis">
|
||||||
|
Otwórz
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- Empty state -->
|
||||||
|
<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.czy_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-outline-light">Zobacz zrealizowane</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
55
zbiorka_app/templates/login.html
Normal file
55
zbiorka_app/templates/login.html
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Logowanie{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-sm-10 col-md-8 col-lg-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title mb-0">Logowanie</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" class="needs-validation" novalidate>
|
||||||
|
{% set next_url = request.args.get('next') %}
|
||||||
|
{% if next_url %}
|
||||||
|
<input type="hidden" name="next" value="{{ next_url }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="uzytkownik" class="form-label">Nazwa użytkownika</label>
|
||||||
|
<input type="text" class="form-control" id="uzytkownik" name="uzytkownik"
|
||||||
|
autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus>
|
||||||
|
<div class="invalid-feedback">Podaj nazwę użytkownika.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
<label for="haslo" 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="haslo" name="haslo"
|
||||||
|
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>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
<script src="{{ asset_url('js/walidacja_logowanie.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
61
zbiorka_app/templates/register.html
Normal file
61
zbiorka_app/templates/register.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Rejestracja{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-sm-10 col-md-8 col-lg-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title mb-0">Rejestracja</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" class="needs-validation" novalidate>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="uzytkownik" class="form-label">Nazwa użytkownika</label>
|
||||||
|
<input type="text" class="form-control" id="uzytkownik" name="uzytkownik"
|
||||||
|
autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus>
|
||||||
|
<div class="invalid-feedback">Podaj nazwę użytkownika.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="haslo" 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="haslo" name="haslo"
|
||||||
|
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>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
<script src="{{ asset_url('js/walidacja_rejestracja.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
414
zbiorka_app/templates/zbiorka.html
Normal file
414
zbiorka_app/templates/zbiorka.html
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}{{ zbiorka.nazwa }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container my-4">
|
||||||
|
|
||||||
|
{# Wyliczenia postępu finansowego #}
|
||||||
|
{% set has_cel = (zbiorka.cel is defined and zbiorka.cel and zbiorka.cel > 0) %}
|
||||||
|
{% set progress = (zbiorka.stan / zbiorka.cel * 100) if has_cel else 0 %}
|
||||||
|
{% set progress_clamped = 100 if progress > 100 else (0 if progress < 0 else progress) %}
|
||||||
|
{% set is_done=(progress_clamped>= 100) %}
|
||||||
|
|
||||||
|
<!-- Nagłówek -->
|
||||||
|
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-0">
|
||||||
|
{% if zbiorka.typ_zbiorki == 'rezerwa' %}
|
||||||
|
<i class="bi bi-wallet2"></i>
|
||||||
|
{% endif %}
|
||||||
|
{{ zbiorka.nazwa }}
|
||||||
|
</h2>
|
||||||
|
{% if zbiorka.typ_zbiorki == 'rezerwa' %}
|
||||||
|
<p class="text-muted mb-0 small">Lista rezerwowa środków</p>
|
||||||
|
{% elif is_done %}
|
||||||
|
<p class="text-muted mb-0 small">Zbiórka zrealizowana</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mb-0 small">Aktywna zbiórka</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||||
|
{% if zbiorka.typ_zbiorki == 'rezerwa' %}
|
||||||
|
<span class="badge bg-info">Lista rezerwowa</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if is_done and zbiorka.typ_zbiorki != 'rezerwa' %}
|
||||||
|
<span class="badge rounded-pill" style="background: var(--accent); color:#111;">Zrealizowana</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if zbiorka.ukryj_kwote %}
|
||||||
|
<span class="badge bg-secondary">Kwoty niepubliczne</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-success">Kwoty widoczne</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<!-- Kolumna lewa: Opis + (opcjonalnie) Lista zakupów + Postęp -->
|
||||||
|
<div class="col-md-8">
|
||||||
|
|
||||||
|
<!-- Card: Opis -->
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="mb-2">Opis</h5>
|
||||||
|
<hr class="hr-bw my-2">
|
||||||
|
<div class="mb-0">
|
||||||
|
{{ zbiorka.opis | markdown }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Czy są produkty? #}
|
||||||
|
{% set items = zbiorka.przedmioty or [] %}
|
||||||
|
{% set has_items = (items|length > 0) %}
|
||||||
|
|
||||||
|
<!-- Card: Lista zakupów (tylko gdy są produkty) -->
|
||||||
|
{% if has_items %}
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||||
|
<h5 class="mb-0">Lista zakupów</h5>
|
||||||
|
<span class="badge bg-secondary">{{ items|length }} pozycji</span>
|
||||||
|
</div>
|
||||||
|
<hr class="hr-bw my-2">
|
||||||
|
{% set posortowane = items|sort(attribute='kupione') %}
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{% for it in posortowane %}
|
||||||
|
<li class="list-group-item bg-transparent d-flex flex-wrap justify-content-between align-items-center py-2">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
{% if it.kupione %}
|
||||||
|
<span class="badge bg-success">Kupione</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-warning text-dark">Do kupienia</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="fw-semibold">{{ it.nazwa }}</span>
|
||||||
|
{% if it.link %}
|
||||||
|
<a href="{{ it.link }}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-light ms-2">Sklep ↗</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{% if not zbiorka.ukryj_kwote %}
|
||||||
|
{% if it.cena is not none %}
|
||||||
|
<span class="badge bg-dark border" style="border-color: var(--border);">
|
||||||
|
{{ it.cena|round(2) }} PLN
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Card: Postęp (POD listą zakupów) -->
|
||||||
|
{# Dodatkowe wyliczenia do postępu zakupów #}
|
||||||
|
{% set total_cnt = items|length %}
|
||||||
|
{% set kupione_cnt = (items|selectattr('kupione')|list|length) %}
|
||||||
|
{% set items_pct = (kupione_cnt / total_cnt * 100) if total_cnt > 0 else 0 %}
|
||||||
|
{% if not zbiorka.ukryj_kwote %}
|
||||||
|
{% set suma_all = (items|selectattr('cena')|map(attribute='cena')|sum) or 0 %}
|
||||||
|
{% set suma_kupione = (items|selectattr('kupione')|selectattr('cena')|map(attribute='cena')|sum) or 0 %}
|
||||||
|
{% set suma_pct = (suma_kupione / suma_all * 100) if suma_all > 0 else 0 %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Pokazuj sekcję postępu TYLKO dla standardowych zbiórek, NIE dla rezerwowych #}
|
||||||
|
{% if zbiorka.typ_zbiorki != 'rezerwa' %}
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
|
||||||
|
<h5 class="mb-0">Postęp</h5>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||||
|
{% if has_cel and not zbiorka.ukryj_kwote and zbiorka.pokaz_postep_finanse %}
|
||||||
|
<span class="badge bg-dark border" style="border-color: var(--border);">
|
||||||
|
Finanse: {{ zbiorka.stan|round(2) }} / {{ zbiorka.cel|round(2) }} PLN
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if has_items and zbiorka.pokaz_postep_pozycje %}
|
||||||
|
<span class="badge bg-secondary">Pozycje: {{ kupione_cnt }}/{{ total_cnt }}</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if has_items and not zbiorka.ukryj_kwote and (suma_all or 0) > 0 and zbiorka.pokaz_postep_kwotowo %}
|
||||||
|
<span class="badge bg-secondary">
|
||||||
|
Zakupy (kwotowo): {{ (suma_kupione or 0)|round(2) }} / {{ (suma_all or 0)|round(2) }} PLN
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="hr-bw my-2">
|
||||||
|
|
||||||
|
{# Pasek: Finanse #}
|
||||||
|
{% if zbiorka.pokaz_postep_finanse %}
|
||||||
|
<div class="mb-2">
|
||||||
|
<small class="text-muted">Finanse</small>
|
||||||
|
<div class="progress" role="progressbar" aria-valuenow="{{ progress_clamped|round(2) }}" aria-valuemin="0"
|
||||||
|
aria-valuemax="100">
|
||||||
|
<div class="progress-bar" style="width: {{ progress_clamped }}%;"></div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">
|
||||||
|
{% if zbiorka.ukryj_kwote %}—{% else %}{{ progress|round(1) }}%{% endif %}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Pasek: Zakupy sztukami #}
|
||||||
|
{% if has_items and zbiorka.pokaz_postep_pozycje %}
|
||||||
|
<div class="mb-2">
|
||||||
|
<small class="text-muted">Zakupy (liczba pozycji)</small>
|
||||||
|
<div class="progress" role="progressbar" aria-valuenow="{{ items_pct|round(2) }}" aria-valuemin="0"
|
||||||
|
aria-valuemax="100">
|
||||||
|
<div class="progress-bar" style="width: {{ items_pct }}%;"></div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">{{ items_pct|round(1) }}%</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Pasek: Zakupy kwotowo #}
|
||||||
|
{% if has_items and not zbiorka.ukryj_kwote and (suma_all or 0) > 0 and zbiorka.pokaz_postep_kwotowo %}
|
||||||
|
<div>
|
||||||
|
<small class="text-muted">Zakupy (kwotowo)</small>
|
||||||
|
<div class="progress" role="progressbar" aria-valuenow="{{ suma_pct|round(2) }}" aria-valuemin="0"
|
||||||
|
aria-valuemax="100">
|
||||||
|
<div class="progress-bar" style="width: {{ suma_pct }}%;"></div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">{{ suma_pct|round(1) }}%</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{# Koniec warunku dla typu zbiórki #}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% set show_iban = zbiorka.uzyj_konta and zbiorka.numer_konta %}
|
||||||
|
{% set show_blik = zbiorka.uzyj_blik and zbiorka.numer_telefonu_blik %}
|
||||||
|
|
||||||
|
<!-- Kolumna prawa: płatności (sticky) -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm wspomoz-card sticky-md" style="top: var(--sticky-offset, 1rem);">
|
||||||
|
<div class="card-body d-flex flex-column gap-2">
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
{% if zbiorka.typ_zbiorki == 'rezerwa' %}
|
||||||
|
Sposoby wsparcia
|
||||||
|
{% else %}
|
||||||
|
Jak wspomóc?
|
||||||
|
{% endif %}
|
||||||
|
</h5>
|
||||||
|
{% if has_cel and not zbiorka.ukryj_kwote and zbiorka.typ_zbiorki != 'rezerwa' %}
|
||||||
|
{% set brak = (zbiorka.cel - zbiorka.stan) %}
|
||||||
|
{% if brak > 0 %}
|
||||||
|
<span class="badge bg-warning text-dark border border-warning">Brakuje: {{ brak|round(2) }} PLN</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge rounded-pill" style="background: var(--accent); color:#111;">Zrealizowana</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if show_iban or show_blik %}
|
||||||
|
{% if show_iban %}
|
||||||
|
<!-- Numer konta -->
|
||||||
|
<div>
|
||||||
|
<label for="ibanInput" class="form-label fw-semibold mb-1">Numer konta</label>
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input id="ibanInput" type="text"
|
||||||
|
class="form-control bg-transparent text-light border monospace-input text-truncate"
|
||||||
|
value="{{ zbiorka.numer_konta }}" readonly autocomplete="off" autocorrect="off" autocapitalize="off"
|
||||||
|
spellcheck="false" inputmode="text" aria-label="Numer konta do wpłaty">
|
||||||
|
<button class="btn btn-outline-light copy-btn" type="button" data-copy-input="#ibanInput"
|
||||||
|
aria-label="Kopiuj numer konta">Kopiuj</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if show_blik %}
|
||||||
|
<!-- Telefon BLIK -->
|
||||||
|
<div>
|
||||||
|
<label for="blikInput" class="form-label fw-semibold mb-1">Telefon / BLIK</label>
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input id="blikInput" type="text"
|
||||||
|
class="form-control bg-transparent text-light border monospace-input text-truncate"
|
||||||
|
value="{{ zbiorka.numer_telefonu_blik }}" readonly autocomplete="off" autocorrect="off"
|
||||||
|
autocapitalize="off" spellcheck="false" inputmode="numeric" aria-label="Telefon BLIK">
|
||||||
|
<button class="btn btn-outline-light copy-btn" type="button" data-copy-input="#blikInput"
|
||||||
|
aria-label="Kopiuj numer BLIK">Kopiuj</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-secondary mb-0">
|
||||||
|
Kanały płatności są wyłączone dla tej zbiórki.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not zbiorka.ukryj_kwote %}
|
||||||
|
<ul class="list-group list-group-flush small mt-2">
|
||||||
|
{% if zbiorka.typ_zbiorki != 'rezerwa' %}
|
||||||
|
{% if has_cel %}
|
||||||
|
<li class="list-group-item bg-transparent d-flex justify-content-between py-1">
|
||||||
|
<span>Cel</span>
|
||||||
|
<span class="fw-semibold">{{ zbiorka.cel|round(2) }} PLN</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<li class="list-group-item bg-transparent d-flex justify-content-between py-1">
|
||||||
|
<span>Stan</span>
|
||||||
|
<span class="fw-semibold text-success">{{ zbiorka.stan|round(2) }} PLN</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{% if zbiorka.typ_zbiorki != 'rezerwa' and has_cel %}
|
||||||
|
<li class="list-group-item bg-transparent d-flex justify-content-between py-1">
|
||||||
|
<span>
|
||||||
|
{% if brak > 0 %}Brakuje{% elif brak == 0 %}Cel{% else %}Nadwyżka{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="fw-semibold {% if brak > 0 %}text-warning{% elif brak < 0 %}text-success{% endif %}">
|
||||||
|
{% if brak > 0 %}
|
||||||
|
{{ brak|round(2) }} PLN
|
||||||
|
{% elif brak == 0 %}
|
||||||
|
osiągnięty
|
||||||
|
{% else %}
|
||||||
|
{{ (-brak)|round(2) }} PLN
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if current_user.is_authenticated and current_user.czy_admin %}
|
||||||
|
<hr class="my-2">
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<a href="{{ url_for('dodaj_wplate', zbiorka_id=zbiorka.id) }}" class="btn btn-outline-light btn-sm">Dodaj wpłatę</a>
|
||||||
|
<a href="{{ url_for('dodaj_wydatek', zbiorka_id=zbiorka.id) }}" class="btn btn-outline-light btn-sm">Dodaj wydatek</a>
|
||||||
|
<a href="{{ url_for('dodaj_przesuniecie', zbiorka_id=zbiorka.id) }}" class="btn btn-outline-light btn-sm">
|
||||||
|
<i class="bi bi-arrow-left-right"></i> Przesuń środki
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('edytuj_stan', zbiorka_id=zbiorka.id) }}" class="btn btn-outline-light btn-sm">Edytuj stan</a>
|
||||||
|
{% if zbiorka.typ_zbiorki != 'rezerwa' %}
|
||||||
|
<a href="{{ url_for('formularz_zbiorek', zbiorka_id=zbiorka.id) }}"
|
||||||
|
class="btn btn-outline-light btn-sm">Edytuj opis</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('edytuj_rezerwe', rezerwa_id=zbiorka.id) }}"
|
||||||
|
class="btn btn-outline-light btn-sm">Edytuj rezerwę</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aktywność -->
|
||||||
|
<div class="card shadow-sm mt-3">
|
||||||
|
<div class="card-header bg-transparent d-flex align-items-center justify-content-between py-2">
|
||||||
|
<h5 class="card-title mb-0">Aktywność / Transakcje</h5>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
{% if aktywnosci and aktywnosci|length > 0 %}
|
||||||
|
<small class="text-muted">Łącznie pozycji: {{ aktywnosci|length }}</small>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.is_authenticated and current_user.czy_admin %}
|
||||||
|
<a href="{{ url_for('transakcje_zbiorki', zbiorka_id=zbiorka.id) }}" class="btn btn-sm btn-outline-light">
|
||||||
|
Zarządzaj
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
{% if aktywnosci and aktywnosci|length > 0 %}
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{% for a in aktywnosci %}
|
||||||
|
<li class="list-group-item bg-transparent d-flex flex-wrap justify-content-between align-items-start py-2">
|
||||||
|
<div class="me-3 flex-grow-1">
|
||||||
|
<strong>{{ a.data|dt("%d.%m.%Y %H:%M") }}</strong>
|
||||||
|
|
||||||
|
{% if a.typ == 'wpłata' %}
|
||||||
|
<span class="badge bg-success ms-2">Wpłata</span>
|
||||||
|
{% elif a.typ == 'wydatek' %}
|
||||||
|
<span class="badge bg-danger ms-2">Wydatek</span>
|
||||||
|
{% elif a.typ == 'przesunięcie_przych' %}
|
||||||
|
<span class="badge bg-info ms-2">Przesunięcie (↓ przychód)</span>
|
||||||
|
{% elif a.typ == 'przesunięcie_wych' %}
|
||||||
|
<span class="badge bg-warning text-dark ms-2">Przesunięcie (↑ wychód)</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if a.opis %}
|
||||||
|
<span class="text-muted">— {{ a.opis }}</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Informacja o przesunięciu wpłaty #}
|
||||||
|
{% if a.typ == 'wpłata' and a.przesuniecie_z %}
|
||||||
|
<div class="text-muted small mt-1">
|
||||||
|
<span class="badge bg-secondary">Przesunięto</span>
|
||||||
|
Źródło:
|
||||||
|
<a href="{{ url_for('zbiorka', zbiorka_id=a.przesuniecie_z.zbiorka_zrodlo_id) }}"
|
||||||
|
class="text-decoration-none">
|
||||||
|
{{ a.przesuniecie_z.zbiorka_zrodlo_nazwa }}
|
||||||
|
</a>
|
||||||
|
{% if a.przesuniecie_z.opis %}
|
||||||
|
<br><span class="text-muted">{{ a.przesuniecie_z.opis }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Link do źródłowej/docelowej zbiórki dla przesunięć ogólnych #}
|
||||||
|
{% if a.typ in ['przesunięcie_przych', 'przesunięcie_wych'] and a.zbiorka_id %}
|
||||||
|
<a href="{{ url_for('zbiorka', zbiorka_id=a.zbiorka_id) }}"
|
||||||
|
class="ms-2 text-decoration-none small">
|
||||||
|
<i class="bi bi-link-45deg"></i>
|
||||||
|
{% if a.typ == 'przesunięcie_przych' %}
|
||||||
|
z: {{ a.zbiorka_nazwa }}
|
||||||
|
{% else %}
|
||||||
|
do: {{ a.zbiorka_nazwa }}
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not zbiorka.ukryj_kwote %}
|
||||||
|
<span class="badge bg-dark border" style="border-color: var(--border);">
|
||||||
|
{% if a.typ == 'wpłata' or a.typ == 'przesunięcie_przych' %}
|
||||||
|
+{{ a.kwota|round(2) }} PLN
|
||||||
|
{% else %}
|
||||||
|
-{{ a.kwota|round(2) }} PLN
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<h6 class="mb-1">Brak aktywności</h6>
|
||||||
|
<p class="text-muted mb-0">Gdy pojawią się pierwsze wpłaty lub wydatki, zobaczysz je tutaj.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</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">Powrót do listy</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
{{ super() }}
|
||||||
|
<script src="{{ asset_url('js/zbiorka.js') }}"></script>
|
||||||
|
<script src="{{ asset_url('js/progress.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
291
zbiorka_app/utils.py
Normal file
291
zbiorka_app/utils.py
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import hashlib
|
||||||
|
import ipaddress
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import current_app, request, url_for
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.exc import OperationalError
|
||||||
|
|
||||||
|
from .extensions import db
|
||||||
|
from .models import UstawieniaGlobalne, Uzytkownik
|
||||||
|
|
||||||
|
try:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
except ImportError:
|
||||||
|
from backports.zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
LOCAL_TZ = ZoneInfo("Europe/Warsaw")
|
||||||
|
_DB_INIT_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def read_commit_and_date(filename="version.txt", root_path=None):
|
||||||
|
base = root_path or os.path.dirname(os.path.abspath(__file__))
|
||||||
|
path = os.path.join(base, filename)
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
try:
|
||||||
|
commit = open(path, "r", encoding="utf-8").read().strip()
|
||||||
|
if commit:
|
||||||
|
commit = commit[:12]
|
||||||
|
except Exception:
|
||||||
|
commit = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
ts = os.path.getmtime(path)
|
||||||
|
date_str = datetime.fromtimestamp(ts).strftime("%Y.%m.%d")
|
||||||
|
except Exception:
|
||||||
|
date_str = None
|
||||||
|
|
||||||
|
return date_str, commit
|
||||||
|
|
||||||
|
|
||||||
|
def set_sqlite_pragma(dbapi_connection, connection_record):
|
||||||
|
if dbapi_connection.__class__.__module__.startswith("sqlite3"):
|
||||||
|
try:
|
||||||
|
cursor = dbapi_connection.cursor()
|
||||||
|
cursor.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cursor.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_real_ip():
|
||||||
|
headers = request.headers
|
||||||
|
cf_ip = headers.get("CF-Connecting-IP")
|
||||||
|
if cf_ip:
|
||||||
|
return cf_ip.split(",")[0].strip()
|
||||||
|
xff = headers.get("X-Forwarded-For")
|
||||||
|
if xff:
|
||||||
|
return xff.split(",")[0].strip()
|
||||||
|
x_real_ip = headers.get("X-Real-IP")
|
||||||
|
if x_real_ip:
|
||||||
|
return x_real_ip.strip()
|
||||||
|
return request.remote_addr
|
||||||
|
|
||||||
|
|
||||||
|
def is_allowed_ip(remote_ip, allowed_hosts_str):
|
||||||
|
if os.path.exists("emergency_access.txt"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not allowed_hosts_str or not allowed_hosts_str.strip():
|
||||||
|
return False
|
||||||
|
|
||||||
|
allowed_ips = set()
|
||||||
|
hosts = re.split(r"[\n,]+", allowed_hosts_str.strip())
|
||||||
|
|
||||||
|
for host in hosts:
|
||||||
|
host = host.strip()
|
||||||
|
if not host:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
allowed_ips.add(ipaddress.ip_address(host))
|
||||||
|
continue
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
infos = socket.getaddrinfo(host, None)
|
||||||
|
for _, _, _, _, sockaddr in infos:
|
||||||
|
ip_str = sockaddr[0]
|
||||||
|
try:
|
||||||
|
allowed_ips.add(ipaddress.ip_address(ip_str))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
except Exception as exc:
|
||||||
|
current_app.logger.warning("Nie mozna rozwiazac hosta %s: %s", host, exc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
remote_ip_obj = ipaddress.ip_address(remote_ip)
|
||||||
|
except ValueError:
|
||||||
|
current_app.logger.warning("Nieprawidlowe IP klienta: %s", remote_ip)
|
||||||
|
return False
|
||||||
|
|
||||||
|
is_allowed = remote_ip_obj in allowed_ips
|
||||||
|
current_app.logger.info("is_allowed_ip: %s -> %s (lista: %s)", remote_ip_obj, is_allowed, allowed_ips)
|
||||||
|
return is_allowed
|
||||||
|
|
||||||
|
|
||||||
|
def to_local(dt):
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(LOCAL_TZ)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_amount(raw: str) -> Decimal:
|
||||||
|
if not raw or not str(raw).strip():
|
||||||
|
raise InvalidOperation("empty amount")
|
||||||
|
norm = str(raw).replace(" ", "").replace("\u00A0", "").replace(",", ".").strip()
|
||||||
|
d = Decimal(norm)
|
||||||
|
if d <= 0:
|
||||||
|
raise InvalidOperation("amount must be > 0")
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def safe_db_rollback() -> None:
|
||||||
|
try:
|
||||||
|
db.session.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def create_admin_account(force_reset_password: bool = False):
|
||||||
|
admin = Uzytkownik.query.filter_by(czy_admin=True).first()
|
||||||
|
if not admin:
|
||||||
|
admin = Uzytkownik(
|
||||||
|
uzytkownik=current_app.config["MAIN_ADMIN_USERNAME"],
|
||||||
|
czy_admin=True,
|
||||||
|
)
|
||||||
|
admin.set_password(current_app.config["MAIN_ADMIN_PASSWORD"])
|
||||||
|
db.session.add(admin)
|
||||||
|
db.session.commit()
|
||||||
|
return admin
|
||||||
|
|
||||||
|
if force_reset_password:
|
||||||
|
admin.set_password(current_app.config["MAIN_ADMIN_PASSWORD"])
|
||||||
|
db.session.commit()
|
||||||
|
return admin
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_user(username: str, is_admin: bool = False) -> Uzytkownik:
|
||||||
|
user = Uzytkownik.query.filter_by(uzytkownik=username).first()
|
||||||
|
if user is None:
|
||||||
|
user = Uzytkownik(uzytkownik=username, czy_admin=is_admin)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.flush()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def set_user_password(username: str, password: str, is_admin: bool | None = None) -> Uzytkownik:
|
||||||
|
user = Uzytkownik.query.filter_by(uzytkownik=username).first()
|
||||||
|
if user is None:
|
||||||
|
user = Uzytkownik(uzytkownik=username, czy_admin=bool(is_admin))
|
||||||
|
db.session.add(user)
|
||||||
|
elif is_admin is not None:
|
||||||
|
user.czy_admin = bool(is_admin)
|
||||||
|
|
||||||
|
user.set_password(password)
|
||||||
|
db.session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def set_login_hosts(hosts: str | None) -> UstawieniaGlobalne:
|
||||||
|
settings = UstawieniaGlobalne.query.first()
|
||||||
|
if settings is None:
|
||||||
|
settings = UstawieniaGlobalne(
|
||||||
|
numer_konta="",
|
||||||
|
numer_telefonu_blik="",
|
||||||
|
)
|
||||||
|
db.session.add(settings)
|
||||||
|
|
||||||
|
settings.dozwolone_hosty_logowania = (hosts or "").strip() or None
|
||||||
|
db.session.commit()
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def init_version(app):
|
||||||
|
root_path = os.path.dirname(app.root_path)
|
||||||
|
deploy_date, commit = read_commit_and_date("version.txt", root_path=root_path)
|
||||||
|
if not deploy_date:
|
||||||
|
deploy_date = datetime.now().strftime("%Y.%m.%d")
|
||||||
|
if not commit:
|
||||||
|
commit = "dev"
|
||||||
|
app.config["APP_VERSION"] = f"{deploy_date}+{commit}"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=512)
|
||||||
|
def _md5_for_static_file(path_str: str, mtime_ns: int, size: int) -> str:
|
||||||
|
digest = hashlib.md5()
|
||||||
|
with open(path_str, "rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(65536), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()[:12]
|
||||||
|
|
||||||
|
|
||||||
|
def static_file_hash(filename: str) -> str:
|
||||||
|
static_root = Path(current_app.static_folder).resolve()
|
||||||
|
candidate = (static_root / filename).resolve()
|
||||||
|
|
||||||
|
try:
|
||||||
|
candidate.relative_to(static_root)
|
||||||
|
except ValueError:
|
||||||
|
return "invalid"
|
||||||
|
|
||||||
|
if not candidate.is_file():
|
||||||
|
return "missing"
|
||||||
|
|
||||||
|
stat = candidate.stat()
|
||||||
|
return _md5_for_static_file(str(candidate), stat.st_mtime_ns, stat.st_size)
|
||||||
|
|
||||||
|
|
||||||
|
def asset_url(filename: str) -> str:
|
||||||
|
return url_for("static", filename=filename, h=static_file_hash(filename))
|
||||||
|
|
||||||
|
|
||||||
|
def is_database_available() -> bool:
|
||||||
|
try:
|
||||||
|
db.session.execute(text("SELECT 1"))
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
safe_db_rollback()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_database_ready(create_schema: bool = True, create_admin: bool = True) -> bool:
|
||||||
|
with _DB_INIT_LOCK:
|
||||||
|
try:
|
||||||
|
db.session.execute(text("SELECT 1"))
|
||||||
|
if create_schema:
|
||||||
|
db.create_all()
|
||||||
|
if create_admin:
|
||||||
|
create_admin_account()
|
||||||
|
current_app.extensions["database_ready"] = True
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
safe_db_rollback()
|
||||||
|
current_app.extensions["database_ready"] = False
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def init_database_with_retry(app, max_attempts: int = 20, delay: int = 3, raise_on_failure: bool = True) -> bool:
|
||||||
|
last_error = None
|
||||||
|
with app.app_context():
|
||||||
|
for attempt in range(1, max_attempts + 1):
|
||||||
|
try:
|
||||||
|
ensure_database_ready(create_schema=True, create_admin=True)
|
||||||
|
current_app.logger.info("Baza danych gotowa po probie %s/%s", attempt, max_attempts)
|
||||||
|
return True
|
||||||
|
except OperationalError as exc:
|
||||||
|
last_error = exc
|
||||||
|
current_app.logger.warning(
|
||||||
|
"Baza danych niedostepna (proba %s/%s): %s",
|
||||||
|
attempt,
|
||||||
|
max_attempts,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
current_app.logger.warning(
|
||||||
|
"Blad inicjalizacji bazy (proba %s/%s): %s",
|
||||||
|
attempt,
|
||||||
|
max_attempts,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
if attempt < max_attempts:
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
if raise_on_failure and last_error:
|
||||||
|
raise last_error
|
||||||
|
return False
|
||||||
Reference in New Issue
Block a user