Compare commits

...

107 Commits

Author SHA1 Message Date
Mateusz Gruszczyński
a776e7c51e wizualne 2025-12-12 11:57:34 +01:00
Mateusz Gruszczyński
fe48f589f0 statystyki i optymalizacje 2025-12-12 09:23:34 +01:00
Mateusz Gruszczyński
20910fa898 typo 2025-12-11 23:00:12 +01:00
Mateusz Gruszczyński
ab19453c73 typo 2025-12-11 22:53:07 +01:00
Mateusz Gruszczyński
ff9283fd5f rezerwa ramka inny kolor 2025-12-11 22:44:16 +01:00
Mateusz Gruszczyński
276a0199f3 rezerwa ramka inny kolor 2025-12-11 22:35:54 +01:00
Mateusz Gruszczyński
c75505a79c rezerwa ramka inny kolor 2025-12-11 22:31:49 +01:00
Mateusz Gruszczyński
5db700f3b0 rezerwa bez progressu 2025-12-11 15:24:55 +01:00
Mateusz Gruszczyński
29ca46a5ef funkcje rezerw i przesuniec 2025-12-11 14:55:34 +01:00
Mateusz Gruszczyński
da2c2ca3bd funkcje rezerw i przesuniec 2025-12-11 14:16:14 +01:00
Mateusz Gruszczyński
19f0bbc438 funkcje rezerw i przesuniec 2025-12-11 13:55:49 +01:00
Mateusz Gruszczyński
2af3b7357c funkcje rezerw i przesuniec 2025-12-11 13:53:43 +01:00
Mateusz Gruszczyński
5220b8cf2c funkcje rezerw i przesuniec 2025-12-11 13:47:57 +01:00
Mateusz Gruszczyński
3e4d1ba78c poprawka w logowaniu 2025-12-10 10:05:41 +01:00
Mateusz Gruszczyński
8ee34d931d poprawka w logowaniu 2025-12-10 09:58:53 +01:00
root
562d8117c3 mniej logow z varnisha 2025-12-07 15:45:30 +01:00
root
f2d43e0b04 mniej logow z varnisha 2025-12-07 15:42:15 +01:00
root
d4253dd4a5 mniej logow z varnisha 2025-12-07 15:40:03 +01:00
Mateusz Gruszczyński
cad4f35997 poprwka sql 2025-12-07 14:43:57 +01:00
Mateusz Gruszczyński
e4bd4d1819 poprwka sql 2025-12-07 14:40:35 +01:00
Mateusz Gruszczyński
05e3265e2c poprwka sql 2025-12-07 14:36:12 +01:00
Mateusz Gruszczyński
236dbc8b16 poprwka sql 2025-12-07 14:29:38 +01:00
Mateusz Gruszczyński
ad42e44933 poprwka sql 2025-12-07 14:24:38 +01:00
Mateusz Gruszczyński
7d6fdaf03a poprwka sql 2025-12-07 14:22:03 +01:00
Mateusz Gruszczyński
95ed3180b2 parsowanie kwot 2025-12-07 14:16:17 +01:00
Mateusz Gruszczyński
91d83d91f0 parsowanie kwot 2025-12-07 14:10:33 +01:00
Mateusz Gruszczyński
c48cf7d92e parsowanie kwot 2025-12-07 14:08:16 +01:00
Mateusz Gruszczyński
b88080e5ac parsowanie kwot 2025-12-07 14:03:22 +01:00
gru
2c98d710f3 Update deploy/app/Dockerfile 2025-11-23 22:27:45 +01:00
Mateusz Gruszczyński
3be89fe9d8 ukrywanie akcji 2025-10-10 14:05:23 +02:00
Mateusz Gruszczyński
f6d2c9955c ukrywanie akcji 2025-10-10 14:04:28 +02:00
Mateusz Gruszczyński
15dd9d1fe9 ukrywanie akcji 2025-10-10 14:04:12 +02:00
Mateusz Gruszczyński
e1367f8bf2 ukrywanie akcji 2025-10-10 14:00:40 +02:00
Mateusz Gruszczyński
35a8d5dd8e ukrywanie akcji 2025-10-10 13:58:36 +02:00
Mateusz Gruszczyński
f9406a46cf wylaczenie sposob platnosci i porpawki ux 2025-10-10 13:30:13 +02:00
Mateusz Gruszczyński
1c69295b9b zmiany ux 2025-10-09 22:07:48 +02:00
Mateusz Gruszczyński
a4887f9c73 zmiany ux 2025-10-09 22:02:29 +02:00
Mateusz Gruszczyński
22b4c3d99a zmiany ux 2025-10-09 21:57:20 +02:00
Mateusz Gruszczyński
16ec180822 zmiany ux 2025-10-09 21:54:22 +02:00
Mateusz Gruszczyński
3bad07ce69 zmiany ux 2025-10-09 21:50:29 +02:00
Mateusz Gruszczyński
086784b4ef zmiany ux 2025-10-09 21:47:31 +02:00
Mateusz Gruszczyński
a4fa47e1a2 zmiany ux 2025-10-09 21:44:53 +02:00
Mateusz Gruszczyński
6709681dfd zmiany ux 2025-10-09 21:38:44 +02:00
Mateusz Gruszczyński
1e7f1e3990 zmiany ux 2025-10-09 21:36:11 +02:00
Mateusz Gruszczyński
d844d354fa zmiany ux 2025-10-09 21:33:22 +02:00
Mateusz Gruszczyński
4de192b865 zmiany ux 2025-10-09 21:27:59 +02:00
Mateusz Gruszczyński
90b9c8d462 zmiany ux 2025-10-09 20:32:49 +02:00
gru
ee2f498744 Update docker-compose.yml 2025-10-07 21:23:45 +02:00
gru
4a357ffd57 Update docker-compose.yml 2025-10-07 21:17:32 +02:00
gru
f78fe5369c Update docker-compose.yml 2025-10-07 21:12:55 +02:00
gru
69edf7844a Update docker-compose.yml 2025-10-07 21:09:29 +02:00
gru
1bedab4825 Update docker-compose.yml 2025-10-07 21:05:41 +02:00
gru
609a411824 Update deploy/varnish/default.vcl.template 2025-10-01 21:29:19 +02:00
root
a4fabcf4bd pgsql 18 2025-09-28 11:21:57 +02:00
Mateusz Gruszczyński
d44681ed0c rename script 2025-09-27 22:24:43 +02:00
Mateusz Gruszczyński
1d8a87216d rename script 2025-09-27 22:23:52 +02:00
Mateusz Gruszczyński
2f961db2ac rename script 2025-09-27 22:22:34 +02:00
Mateusz Gruszczyński
e79eebed17 python3.14 2025-09-27 21:31:07 +02:00
Mateusz Gruszczyński
c0daa93fe9 python3.1 2025-09-27 21:28:17 +02:00
Mateusz Gruszczyński
0d09abc4eb dockerfile 2025-09-26 23:36:44 +02:00
Mateusz Gruszczyński
413c80baa2 sitche_form 2025-09-26 23:32:47 +02:00
Mateusz Gruszczyński
0390a59008 sitche_form 2025-09-26 23:30:40 +02:00
Mateusz Gruszczyński
ee05bf74b5 sitche_form 2025-09-26 23:25:13 +02:00
Mateusz Gruszczyński
052439b340 sitche_form 2025-09-26 23:22:13 +02:00
Mateusz Gruszczyński
a673fe99f8 fix w formularzu 2025-09-26 23:19:57 +02:00
Mateusz Gruszczyński
fd46242cf5 fix w formularzu 2025-09-26 23:14:57 +02:00
Mateusz Gruszczyński
95b01665b9 fix w formularzu 2025-09-26 23:07:17 +02:00
Mateusz Gruszczyński
f7a99df93d fix w formularzu 2025-09-26 23:02:59 +02:00
Mateusz Gruszczyński
64ad6b4bbf multidb 2025-09-26 22:53:52 +02:00
Mateusz Gruszczyński
d7abf96080 multidb 2025-09-26 22:52:48 +02:00
Mateusz Gruszczyński
8887ed2c62 multidb 2025-09-26 22:46:23 +02:00
Mateusz Gruszczyński
dfa3028dad multidb 2025-09-26 22:42:10 +02:00
Mateusz Gruszczyński
b679700c6d multidb support 2025-09-26 22:03:09 +02:00
Mateusz Gruszczyński
eb9f11b2d6 alters 2025-09-26 13:59:10 +02:00
Mateusz Gruszczyński
6a1734024a spolszczenie wszystkiego i poprawki 2025-09-26 13:52:06 +02:00
Mateusz Gruszczyński
fdcfaff80e fix w dodawaniu zbiórki 2025-09-25 16:30:36 +02:00
Mateusz Gruszczyński
abb8ee0ae7 varnish reconfig 2025-09-25 10:31:41 +02:00
Mateusz Gruszczyński
37061442d2 varnish config 2025-09-25 09:22:16 +02:00
Mateusz Gruszczyński
113f9e385e varnish config 2025-09-25 09:18:58 +02:00
Mateusz Gruszczyński
34d3f3ae0c varnish throttle 2025-09-24 16:09:35 +02:00
Mateusz Gruszczyński
72b82ae40c varnish throttle 2025-09-24 16:06:57 +02:00
Mateusz Gruszczyński
9a7660fdf7 varnish throttle 2025-09-24 16:02:48 +02:00
Mateusz Gruszczyński
56c02e072d varnish throttle 2025-09-24 15:53:43 +02:00
Mateusz Gruszczyński
b45a259455 varnish add 2025-09-24 13:08:35 +02:00
Mateusz Gruszczyński
d0df1cdee9 varnish add 2025-09-24 13:05:23 +02:00
Mateusz Gruszczyński
71a2b33a7a varnish add 2025-09-24 12:55:50 +02:00
Mateusz Gruszczyński
16beaac932 varnish add 2025-09-24 12:54:01 +02:00
Mateusz Gruszczyński
eec3985c5a varnish add 2025-09-24 12:49:22 +02:00
Mateusz Gruszczyński
b80b92a5df varnish add 2025-09-24 12:46:12 +02:00
Mateusz Gruszczyński
9b2353c82a varnish add 2025-09-24 12:44:40 +02:00
Mateusz Gruszczyński
dec43f324e varnish add 2025-09-24 12:42:16 +02:00
Mateusz Gruszczyński
2b024e2b3a varnish add 2025-09-24 12:40:34 +02:00
Mateusz Gruszczyński
5c54ccfe83 varnish add 2025-09-24 12:37:42 +02:00
Mateusz Gruszczyński
1ff6eac9a2 varnish add 2025-09-24 12:34:38 +02:00
Mateusz Gruszczyński
7495a6baca varnish add 2025-09-24 12:32:48 +02:00
Mateusz Gruszczyński
f4ebcdb5b1 zmiany w ux 2025-09-24 12:20:33 +02:00
Mateusz Gruszczyński
4515a1b391 zmiany w ux 2025-09-24 10:55:04 +02:00
Mateusz Gruszczyński
8b6fb41a68 version_app 2025-09-23 12:52:23 +02:00
Mateusz Gruszczyński
6238cf9e4b version_app 2025-09-23 12:45:35 +02:00
Mateusz Gruszczyński
f2b7c8a64a version_app 2025-09-23 12:42:22 +02:00
Mateusz Gruszczyński
bbe318f995 zmiany ux i kodowe 2025-09-23 10:25:11 +02:00
Mateusz Gruszczyński
1a423a8b92 listy, i inne funkcje 2025-09-22 14:01:57 +02:00
Mateusz Gruszczyński
0b221696d4 opcje wydatkow w zbiorce 2025-09-20 16:19:30 +02:00
Mateusz Gruszczyński
b9e85ab5d4 fix typo 2025-09-15 16:50:25 +02:00
Mateusz Gruszczyński
b3904f25b9 fix typo 2025-09-15 16:46:39 +02:00
Mateusz Gruszczyński
31e65fe8a4 fi w compose 2025-08-28 14:04:14 +02:00
Mateusz Gruszczyński
f30668d987 drobne 2025-08-28 14:03:19 +02:00
45 changed files with 4963 additions and 800 deletions

View File

@@ -36,4 +36,32 @@ USE_ETAGS=True
PRAGMA_HEADER= PRAGMA_HEADER=
# Wartość nagłówka X-Robots-Tag, gdy BLOCK_BOTS=True # Wartość nagłówka X-Robots-Tag, gdy BLOCK_BOTS=True
ROBOTS_TAG=noindex, nofollow, nosnippet, noarchive 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

4
.gitignore vendored
View File

@@ -3,3 +3,7 @@ data/
instance/ instance/
venv/ venv/
.env .env
version.txt
deploy/varnish/default.vcl
*.tar.gz
db/*

View File

@@ -1,19 +0,0 @@
FROM python:3.13-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
EXPOSE 8080
CMD ["python", "run_waitress.py"]

1
Dockerfile Symbolic link
View File

@@ -0,0 +1 @@
deploy/app/Dockerfile

38
_tools/db/migrate.txt Normal file
View 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$$;

View 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()

View File

@@ -1,68 +1,6 @@
-- WŁĄCZ/wyłącz FK zależnie od etapu migracji ALTER TABLE zbiorka ADD COLUMN typ_zbiorki VARCHAR(20) NOT NULL DEFAULT 'standardowa';
PRAGMA foreign_keys = OFF; CREATE INDEX idx_zbiorka_typ ON zbiorka(typ_zbiorki);
BEGIN TRANSACTION;
-- 1) Nowa tabela z właściwym FK (ON DELETE CASCADE)
CREATE TABLE wplata_new (
id INTEGER PRIMARY KEY,
zbiorka_id INTEGER NOT NULL,
kwota REAL NOT NULL,
data DATETIME,
opis TEXT,
FOREIGN KEY(zbiorka_id) REFERENCES zbiorka(id) ON DELETE CASCADE
);
-- 2) (opcjonalnie) upewnij się, że nie ma „sierotek”
-- SELECT w.* FROM wplata w LEFT JOIN zbiorka z ON z.id = w.zbiorka_id WHERE z.id IS NULL;
-- 3) Kopiowanie danych
INSERT INTO wplata_new (id, zbiorka_id, kwota, data, opis)
SELECT id, zbiorka_id, kwota, data, opis
FROM wplata;
-- 4) Usunięcie starej tabeli
DROP TABLE wplata;
-- 5) Zmiana nazwy nowej tabeli na właściwą
ALTER TABLE wplata_new RENAME TO wplata;
-- 6) Odtwórz indeksy/trigger-y jeśli jakieś były (przykład indeksu po FK)
-- CREATE INDEX idx_wplata_zbiorka_id ON wplata(zbiorka_id);
COMMIT;
PRAGMA foreign_keys = ON;
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ ALTER TABLE ustawienia_globalne
ADD COLUMN kolejnosc_rezerwowych VARCHAR(20) NOT NULL DEFAULT 'id';
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
ALTER TABLE global_settings ADD COLUMN logo_url TEXT DEFAULT '';
ALTER TABLE global_settings ADD COLUMN site_title TEXT DEFAULT '';
ALTER TABLE global_settings ADD COLUMN show_logo_in_navbar BOOLEAN DEFAULT 0;
COMMIT;
PRAGMA foreign_keys=ON;
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
-- 1) Dodajemy nowe kolumny (SQLite pozwala tylko na ADD COLUMN)
ALTER TABLE global_settings ADD COLUMN navbar_brand_mode TEXT DEFAULT 'text';
ALTER TABLE global_settings ADD COLUMN footer_brand_mode TEXT DEFAULT 'text';
ALTER TABLE global_settings ADD COLUMN footer_text TEXT;
-- 2) Backfill: zgodność wsteczna z show_logo_in_navbar
UPDATE global_settings
SET navbar_brand_mode = 'logo'
WHERE COALESCE(show_logo_in_navbar, 0) = 1;
-- 3) Upewnij się, że wartości są ustawione (na wypadek NULL-i)
UPDATE global_settings
SET navbar_brand_mode = COALESCE(navbar_brand_mode, 'text'),
footer_brand_mode = COALESCE(footer_brand_mode, 'text');

1394
app.py

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,7 @@
import os import os
basedir = os.path.abspath(os.path.dirname(__file__))
def _get_bool(name: str, default: bool) -> bool: def _get_bool(name: str, default: bool) -> bool:
val = os.environ.get(name) val = os.environ.get(name)
if val is None: if val is None:
@@ -24,8 +26,8 @@ class Config:
- ROBOTS_TAG - ROBOTS_TAG
""" """
# Baza danych
SQLALCHEMY_DATABASE_URI = _get_str("DATABASE_URL", "sqlite:///baza.db") #SQLALCHEMY_DATABASE_URI = _get_str("DATABASE_URL", "sqlite:///baza.db")
# Flask # Flask
SECRET_KEY = _get_str("SECRET_KEY", "tajny_klucz") SECRET_KEY = _get_str("SECRET_KEY", "tajny_klucz")
@@ -49,3 +51,21 @@ class Config:
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
HEALTHCHECK_TOKEN = _get_str("HEALTHCHECK_TOKEN", "healthcheck") 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']}"
)
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']}"
)
else:
raise ValueError("Nieobsługiwany typ bazy danych.")

View File

@@ -1,56 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# === Konfiguracja (możesz nadpisać zmiennymi środowiskowymi) ===
REPO_DIR="${REPO_DIR:-$(pwd)}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}" # albo compose.yaml
GIT_REMOTE="${GIT_REMOTE:-origin}"
GIT_BRANCH="${GIT_BRANCH:-$(git -C "$REPO_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)}"
# Jeśli chcesz uruchomić tylko wybrane serwisy, podaj je po komendzie,
# np.: ./deploy.sh web api
SERVICES=("$@")
log() { printf "\n==> %s\n" "$*"; }
# --- Kontrole wstępne ---
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
# Spróbuj alternatywnej nazwy
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
# --- Praca w katalogu repo ---
cd "$REPO_DIR"
# --- Aktualizacja kodu ---
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"
# --- Zatrzymanie i usunięcie bieżącego stacka ---
log "Docker Compose DOWN (usuwanie kontenerów i osieroconych usług)"
docker compose -f "$COMPOSE_FILE" down --remove-orphans
# --- Budowanie i uruchamianie bez restartu zależności ---
log "Docker Compose UP (build bez deps) dla: ${SERVICES[*]:-(wszystkie)}"
if [[ ${#SERVICES[@]} -gt 0 ]]; then
docker compose -f "$COMPOSE_FILE" up -d --no-deps --build "${SERVICES[@]}"
else
docker compose -f "$COMPOSE_FILE" up -d --no-deps --build
fi
log "Gotowe ✅"

18
deploy/app/Dockerfile Normal file
View 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
View 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"]

View 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
View 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))"

View File

@@ -2,10 +2,12 @@ services:
app: app:
build: . build: .
container_name: zbiorka-app container_name: zbiorka-app
ports: #ports:
- "${APP_PORT:-8080}:8080" # - "${APP_PORT:-8080}:${APP_PORT}"
expose:
- "${APP_PORT}"
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; import sys; req = urllib.request.Request('http://localhost:8080/healthcheck', headers={'X-Internal-Check': '${HEALTHCHECK_TOKEN}'}); sys.exit(0) if urllib.request.urlopen(req).read() == b'OK' else sys.exit(1)"] test: [ "CMD", "python", "-c", "import urllib.request; import sys; req = urllib.request.Request('http://localhost:${APP_PORT}/healthcheck', headers={'X-Internal-Check': '${HEALTHCHECK_TOKEN}'}); sys.exit(0) if urllib.request.urlopen(req).read() == b'OK' else sys.exit(1)" ]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
@@ -14,4 +16,59 @@ services:
- .env - .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

View File

@@ -4,3 +4,6 @@ Flask-Login
Werkzeug Werkzeug
waitress waitress
markdown markdown
psycopg2-binary # pgsql
pymysql # mysql
cryptography # mysql8

View File

@@ -304,3 +304,137 @@ select.form-select:focus {
.CodeMirror-cursor { .CodeMirror-cursor {
border-left: 1px solid #e0e0e0 !important; 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;
}

View File

@@ -0,0 +1,5 @@
document.addEventListener('input', (e) => {
if (e.target && e.target.id === 'opis') {
document.getElementById('opisCount').textContent = e.target.value.length;
}
});

View File

@@ -26,15 +26,20 @@
const val = clamp(Number(input.value)); const val = clamp(Number(input.value));
const p = Math.max(0, Math.min(100, pct(val))); const p = Math.max(0, Math.min(100, pct(val)));
if (previewPct) previewPct.textContent = pct(val).toFixed(1); if (previewPct) previewPct.textContent = p.toFixed(1);
if (previewBar) previewBar.style.setProperty('--progress-width', p + '%');
if (previewBar) {
previewBar.style.width = p + '%';
previewBar.setAttribute('aria-valuenow', p.toFixed(2));
}
if (previewNote) { if (previewNote) {
if (cel > 0) { if (cel > 0) {
const diff = cel - val; const diff = cel - val;
if (diff > 0) { const isZero = Math.abs(diff) < 0.005; // float-safe
if (diff > 0 && !isZero) {
previewNote.textContent = 'Do celu brakuje: ' + diff.toFixed(2) + ' PLN'; previewNote.textContent = 'Do celu brakuje: ' + diff.toFixed(2) + ' PLN';
} else if (diff === 0) { } else if (isZero) {
previewNote.textContent = 'Cel osiągnięty.'; previewNote.textContent = 'Cel osiągnięty.';
} else { } else {
previewNote.textContent = 'Przekroczono cel o: ' + Math.abs(diff).toFixed(2) + ' PLN'; previewNote.textContent = 'Przekroczono cel o: ' + Math.abs(diff).toFixed(2) + ' PLN';
@@ -45,6 +50,7 @@
} }
} }
// Zmiana ręczna // Zmiana ręczna
if (input) { if (input) {
input.addEventListener('input', updatePreview); input.addEventListener('input', updatePreview);

View File

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

View File

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

View File

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

View 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());
});
})();

View 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
static/js/transakcje.js Normal file
View 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();
});
});
});

View File

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

View File

@@ -9,11 +9,11 @@
}, false); }, false);
})(); })();
const pw = document.getElementById('password'); const pw = document.getElementById("haslo");
const toggle = document.getElementById('togglePw'); const toggle = document.getElementById('togglePw');
toggle.addEventListener('click', () => { toggle.addEventListener('click', () => {
const isText = pw.type === 'text'; const isText = pw.type === 'text';
pw.type = isText ? 'password' : 'text'; pw.type = isText ? "haslo" : 'text';
toggle.textContent = isText ? 'Pokaż' : 'Ukryj'; toggle.textContent = isText ? 'Pokaż' : 'Ukryj';
toggle.setAttribute('aria-pressed', (!isText).toString()); toggle.setAttribute('aria-pressed', (!isText).toString());
pw.focus(); pw.focus();

View File

@@ -5,7 +5,7 @@
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
} }
const pw1 = document.getElementById('password'); const pw1 = document.getElementById("haslo");
const pw2 = document.getElementById('password2'); const pw2 = document.getElementById('password2');
if (pw1.value !== pw2.value) { if (pw1.value !== pw2.value) {
e.preventDefault(); e.preventDefault();
@@ -19,11 +19,11 @@
}, false); }, false);
})(); })();
const pw = document.getElementById('password'); const pw = document.getElementById("haslo");
const toggle = document.getElementById('togglePw'); const toggle = document.getElementById('togglePw');
toggle.addEventListener('click', () => { toggle.addEventListener('click', () => {
const isText = pw.type === 'text'; const isText = pw.type === 'text';
pw.type = isText ? 'password' : 'text'; pw.type = isText ? "haslo" : 'text';
toggle.textContent = isText ? 'Pokaż' : 'Ukryj'; toggle.textContent = isText ? 'Pokaż' : 'Ukryj';
pw.focus(); pw.focus();
}); });

View File

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

View File

@@ -6,29 +6,80 @@
<!-- Nagłówek + akcje globalne --> <!-- Nagłówek + akcje globalne -->
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-4"> <div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-4">
<h2 class="mb-0">Panel Admina</h2> <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"> <div class="d-flex flex-wrap gap-2">
<a href="{{ url_for('formularz_zbiorek') }}" class="btn btn-primary"> <a href="{{ url_for('admin_statystyki') }}" class="btn btn-info">
Dodaj zbiórkę <i class="bi bi-graph-up"></i> Statystyki
</a> </a>
<a href="{{ url_for('admin_ustawienia') }}" class="btn btn-outline-light border"> <a href="{{ url_for('formularz_zbiorek') }}" class="btn btn-primary">
⚙️ Ustawienia <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> </a>
</div> </div>
</div> </div>
<!-- Pigułki: Aktywne / Zrealizowane (zakładki w obrębie panelu) --> <!-- 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"> <ul class="nav nav-pills mb-3" id="adminTabs" role="tablist">
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-aktywne" data-bs-toggle="tab" data-bs-target="#pane-aktywne" <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"> type="button" role="tab" aria-controls="pane-aktywne" aria-selected="true">
Aktywne zbiórki <i class="bi bi-lightning"></i> Aktywne zbiórki ({{ active_zbiorki|length }})
</button> </button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="tab-zrealizowane" data-bs-toggle="tab" data-bs-target="#pane-zrealizowane" <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"> type="button" role="tab" aria-controls="pane-zrealizowane" aria-selected="false">
Zrealizowane <i class="bi bi-check-circle"></i> Zrealizowane ({{ completed_zbiorki|length }})
</button> </button>
</li> </li>
</ul> </ul>
@@ -56,12 +107,20 @@
<td class="text-muted">{{ z.id }}</td> <td class="text-muted">{{ z.id }}</td>
<td> <td>
<div class="d-flex flex-column"> <div class="d-flex flex-column">
<span class="fw-semibold">{{ z.nazwa }}</span> <a href="{{ url_for('zbiorka', zbiorka_id=z.id) }}" class="fw-semibold text-decoration-none">
{# opcjonalnie: mini-meta z celem/stanem jeśli masz te pola #} {{ z.nazwa }}
</a>
{% if z.cel is defined or z.stan is defined %} {% if z.cel is defined or z.stan is defined %}
<small class="text-muted"> <small class="text-muted">
{% if z.cel is defined %} Cel: {{ z.cel|round(2) }} PLN {% endif %} {% if z.cel is defined and z.cel > 0 %}
{% if z.stan is defined %} · Stan: {{ z.stan|round(2) }} PLN {% endif %} 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> </small>
{% endif %} {% endif %}
</div> </div>
@@ -69,57 +128,82 @@
<td> <td>
{% if z.ukryta %} {% if z.ukryta %}
<span class="badge bg-secondary border" <span class="badge bg-secondary border"
style="border-color: var(--border);">Ukryta</span> style="border-color: var(--border);"><i class="bi bi-eye-slash"></i> Ukryta</span>
{% else %} {% else %}
<span class="badge rounded-pill" <span class="badge bg-success"><i class="bi bi-eye"></i> Widoczna</span>
style="background: var(--accent); color:#111;">Widoczna</span>
{% endif %} {% endif %}
</td> </td>
<td class="text-end"> <td class="text-end">
<!-- Grupa akcji: główne + rozwijane --> <!-- Grupa akcji: główne + rozwijane -->
<div class="btn-group"> <div class="btn-group">
<a href="{{ url_for('formularz_zbiorek', zbiorka_id=z.id) }}" <a href="{{ url_for('formularz_zbiorek', zbiorka_id=z.id) }}"
class="btn btn-sm btn-outline-light">Edytuj</a> 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" <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"> data-bs-toggle="dropdown" aria-expanded="false" aria-label="Więcej opcji">
<span class="visually-hidden">Więcej</span> <span class="visually-hidden">Więcej</span>
</button> </button>
<ul class="dropdown-menu dropdown-menu-dark dropdown-menu-end shadow"> <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> <li>
<a class="dropdown-item" <a class="dropdown-item"
href="{{ url_for('dodaj_wplate', zbiorka_id=z.id) }}">Dodaj href="{{ url_for('dodaj_wplate', zbiorka_id=z.id) }}">
wpłatę</a> <i class="bi bi-plus-circle text-success"></i> Dodaj wpłatę
</a>
</li> </li>
<li> <li>
<a class="dropdown-item" <a class="dropdown-item"
href="{{ url_for('edytuj_stan', zbiorka_id=z.id) }}">Edytuj stan</a> href="{{ url_for('dodaj_wydatek', zbiorka_id=z.id) }}">
<i class="bi bi-dash-circle text-danger"></i> Dodaj wydatek
</a>
</li> </li>
<li> <li>
<hr class="dropdown-divider"> <a class="dropdown-item"
href="{{ url_for('transakcje_zbiorki', zbiorka_id=z.id) }}">
<i class="bi bi-list-ul"></i> Transakcje
</a>
</li> </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> <li>
<form action="{{ url_for('oznacz_zrealizowana', zbiorka_id=z.id) }}" <form action="{{ url_for('oznacz_zrealizowana', zbiorka_id=z.id) }}"
method="post" class="m-0"> method="post" class="m-0">
<button type="submit" class="dropdown-item">Oznacz jako <button type="submit" class="dropdown-item">
zrealizowaną</button> <i class="bi bi-check-circle text-success"></i> Oznacz jako zrealizowaną
</button>
</form> </form>
</li> </li>
<li> <li>
<form action="{{ url_for('zmien_widzialnosc', zbiorka_id=z.id) }}" <form action="{{ url_for('zmien_widzialnosc', zbiorka_id=z.id) }}"
method="post" class="m-0"> method="post" class="m-0">
<button type="submit" class="dropdown-item"> <button type="submit" class="dropdown-item">
{% if z.ukryta %}Pokaż{% else %}Ukryj{% endif %} {% if z.ukryta %}
<i class="bi bi-eye"></i> Pokaż
{% else %}
<i class="bi bi-eye-slash"></i> Ukryj
{% endif %}
</button> </button>
</form> </form>
</li> </li>
<li> <li><hr class="dropdown-divider"></li>
<hr class="dropdown-divider">
</li>
<li> <li>
<form action="{{ url_for('usun_zbiorka', zbiorka_id=z.id) }}" method="post" <form action="{{ url_for('usun_zbiorka', zbiorka_id=z.id) }}" method="post"
class="m-0" class="m-0"
onsubmit="return confirm('Czy na pewno chcesz usunąć tę zbiórkę?');"> onsubmit="return confirm('Czy na pewno chcesz usunąć tę zbiórkę?');">
<button type="submit" class="dropdown-item text-danger">Usuń</button> <button type="submit" class="dropdown-item text-danger">
<i class="bi bi-trash"></i> Usuń
</button>
</form> </form>
</li> </li>
</ul> </ul>
@@ -134,15 +218,18 @@
<!-- Empty state --> <!-- Empty state -->
<div class="card"> <div class="card">
<div class="card-body text-center py-5"> <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> <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> <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">Utwórz nową zbiórkę</a> <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>
</div> </div>
{% endif %} {% endif %}
</div> </div>
<!-- PANE: Zrealizowane --> <!-- PANEL: Zrealizowane -->
<div class="tab-pane fade" id="pane-zrealizowane" role="tabpanel" aria-labelledby="tab-zrealizowane" <div class="tab-pane fade" id="pane-zrealizowane" role="tabpanel" aria-labelledby="tab-zrealizowane"
tabindex="0"> tabindex="0">
@@ -163,7 +250,9 @@
<td class="text-muted">{{ z.id }}</td> <td class="text-muted">{{ z.id }}</td>
<td> <td>
<div class="d-flex flex-column"> <div class="d-flex flex-column">
<span class="fw-semibold">{{ z.nazwa }}</span> <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 %} {% if z.cel is defined or z.stan is defined %}
<small class="text-muted"> <small class="text-muted">
{% if z.cel is defined %} Cel: {{ z.cel|round(2) }} PLN {% endif %} {% if z.cel is defined %} Cel: {{ z.cel|round(2) }} PLN {% endif %}
@@ -175,59 +264,87 @@
<td> <td>
<div class="d-flex align-items-center gap-2 flex-wrap"> <div class="d-flex align-items-center gap-2 flex-wrap">
<span class="badge rounded-pill" <span class="badge rounded-pill"
style="background: var(--accent); color:#111;">Zrealizowana</span> style="background: var(--accent); color:#111;">
<i class="bi bi-check-circle"></i> Zrealizowana
</span>
{% if z.ukryta %} {% if z.ukryta %}
<span class="badge bg-secondary border" <span class="badge bg-secondary border"
style="border-color: var(--border);">Ukryta</span> style="border-color: var(--border);"><i class="bi bi-eye-slash"></i> Ukryta</span>
{% else %} {% else %}
<span class="badge bg-success">Widoczna</span> <span class="badge bg-success"><i class="bi bi-eye"></i> Widoczna</span>
{% endif %} {% endif %}
</div> </div>
</td> </td>
<td class="text-end"> <td class="text-end">
<div class="btn-group"> <div class="btn-group">
<a href="{{ url_for('formularz_zbiorek', zbiorka_id=z.id) }}" <a href="{{ url_for('formularz_zbiorek', zbiorka_id=z.id) }}"
class="btn btn-sm btn-outline-light">Edytuj</a> 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" <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"> data-bs-toggle="dropdown" aria-expanded="false" aria-label="Więcej opcji">
<span class="visually-hidden">Więcej</span> <span class="visually-hidden">Więcej</span>
</button> </button>
<ul class="dropdown-menu dropdown-menu-dark dropdown-menu-end shadow"> <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> <li>
<a class="dropdown-item" <a class="dropdown-item"
href="{{ url_for('dodaj_wplate', zbiorka_id=z.id) }}">Dodaj href="{{ url_for('dodaj_wplate', zbiorka_id=z.id) }}">
wpłatę</a> <i class="bi bi-plus-circle text-success"></i> Dodaj wpłatę
</a>
</li> </li>
<li> <li>
<a class="dropdown-item" <a class="dropdown-item"
href="{{ url_for('edytuj_stan', zbiorka_id=z.id) }}">Edytuj stan</a> href="{{ url_for('dodaj_wydatek', zbiorka_id=z.id) }}">
<i class="bi bi-dash-circle text-danger"></i> Dodaj wydatek
</a>
</li> </li>
<li> <li>
<hr class="dropdown-divider"> <a class="dropdown-item"
href="{{ url_for('transakcje_zbiorki', zbiorka_id=z.id) }}">
<i class="bi bi-list-ul"></i> Transakcje
</a>
</li> </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> <li>
<form action="{{ url_for('oznacz_niezrealizowana', zbiorka_id=z.id) }}" <form action="{{ url_for('oznacz_niezrealizowana', zbiorka_id=z.id) }}"
method="post" class="m-0"> method="post" class="m-0">
<button type="submit" class="dropdown-item">Oznacz jako <button type="submit" class="dropdown-item">
niezrealizowaną</button> <i class="bi bi-arrow-counterclockwise"></i> Oznacz jako niezrealizowaną
</button>
</form> </form>
</li> </li>
<li> <li>
<form action="{{ url_for('zmien_widzialnosc', zbiorka_id=z.id) }}" <form action="{{ url_for('zmien_widzialnosc', zbiorka_id=z.id) }}"
method="post" class="m-0"> method="post" class="m-0">
<button type="submit" class="dropdown-item"> <button type="submit" class="dropdown-item">
{% if z.ukryta %}Pokaż{% else %}Ukryj{% endif %} {% if z.ukryta %}
<i class="bi bi-eye"></i> Pokaż
{% else %}
<i class="bi bi-eye-slash"></i> Ukryj
{% endif %}
</button> </button>
</form> </form>
</li> </li>
<li> <li><hr class="dropdown-divider"></li>
<hr class="dropdown-divider">
</li>
<li> <li>
<form action="{{ url_for('usun_zbiorka', zbiorka_id=z.id) }}" method="post" <form action="{{ url_for('usun_zbiorka', zbiorka_id=z.id) }}" method="post"
class="m-0" class="m-0"
onsubmit="return confirm('Czy na pewno chcesz usunąć tę zbiórkę?');"> onsubmit="return confirm('Czy na pewno chcesz usunąć tę zbiórkę?');">
<button type="submit" class="dropdown-item text-danger">Usuń</button> <button type="submit" class="dropdown-item text-danger">
<i class="bi bi-trash"></i> Usuń
</button>
</form> </form>
</li> </li>
</ul> </ul>
@@ -241,10 +358,12 @@
{% else %} {% else %}
<div class="card"> <div class="card">
<div class="card-body text-center py-5"> <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> <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> <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 border">Utwórz nową <a href="{{ url_for('formularz_zbiorek') }}" class="btn btn-outline-light">
zbiórkę</a> <i class="bi bi-plus-circle"></i> Utwórz nową zbiórkę
</a>
</div> </div>
</div> </div>
{% endif %} {% endif %}

View File

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

View File

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

View 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="{{ url_for('static', filename='js/dodaj_wydatek.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %}

View File

@@ -6,39 +6,57 @@
<!-- Nawigacja --> <!-- Nawigacja -->
<div class="d-flex align-items-center gap-2 mb-3"> <div class="d-flex align-items-center gap-2 mb-3">
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-sm btn-outline-light border">← Szczegóły <a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-sm btn-outline-light">← Szczegóły zbiórki</a>
zbiórki</a> <a href="{{ url_for('admin_dashboard') }}" class="btn btn-sm btn-outline-light">← Panel Admina</a>
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-sm btn-outline-light border">← Panel Admina</a>
</div> </div>
{# Obliczenia wstępne (do inicjalnego podglądu) #} {# Obliczenia wstępne (do inicjalnego podglądu) #}
{% set has_cel = (zbiorka.cel is defined and zbiorka.cel and zbiorka.cel > 0) %} {% set has_cel = (zbiorka.cel is defined and zbiorka.cel and zbiorka.cel > 0 and zbiorka.typ_zbiorki != 'rezerwa') %}
{% set progress = (zbiorka.stan / zbiorka.cel * 100) if has_cel else 0 %} {% set progress = (zbiorka.stan / zbiorka.cel * 100) if has_cel else 0 %}
{% set progress_clamped = 100 if progress > 100 else (0 if progress < 0 else progress) %} <div class="card shadow-sm" {% set progress_clamped = 100 if progress > 100 else (0 if progress < 0 else progress) %}
data-module="edit-stan" data-cel="{{ (zbiorka.cel|round(2)) if has_cel else 0 }}">
<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"> <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> <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"> <div class="d-flex align-items-center flex-wrap gap-2">
{% if has_cel %} {% if has_cel %}
<span class="badge bg-dark border" style="border-color: var(--border);">Cel: {{ zbiorka.cel|round(2) }} <span class="badge bg-dark border" style="border-color: var(--border);">
PLN</span> 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 %} {% endif %}
<span class="badge bg-dark border" style="border-color: var(--border);">Obecnie: {{ zbiorka.stan|round(2) }}
PLN</span>
</div> </div>
</div> </div>
<!-- Mini progress (aktualny) --> <!-- Mini progress (aktualny) - tylko dla standardowych zbiórek -->
{% if zbiorka.typ_zbiorki != 'rezerwa' %}
<div class="px-3 pt-3"> <div class="px-3 pt-3">
<div class="progress" role="progressbar" aria-valuenow="{{ progress_clamped|round(2) }}" aria-valuemin="0" <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"> aria-valuemax="100" aria-label="Postęp zbiórki {{ progress_clamped|round(0) }} procent">
<div class="progress-bar" style="width: {{ progress_clamped }}%;"></div> <div class="progress-bar" style="width: {{ progress_clamped }}%;"></div>
</div> </div>
<small class="text-muted d-block mt-1 mb-2">Aktualnie: {{ progress|round(1) }}%</small> <small class="text-muted d-block mt-1 mb-2">Aktualnie: {{ progress|round(1) }}%</small>
</div> </div>
{% endif %}
<div class="card-body pt-0"> <div class="card-body {% if zbiorka.typ_zbiorki == 'rezerwa' %}pt-3{% else %}pt-0{% endif %}">
<form method="post" novalidate> <form method="post" novalidate>
{# {{ form.csrf_token }} #} {# {{ form.csrf_token }} #}
@@ -57,25 +75,19 @@
<!-- Szybkie korekty --> <!-- Szybkie korekty -->
<div class="d-flex flex-wrap gap-2 mt-2"> <div class="d-flex flex-wrap gap-2 mt-2">
{% for delta in [10,50,100,200] %} {% for delta in [10,50,100,200] %}
<button type="button" class="btn btn-sm btn-outline-light border btn-delta" data-delta="{{ delta }}">+{{ <button type="button" class="btn btn-sm btn-outline-light btn-delta" data-delta="{{ delta }}">+{{ delta }} PLN</button>
delta }} PLN</button> <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 border btn-delta" data-delta="-{{ delta }}">-{{
delta }} PLN</button>
{% endfor %} {% endfor %}
{% if has_cel %} {% if has_cel %}
<button type="button" class="btn btn-sm btn-outline-light border btn-set" <button type="button" class="btn btn-sm btn-outline-light btn-set"
data-value="{{ zbiorka.cel|round(2) }}">Ustaw: do celu</button> data-value="{{ zbiorka.cel|round(2) }}">Ustaw: do celu</button>
{% set brakujace = (zbiorka.cel - zbiorka.stan) if (zbiorka.cel - zbiorka.stan) > 0 else 0 %}
{% if brakujace > 0 %}
<span class="badge bg-dark border" style="border-color: var(--border);">Brakuje: {{ brakujace|round(2) }}
PLN</span>
{% endif %} {% endif %}
{% endif %} <button type="button" class="btn btn-sm btn-outline-light btn-set" data-value="0">Ustaw: 0</button>
<button type="button" class="btn btn-sm btn-outline-light border btn-set" data-value="0">Ustaw: 0</button>
</div> </div>
</div> </div>
<!-- Podgląd po zmianie --> <!-- Podgląd po zmianie - tylko dla standardowych zbiórek -->
{% if zbiorka.typ_zbiorki != 'rezerwa' %}
<div class="mb-3"> <div class="mb-3">
<div class="card bg-dark border" style="border-color: var(--border);"> <div class="card bg-dark border" style="border-color: var(--border);">
<div class="card-body"> <div class="card-body">
@@ -90,11 +102,11 @@
</div> </div>
<div class="mt-2"> <div class="mt-2">
<div class="progress" aria-hidden="true"> <div class="progress" aria-hidden="true">
<div class="progress-bar" style="width: {{ progress_clamped }}%;"></div> <div id="previewBar" class="progress-bar" style="width: {{ progress_clamped }}%;"></div>
</div> </div>
<small class="text-muted d-block mt-1" id="previewNote"> <small class="text-muted d-block mt-1" id="previewNote">
{% if has_cel %} {% if has_cel %}
{% set brakujace = (zbiorka.cel - zbiorka.stan) %}
{% if brakujace > 0 %} {% if brakujace > 0 %}
Do celu brakuje: {{ brakujace|round(2) }} PLN Do celu brakuje: {{ brakujace|round(2) }} PLN
{% elif brakujace == 0 %} {% elif brakujace == 0 %}
@@ -110,19 +122,20 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
<!-- CTA --> <!-- CTA -->
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
<button type="submit" class="btn btn-success">Aktualizuj stan</button> <button type="submit" class="btn btn-success">Aktualizuj stan</button>
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-outline-light border">Anuluj</a> <a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-outline-light">Anuluj</a>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_scripts %} {% block extra_scripts %}
{{ super() }} {{ super() }}
<script src="{{ url_for('static', filename='js/edytuj_stan.js') }}"></script> <script src="{{ url_for('static', filename='js/edytuj_stan.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %} {% endblock %}

View File

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

View File

@@ -1,7 +1,8 @@
{# templates/zbiorka_form.html #} {# templates/formularz_zbiorek.html #}
{% extends 'base.html' %} {% extends 'base.html' %}
{% set is_edit = zbiorka is not none %} {% 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 title %}{{ 'Edytuj zbiórkę' if is_edit else 'Dodaj zbiórkę' }}{% endblock %}
@@ -15,11 +16,11 @@
<!-- Nawigacja / powrót --> <!-- Nawigacja / powrót -->
<div class="d-flex align-items-center gap-2 mb-3"> <div class="d-flex align-items-center gap-2 mb-3">
{% if is_edit %} {% if is_edit and zbiorka and zbiorka.id %}
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-sm btn-outline-light border"> <a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-sm btn-outline-light"> Szczegóły
Szczegóły zbiórki</a> zbiórki</a>
{% else %} {% else %}
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-sm btn-outline-light border">← Panel Admina</a> <a href="{{ url_for('admin_dashboard') }}" class="btn btn-sm btn-outline-light">← Panel Admina</a>
{% endif %} {% endif %}
</div> </div>
@@ -37,11 +38,30 @@
Cel: {{ zbiorka.cel|round(2) }} PLN Cel: {{ zbiorka.cel|round(2) }} PLN
</span> </span>
{% endif %} {% endif %}
{% if zbiorka.ukryj_kwote %}
<span class="badge bg-secondary">Kwoty ukryte</span> {% if not zbiorka.ukryj_kwote %}
{% else %} <span class="badge bg-dark border" style="border-color: var(--border);">
<span class="badge bg-success">Kwoty widoczne</span> Stan: {{ (zbiorka.stan or 0)|round(2) }} PLN
{% endif %} </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> </div>
{% else %} {% else %}
<small class="opacity-75">Uzupełnij podstawowe dane i dane płatności</small> <small class="opacity-75">Uzupełnij podstawowe dane i dane płatności</small>
@@ -60,17 +80,18 @@
<label for="nazwa" class="form-label">Nazwa zbiórki</label> <label for="nazwa" class="form-label">Nazwa zbiórki</label>
<input type="text" class="form-control" id="nazwa" name="nazwa" maxlength="120" <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' }}" placeholder="{{ 'Krótki, zrozumiały tytuł' if is_edit else 'Np. Wsparcie dla schroniska Azor' }}"
value="{{ zbiorka.nazwa if is_edit else '' }}" required aria-describedby="nazwaHelp"> 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 id="nazwaHelp" class="form-text">Krótki, zrozumiały tytuł. Max 120 znaków.</div>
</div> </div>
<div class="col-12"> <div class="col-12">
<label for="opis" class="form-label">Opis (Markdown)</label> <label for="opis" class="form-label">Opis (Markdown)</label>
<textarea class="form-control" id="opis" name="opis" rows="8" required <textarea class="form-control" id="opis" name="opis" rows="8" required
aria-describedby="opisHelp">{{ zbiorka.opis if is_edit else '' }}</textarea> aria-describedby="opisHelp">{{ (zbiorka.opis if zbiorka else request.form.get('opis','')) }}</textarea>
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<small id="opisHelp" class="form-text text-muted"> <small id="opisHelp" class="form-text text-muted">
Możesz używać **Markdown** (nagłówki, listy, linki). W edytorze włącz podgląd 👁️. Możesz używać Markdown (nagłówki, listy, linki). W edytorze włącz podgląd 👁️.
</small> </small>
<small class="text-muted"><span id="opisCount">0</span> znaków</small> <small class="text-muted"><span id="opisCount">0</span> znaków</small>
</div> </div>
@@ -80,44 +101,163 @@
<hr class="my-4" /> <hr class="my-4" />
<!-- SEKCJA: Lista produktów -->
<div class="mb-4">
<h6 class="text-muted mb-2">Lista produktów / 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 --> <!-- SEKCJA: Dane płatności -->
<div class="mb-4"> <div class="mb-4">
<h6 class="text-muted mb-2">Dane płatności</h6> <h6 class="text-muted mb-2">Dane płatności</h6>
<div class="row g-3"> <div class="row g-3">
<div class="col-12"> <!-- 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> <label for="numer_konta" class="form-label">Numer konta (IBAN)</label>
<div class="input-group"> <div class="input-group">
<span class="input-group-text">PL</span> <span class="input-group-text">PL</span>
<input type="text" class="form-control" id="numer_konta" name="numer_konta" <input type="text" class="form-control" id="numer_konta" name="numer_konta"
inputmode="numeric" autocomplete="off" inputmode="numeric" autocomplete="off"
placeholder="12 3456 7890 1234 5678 9012 3456" required aria-describedby="ibanHelp" placeholder="12 3456 7890 1234 5678 9012 3456" {% if zbiorka and not
value="{% if is_edit and zbiorka.numer_konta %}{{ zbiorka.numer_konta }}{% elif global_settings %}{{ global_settings.numer_konta }}{% else %}{% endif %}"> zbiorka.uzyj_konta %}disabled{% endif %} {% if zbiorka is none or zbiorka.uzyj_konta
</div> %}required{% endif %} aria-describedby="ibanHelp"
<div id="ibanHelp" class="form-text"> value="{% if zbiorka and zbiorka.numer_konta %}{{ zbiorka.numer_konta }}{% elif global_settings %}{{ global_settings.numer_konta }}{% else %}{{ request.form.get('numer_konta','') }}{% endif %}">
Wpisz ciąg cyfr; spacje dodadzą się automatycznie dla czytelności.
</div> </div>
<div id="ibanHelp" class="form-text">Wpisz ciąg cyfr; spacje dodadzą się automatycznie dla
czytelności.</div>
</div> </div>
<div class="col-12 col-md-6"> <!-- BLIK -->
<div class="col-12 col-md-6" id="pole_blik">
<label for="numer_telefonu_blik" class="form-label">Numer telefonu BLIK</label> <label for="numer_telefonu_blik" class="form-label">Numer telefonu BLIK</label>
<div class="input-group"> <div class="input-group">
<span class="input-group-text">+48</span> <span class="input-group-text">+48</span>
<input type="tel" class="form-control" id="numer_telefonu_blik" <input type="tel" class="form-control" id="numer_telefonu_blik"
name="numer_telefonu_blik" inputmode="tel" pattern="[0-9 ]{9,13}" name="numer_telefonu_blik" inputmode="tel" pattern="[0-9 ]{9,13}"
placeholder="123 456 789" required aria-describedby="blikHelp" placeholder="123 456 789" {% if zbiorka and not zbiorka.uzyj_blik %}disabled{% endif
value="{% if is_edit and zbiorka.numer_telefonu_blik %}{{ zbiorka.numer_telefonu_blik }}{% elif global_settings %}{{ global_settings.numer_telefonu_blik }}{% else %}{% 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>
<div id="blikHelp" class="form-text">Dziewięć cyfr telefonu powiązanego z BLIK. Spacje <div id="blikHelp" class="form-text">Dziewięć cyfr telefonu powiązanego z BLIK. Spacje
opcjonalne.</div> opcjonalne.</div>
</div> </div>
{% if is_edit %} {% if is_edit %}
<div class="col-12 col-md-12 d-flex align-items-end"> <div class="col-12 col-md-12 d-flex align-items-end">
<button type="button" class="btn btn-sm btn-outline-light border" id="ustaw-globalne" <button type="button" class="btn btn-sm btn-outline-light" id="ustaw-globalne"
title="Wstaw wartości z ustawień globalnych" {% if global_settings %} title="Wstaw wartości z ustawień globalnych" {% if global_settings %}
data-iban="{{ global_settings.numer_konta }}" data-iban="{{ global_settings.numer_konta }}"
data-blik="{{ global_settings.numer_telefonu_blik }}" {% endif %}>Wstaw data-blik="{{ global_settings.numer_telefonu_blik }}" {% endif %}>
globalne ustawienia</button> Wstaw globalne ustawienia
</button>
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -128,35 +268,77 @@
<!-- SEKCJA: Cel i widoczność --> <!-- SEKCJA: Cel i widoczność -->
<div class="mb-4"> <div class="mb-4">
<h6 class="text-muted mb-2">Cel i widoczność</h6> <h6 class="text-muted mb-2">Cel i widoczność</h6>
<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="row g-3">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<label for="cel" class="form-label">Cel zbiórki</label> <label for="cel" class="form-label">Cel zbiórki</label>
<div class="input-group"> <div class="input-group">
<span class="input-group-text">PLN</span> <span class="input-group-text">PLN</span>
<input type="number" class="form-control" id="cel" name="cel" step="0.01" min="0.01" <input type="text" inputmode="decimal" class="form-control" id="cel" name="cel"
placeholder="0,00" required aria-describedby="celHelp" placeholder="0,00" required aria-describedby="celHelp"
value="{{ zbiorka.cel if is_edit else '' }}"> value="{% if zbiorka and zbiorka.cel is not none %}{{ zbiorka.cel }}{% else %}{{ request.form.get('cel','') }}{% endif %}">
</div> </div>
<div id="celHelp" class="form-text">Minimalnie 0,01 PLN. Możesz to później edytować.</div> <div id="celHelp" class="form-text">Minimalnie 0,01 PLN. Można później edytować.</div>
</div> </div>
<div class="col-12 col-md-12 d-flex align-items-end"> <div class="col-12 col-md-12 d-flex align-items-end">
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="ukryj_kwote" name="ukryj_kwote" {% <input class="form-check-input" type="checkbox" id="ukryj_kwote" name="ukryj_kwote" {%
if is_edit and zbiorka.ukryj_kwote %}checked{% endif %}> if zbiorka %}{% if zbiorka.ukryj_kwote %}checked{% endif %}{% endif %}>
<label class="form-check-label" for="ukryj_kwote">Ukryj kwoty (cel i stan)</label> <label class="form-check-label" for="ukryj_kwote">Ukryj kwoty (cel i stan)</label>
</div> </div>
</div> </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> </div>
<!-- CTA --> <!-- CTA -->
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
<button type="submit" class="btn btn-success"> <button type="submit" class="btn btn-success">
{{ ' Zaktualizuj zbiórkę' if is_edit else 'Dodaj zbiórkę' }} </button> {{ ' Zaktualizuj zbiórkę' if is_edit else 'Dodaj zbiórkę' }}
</button>
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-light border">Anuluj</a> <a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-light">Anuluj</a>
</div> </div>
</form> </form>
</div> </div>
@@ -167,6 +349,10 @@
{% block extra_scripts %} {% block extra_scripts %}
{{ super() }} {{ super() }}
<script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script> <script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script>
<script src="{{ url_for('static', filename='js/mde_custom.js') }}"></script> <script src="{{ url_for('static', filename='js/mde_custom.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static', filename='js/formularz_zbiorek.js') }}"></script> <script src="{{ url_for('static', filename='js/formularz_zbiorek.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static', filename='js/produkty_formularz.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static', filename='js/kwoty_formularz.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static', filename='js/przelaczniki_zabezpieczenie.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static', filename='js/sposoby_wplat.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %} {% endblock %}

View 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 %}

View File

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

View File

@@ -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 %}

View 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="{{ url_for('static', filename='js/transakcje.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %}

View File

@@ -3,14 +3,30 @@
{% block content %} {% block content %}
<div class="container my-4"> <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"> <form method="post" novalidate id="form-global-settings">
{# {{ form.csrf_token }} jeśli używasz Flask-WTF #}
<!-- SEKCJA: Dane płatności --> <!-- SEKCJA: Dane płatności -->
<div class="card shadow-sm mb-4"> <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"> <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> <h3 class="card-title mb-0">Dane płatności</h3>
<small class="opacity-75">Używane jako wartości domyślne przy dodawaniu/edycji zbiórek</small> <small class="opacity-75">Wartości domyślne dla zbiórek</small>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -23,7 +39,7 @@
value="{{ settings.numer_konta if settings else '' }}" inputmode="numeric" autocomplete="off" value="{{ settings.numer_konta if settings else '' }}" inputmode="numeric" autocomplete="off"
placeholder="12 3456 7890 1234 5678 9012 3456" required aria-describedby="ibanHelp"> placeholder="12 3456 7890 1234 5678 9012 3456" required aria-describedby="ibanHelp">
</div> </div>
<div id="ibanHelp" class="form-text">Wpisz ciąg cyfr — spacje dodadzą się automatycznie co 4 znaki.</div> <div id="ibanHelp" class="form-text">Wpisz ciąg cyfr — spacje dodadzą się automatycznie co 4 znaki</div>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
@@ -34,7 +50,7 @@
value="{{ settings.numer_telefonu_blik if settings else '' }}" inputmode="tel" pattern="[0-9 ]{9,13}" value="{{ settings.numer_telefonu_blik if settings else '' }}" inputmode="tel" pattern="[0-9 ]{9,13}"
placeholder="123 456 789" required aria-describedby="blikHelp"> placeholder="123 456 789" required aria-describedby="blikHelp">
</div> </div>
<div id="blikHelp" class="form-text">9 cyfr. Spacje i format 3-3-3 dodajemy dla czytelności.</div> <div id="blikHelp" class="form-text">9 cyfr. Format 3-3-3 dla czytelności</div>
</div> </div>
</div> </div>
</div> </div>
@@ -43,35 +59,77 @@
<!-- SEKCJA: Dostępy / biała lista IP --> <!-- SEKCJA: Dostępy / biała lista IP -->
<div class="card shadow-sm mb-4"> <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"> <div class="card-header bg-secondary text-white d-flex align-items-center justify-content-between gap-2">
<h3 class="card-title mb-0">Dostęp — dozwolone adresy IP / hosty</h3> <h3 class="card-title mb-0">Kontrola dostępu</h3>
<small class="opacity-75">Zależnie od konfiguracji, logowanie może wymagać dopasowania do białej listy</small> <small class="opacity-75">Biała lista IP/hostów dla logowania</small>
</div> </div>
<div class="card-body"> <div class="card-body">
<!-- Wiersz z inputem i przyciskiem dodawania -->
<div class="row g-3 align-items-end"> <div class="row g-3 align-items-end">
<div class="col-12 col-md-6"> <div class="col-12 col-lg-8">
<label for="host_input" class="form-label">Dodaj pojedynczy IP/host</label> <label for="host_input" class="form-label">Dodaj IP/host</label>
<input type="text" class="form-control" id="host_input" placeholder="np. 203.0.113.42 lub corp.example.com" <div class="input-group">
aria-describedby="hostAddHelp"> <input type="text" class="form-control" id="host_input"
<div id="hostAddHelp" class="form-text">Po wpisaniu kliknij „Dodaj do listy”. Duplikaty są pomijane.</div> 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>
<div class="col-12 col-md-6 d-flex gap-2">
<button type="button" class="btn btn-outline-light border" id="btn-add-host">Dodaj do listy</button> <div class="col-12 col-lg-4">
<button type="button" class="btn btn-light text-dark" id="btn-add-my-ip" data-my-ip="{{ client_ip }}">Dodaj <div class="d-flex flex-wrap gap-2 justify-content-lg-end">
moje IP ({{ client_ip }})</button> <button type="button" class="btn btn-light text-dark" id="btn-add-my-ip" data-my-ip="{{ client_ip }}">
<button type="button" class="btn btn-outline-light border" id="btn-dedupe">Usuń duplikaty</button> Dodaj moje IP ({{ client_ip }})
</button>
<button type="button" class="btn btn-outline-light" id="btn-dedupe">
Usuń duplikaty
</button>
</div>
</div> </div>
</div> </div>
<div class="mt-3"> <div class="mt-3">
<label for="allowed_login_hosts" class="form-label">Dozwolone hosty logowania (jeden na linię lub rozdzielone <div class="d-flex justify-content-between align-items-center mb-1">
przecinkami)</label> <label for="dozwolone_hosty_logowania" class="form-label mb-0">
<textarea class="form-control" id="allowed_login_hosts" name="allowed_login_hosts" rows="6" Dozwolone hosty logowania (jeden na linię lub rozdzielone przecinkami)
placeholder="Adresy IP lub nazwy domen — każdy w osobnej linii lub rozdzielony przecinkiem">{{ settings.allowed_login_hosts if settings and settings.allowed_login_hosts else '' }}</textarea> </label>
<div class="d-flex justify-content-between mt-1"> <span class="badge text-bg-secondary">Pozycji: <span id="hostsCount">0</span></span>
<small class="text-muted">Akceptowane separatory: przecinek (`,`), średnik (`;`) i nowa linia.</small>
<small class="text-muted">Pozycji na liście: <span id="hostsCount">0</span></small>
</div> </div>
<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> </div>
</div> </div>
@@ -90,7 +148,7 @@
<label for="logo_url" class="form-label">Logo (URL PNG/SVG)</label> <label for="logo_url" class="form-label">Logo (URL PNG/SVG)</label>
<input type="text" class="form-control" id="logo_url" name="logo_url" <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"> value="{{ settings.logo_url if settings else '' }}" placeholder="https://example.com/logo.svg">
<div class="form-text">Transparentne, do ~60px wysokości.</div> <div class="form-text">Transparentne, do ~60px wysokości</div>
{% if settings and settings.logo_url %} {% if settings and settings.logo_url %}
<div class="mt-2"> <div class="mt-2">
<img src="{{ settings.logo_url }}" alt="Logo preview" style="max-height:50px"> <img src="{{ settings.logo_url }}" alt="Logo preview" style="max-height:50px">
@@ -99,9 +157,9 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label for="site_title" class="form-label">Tytuł serwisu</label> <label for="tytul_strony" class="form-label">Tytuł serwisu</label>
<input type="text" class="form-control" id="site_title" name="site_title" <input type="text" class="form-control" id="tytul_strony" name="tytul_strony"
value="{{ settings.site_title if settings else '' }}" placeholder="Np. Zbiórki unitraklub.pl"> value="{{ settings.tytul_strony if settings else '' }}" placeholder="Np. Zbiórki unitraklub.pl">
</div> </div>
</div> </div>
@@ -112,49 +170,48 @@
<div class="col-md-6"> <div class="col-md-6">
<h6 class="mb-2">Menu (navbar)</h6> <h6 class="mb-2">Menu (navbar)</h6>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="radio" name="navbar_brand_mode" id="navbar_mode_logo" value="logo" <input class="form-check-input" type="radio" name="typ_navbar" id="navbar_mode_logo" value="logo" {% if
{% if settings and settings.navbar_brand_mode=='logo' or (settings and settings.show_logo_in_navbar) settings and settings.typ_navbar=='logo' or (settings and settings.pokaz_logo_w_navbar) %}checked{%
%}checked{% endif %}> endif %}>
<label class="form-check-label" for="navbar_mode_logo">Pokaż logo</label> <label class="form-check-label" for="navbar_mode_logo">Pokaż logo</label>
</div> </div>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="radio" name="navbar_brand_mode" id="navbar_mode_text" value="text" <input class="form-check-input" type="radio" name="typ_navbar" id="navbar_mode_text" value="text" {% if
{% if not settings or (settings and settings.navbar_brand_mode !='logo' and not not settings or (settings and settings.typ_navbar !='logo' and not settings.pokaz_logo_w_navbar)
settings.show_logo_in_navbar) %}checked{% endif %}> %}checked{% endif %}>
<label class="form-check-label" for="navbar_mode_text">Pokaż tekst</label> <label class="form-check-label" for="navbar_mode_text">Pokaż tekst</label>
</div> </div>
<div class="form-text mt-1">Jeśli wybierzesz logo, użyjemy adresu z pola "Tytuł serwisu".</div> <div class="form-text mt-1">Jeśli wybierzesz logo, użyjemy adresu z pola "Logo URL"</div>
</div> </div>
<!-- STOPKA --> <!-- STOPKA -->
<div class="col-md-6"> <div class="col-md-6">
<h6 class="mb-2">Stopka</h6> <h6 class="mb-2">Stopka</h6>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="radio" name="footer_brand_mode" id="footer_mode_logo" value="logo" <input class="form-check-input" type="radio" name="typ_stopka" id="footer_mode_logo" value="logo" {% if
{% if settings and settings.footer_brand_mode=='logo' %}checked{% endif %}> settings and settings.typ_stopka=='logo' %}checked{% endif %}>
<label class="form-check-label" for="footer_mode_logo">Logo</label> <label class="form-check-label" for="footer_mode_logo">Logo</label>
</div> </div>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="radio" name="footer_brand_mode" id="footer_mode_text" value="text" <input class="form-check-input" type="radio" name="typ_stopka" id="footer_mode_text" value="text" {% if
{% if not settings or (settings and settings.footer_brand_mode !='logo' ) %}checked{% endif %}> not settings or (settings and settings.typ_stopka !='logo' ) %}checked{% endif %}>
<label class="form-check-label" for="footer_mode_text">Tekst</label> <label class="form-check-label" for="footer_mode_text">Tekst</label>
</div> </div>
<label for="footer_text" class="form-label mt-2">Tekst w stopce (gdy wybrano „Tekst)</label> <label for="stopka_text" class="form-label mt-2">Tekst w stopce (gdy wybrano „Tekst")</label>
<input type="text" class="form-control" id="footer_text" name="footer_text" <input type="text" class="form-control" id="stopka_text" name="stopka_text"
value="{{ settings.footer_text if settings and settings.footer_text else '' }}" value="{{ settings.stopka_text if settings and settings.stopka_text else '' }}"
placeholder="Np. © {{ now().year if now else '2025' }} Zbiórki"> placeholder="Np. © {{ now().year if now else '2025' }} Zbiórki">
<div class="form-text">Pozostaw pusty, by użyć domyślnego.</div> <div class="form-text">Pozostaw pusty, by użyć domyślnego</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- CTA --> <!-- CTA -->
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-light border">Powrót</a> <a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-light">Powrót</a>
<button type="submit" class="btn btn-primary">Zapisz ustawienia</button> <button type="submit" class="btn btn-success">Zapisz ustawienia</button>
</div> </div>
</form> </form>
</div> </div>
@@ -162,5 +219,5 @@
{% block extra_scripts %} {% block extra_scripts %}
{{ super() }} {{ super() }}
<script src="{{ url_for('static', filename='js/ustawienia.js') }}"></script> <script src="{{ url_for('static', filename='js/ustawienia.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %} {% endblock %}

View File

@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" /> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<title>{% block title %}Aplikacja Zbiórek{% endblock %}</title> <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="https://cdn.jsdelivr.net/npm/bootswatch@5.3.0/dist/darkly/bootstrap.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/custom.css') }}" /> <link rel="stylesheet" href="{{ url_for('static', filename='css/custom.css') }}?v={{ APP_VERSION }}" />
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </head>
@@ -14,13 +14,13 @@
<nav class="navbar navbar-expand-lg"> <nav class="navbar navbar-expand-lg">
<div class="container"> <div class="container">
<a class="navbar-brand d-flex align-items-center gap-2" href="{{ url_for('index') }}"> <a class="navbar-brand d-flex align-items-center gap-2" href="{{ url_for('index') }}">
{% set nav_mode = (global_settings.navbar_brand_mode if global_settings and {% set nav_mode = (global_settings.typ_navbar if global_settings and
global_settings.navbar_brand_mode else ('logo' if global_settings and global_settings.typ_navbar else ('logo' if global_settings and
global_settings.show_logo_in_navbar else 'text')) %} global_settings.pokaz_logo_w_navbar else 'text')) %}
{% if nav_mode == 'logo' and global_settings and global_settings.logo_url %} {% 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;"> <img src="{{ global_settings.logo_url }}" alt="Logo" style="max-height:40px; vertical-align:middle;">
{% else %} {% else %}
<span>{{ global_settings.site_title if global_settings and global_settings.site_title else "Zbiórki" <span>{{ global_settings.tytul_strony if global_settings and global_settings.tytul_strony else "Zbiórki"
}}</span> }}</span>
{% endif %} {% endif %}
</a> </a>
@@ -47,7 +47,7 @@
</li> </li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('wyloguj') }}">Wyloguj</a></li> <li class="nav-item"><a class="nav-link" href="{{ url_for('wyloguj') }}">Wyloguj</a></li>
{% else %} {% else %}
{% if is_ip_allowed %} {% if is_ip_allowed|default(false) %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('zaloguj') }}">Zaloguj</a></li> <li class="nav-item"><a class="nav-link" href="{{ url_for('zaloguj') }}">Zaloguj</a></li>
{% endif %} {% endif %}
{% endif %} {% endif %}
@@ -72,18 +72,20 @@
<!-- stopka --> <!-- stopka -->
<footer class="mt-auto text-center py-3 border-top" style="background: var(--surface-0);"> <footer class="mt-auto text-center py-3 border-top" style="background: var(--surface-0);">
{% set footer_mode = global_settings.footer_brand_mode if global_settings and global_settings.footer_brand_mode {% set footer_mode = global_settings.typ_stopka if global_settings and global_settings.typ_stopka
else 'text' %} else 'text' %}
{% if footer_mode == 'logo' and global_settings and global_settings.logo_url %} {% if footer_mode == 'logo' and global_settings and global_settings.logo_url %}
<img src="{{ global_settings.logo_url }}" alt="Logo" style="max-height:28px;"> <img src="{{ global_settings.logo_url }}" alt="Logo" style="max-height:28px;">
{% else %} {% else %}
{{ global_settings.footer_text if global_settings and global_settings.footer_text else "© " ~ (now().year if now {{ global_settings.stopka_text if global_settings and global_settings.stopka_text else "© " ~ (now().year if now
else '2025') ~ " linuxiarz.pl" }} else '2025') ~ " linuxiarz.pl" }}
{% endif %} {% endif %}
<div class="small text-muted">v{{ APP_VERSION }}</div>
</footer> </footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='js/progress.js') }}"></script> <script src="{{ url_for('static', filename='js/progress.js') }}?v={{ APP_VERSION }}"></script>
{% block extra_scripts %}{% endblock %} {% block extra_scripts %}{% endblock %}
</body> </body>

View File

@@ -1,100 +1,143 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}{% if request.path == url_for('zbiorki_zrealizowane') %}Zrealizowane zbiórki{% else %}Aktualnie aktywne {% block title %}{% if request.path == url_for('zbiorki_zrealizowane') %}Zrealizowane zbiórki{% else %}Aktualnie aktywne zbiórki{% endif %}{% endblock %}
zbiórki{% endif %}{% endblock %}
{% block content %} {% block content %}
{# Ustal kontekst listy #} {# Ustal kontekst listy #}
{% set is_completed_view = (request.path == url_for('zbiorki_zrealizowane')) %} {% set is_completed_view = (request.path == url_for('zbiorki_zrealizowane')) %}
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-4"> <div class="container my-4">
<h2 class="mb-0"> <!-- Nagłówek z przełącznikiem -->
{% if is_completed_view %}Zrealizowane zbiórki{% else %}Aktualnie aktywne zbiórki{% endif %} <div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
</h2> <div>
<ul class="nav nav-pills"> <h2 class="mb-0">
<li class="nav-item"> {% if is_completed_view %}Zrealizowane zbiórki{% else %}Aktywne zbiórki{% endif %}
<a class="nav-link {% if not is_completed_view %}active{% endif %}" </h2>
href="{{ url_for('index') }}">Aktywne</a> <p class="text-muted mb-0 small">
</li> {% if is_completed_view %}
<li class="nav-item"> Ukończone projekty i osiągnięte cele
<a class="nav-link {% if is_completed_view %}active{% endif %}" {% else %}
href="{{ url_for('zbiorki_zrealizowane') }}">Zrealizowane</a> Trwające zbiórki, które możesz wesprzeć
</li> {% endif %}
</ul> </p>
</div> </div>
<ul class="nav nav-pills">
{% if zbiorki and zbiorki|length > 0 %} <li class="nav-item">
<div class="row g-4"> <a class="nav-link {% if not is_completed_view %}active{% endif %}"
{% for z in zbiorki %} href="{{ url_for('index') }}">
{% set progress = (z.stan / z.cel * 100) if z.cel > 0 else 0 %} Aktywne
{% set progress_clamped = 100 if progress > 100 else (0 if progress < 0 else progress) %} <div {% if not is_completed_view and zbiorki %}
class="col-sm-12 col-md-6 col-lg-4"> <span class="badge bg-light text-dark ms-1">{{ zbiorki|length }}</span>
<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>
{# Spójny badge zrealizowania: w zakładce „Zrealizowane” lub gdy >=100% #}
{% if is_completed_view or progress_clamped >= 100 %}
<span class="badge rounded-pill" style="background: var(--accent); color:#111;">Zrealizowana</span>
{% endif %} {% endif %}
</div> </a>
</li>
<div class="mb-2 d-flex flex-wrap gap-2"> <li class="nav-item">
{% if not z.ukryj_kwote %} <a class="nav-link {% if is_completed_view %}active{% endif %}"
{% if z.cel > 0 %} href="{{ url_for('zbiorki_zrealizowane') }}">
<span class="badge bg-dark border" style="border-color: var(--border);"> Zrealizowane
Cel: {{ z.cel|round(2) }} PLN {% if is_completed_view and zbiorki %}
</span> <span class="badge bg-light text-dark ms-1">{{ zbiorki|length }}</span>
{% endif %} {% endif %}
<span class="badge bg-dark border" style="border-color: var(--border);"> </a>
Stan: {{ z.stan|round(2) }} PLN </li>
</span> </ul>
{% else %} </div>
<span class="badge bg-secondary">Kwoty ukryte</span>
{% endif %}
</div>
<div class="mb-1"> {% if zbiorki and zbiorki|length > 0 %}
<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" <div class="row g-3 pb-4">
aria-valuenow="{{ progress_clamped|round(2) if not z.ukryj_kwote else '' }}" {% for z in zbiorki %}
aria-label="{% if z.ukryj_kwote %}Postęp ukryty{% else %}Postęp zbiórki {{ progress_clamped|round(0) }} procent{% endif %}"> {% set progress = (z.stan / z.cel * 100) if z.cel > 0 else 0 %}
<div class="progress-bar" style="width: {{ progress_clamped }}%;"></div> {% 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> </div>
{% if not z.ukryj_kwote %} {# Progress bar TYLKO dla standardowych zbiórek (nie dla rezerwowych) #}
<small class="text-muted">{{ progress_clamped|round(1) }}%</small> {% if z.typ_zbiorki != 'rezerwa' %}
{% else %} <div class="mb-2">
<small class="text-muted">Postęp ukryty</small> <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 %} {% endif %}
</div>
<div class="mt-auto pt-1">
<div class="mt-auto pt-2"> <div class="d-grid">
<a href="{{ url_for('zbiorka', zbiorka_id=z.id) }}" class="stretched-link"></a> <a href="{{ url_for('zbiorka', zbiorka_id=z.id) }}"
<a href="{{ url_for('zbiorka', zbiorka_id=z.id) }}" class="btn btn-primary btn-sm">Szczegóły</a> class="btn btn-outline-light btn-sm w-100 btn-opis">
Otwórz
</a>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> {% endfor %}
{% endfor %}
</div>
{% else %}
<div class="card">
<div class="card-body text-center py-5">
{% if is_completed_view %}
<h5 class="mb-2">Brak zrealizowanych zbiórek</h5>
<p class="text-muted mb-4">Gdy jakaś zbiórka osiągnie 100%, pojawi się tutaj.</p>
<a href="{{ url_for('index') }}" class="btn btn-primary">Zobacz aktywne</a>
{% else %}
<h5 class="mb-2">Brak aktywnych zbiórek</h5>
<p class="text-muted mb-4">Wygląda na to, że teraz nic nie zbieramy.</p>
{% if current_user.is_authenticated and current_user.is_admin %}
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-primary">Utwórz nową zbiórkę</a>
{% else %}
<a href="{{ url_for('zbiorki_zrealizowane') }}" class="btn btn-primary">Zobacz zrealizowane</a>
{% endif %}
{% endif %}
</div> </div>
{% 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> </div>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -18,21 +18,21 @@
{% endif %} {% endif %}
<div class="mb-3"> <div class="mb-3">
<label for="username" class="form-label">Nazwa użytkownika</label> <label for="uzytkownik" class="form-label">Nazwa użytkownika</label>
<input type="text" class="form-control" id="username" name="username" <input type="text" class="form-control" id="uzytkownik" name="uzytkownik"
autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus> autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus>
<div class="invalid-feedback">Podaj nazwę użytkownika.</div> <div class="invalid-feedback">Podaj nazwę użytkownika.</div>
</div> </div>
<div class="mb-2"> <div class="mb-2">
<label for="password" class="form-label d-flex justify-content-between align-items-center"> <label for="haslo" class="form-label d-flex justify-content-between align-items-center">
<span>Hasło</span> <span>Hasło</span>
<small id="capsWarning" class="text-muted" style="display:none;">CAPS LOCK <small id="capsWarning" class="text-muted" style="display:none;">CAPS LOCK
włączony</small> włączony</small>
</label> </label>
<div class="input-group"> <div class="input-group">
<input type="password" class="form-control" id="password" name="password" <input type="password" class="form-control" id="haslo" name="haslo"
autocomplete="current-password" required minlength="5"> autocomplete="current-password" required minlength="5">
<button type="button" class="btn btn-secondary rounded-end" id="togglePw" <button type="button" class="btn btn-secondary rounded-end" id="togglePw"
aria-label="Pokaż/ukryj hasło">Pokaż</button> aria-label="Pokaż/ukryj hasło">Pokaż</button>
@@ -51,5 +51,5 @@
{% endblock %} {% endblock %}
{% block extra_scripts %} {% block extra_scripts %}
<script src="{{ url_for('static', filename='js/walidacja_logowanie.js') }}"></script> <script src="{{ url_for('static', filename='js/walidacja_logowanie.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %} {% endblock %}

View File

@@ -14,20 +14,20 @@
<form method="post" class="needs-validation" novalidate> <form method="post" class="needs-validation" novalidate>
<div class="mb-3"> <div class="mb-3">
<label for="username" class="form-label">Nazwa użytkownika</label> <label for="uzytkownik" class="form-label">Nazwa użytkownika</label>
<input type="text" class="form-control" id="username" name="username" <input type="text" class="form-control" id="uzytkownik" name="uzytkownik"
autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus> autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus>
<div class="invalid-feedback">Podaj nazwę użytkownika.</div> <div class="invalid-feedback">Podaj nazwę użytkownika.</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="password" class="form-label d-flex justify-content-between align-items-center"> <label for="haslo" class="form-label d-flex justify-content-between align-items-center">
<span>Hasło</span> <span>Hasło</span>
<small id="capsWarning" class="text-muted" style="display:none;">CAPS LOCK <small id="capsWarning" class="text-muted" style="display:none;">CAPS LOCK
włączony</small> włączony</small>
</label> </label>
<div class="input-group"> <div class="input-group">
<input type="password" class="form-control" id="password" name="password" <input type="password" class="form-control" id="haslo" name="haslo"
autocomplete="new-password" required minlength="6"> autocomplete="new-password" required minlength="6">
<button type="button" class="btn btn-secondary" id="togglePw" <button type="button" class="btn btn-secondary" id="togglePw"
aria-label="Pokaż/ukryj hasło">Pokaż</button> aria-label="Pokaż/ukryj hasło">Pokaż</button>
@@ -57,5 +57,5 @@
{% endblock %} {% endblock %}
{% block extra_scripts %} {% block extra_scripts %}
<script src="{{ url_for('static', filename='js/walidacja_rejestracja.js') }}"></script> <script src="{{ url_for('static', filename='js/walidacja_rejestracja.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %} {% endblock %}

View File

@@ -4,137 +4,395 @@
{% block content %} {% block content %}
<div class="container my-4"> <div class="container my-4">
{# Postęp 0100 #} {# Wyliczenia postępu finansowego #}
{% set has_cel = (zbiorka.cel is defined and zbiorka.cel and zbiorka.cel > 0) %} {% set has_cel = (zbiorka.cel is defined and zbiorka.cel and zbiorka.cel > 0) %}
{% set progress = (zbiorka.stan / zbiorka.cel * 100) if has_cel else 0 %} {% set progress = (zbiorka.stan / zbiorka.cel * 100) if has_cel else 0 %}
{% set progress_clamped = 100 if progress > 100 else (0 if progress < 0 else progress) %} {% set {% set progress_clamped = 100 if progress > 100 else (0 if progress < 0 else progress) %}
is_done=(progress_clamped>= 100) %} {% set is_done=(progress_clamped>= 100) %}
<!-- Nagłówek --> <!-- Nagłówek -->
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3"> <div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<h2 class="mb-0">{{ zbiorka.nazwa }}</h2> <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"> <div class="d-flex flex-wrap align-items-center gap-2">
{% if is_done %} {% if zbiorka.typ_zbiorki == 'rezerwa' %}
<span class="badge bg-info">Lista rezerwowa</span>
{% endif %}
{% if is_done and zbiorka.typ_zbiorki != 'rezerwa' %}
<span class="badge rounded-pill" style="background: var(--accent); color:#111;">Zrealizowana</span> <span class="badge rounded-pill" style="background: var(--accent); color:#111;">Zrealizowana</span>
{% endif %} {% endif %}
{% if zbiorka.ukryj_kwote %} {% if zbiorka.ukryj_kwote %}
<span class="badge bg-secondary">Kwoty ukryte</span> <span class="badge bg-secondary">Kwoty niepubliczne</span>
{% else %} {% else %}
<span class="badge bg-success">Kwoty widoczne</span> <span class="badge bg-success">Kwoty widoczne</span>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="row g-4"> <div class="row g-3">
<!-- Kolumna: opis + progress --> <!-- Kolumna lewa: Opis + (opcjonalnie) Lista zakupów + Postęp -->
<div class="col-md-8"> <div class="col-md-8">
<div class="card shadow-sm h-100">
<!-- Card: Opis -->
<div class="card shadow-sm mb-3">
<div class="card-body"> <div class="card-body">
<h5 class="mb-2">Opis</h5> <h5 class="mb-2">Opis</h5>
<div class="mb-4"> <hr class="hr-bw my-2">
<div class="mb-0">
{{ zbiorka.opis | markdown }} {{ zbiorka.opis | markdown }}
</div> </div>
</div>
</div>
<h5 class="mb-2">Postęp</h5> {# Czy są produkty? #}
<div class="progress mb-2" role="progressbar" aria-valuenow="{{ progress_clamped|round(2) }}" {% set items = zbiorka.przedmioty or [] %}
aria-valuemin="0" aria-valuemax="100" aria-label="Postęp zbiórki {{ progress_clamped|round(0) }} procent"> {% set has_items = (items|length > 0) %}
<div class="progress-bar" style="width: {{ progress_clamped }}%;"></div>
<!-- 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> </div>
<small class="text-muted"> <hr class="hr-bw my-2">
{% if zbiorka.ukryj_kwote %} {% 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 %} {% else %}
{{ progress|round(1) }}% <span class="badge rounded-pill" style="background: var(--accent); color:#111;">Zrealizowana</span>
{% endif %} {% endif %}
</small> {% 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>
</div> </div>
<!-- Kolumna: płatności (sticky) --> </div>
<div class="col-md-4">
<div class="card shadow-sm wspomoz-card position-sticky" style="top: 1rem;">
<div class="card-body">
<div class="mb-3">
<div class="d-flex align-items-center justify-content-between">
<strong>Numer konta</strong>
<button class="btn btn-sm btn-outline-light border" type="button"
data-copy-target="#ibanDisplay">Kopiuj</button>
</div>
<div class="fs-5" id="ibanDisplay">{{ zbiorka.numer_konta }}</div>
</div>
<div class="mb-3"> <!-- Aktywność -->
<div class="d-flex align-items-center justify-content-between"> <div class="card shadow-sm mt-3">
<strong>Telefon BLIK</strong> <div class="card-header bg-transparent d-flex align-items-center justify-content-between py-2">
<button class="btn btn-sm btn-outline-light border" type="button" <h5 class="card-title mb-0">Aktywność / Transakcje</h5>
data-copy-target="#blikDisplay">Kopiuj</button> <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> </div>
<div class="fs-5" id="blikDisplay">{{ zbiorka.numer_telefonu_blik }}</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> </div>
{% if not zbiorka.ukryj_kwote %} {% if not zbiorka.ukryj_kwote %}
<hr class="my-3"> <span class="badge bg-dark border" style="border-color: var(--border);">
<div class="d-flex flex-column gap-1"> {% if a.typ == 'wpłata' or a.typ == 'przesunięcie_przych' %}
{% if has_cel %} +{{ a.kwota|round(2) }} PLN
<div><strong>Cel:</strong> <span class="fs-6">{{ zbiorka.cel|round(2) }} PLN</span></div> {% else %}
-{{ a.kwota|round(2) }} PLN
{% endif %} {% endif %}
<div><strong>Stan:</strong> <span class="fs-6">{{ zbiorka.stan|round(2) }} PLN</span></div>
{% if has_cel %}
{% set brak = (zbiorka.cel - zbiorka.stan) %}
<small class="text-muted">
{% if brak > 0 %}
Do celu brakuje: {{ brak|round(2) }} PLN
{% elif brak == 0 %}
Cel osiągnięty.
{% else %}
Przekroczono cel o: {{ (brak * -1)|round(2) }} PLN
{% endif %}
</small>
{% endif %}
</div>
{% endif %}
{% if current_user.is_authenticated and current_user.is_admin %}
<div class="d-grid mt-3">
<a href="{{ url_for('dodaj_wplate', zbiorka_id=zbiorka.id) }}" class="btn btn-primary">Dodaj
wpłatę</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Historia wpłat -->
<div class="card shadow-sm mt-4">
<div class="card-header d-flex align-items-center justify-content-between">
<h5 class="card-title mb-0">Historia wpłat</h5>
{% if zbiorka.wplaty|length > 0 %}
<small class="text-muted">Łącznie pozycji: {{ zbiorka.wplaty|length }}</small>
{% endif %}
</div>
<div class="card-body">
{% if zbiorka.wplaty and zbiorka.wplaty|length > 0 %}
<ul class="list-group list-group-flush">
{% for w in zbiorka.wplaty %}
<li class="list-group-item bg-transparent d-flex flex-wrap justify-content-between align-items-center">
<div class="me-3">
<strong>{{ w.data.strftime('%Y-%m-%d %H:%M:%S') }}</strong>
{% if w.opis %}
<span class="text-muted">— {{ w.opis }}</span>
{% endif %}
</div>
<span class="badge bg-dark border ms-auto" style="border-color: var(--border);">
{{ w.kwota|round(2) }} PLN
</span> </span>
{% endif %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<div class="text-center py-4"> <div class="text-center py-4">
<h6 class="mb-1">Brak wpłat</h6> <h6 class="mb-1">Brak aktywności</h6>
<p class="text-muted mb-0">Gdy pojawią się pierwsze wpłaty, zobaczysz je tutaj.</p> <p class="text-muted mb-0">Gdy pojawią się pierwsze wpłaty lub wydatki, zobaczysz je tutaj.</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -143,7 +401,7 @@
<!-- Akcje dolne --> <!-- Akcje dolne -->
<div class="d-flex gap-2 justify-content-between mt-3"> <div class="d-flex gap-2 justify-content-between mt-3">
<div></div> <div></div>
<a href="{{ url_for('index') }}" class="btn btn-outline-light border">Powrót do listy</a> <a href="{{ url_for('index') }}" class="btn btn-outline-light">Powrót do listy</a>
</div> </div>
</div> </div>
@@ -151,6 +409,6 @@
{% block extra_scripts %} {% block extra_scripts %}
{{ super() }} {{ super() }}
<script src="{{ url_for('static', filename='js/zbiorka.js') }}"></script> <script src="{{ url_for('static', filename='js/zbiorka.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static', filename='js/progress.js') }}"></script> <script src="{{ url_for('static', filename='js/progress.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %} {% endblock %}