34 Commits

Author SHA1 Message Date
Mateusz Gruszczyński
81985f7f84 fix dla xiastek not secure 2025-07-26 23:47:54 +02:00
Mateusz Gruszczyński
50d67d5b1a fix dla xiastek not secure 2025-07-26 23:45:38 +02:00
Mateusz Gruszczyński
e5e498a5a9 fix dla xiastek not secure 2025-07-26 23:41:26 +02:00
Mateusz Gruszczyński
4cea094465 fix dla xiastek not secure 2025-07-26 23:35:42 +02:00
Mateusz Gruszczyński
b7b6453b42 fix dla xiastek not secure 2025-07-26 23:29:05 +02:00
Mateusz Gruszczyński
7e69610981 fix dla xiastek not secure 2025-07-26 23:22:33 +02:00
Mateusz Gruszczyński
bc6f64e546 logi 2025-07-26 22:50:50 +02:00
Mateusz Gruszczyński
e5ef1309e7 logi 2025-07-26 22:48:28 +02:00
Mateusz Gruszczyński
6b2469778f logi 2025-07-26 22:45:04 +02:00
Mateusz Gruszczyński
07d06ded60 logi 2025-07-26 22:40:28 +02:00
Mateusz Gruszczyński
a2c333014e ustawinia do env 2025-07-26 22:22:34 +02:00
Mateusz Gruszczyński
04c187d3d3 ustawinia do env 2025-07-26 22:19:07 +02:00
Mateusz Gruszczyński
8db5cd82ac fix js, html 2025-07-26 12:30:29 +02:00
Mateusz Gruszczyński
f2811148f1 comment logging 2025-07-25 21:32:40 +02:00
Mateusz Gruszczyński
c8a5db6715 talisman skip_if=csp_exempt 2025-07-25 21:25:44 +02:00
Mateusz Gruszczyński
e806976453 talisman skip_if=csp_exempt 2025-07-25 21:19:22 +02:00
Mateusz Gruszczyński
d8d786aed8 talisman skip_if=csp_exempt 2025-07-25 21:17:05 +02:00
Mateusz Gruszczyński
b17a12b9fd debug mode 2025-07-25 21:14:21 +02:00
Mateusz Gruszczyński
1a98b7165d debug mode 2025-07-25 21:07:56 +02:00
Mateusz Gruszczyński
0357a63dcf permission policy 2025-07-25 20:24:38 +02:00
Mateusz Gruszczyński
ddbd224e06 fix ukrytego bloku ocr 2025-07-25 20:11:21 +02:00
Mateusz Gruszczyński
a417889810 poprawki w naglowkach w trybie lokalnym, poprawka progressbaru 2025-07-25 19:58:05 +02:00
Mateusz Gruszczyński
d42d973ffd poprawki w naglowkach w trybie lokalnym, poprawka progressbaru 2025-07-25 19:55:53 +02:00
Mateusz Gruszczyński
7dc49fe160 flask-talisman + naglowki 2025-07-25 19:06:19 +02:00
Mateusz Gruszczyński
5e782ba170 flask-talisman + naglowki 2025-07-25 19:01:52 +02:00
Mateusz Gruszczyński
be986fc8f5 poprawki w compose 2025-07-25 18:33:16 +02:00
Mateusz Gruszczyński
cd06fc3ca4 nowe funkcje i fixy 2025-07-25 18:29:32 +02:00
Mateusz Gruszczyński
e4322f2bc6 nowe funkcje i foxy 2025-07-25 18:27:58 +02:00
Mateusz Gruszczyński
bb667a2cbd poprawki w user_expenses 2025-07-25 10:53:50 +02:00
Mateusz Gruszczyński
0d5b170cac zmiany w sablonach i poprawki w ocr 2025-07-25 10:42:07 +02:00
Mateusz Gruszczyński
34205f0e65 commit #1 2025-07-24 23:30:51 +02:00
Mateusz Gruszczyński
04bc3773e1 drobne i readme 2025-07-24 15:57:27 +02:00
Mateusz Gruszczyński
c9ef1c488b drobne i readme 2025-07-24 15:51:30 +02:00
gru
c63995d750 Delete .app.py.swp 2025-07-24 10:11:40 +02:00
21 changed files with 1139 additions and 423 deletions

Binary file not shown.

View File

@@ -1,29 +1,60 @@
# Domyślny port aplikacji
# APP_PORT:
# Domyślny port, na którym uruchamiana jest aplikacja Flask
# Domyślnie: 8000
APP_PORT=8000
# Klucz bezpieczeństwa Flask
# SECRET_KEY:
# Klucz używany przez Flask do zabezpieczenia sesji, tokenów i formularzy
# Powinien być długi i trudny do odgadnięcia
SECRET_KEY=supersekretnyklucz123
# Hasło główne do systemu
# SYSTEM_PASSWORD:
# Hasło główne administratora systemowego, używane np. przy inicjalizacji
# Domyślnie: admin
SYSTEM_PASSWORD=admin
# Domyślny admin (login i hasło)
# DEFAULT_ADMIN_USERNAME:
# Domyślna nazwa użytkownika administratora (tworzona przy starcie)
# Domyślnie: admin
DEFAULT_ADMIN_USERNAME=admin
# DEFAULT_ADMIN_PASSWORD:
# Domyślne hasło administratora
# Domyślnie: admin123
DEFAULT_ADMIN_PASSWORD=admin123
# Katalog wgrywanych plików
# UPLOAD_FOLDER:
# Ścieżka (względna) do katalogu, gdzie zapisywane są wgrywane pliki
# Domyślnie: uploads
UPLOAD_FOLDER=uploads
AUTHORIZED_COOKIE_VALUE=twoj_wlasny_hash
# SESSION_TIMEOUT_MINUTES:
# Czas bezczynności użytkownika (w minutach), po którym sesja wygasa
# Domyślnie: 10080 (7 dni)
SESSION_TIMEOUT_MINUTES=10080
# czas zycia cookie
# AUTH_COOKIE_MAX_AGE:
# Czas życia ciasteczka autoryzacyjnego (w sekundach)
# Domyślnie: 86400 (1 dzień)
AUTH_COOKIE_MAX_AGE=86400
# dla compose
HEALTHCHECK_TOKEN=alamapsaikota123
# AUTHORIZED_COOKIE_VALUE:
# Wartość ciasteczka uprawniającego do dostępu (np. do zasobów zabezpieczonych)
# Powinna być trudna do przewidzenia
AUTHORIZED_COOKIE_VALUE=twoj_wlasny_hash
# sesja zalogowanego usera (domyślnie 7 dni)
SESSION_TIMEOUT_MINUTES=10080
# SESSION_COOKIE_SECURE:
# Określa, czy ciasteczko sesyjne (Flask session) ma mieć ustawiony atrybut "Secure".
# Wymusza, by przeglądarka przesyłała je tylko przez HTTPS.
# W środowisku deweloperskim (HTTP) ustaw na 0, by uniknąć błędu "secure cookie over insecure connection".
# Zalecane: 1 w produkcji (HTTPS), 0 w dev.
SESSION_COOKIE_SECURE=0
# HEALTHCHECK_TOKEN:
# Token wykorzystywany do sprawdzania stanu aplikacji (np. w Docker Compose)
# Domyślnie: alamapsaikota123
HEALTHCHECK_TOKEN=alamapsaikota123
# Rodzaj bazy: sqlite, pgsql, mysql
# Mozliwe wartosci: sqlite / pgsql / mysql
@@ -37,17 +68,93 @@ DB_ENGINE=sqlite
# Ustaw DB_ENGINE=pgsql
# Domyslny port PostgreSQL to 5432
# Wymaga dzialajacego serwera PostgreSQL (np. kontener `postgres`)
# Przyklad URI: postgresql://user:pass@db:5432/myapp
# --- Konfiguracja dla mysql ---
# Ustaw DB_ENGINE=mysql
# Domyslny port MySQL to 3306
# Wymaga kontenera z MySQL i uzytkownika z dostepem do bazy
# Przyklad URI: mysql+pymysql://user:pass@db:3306/myapp
# Wspolne zmienne (dla pgsql, mysql)
DB_HOST=db
# 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
# ========================
# Nagłówki bezpieczeństwa
# ========================
# ENABLE_HSTS:
# Wymusza HTTPS poprzez ustawienie nagłówka Strict-Transport-Security.
# Zalecane (1) jeśli aplikacja działa za HTTPS. Ustaw 0, jeśli korzystasz z HTTP lokalnie.
ENABLE_HSTS=1
# ENABLE_XFO:
# Ustawia nagłówek X-Frame-Options: DENY, który blokuje osadzanie strony w <iframe>.
# Chroni przed atakami typu clickjacking. Ustaw 0, jeśli celowo korzystasz z osadzania.
ENABLE_XFO=1
# ENABLE_XCTO:
# Ustawia nagłówek X-Content-Type-Options: nosniff, który zapobiega sniffowaniu MIME przez przeglądarkę.
# Chroni przed błędną interpretacją typów plików (np. skrypt JS jako obraz). Zalecane: 1.
ENABLE_XCTO=1
# ENABLE_CSP:
# Ustawia podstawową politykę Content-Security-Policy (CSP), która ogranicza wczytywanie zasobów tylko z własnej domeny.
# Zalecane: 1. Ustaw 0, jeśli używasz zewnętrznych skryptów lub masz problemy z WebSocketami (w CSP: connect-src 'self').
ENABLE_CSP=1
# REFERRER_POLICY:
# Ustawia nagłówek Referrer-Policy, który kontroluje, ile informacji o źródle (refererze)
# jest przekazywane podczas nawigacji lub zapytań sieciowych.
# Domyślnie: strict-origin-when-cross-origin — pełny URL tylko w obrębie tej samej domeny,
# a przy przejściach między domenami tylko origin (np. https://example.com).
# Zalecane ustawienie dla dobrej równowagi między prywatnością a funkcjonalnością.
# Inne możliwe wartości: no-referrer, same-origin, origin, strict-origin, unsafe-url itd.
REFERRER_POLICY="strict-origin-when-cross-origin"
# DEBUG_MODE:
# Czy uruchomić aplikację w trybie debugowania (z konsolą błędów i autoreloaderem)
# Domyślnie: 1
DEBUG_MODE=1
# DISABLE_ROBOTS:
# Czy zablokować indeksowanie przez roboty (serwuje robots.txt z Disallow: /)
# Domyślnie: 0
DISABLE_ROBOTS=0
# ========================
# Nagłówki cache
# ========================
# JS_CACHE_CONTROL:
# Nagłówki Cache-Control dla plików JS (/static/js/)
# Domyślnie: "no-cache, no-store, must-revalidate"
JS_CACHE_CONTROL="no-cache, no-store, must-revalidate"
# CSS_CACHE_CONTROL:
# Nagłówki Cache-Control dla plików CSS (/static/css/)
# Domyślnie: "public, max-age=3600"
CSS_CACHE_CONTROL="public, max-age=3600"
# LIB_JS_CACHE_CONTROL:
# Nagłówki Cache-Control dla bibliotek JS (/static/lib/js/)
# Domyślnie: "public, max-age=604800"
LIB_JS_CACHE_CONTROL="public, max-age=604800"
# LIB_CSS_CACHE_CONTROL:
# Nagłówki Cache-Control dla bibliotek CSS (/static/lib/css/)
# Domyślnie: "public, max-age=604800"
LIB_CSS_CACHE_CONTROL="public, max-age=604800"
# UPLOADS_CACHE_CONTROL:
# Nagłówki Cache-Control dla wgrywanych plików (/uploads/)
# Domyślnie: "public, max-age=2592000, immutable"
UPLOADS_CACHE_CONTROL="public, max-age=2592000, immutable"

View File

@@ -1,59 +1,76 @@
# Live Lista Zakupów
# Aplikacja List Zakupów
Aplikacja webowa do współdzielonych list zakupów z obsługą wielu użytkowników, trybem współpracy w czasie rzeczywistym, panelami administracyjnymi oraz możliwością załączania paragonów.
Prosta aplikacja webowa do zarządzania listami zakupów z obsługą użytkowników, OCR paragonów, statystykami i trybem współdzielenia.
## Funkcje
## Główne funkcje
- Tworzenie, edycja i archiwizacja list zakupów
- Dodawanie, edycja, usuwanie produktów i oznaczanie ich jako kupione
- Udostępnianie list przez link (token)
- Wgrywanie zdjęć paragonów do listy zakupów
- Wyszukiwarka produktów i podpowiedzi
- Komentarze do produktów
- Panel administracyjny (zarządzanie użytkownikami, listami, paragonami)
- Obsługa w czasie rzeczywistym (Socket.IO)
- Logowanie i autoryzacja użytkowników
- Systemowe hasło dostępu do aplikacji
- Logowanie i zarządzanie użytkownikami (admin/user)
- Tworzenie list zakupów z pozycjami i ilością
- Wgrywanie paragonów (podstawowa obsługa OCR)
- Archiwizacja i udostępnianie list (publiczne/prywatne)
- Statystyki wydatków z podziałem na okresy, statystyki dla użytkowników
- Panel administracyjny (statystyki, produkty, paragony, zarządzanie, użytkowmicy)
## Wymagania
- Docker
- Docker Compose
- Python 3.9+
- Docker (opcjonalnie dla produkcji)
## Sposób uruchomienia z Docker Compose
## Instalacja lokalna
1. **Przygotuj plik `.env` w katalogu głównym projektu** (przykład):
1. Sklonuj repozytorium:
`APP_PORT=8000`
```bash
git https://gitea.linuxiarz.pl/gru/lista_zakupowa_live.git
cd lista_zakupowa_live
```
`SECRET_KEY=twoj_super_tajny_klucz`
2. Utwórz i uzupełnij plik `.env` (zobacz `.env example`).
`SYSTEM_PASSWORD=haslo_do_aplikacji`
3. Utwórz środowisko i zainstaluj zależności:
`DEFAULT_ADMIN_USERNAME=admin`
```bash
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
`DEFAULT_ADMIN_PASSWORD=admin123`
4. Uruchom aplikację:
2. **Uruchom aplikację:**
```bash
flask --app app.py run
```
Domyślnie aplikacja będzie dostępna pod adresem:
**http://localhost:8000**
## Deploy z Docker Compose
3. **Pierwsze logowanie:**
- Po wejściu na stronę zostaniesz poproszony o podanie hasła systemowego (`SYSTEM_PASSWORD`).
- Przy pierwszym uruchomieniu zostanie automatycznie utworzone konto administratora na podstawie zmiennych `DEFAULT_ADMIN_USERNAME` i `DEFAULT_ADMIN_PASSWORD`.
1. Skonfiguruj `.env`.
2. Uruchom:
```bash
docker-compose up --build
```
Aplikacja będzie dostępna pod `http://localhost:8000`.
## Domyślne dane logowania
- **Login administratora:** `admin` (lub wartość z `DEFAULT_ADMIN_USERNAME`)
- **Hasło administratora:** `admin123` (lub wartość z `DEFAULT_ADMIN_PASSWORD`)
- Główne hasło systemowe: `admin`
- Admin: `admin` / `admin123`
4. **Aby uruchomić aplikację w Dockerze, wykonaj następujące kroki:**
## Konfiguracja bazy danych
* Przygotuj plik .env w katalogu projektu z wymaganymi zmiennymi środowiskowymi
* Uruchom aplikację poleceniem:
docker compose up --build
Obsługiwane silniki: `sqlite`, `pgsql`, `mysql`.
---
Ustaw `DB_ENGINE` oraz odpowiednie zmienne w `.env`:
Przykład dla PostgreSQL:
```env
DB_ENGINE=pgsql
DB_HOST=db
DB_PORT=5432
DB_NAME=myapp
DB_USER=user
DB_PASSWORD=pass
```

464
app.py
View File

@@ -8,10 +8,11 @@ import platform
import psutil
import hashlib
import re
import traceback
from pillow_heif import register_heif_opener
from datetime import datetime, timedelta, UTC, timezone
from urllib.parse import urlparse, urlunparse
from flask import (
Flask,
@@ -45,18 +46,53 @@ from config import Config
from PIL import Image, ExifTags, ImageFilter, ImageOps
from werkzeug.utils import secure_filename
from werkzeug.middleware.proxy_fix import ProxyFix
from sqlalchemy import func, extract, inspect
from sqlalchemy import func, extract, inspect, or_
from sqlalchemy.orm import joinedload
from collections import defaultdict, deque
from functools import wraps
from flask_talisman import Talisman
# OCR
from collections import Counter
import pytesseract
from collections import Counter
from pytesseract import Output
import logging
app = Flask(__name__)
app.config.from_object(Config)
# Konfiguracja nagłówków bezpieczeństwa z .env
csp_policy = None
if app.config.get("ENABLE_CSP", True):
csp_policy = {
"default-src": "'self'",
"script-src": "'self'", # wciąż bez inline JS
"style-src": "'self' 'unsafe-inline'", # dopuszczamy style w HTML-u
"img-src": "'self' data:", # pozwalamy na data:image (np. SVG)
"connect-src": "'self'", # WebSockety
"script-src": "'self' 'unsafe-inline'",
}
permissions_policy = {"browsing-topics": "()"} if app.config["ENABLE_PP"] else None
talisman_kwargs = {
"force_https": False,
"strict_transport_security": app.config.get("ENABLE_HSTS", True),
"frame_options": "DENY" if app.config.get("ENABLE_XFO", True) else None,
"permissions_policy": permissions_policy,
"content_security_policy": csp_policy,
"x_content_type_options": app.config.get("ENABLE_XCTO", True),
"strict_transport_security_include_subdomains": False,
"session_cookie_secure": app.config["SESSION_COOKIE_SECURE"],
}
if app.config.get("REFERRER_POLICY"):
talisman_kwargs["referrer_policy"] = app.config["REFERRER_POLICY"]
talisman = Talisman(app, **talisman_kwargs)
register_heif_opener() # pillow_heif dla HEIC
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp", "heic"}
@@ -74,6 +110,7 @@ SESSION_TIMEOUT_MINUTES = int(app.config.get("SESSION_TIMEOUT_MINUTES", 10080))
app.config["COMPRESS_ALGORITHM"] = ["zstd", "br", "gzip", "deflate"]
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(minutes=SESSION_TIMEOUT_MINUTES)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
DEBUG_MODE = app.config.get("DEBUG_MODE", False)
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
@@ -114,7 +151,10 @@ class ShoppingList(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(150), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
owner_id = db.Column(db.Integer, db.ForeignKey("user.id"))
owner = db.relationship("User", backref="lists", foreign_keys=[owner_id])
is_temporary = db.Column(db.Boolean, default=False)
share_token = db.Column(db.String(64), unique=True, nullable=True)
# expires_at = db.Column(db.DateTime, nullable=True)
@@ -131,6 +171,10 @@ class Item(db.Model):
# added_at = db.Column(db.DateTime, default=datetime.utcnow)
added_at = db.Column(db.DateTime, default=utcnow)
added_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
added_by_user = db.relationship(
"User", backref="added_items", lazy=True, foreign_keys=[added_by]
)
purchased = db.Column(db.Boolean, default=False)
purchased_at = db.Column(db.DateTime, nullable=True)
quantity = db.Column(db.Integer, default=1)
@@ -188,11 +232,10 @@ with app.app_context():
@static_bp.route("/static/js/<path:filename>")
def serve_js(filename):
response = send_from_directory("static/js", filename)
response.cache_control.no_cache = True
response.cache_control.no_store = True
response.cache_control.must_revalidate = True
# response.expires = 0
response.pragma = "no-cache"
#response.cache_control.no_cache = True
#response.cache_control.no_store = True
#response.cache_control.must_revalidate = True
response.headers["Cache-Control"] = app.config["JS_CACHE_CONTROL"]
response.headers.pop("Content-Disposition", None)
response.headers.pop("Etag", None)
return response
@@ -201,7 +244,7 @@ def serve_js(filename):
@static_bp.route("/static/css/<path:filename>")
def serve_css(filename):
response = send_from_directory("static/css", filename)
response.headers["Cache-Control"] = "public, max-age=3600"
response.headers["Cache-Control"] = app.config["CSS_CACHE_CONTROL"]
response.headers.pop("Content-Disposition", None)
response.headers.pop("Etag", None)
return response
@@ -210,7 +253,7 @@ def serve_css(filename):
@static_bp.route("/static/lib/js/<path:filename>")
def serve_js_lib(filename):
response = send_from_directory("static/lib/js", filename)
response.headers["Cache-Control"] = "public, max-age=604800"
response.headers["Cache-Control"] = app.config["LIB_JS_CACHE_CONTROL"]
response.headers.pop("Content-Disposition", None)
response.headers.pop("Etag", None)
return response
@@ -220,7 +263,7 @@ def serve_js_lib(filename):
@static_bp.route("/static/lib/css/<path:filename>")
def serve_css_lib(filename):
response = send_from_directory("static/lib/css", filename)
response.headers["Cache-Control"] = "public, max-age=604800"
response.headers["Cache-Control"] = app.config["LIB_CSS_CACHE_CONTROL"]
response.headers.pop("Content-Disposition", None)
response.headers.pop("Etag", None)
return response
@@ -287,7 +330,9 @@ def save_resized_image(file, path):
image.info.clear()
new_path = path.rsplit(".", 1)[0] + ".webp"
image.save(new_path, format="WEBP", quality=100, method=0)
# image.save(new_path, format="WEBP", quality=100, method=0)
image.save(new_path, format="WEBP", lossless=True, method=6)
except Exception as e:
raise ValueError(f"Błąd podczas przetwarzania obrazu: {e}")
@@ -387,22 +432,22 @@ def preprocess_image_for_tesseract(image):
def extract_total_tesseract(image):
text = pytesseract.image_to_string(image, lang="pol", config="--psm 4")
lines = text.splitlines()
candidates = []
keyword_lines_debug = []
fuzzy_regex = re.compile(r"[\dOo][.,:;g9zZ][\d]{2}")
keyword_pattern = re.compile(
blacklist_keywords = re.compile(r"\b(ptu|vat|podatek|stawka)\b", re.IGNORECASE)
priority_keywords = re.compile(
r"""
\b(
[5s]u[mn][aąo0]? |
razem |
zap[łl][aąo0]ty |
do\s+zap[łl][aąo0]ty |
razem\s*do\s*zap[łl][aąo0]ty |
do\s*zap[łl][aąo0]ty |
suma |
kwota |
płatno[śćs] |
warto[śćs] |
płatno[śćs] |
total |
amount
)\b
@@ -410,84 +455,71 @@ def extract_total_tesseract(image):
re.IGNORECASE | re.VERBOSE,
)
for idx, line in enumerate(lines):
if keyword_pattern.search(line[:30]):
keyword_lines_debug.append((idx, line))
for line in lines:
if not line.strip():
continue
matches = re.findall(r"\d{1,4}\s?[.,]\d{2}", line)
if blacklist_keywords.search(line):
continue
is_priority = priority_keywords.search(line)
matches = re.findall(r"\d{1,4}[.,]\d{2}", line)
for match in matches:
try:
val = float(match.replace(" ", "").replace(",", "."))
val = float(match.replace(",", "."))
if 0.1 <= val <= 100000:
candidates.append((val, line))
candidates.append((val, line, is_priority is not None))
except:
continue
spaced = re.findall(r"\d{1,4}\s\d{2}", line)
for match in spaced:
try:
val = float(match.replace(" ", "."))
if 0.1 <= val <= 100000:
candidates.append((val, line))
except:
continue
# Tylko w liniach priorytetowych: sprawdzamy spaced fallback
if is_priority:
spaced = re.findall(r"\d{1,4}\s\d{2}", line)
for match in spaced:
try:
val = float(match.replace(" ", "."))
if 0.1 <= val <= 100000:
candidates.append((val, line, True))
except:
continue
fuzzy_matches = fuzzy_regex.findall(line)
for match in fuzzy_matches:
cleaned = (
match.replace("O", "0")
.replace("o", "0")
.replace(":", ".")
.replace(";", ".")
.replace(",", ".")
.replace("g", "9")
.replace("z", "9")
.replace("Z", "9")
)
try:
val = float(cleaned)
if 0.1 <= val <= 100000:
candidates.append((val, line))
except:
continue
preferred = [
(val, line) for val, line in candidates if keyword_pattern.search(line.lower())
]
# Preferujemy linie priorytetowe
preferred = [(val, line) for val, line, is_pref in candidates if is_pref]
if preferred:
max_val = max(preferred, key=lambda x: x[0])[0]
return round(max_val, 2), lines
best_val = max(preferred, key=lambda x: x[0])[0]
if best_val < 99999:
return round(best_val, 2), lines
if candidates:
max_val = max([val for val, _ in candidates])
return round(max_val, 2), lines
best_val = max(candidates, key=lambda x: x[0])[0]
if best_val < 99999:
return round(best_val, 2), lines
# Fallback: największy font + bold
data = pytesseract.image_to_data(
image, lang="pol", config="--psm 4", output_type=Output.DICT
)
font_candidates = []
font_candidates = []
for i in range(len(data["text"])):
word = data["text"][i].strip()
if not word:
if not word or not re.match(r"^\d{1,5}[.,\s]\d{2}$", word):
continue
if re.match(r"^\d{1,5}[.,\s]\d{2}$", word):
try:
val = float(word.replace(",", ".").replace(" ", "."))
height = data["height"][i]
if 0.1 <= val <= 10000:
font_candidates.append((val, height, word))
except:
continue
try:
val = float(word.replace(",", ".").replace(" ", "."))
height = data["height"][i]
conf = int(data.get("conf", ["0"] * len(data["text"]))[i])
if 0.1 <= val <= 100000:
font_candidates.append((val, height, conf))
except:
continue
if font_candidates:
best = max(font_candidates, key=lambda x: x[1])
# Preferuj najwyższy font z sensownym confidence
best = max(font_candidates, key=lambda x: (x[1], x[2]))
return round(best[0], 2), lines
return 0.0, lines
@@ -524,6 +556,15 @@ def attempts_remaining(ip):
####################################################
def get_client_ip():
# Obsługuje: X-Forwarded-For, X-Real-IP, fallback na remote_addr
for header in ["X-Forwarded-For", "X-Real-IP"]:
if header in request.headers:
# Pierwszy IP w X-Forwarded-For jest najczęściej klientem
ip = request.headers[header].split(",")[0].strip()
if ip:
return ip
return request.remote_addr
@login_manager.user_loader
def load_user(user_id):
@@ -587,13 +628,34 @@ def require_system_password():
if request.path == "/":
return redirect(url_for("system_auth"))
from urllib.parse import urlparse, urlunparse
parsed = urlparse(request.url)
fixed_url = urlunparse(parsed._replace(netloc=request.host))
return redirect(url_for("system_auth", next=fixed_url))
@app.before_request
def start_timer():
request._start_time = time.time()
@app.after_request
def log_request(response):
if request.path == "/healthcheck":
return response
ip = get_client_ip()
method = request.method
path = request.path
status = response.status_code
length = response.content_length or "-"
start = getattr(request, "_start_time", None)
duration = round((time.time() - start) * 1000, 2) if start else "-"
agent = request.headers.get("User-Agent", "-")
log_msg = f"{ip} - \"{method} {path}\" {status} {length} {duration}ms \"{agent}\""
app.logger.info(log_msg)
return response
@app.template_filter("filemtime")
def file_mtime_filter(path):
try:
@@ -664,13 +726,34 @@ def favicon():
@app.route("/")
def main_page():
# now = datetime.utcnow()
now = datetime.now(timezone.utc)
month_str = request.args.get("month")
start = end = None
if month_str:
try:
year, month = map(int, month_str.split("-"))
start = datetime(year, month, 1, tzinfo=timezone.utc)
end = (start + timedelta(days=31)).replace(day=1)
except:
start = end = None
def date_filter(query):
if start and end:
query = query.filter(
ShoppingList.created_at >= start, ShoppingList.created_at < end
)
return query
if current_user.is_authenticated:
user_lists = (
ShoppingList.query.filter_by(owner_id=current_user.id, is_archived=False)
.filter((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now))
date_filter(
ShoppingList.query.filter_by(
owner_id=current_user.id, is_archived=False
).filter(
(ShoppingList.expires_at == None) | (ShoppingList.expires_at > now)
)
)
.order_by(ShoppingList.created_at.desc())
.all()
)
@@ -682,11 +765,16 @@ def main_page():
)
public_lists = (
ShoppingList.query.filter(
ShoppingList.is_public == True,
ShoppingList.owner_id != current_user.id,
((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now)),
ShoppingList.is_archived == False,
date_filter(
ShoppingList.query.filter(
ShoppingList.is_public == True,
ShoppingList.owner_id != current_user.id,
(
(ShoppingList.expires_at == None)
| (ShoppingList.expires_at > now)
),
ShoppingList.is_archived == False,
)
)
.order_by(ShoppingList.created_at.desc())
.all()
@@ -695,10 +783,15 @@ def main_page():
user_lists = []
archived_lists = []
public_lists = (
ShoppingList.query.filter(
ShoppingList.is_public == True,
((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now)),
ShoppingList.is_archived == False,
date_filter(
ShoppingList.query.filter(
ShoppingList.is_public == True,
(
(ShoppingList.expires_at == None)
| (ShoppingList.expires_at > now)
),
ShoppingList.is_archived == False,
)
)
.order_by(ShoppingList.created_at.desc())
.all()
@@ -712,6 +805,8 @@ def main_page():
user_lists=user_lists,
public_lists=public_lists,
archived_lists=archived_lists,
now=now,
timedelta=timedelta,
)
@@ -739,7 +834,12 @@ def system_auth():
reset_failed_attempts(ip)
resp = redirect(next_page)
max_age = app.config.get("AUTH_COOKIE_MAX_AGE", 86400)
resp.set_cookie("authorized", AUTHORIZED_COOKIE_VALUE, max_age=max_age)
resp.set_cookie(
"authorized",
AUTHORIZED_COOKIE_VALUE,
max_age=max_age,
secure=request.is_secure
)
return resp
else:
register_failed_attempt(ip)
@@ -892,12 +992,14 @@ def login():
if user and check_password_hash(user.password_hash, request.form["password"]):
session.permanent = True
login_user(user)
#session["logged"] = True
flash("Zalogowano pomyślnie", "success")
return redirect(url_for("main_page"))
flash("Nieprawidłowy login lub hasło", "danger")
return render_template("login.html")
@app.route("/logout")
@login_required
def logout():
@@ -958,17 +1060,47 @@ def view_list(list_id):
@app.route("/user_expenses")
@login_required
def user_expenses():
from sqlalchemy.orm import joinedload
start_date_str = request.args.get("start_date")
end_date_str = request.args.get("end_date")
show_all = request.args.get("show_all", "false").lower() == "true"
expenses = (
Expense.query.join(ShoppingList, Expense.list_id == ShoppingList.id)
.options(joinedload(Expense.list))
.filter(ShoppingList.owner_id == current_user.id)
.order_by(Expense.added_at.desc())
start = None
end = None
expenses_query = Expense.query.join(
ShoppingList, Expense.list_id == ShoppingList.id
).options(joinedload(Expense.list))
# Jeśli show_all to False, filtruj tylko po bieżącym użytkowniku
if not show_all:
expenses_query = expenses_query.filter(ShoppingList.owner_id == current_user.id)
else:
expenses_query = expenses_query.filter(
or_(
ShoppingList.owner_id == current_user.id, ShoppingList.is_public == True
)
)
if start_date_str and end_date_str:
try:
start = datetime.strptime(start_date_str, "%Y-%m-%d")
end = datetime.strptime(end_date_str, "%Y-%m-%d") + timedelta(days=1)
expenses_query = expenses_query.filter(
Expense.added_at >= start, Expense.added_at < end
)
except ValueError:
flash("Błędny zakres dat", "danger")
expenses = expenses_query.order_by(Expense.added_at.desc()).all()
list_ids = {e.list_id for e in expenses}
lists = (
ShoppingList.query.filter(ShoppingList.id.in_(list_ids))
.order_by(ShoppingList.created_at.desc())
.all()
)
rows = [
expense_table = [
{
"title": e.list.title if e.list else "Nieznana",
"amount": e.amount,
@@ -977,25 +1109,53 @@ def user_expenses():
for e in expenses
]
return render_template("user_expenses.html", expense_table=rows)
lists_data = [
{
"id": l.id,
"title": l.title,
"created_at": l.created_at,
"total_expense": sum(
e.amount
for e in l.expenses
if (not start or not end) or (e.added_at >= start and e.added_at < end)
),
"owner_username": l.owner.username if l.owner else "?",
}
for l in lists
]
return render_template(
"user_expenses.html",
expense_table=expense_table,
lists_data=lists_data,
show_all=show_all,
)
@app.route("/user/expenses_data")
@app.route("/user_expenses_data")
@login_required
def user_expenses_data():
range_type = request.args.get("range", "monthly")
start_date = request.args.get("start_date")
end_date = request.args.get("end_date")
show_all = request.args.get("show_all", "false").lower() == "true"
query = Expense.query.join(ShoppingList, Expense.list_id == ShoppingList.id).filter(
ShoppingList.owner_id == current_user.id
)
query = Expense.query.join(ShoppingList, Expense.list_id == ShoppingList.id)
if show_all:
query = query.filter(
or_(
ShoppingList.owner_id == current_user.id, ShoppingList.is_public == True
)
)
else:
query = query.filter(ShoppingList.owner_id == current_user.id)
if start_date and end_date:
try:
start = datetime.strptime(start_date, "%Y-%m-%d")
end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
query = query.filter(Expense.timestamp >= start, Expense.timestamp < end)
query = query.filter(Expense.added_at >= start, Expense.added_at < end)
except ValueError:
return jsonify({"error": "Błędne daty"}), 400
@@ -1191,7 +1351,7 @@ def upload_receipt(list_id):
@app.route("/uploads/<filename>")
def uploaded_file(filename):
response = send_from_directory(app.config["UPLOAD_FOLDER"], filename)
response.headers["Cache-Control"] = "public, max-age=2592000, immutable"
response.headers["Cache-Control"] = app.config["UPLOADS_CACHE_CONTROL"]
response.headers.pop("Pragma", None)
response.headers.pop("Content-Disposition", None)
mime, _ = mimetypes.guess_type(filename)
@@ -1285,8 +1445,6 @@ def analyze_receipts_for_list(list_id):
value, lines = extract_total_tesseract(image)
except Exception as e:
import traceback
print(f"OCR error for {receipt.filename}:\n{traceback.format_exc()}")
value = 0.0
lines = []
@@ -2062,15 +2220,15 @@ def crop_receipt():
old_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename)
try:
image = Image.open(file).convert("RGB")
new_filename = generate_new_receipt_filename(receipt.list_id)
new_path = os.path.join(app.config["UPLOAD_FOLDER"], new_filename)
image.save(new_path, format="WEBP", quality=100)
save_resized_image(file, new_path)
if os.path.exists(old_path):
os.remove(old_path)
receipt.filename = new_filename
receipt.filename = os.path.basename(new_path)
db.session.commit()
return jsonify(success=True)
@@ -2106,7 +2264,6 @@ def recalculate_filesizes():
)
return redirect(url_for("admin_receipts", id="all"))
@app.route("/healthcheck")
def healthcheck():
header_token = request.headers.get("X-Internal-Check")
@@ -2116,6 +2273,12 @@ def healthcheck():
abort(404)
return "OK", 200
@app.route("/robots.txt")
def robots_txt():
if app.config.get("DISABLE_ROBOTS", False):
return "User-agent: *\nDisallow: /", 200, {"Content-Type": "text/plain"}
return "User-agent: *\nAllow: /", 200, {"Content-Type": "text/plain"}
# =========================================================================================
# SOCKET.IO
@@ -2213,6 +2376,10 @@ def handle_add_item(data):
name = data["name"].strip()
quantity = data.get("quantity", 1)
list_obj = db.session.get(ShoppingList, list_id)
if not list_obj:
return
try:
quantity = int(quantity)
if quantity < 1:
@@ -2248,12 +2415,15 @@ def handle_add_item(data):
if max_position is None:
max_position = 0
user_id = current_user.id if current_user.is_authenticated else None
user_name = current_user.username if current_user.is_authenticated else "Gość"
new_item = Item(
list_id=list_id,
name=name,
quantity=quantity,
position=max_position + 1,
added_by=current_user.id if current_user.is_authenticated else None,
added_by=user_id,
)
db.session.add(new_item)
@@ -2271,9 +2441,9 @@ def handle_add_item(data):
"id": new_item.id,
"name": new_item.name,
"quantity": new_item.quantity,
"added_by": (
current_user.username if current_user.is_authenticated else "Gość"
),
"added_by": user_name,
"added_by_id": user_id,
"owner_id": list_obj.owner_id,
},
to=str(list_id),
include_self=True,
@@ -2345,7 +2515,19 @@ def handle_uncheck_item(data):
@socketio.on("request_full_list")
def handle_request_full_list(data):
list_id = data["list_id"]
items = Item.query.filter_by(list_id=list_id).order_by(Item.position.asc()).all()
shopping_list = db.session.get(ShoppingList, list_id)
if not shopping_list:
return
owner_id = shopping_list.owner_id
items = (
Item.query.options(joinedload(Item.added_by_user))
.filter_by(list_id=list_id)
.order_by(Item.position.asc())
.all()
)
items_data = []
for item in items:
@@ -2358,6 +2540,9 @@ def handle_request_full_list(data):
"not_purchased": item.not_purchased,
"not_purchased_reason": item.not_purchased_reason,
"note": item.note or "",
"added_by": item.added_by_user.username if item.added_by_user else None,
"added_by_id": item.added_by_user.id if item.added_by_user else None,
"owner_id": owner_id,
}
)
@@ -2433,21 +2618,50 @@ def handle_unmark_not_purchased(data):
emit("item_unmarked_not_purchased", {"item_id": item.id}, to=str(item.list_id))
""" @socketio.on('receipt_uploaded')
def handle_receipt_uploaded(data):
list_id = data['list_id']
url = data['url']
emit('receipt_added', {
'url': url
}, to=str(list_id), include_self=False) """
@app.cli.command("create_db")
def create_db():
db.create_all()
print("Database created.")
inspector = inspect(db.engine)
expected_tables = set(db.Model.metadata.tables.keys())
actual_tables = set(inspector.get_table_names())
missing_tables = expected_tables - actual_tables
extra_tables = actual_tables - expected_tables
if missing_tables:
print(f"Brakuje tabel: {', '.join(sorted(missing_tables))}")
if extra_tables:
print(f"Dodatkowe tabele w bazie: {', '.join(sorted(extra_tables))}")
critical_error = False
for table in expected_tables & actual_tables:
expected_columns = set(c.name for c in db.Model.metadata.tables[table].columns)
actual_columns = set(c["name"] for c in inspector.get_columns(table))
missing_cols = expected_columns - actual_columns
extra_cols = actual_columns - expected_columns
if missing_cols:
print(
f"Brakuje kolumn w tabeli '{table}': {', '.join(sorted(missing_cols))}"
)
critical_error = True
if extra_cols:
print(
f"Dodatkowe kolumny w tabeli '{table}': {', '.join(sorted(extra_cols))}"
)
if missing_tables or critical_error:
print("Struktura bazy jest niekompletna lub niezgodna. Przerwano.")
return
if not actual_tables:
db.create_all()
print("Utworzono strukturę bazy danych.")
else:
print("Struktura bazy danych jest poprawna.")
if __name__ == "__main__":
socketio.run(app, host="0.0.0.0", port=8000, debug=True)
logging.basicConfig(level=logging.DEBUG if DEBUG_MODE else logging.INFO)
socketio.run(app, host="0.0.0.0", port=8000, debug=DEBUG_MODE)

View File

@@ -30,3 +30,19 @@ class Config:
SESSION_TIMEOUT_MINUTES = int(os.environ.get("SESSION_TIMEOUT_MINUTES", "10080") or "10080")
except ValueError:
SESSION_TIMEOUT_MINUTES = 10080
SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "0") == "1"
ENABLE_HSTS = os.environ.get("ENABLE_HSTS", "0") == "1"
ENABLE_XFO = os.environ.get("ENABLE_XFO", "0") == "1"
ENABLE_XCTO = os.environ.get("ENABLE_XCTO", "0") == "1"
ENABLE_CSP = os.environ.get("ENABLE_CSP", "0") == "1"
ENABLE_PP = os.environ.get("ENABLE_PP", "0") == "1"
REFERRER_POLICY = os.environ.get("REFERRER_POLICY") or None
DEBUG_MODE = os.environ.get("DEBUG_MODE", "1") == "1"
DISABLE_ROBOTS = os.environ.get("DISABLE_ROBOTS", "0") == "1"
JS_CACHE_CONTROL = os.environ.get("JS_CACHE_CONTROL", "no-cache, no-store, must-revalidate")
CSS_CACHE_CONTROL = os.environ.get("CSS_CACHE_CONTROL", "public, max-age=3600")
LIB_JS_CACHE_CONTROL = os.environ.get("LIB_JS_CACHE_CONTROL", "public, max-age=604800")
LIB_CSS_CACHE_CONTROL = os.environ.get("LIB_CSS_CACHE_CONTROL", "public, max-age=604800")
UPLOADS_CACHE_CONTROL = os.environ.get("UPLOADS_CACHE_CONTROL", "public, max-age=2592000, immutable")

View File

@@ -27,10 +27,7 @@ services:
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- ./db/pgsql:/var/lib/postgresql/data
#ports:
# - ":5432:5432"
restart: unless-stopped
hostname: db
profiles: ["pgsql"]
mysql:
@@ -43,8 +40,5 @@ services:
MYSQL_ROOT_PASSWORD: 89o38kUX5T4C
volumes:
- ./db/mysql:/var/lib/mysql
#ports:
# - "3306:3306"
restart: unless-stopped
hostname: db
profiles: ["mysql"]
profiles: ["mysql"]

View File

@@ -13,4 +13,5 @@ pytesseract
opencv-python-headless
psycopg2-binary # pgsql
pymysql # mysql
cryptography
cryptography # mysql8
flask-talisman # nagłówki

View File

@@ -205,7 +205,6 @@ input.form-control {
box-shadow: 0 -1px 4px rgba(0, 0, 0, 0.25);
}
@media (max-width: 768px) {
.info-bar-fixed {
position: static;

View File

@@ -272,8 +272,97 @@ function isListDifferent(oldItems, newItems) {
return false;
}
function updateListSmoothly(newItems) {
function renderItem(item, isShare = window.IS_SHARE, showEditOnly = false) {
const li = document.createElement('li');
li.id = `item-${item.id}`;
li.dataset.name = item.name.toLowerCase();
li.className = `list-group-item d-flex justify-content-between align-items-center flex-wrap clickable-item ${item.purchased ? 'bg-success text-white'
: item.not_purchased ? 'bg-warning text-dark'
: 'item-not-checked'
}`;
let quantityBadge = '';
if (item.quantity && item.quantity > 1) {
quantityBadge = `<span class="badge bg-secondary">x${item.quantity}</span>`;
}
let checkboxOrIcon = item.not_purchased
? `<span class="ms-1 block-icon">🚫</span>`
: `<input id="checkbox-${item.id}" class="large-checkbox" type="checkbox" ${item.purchased ? 'checked' : ''}>`;
let noteHTML = item.note
? `<small class="text-danger ms-4">[ <b>${item.note}</b> ]</small>` : '';
let reasonHTML = item.not_purchased_reason
? `<small class="text-dark ms-4">[ <b>Powód: ${item.not_purchased_reason}</b> ]</small>` : '';
let dragHandle = window.isSorting ? `<span class="drag-handle me-2 text-danger" style="cursor: grab;">☰</span>` : '';
let left = `
<div class="d-flex align-items-center gap-2 flex-grow-1">
${dragHandle}
${checkboxOrIcon}
<span id="name-${item.id}" class="text-white">${item.name} ${quantityBadge}</span>
${noteHTML}
${reasonHTML}
</div>`;
let rightButtons = '';
// ✏️ i 🗑️ — tylko jeśli nie jesteśmy w trybie /share lub jesteśmy w 15s (tymczasowo)
if (!isShare || showEditOnly) {
rightButtons += `
<button type="button" class="btn btn-outline-light"
onclick="editItem(${item.id}, '${item.name.replace(/'/g, "\\'")}', ${item.quantity || 1})">
✏️
</button>
<button type="button" class="btn btn-outline-light"
onclick="deleteItem(${item.id})">
🗑️
</button>`;
}
// ✅ Jeśli element jest oznaczony jako niekupiony — pokaż "Przywróć"
if (item.not_purchased) {
rightButtons += `
<button type="button" class="btn btn-outline-light me-auto"
onclick="unmarkNotPurchased(${item.id})">
✅ Przywróć
</button>`;
}
// ⚠️ tylko jeśli NIE jest oznaczony jako niekupiony i nie jesteśmy w 15s
if (!item.not_purchased && !showEditOnly) {
rightButtons += `
<button type="button" class="btn btn-outline-light"
onclick="markNotPurchasedModal(event, ${item.id})">
⚠️
</button>`;
}
// 📝 tylko jeśli jesteśmy w /share i nie jesteśmy w 15s
if (isShare && !showEditOnly) {
rightButtons += `
<button type="button" class="btn btn-outline-light"
onclick="openNoteModal(event, ${item.id})">
📝
</button>`;
}
li.innerHTML = `${left}<div class="btn-group btn-group-sm" role="group">${rightButtons}</div>`;
if (item.added_by && item.owner_id && item.added_by_id && item.added_by_id !== item.owner_id) {
const infoEl = document.createElement('small');
infoEl.className = 'text-info ms-4';
infoEl.innerHTML = `[Dodał/a: <b>${item.added_by}</b>]`;
li.querySelector('.d-flex.align-items-center')?.appendChild(infoEl);
}
return li;
}
function updateListSmoothly(newItems) {
const itemsContainer = document.getElementById('items');
const existingItemsMap = new Map();
@@ -285,68 +374,7 @@ function updateListSmoothly(newItems) {
const fragment = document.createDocumentFragment();
newItems.forEach(item => {
let li = existingItemsMap.get(item.id);
let quantityBadge = '';
if (item.quantity && item.quantity > 1) {
quantityBadge = `<span class="badge bg-secondary">x${item.quantity}</span>`;
}
if (!li) {
li = document.createElement('li');
li.id = `item-${item.id}`;
}
// Klasy tła
li.className = `list-group-item d-flex justify-content-between align-items-center flex-wrap clickable-item ${item.purchased ? 'bg-success text-white' :
item.not_purchased ? 'bg-warning text-dark' : 'item-not-checked'
}`;
// Wewnętrzny HTML
li.innerHTML = `
<div class="d-flex align-items-center gap-2 flex-grow-1">
${isSorting ? `<span class="drag-handle me-2 text-danger" style="cursor: grab;">☰</span>` : ''}
${!item.not_purchased ? `
<input id="checkbox-${item.id}" class="large-checkbox" type="checkbox"
${item.purchased ? 'checked' : ''}>
` : `
<span class="ms-1 block-icon">🚫</span>
`}
<span id="name-${item.id}" class="text-white">${item.name} ${quantityBadge}</span>
${item.note ? `<small class="text-danger ms-4">[ <b>${item.note}</b> ]</small>` : ''}
${item.not_purchased_reason ? `<small class="text-dark ms-4">[ <b>Powód: ${item.not_purchased_reason}</b> ]</small>` : ''}
</div>
<div class="btn-group btn-group-sm" role="group">
${item.not_purchased ? `
<button type="button" class="btn btn-outline-light me-auto"
onclick="unmarkNotPurchased(${item.id})">
✅ Przywróć
</button>
` : `
<button type="button" class="btn btn-outline-light"
onclick="markNotPurchasedModal(event, ${item.id})">
⚠️
</button>
${window.IS_SHARE ? `
<button type="button" class="btn btn-outline-light"
onclick="openNoteModal(event, ${item.id})">
📝
</button>
` : ''}
`}
${!window.IS_SHARE ? `
<button type="button" class="btn btn-outline-light"
onclick="editItem(${item.id}, '${item.name.replace(/'/g, "\\'")}', ${item.quantity || 1})">
✏️
</button>
<button type="button" class="btn btn-outline-light"
onclick="deleteItem(${item.id})">
🗑️
</button>
` : ''}
</div>
`;
const li = renderItem(item);
fragment.appendChild(li);
});

View File

@@ -127,69 +127,59 @@ function setupList(listId, username) {
showToast(`Dodano wydatek: ${data.amount.toFixed(2)} PLN`, 'info');
});
socket.on('item_added', data => {
showToast(`${data.added_by} dodał: ${data.name}`, 'info');
const li = document.createElement('li');
li.className = 'list-group-item d-flex justify-content-between align-items-center flex-wrap item-not-checked';
li.id = `item-${data.id}`;
let quantityBadge = '';
if (data.quantity && data.quantity > 1) {
quantityBadge = `<span class="badge bg-secondary">x${data.quantity}</span>`;
}
const countdownId = `countdown-${data.id}`;
const countdownBtn = `
<button type="button" class="btn btn-outline-warning" id="${countdownId}" disabled>15s</button>
`;
li.innerHTML = `
<div class="d-flex align-items-center flex-wrap gap-2 flex-grow-1">
<input class="large-checkbox" type="checkbox">
<span id="name-${data.id}" class="text-white">
${data.name} ${quantityBadge}
</span>
</div>
<div class="btn-group btn-group-sm" role="group">
${countdownBtn}
<button type="button" class="btn btn-outline-light"
onclick="editItem(${data.id}, '${data.name.replace(/'/g, "\\'")}', ${data.quantity || 1})">
✏️
</button>
<button type="button" class="btn btn-outline-light"
onclick="deleteItem(${data.id})">
🗑️
</button>
</div>
`;
const item = {
...data,
purchased: false,
not_purchased: false,
not_purchased_reason: '',
note: ''
};
const li = renderItem(item, false, true); // ← tryb 15s
document.getElementById('items').appendChild(li);
toggleEmptyPlaceholder();
updateProgressBar();
// ⏳ Licznik odliczania
let seconds = 15;
const countdownEl = document.getElementById(countdownId);
const intervalId = setInterval(() => {
seconds--;
if (countdownEl) {
countdownEl.textContent = `${seconds}s`;
}
if (seconds <= 0) {
clearInterval(intervalId);
if (countdownEl) countdownEl.remove();
}
}, 1000);
if (window.IS_SHARE) {
const countdownId = `countdown-${data.id}`;
const countdownBtn = document.createElement('button');
countdownBtn.type = 'button';
countdownBtn.className = 'btn btn-outline-warning';
countdownBtn.id = countdownId;
countdownBtn.disabled = true;
countdownBtn.textContent = '15s';
// 🔁 Request listy po 15s
setTimeout(() => {
if (window.LIST_ID) {
socket.emit('request_full_list', { list_id: window.LIST_ID });
}
}, 15000);
li.querySelector('.btn-group')?.prepend(countdownBtn);
let seconds = 15;
const intervalId = setInterval(() => {
const el = document.getElementById(countdownId);
if (el) {
seconds--;
el.textContent = `${seconds}s`;
if (seconds <= 0) {
el.remove();
clearInterval(intervalId);
}
} else {
clearInterval(intervalId);
}
}, 1000);
setTimeout(() => {
const existing = document.getElementById(`item-${data.id}`);
if (existing) {
const updated = renderItem(item, true);
existing.replaceWith(updated);
}
}, 15000);
}
});
socket.on('item_deleted', data => {
const li = document.getElementById(`item-${data.item_id}`);
if (li) {

View File

@@ -4,22 +4,22 @@ let currentReceiptId;
document.addEventListener("DOMContentLoaded", function () {
const cropModal = document.getElementById("cropModal");
const cropImage = document.getElementById("cropImage");
const spinner = document.getElementById("cropLoading");
cropModal.addEventListener("shown.bs.modal", function (event) {
const button = event.relatedTarget;
const imgSrc = button.getAttribute("data-img-src");
currentReceiptId = button.getAttribute("data-receipt-id");
const image = document.getElementById("cropImage");
image.src = imgSrc;
cropImage.src = imgSrc;
if (cropper) {
cropper.destroy();
cropper = null;
}
image.onload = () => {
cropper = new Cropper(image, {
cropImage.onload = () => {
cropper = new Cropper(cropImage, {
viewMode: 1,
autoCropArea: 1,
responsive: true,
@@ -36,7 +36,51 @@ document.addEventListener("DOMContentLoaded", function () {
document.getElementById("saveCrop").addEventListener("click", function () {
if (!cropper) return;
cropper.getCroppedCanvas().toBlob(function (blob) {
spinner.classList.remove("d-none");
const cropData = cropper.getData();
const imageData = cropper.getImageData();
const scaleX = imageData.naturalWidth / imageData.width;
const scaleY = imageData.naturalHeight / imageData.height;
const width = cropData.width * scaleX;
const height = cropData.height * scaleY;
if (width < 1 || height < 1) {
spinner.classList.add("d-none");
showToast("Obszar przycięcia jest zbyt mały lub pusty", "danger");
return;
}
// Ogranicz do 2000x2000 w proporcji
const maxDim = 2000;
const scale = Math.min(1, maxDim / Math.max(width, height));
const finalWidth = Math.round(width * scale);
const finalHeight = Math.round(height * scale);
const croppedCanvas = cropper.getCroppedCanvas({
width: finalWidth,
height: finalHeight,
imageSmoothingEnabled: true,
imageSmoothingQuality: 'high',
});
if (!croppedCanvas) {
spinner.classList.add("d-none");
showToast("Nie można uzyskać obrazu przycięcia", "danger");
return;
}
croppedCanvas.toBlob(function (blob) {
if (!blob) {
spinner.classList.add("d-none");
showToast("Nie udało się zapisać obrazu", "danger");
return;
}
const formData = new FormData();
formData.append("receipt_id", currentReceiptId);
formData.append("cropped_image", blob);
@@ -47,6 +91,7 @@ document.addEventListener("DOMContentLoaded", function () {
})
.then((res) => res.json())
.then((data) => {
spinner.classList.add("d-none");
if (data.success) {
showToast("Zapisano przycięty paragon", "success");
setTimeout(() => location.reload(), 1500);
@@ -55,9 +100,10 @@ document.addEventListener("DOMContentLoaded", function () {
}
})
.catch((err) => {
spinner.classList.add("d-none");
showToast("Błąd sieci", "danger");
console.error(err);
});
}, "image/webp");
}, "image/webp", 1.0);
});
});

View File

@@ -16,7 +16,6 @@ if (!window.receiptUploaderInitialized) {
const isDesktop = window.matchMedia("(pointer: fine)").matches;
// 🧼 Jedno miejsce, pełna logika desktopowa
if (isDesktop) {
if (cameraBtn) cameraBtn.remove(); // całkowicie usuń przycisk
if (inputCamera) inputCamera.remove(); // oraz input
@@ -80,6 +79,12 @@ if (!window.receiptUploaderInitialized) {
}
lightbox = GLightbox({ selector: ".glightbox" });
// Pokaż sekcję OCR jeśli była ukryta
const analysisBlock = document.getElementById("receiptAnalysisBlock");
if (analysisBlock) {
analysisBlock.classList.remove("d-none");
}
if (!window.receiptToastShown) {
showToast("Wgrano paragon", "success");
window.receiptToastShown = true;
@@ -96,7 +101,6 @@ if (!window.receiptUploaderInitialized) {
}
};
xhr.send(formData);
}

14
static/js/select_month.js Normal file
View File

@@ -0,0 +1,14 @@
document.addEventListener("DOMContentLoaded", () => {
const select = document.getElementById("monthSelect");
if (!select) return;
select.addEventListener("change", () => {
const month = select.value;
const url = new URL(window.location.href);
if (month) {
url.searchParams.set("month", month);
} else {
url.searchParams.delete("month");
}
window.location.href = url.toString();
});
});

View File

@@ -2,53 +2,54 @@ let sortable = null;
let isSorting = false;
function enableSortMode() {
if (sortable || isSorting) return;
if (isSorting) return;
isSorting = true;
window.isSorting = true;
localStorage.setItem('sortModeEnabled', 'true');
const itemsContainer = document.getElementById('items');
const listId = window.LIST_ID;
if (!itemsContainer || !listId) return;
sortable = Sortable.create(itemsContainer, {
animation: 150,
handle: '.drag-handle',
ghostClass: 'drag-ghost',
filter: 'input, button',
preventOnFilter: false,
onEnd: function () {
const order = Array.from(itemsContainer.children)
.map(li => parseInt(li.id.replace('item-', '')))
.filter(id => !isNaN(id));
fetch('/reorder_items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ list_id: listId, order })
}).then(() => {
showToast('Zapisano nową kolejność', 'success');
if (window.currentItems) {
window.currentItems = order.map(id =>
window.currentItems.find(item => item.id === id)
);
updateListSmoothly(window.currentItems);
}
});
}
});
const btn = document.getElementById('sort-toggle-btn');
if (btn) {
btn.textContent = '✔️ Zakończ sortowanie';
btn.classList.remove('btn-outline-warning');
btn.classList.add('btn-outline-success');
}
// Odśwież widok listy z uchwytami (☰)
if (window.currentItems) {
updateListSmoothly(window.currentItems);
}
// Poczekaj na DOM po odświeżeniu listy
setTimeout(() => {
if (sortable) sortable.destroy();
sortable = Sortable.create(itemsContainer, {
animation: 150,
handle: '.drag-handle',
ghostClass: 'drag-ghost',
filter: 'input, button',
preventOnFilter: false,
onEnd: () => {
const order = Array.from(itemsContainer.children)
.map(li => parseInt(li.id.replace('item-', '')))
.filter(id => !isNaN(id));
fetch('/reorder_items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ list_id: listId, order })
}).then(() => {
showToast('Zapisano nową kolejność', 'success');
if (window.currentItems) {
window.currentItems = order.map(id =>
window.currentItems.find(item => item.id === id)
);
updateListSmoothly(window.currentItems);
}
});
}
});
updateSortButtonUI(true);
}, 50);
}
function disableSortMode() {
@@ -56,28 +57,40 @@ function disableSortMode() {
sortable.destroy();
sortable = null;
}
isSorting = false;
localStorage.removeItem('sortModeEnabled');
const btn = document.getElementById('sort-toggle-btn');
if (btn) {
btn.textContent = '✳️ Zmień kolejność';
btn.classList.remove('btn-outline-success');
btn.classList.add('btn-outline-warning');
}
window.isSorting = false;
if (window.currentItems) {
updateListSmoothly(window.currentItems);
}
updateSortButtonUI(false);
}
function toggleSortMode() {
isSorting ? disableSortMode() : enableSortMode();
}
function updateSortButtonUI(active) {
const btn = document.getElementById('sort-toggle-btn');
if (!btn) return;
if (active) {
btn.textContent = '✔️ Zakończ sortowanie';
btn.classList.remove('btn-outline-warning');
btn.classList.add('btn-outline-success');
} else {
btn.textContent = '✳️ Zmień kolejność';
btn.classList.remove('btn-outline-success');
btn.classList.add('btn-outline-warning');
}
}
document.addEventListener('DOMContentLoaded', () => {
const wasSorting = localStorage.getItem('sortModeEnabled') === 'true';
if (wasSorting) {
enableSortMode();
}
});
});

View File

@@ -0,0 +1,160 @@
document.addEventListener('DOMContentLoaded', () => {
const checkboxes = document.querySelectorAll('.list-checkbox');
const totalEl = document.getElementById('listsTotal');
const filterButtons = document.querySelectorAll('.range-btn');
const rows = document.querySelectorAll('#listsTableBody tr');
const onlyWith = document.getElementById('onlyWithExpenses');
const customStart = document.getElementById('customStart');
const customEnd = document.getElementById('customEnd');
// Przywróć zapisane daty
if (localStorage.getItem('customStart')) {
customStart.value = localStorage.getItem('customStart');
}
if (localStorage.getItem('customEnd')) {
customEnd.value = localStorage.getItem('customEnd');
}
function updateTotal() {
let total = 0;
checkboxes.forEach(cb => {
const row = cb.closest('tr');
if (cb.checked && row.style.display !== 'none') {
total += parseFloat(cb.dataset.amount);
}
});
totalEl.textContent = total.toFixed(2) + ' PLN';
totalEl.parentElement.classList.add('animate__animated', 'animate__fadeIn');
setTimeout(() => {
totalEl.parentElement.classList.remove('animate__animated', 'animate__fadeIn');
}, 400);
}
checkboxes.forEach(cb => cb.addEventListener('change', updateTotal));
filterButtons.forEach(btn => {
btn.addEventListener('click', () => {
filterButtons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const range = btn.dataset.range;
// Czyść lokalne daty przy kliknięciu zakresu
localStorage.removeItem('customStart');
localStorage.removeItem('customEnd');
const now = new Date();
const todayStr = now.toISOString().slice(0, 10);
const year = now.getFullYear();
const month = now.toISOString().slice(0, 7);
const week = `${year}-${String(getISOWeek(now)).padStart(2, '0')}`;
rows.forEach(row => {
const rDate = row.dataset.date;
const rMonth = row.dataset.month;
const rWeek = row.dataset.week;
const rYear = row.dataset.year;
let show = true;
if (range === 'day') show = rDate === todayStr;
if (range === 'month') show = rMonth === month;
if (range === 'week') show = rWeek === week;
if (range === 'year') show = rYear === String(year);
row.style.display = show ? '' : 'none';
});
applyExpenseFilter();
updateTotal();
});
});
function getISOWeek(date) {
const target = new Date(date.valueOf());
const dayNr = (date.getDay() + 6) % 7;
target.setDate(target.getDate() - dayNr + 3);
const firstThursday = new Date(target.getFullYear(), 0, 4);
const dayDiff = (target - firstThursday) / 86400000;
return 1 + Math.floor(dayDiff / 7);
}
document.getElementById('applyCustomRange').addEventListener('click', () => {
const start = customStart.value;
const end = customEnd.value;
// Zapamiętaj daty
localStorage.setItem('customStart', start);
localStorage.setItem('customEnd', end);
filterButtons.forEach(b => b.classList.remove('active'));
rows.forEach(row => {
const date = row.dataset.date;
const show = (!start || date >= start) && (!end || date <= end);
row.style.display = show ? '' : 'none';
});
applyExpenseFilter();
updateTotal();
});
if (onlyWith) {
onlyWith.addEventListener('change', () => {
applyExpenseFilter();
updateTotal();
});
}
function applyExpenseFilter() {
if (!onlyWith || !onlyWith.checked) return;
rows.forEach(row => {
const amt = parseFloat(row.querySelector('.list-checkbox').dataset.amount || 0);
if (amt <= 0) row.style.display = 'none';
});
}
// Domyślnie kliknij „Miesiąc”
const defaultBtn = document.querySelector('.range-btn[data-range="month"]');
if (defaultBtn && !customStart.value && !customEnd.value) {
defaultBtn.click();
}
});
document.addEventListener("DOMContentLoaded", function () {
const toggleBtn = document.getElementById("toggleAllCheckboxes");
let allChecked = false;
toggleBtn?.addEventListener("click", () => {
const checkboxes = document.querySelectorAll(".list-checkbox");
allChecked = !allChecked;
checkboxes.forEach(cb => {
cb.checked = allChecked;
});
toggleBtn.textContent = allChecked ? "🚫 Odznacz wszystkie" : "✅ Zaznacz wszystkie";
const updateTotalEvent = new Event('change');
checkboxes.forEach(cb => cb.dispatchEvent(updateTotalEvent));
});
});
document.getElementById("applyCustomRange")?.addEventListener("click", () => {
const start = document.getElementById("customStart")?.value;
const end = document.getElementById("customEnd")?.value;
if (start && end) {
const url = `/user_expenses?start_date=${start}&end_date=${end}`;
window.location.href = url;
}
});
document.getElementById("showAllLists").addEventListener("change", function () {
const checked = this.checked;
const url = new URL(window.location.href);
if (checked) {
url.searchParams.set("show_all", "true");
} else {
url.searchParams.delete("show_all");
}
window.location.href = url.toString();
});

View File

@@ -3,7 +3,11 @@ document.addEventListener("DOMContentLoaded", function () {
const rangeLabel = document.getElementById("chartRangeLabel");
function loadExpenses(range = "monthly", startDate = null, endDate = null) {
let url = '/user/expenses_data?range=' + range;
let url = '/user_expenses_data?range=' + range;
const showAllCheckbox = document.getElementById("showAllLists");
if (showAllCheckbox && showAllCheckbox.checked) {
url += '&show_all=true';
}
if (startDate && endDate) {
url += `&start_date=${startDate}&end_date=${endDate}`;
}

View File

@@ -78,6 +78,11 @@
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">Anuluj</button>
<button class="btn btn-success" id="saveCrop">Zapisz</button>
<div id="cropLoading" class="position-absolute top-50 start-50 translate-middle text-center d-none"
style="z-index: 1055;">
<div class="spinner-border text-light" role="status"></div>
<div class="mt-2 text-light">⏳ Pracuję...</div>
</div>
</div>
</div>
</div>

View File

@@ -118,26 +118,26 @@
</div>
<div class="btn-group btn-group-sm" role="group">
{% if item.not_purchased %}
<button type="button" class="btn btn-outline-success" {% if list.is_archived %}disabled{% else
%}onclick="unmarkNotPurchased({{ item.id }})" {% endif %}>
✅ Przywróć
{% if not is_share %}
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else %}
onclick="editItem({{ item.id }}, '{{ item.name }}', {{ item.quantity or 1 }})" {% endif %}>
✏️
</button>
{% else %}
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else
%}onclick="markNotPurchasedModal(event, {{ item.id }})" {% endif %}>
⚠️
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else %}
onclick="deleteItem({{ item.id }})" {% endif %}>
🗑️
</button>
{% endif %}
{% if not is_share %}
<button type="button" class="btn btn-outline-warning" {% if list.is_archived %}disabled{% else
%}onclick="editItem({{ item.id }}, '{{ item.name }}', {{ item.quantity or 1 }})" {% endif %}>
✏️
{% if item.not_purchased %}
<button type="button" class="btn btn-outline-light me-auto" {% if list.is_archived %}disabled{% else %}
onclick="unmarkNotPurchased({{ item.id }})" {% endif %}>
✅ Przywróć
</button>
<button type="button" class="btn btn-outline-danger" {% if list.is_archived %}disabled{% else
%}onclick="deleteItem({{ item.id }})" {% endif %}>
🗑️
{% elif not item.not_purchased %}
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else %}
onclick="markNotPurchasedModal(event, {{ item.id }})" {% endif %}>
⚠️
</button>
{% endif %}
</div>

View File

@@ -53,21 +53,23 @@
<div class="btn-group btn-group-sm" role="group">
{% if item.not_purchased %}
<button type="button" class="btn btn-outline-success" {% if list.is_archived %}disabled{% else
%}onclick="unmarkNotPurchased({{ item.id }})" {% endif %}>
<button type="button" class="btn btn-outline-light me-auto" {% if list.is_archived %}disabled{% else %}
onclick="unmarkNotPurchased({{ item.id }})" {% endif %}>
✅ Przywróć
</button>
{% else %}
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else
%}onclick="markNotPurchasedModal(event, {{ item.id }})" {% endif %}>
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else %}
onclick="markNotPurchasedModal(event, {{ item.id }})" {% endif %}>
⚠️
</button>
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else
%}onclick="openNoteModal(event, {{ item.id }})" {% endif %}>
{% endif %}
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else %}
onclick="openNoteModal(event, {{ item.id }})" {% endif %}>
📝
</button>
{% endif %}
</div>
</li>
{% else %}
<li id="empty-placeholder" class="list-group-item bg-dark text-secondary text-center w-100">
@@ -105,9 +107,9 @@
<div class="collapse" id="receiptSection">
{% set receipt_pattern = 'list_' ~ list.id %}
{% if receipt_files %}
<hr>
<div class="mt-3 p-3 border border-secondary rounded bg-dark text-white" id="receiptAnalysisBlock">
<div class="mt-3 p-3 border border-secondary rounded bg-dark text-white {% if not receipt_files %}d-none{% endif %}"
id="receiptAnalysisBlock">
<h5>🧠 Analiza paragonów (OCR)</h5>
<p class="text-small">System spróbuje automatycznie rozpoznać kwoty z dodanych paragonów.</p>
@@ -118,14 +120,10 @@
{% else %}
<div class="alert alert-warning">🔒 Tylko zalogowani użytkownicy mogą zlecać analizę OCR paragonów.</div>
{% endif %}
<div id="analysisResults" class="mt-2"></div>
</div>
{% endif %}
<h5 class="mt-4">📸 Paragony dodane do tej listy</h5>
<div class="row g-3 mt-2" id="receiptGallery">
{% if receipt_files %}
{% for file in receipt_files %}
@@ -163,14 +161,15 @@
</label>
<input type="file" name="receipt" accept="image/*" class="d-none" id="galleryInput">
<div id="progressContainer" class="progress" style="height: 20px; display: none;">
<div id="progressBar" class="progress-bar bg-success fw-bold" role="progressbar" style="width: 0%;">0%</div>
<div id="progressContainer" class="progress progress-dark rounded-3 overflow-hidden shadow-sm"
style="height: 20px; display: none;">
<div id="progressBar" class="progress-bar bg-success fw-bold text-white text-center" role="progressbar"
style="width: 0%;">0%</div>
</div>
<div id="receiptGallery" class="mt-3"></div>
</form>
{% endif %}
</div>
<!-- Modal notatki -->

View File

@@ -31,6 +31,33 @@
</div>
{% endif %}
{% set month_names = ["styczeń", "luty", "marzec", "kwiecień", "maj", "czerwiec", "lipiec", "sierpień", "wrzesień",
"październik", "listopad", "grudzień"] %}
{% set selected_month = request.args.get('month') or now.strftime('%Y-%m') %}
<!-- Pulpit: zwykły <select> -->
<div class="d-none d-md-flex justify-content-end align-items-center flex-wrap gap-2 mb-3">
<label for="monthSelect" class="text-white small mb-0">📅 Wybierz miesiąc:</label>
<select id="monthSelect" class="form-select form-select-sm bg-dark text-white border-secondary"
style="min-width: 180px;">
{% for offset in range(0, 6) %}
{% set d = (now - timedelta(days=offset * 30)) %}
{% set val = d.strftime('%Y-%m') %}
<option value="{{ val }}" {% if selected_month==val %}selected{% endif %}>
{{ month_names[d.month - 1] }} {{ d.year }}
</option>
{% endfor %}
<option value="">Wyświetl wszystko</option>
</select>
</div>
<!-- Telefon: przycisk otwierający modal -->
<div class="d-md-none mb-3">
<button class="btn btn-outline-light w-100" data-bs-toggle="modal" data-bs-target="#monthPickerModal">
📅 Wybierz miesiąc
</button>
</div>
{% if current_user.is_authenticated %}
<h3 class="mt-4 d-flex justify-content-between align-items-center flex-wrap">
Twoje listy
@@ -78,8 +105,7 @@
{% endfor %}
</ul>
{% else %}
<p><span class="badge rounded-pill bg-secondary opacity-75">Nie masz jeszcze żadnych list. Utwórz pierwszą, korzystając
z formularza powyżej</span></p>
<p><span class="badge rounded-pill bg-secondary opacity-75">Nie utworzono żadnej listy</span></p>
{% endif %}
{% endif %}
@@ -114,7 +140,6 @@
<p><span class="badge rounded-pill bg-secondary opacity-75">Brak dostępnych list publicznych do wyświetlenia</span></p>
{% endif %}
<div class="modal fade" id="archivedModal" tabindex="-1" aria-labelledby="archivedModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content bg-dark text-white">
@@ -146,8 +171,32 @@
</div>
</div>
<div class="modal fade" id="monthPickerModal" tabindex="-1" aria-labelledby="monthPickerModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title">📅 Wybierz miesiąc</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body">
<div class="d-grid gap-2">
{% for offset in range(0, 6) %}
{% set d = (now - timedelta(days=offset * 30)) %}
{% set val = d.strftime('%Y-%m') %}
<a href="{{ url_for('main_page', month=val) }}" class="btn btn-outline-light">
{{ month_names[d.month - 1] }} {{ d.year }}
</a>
{% endfor %}
<a href="{{ url_for('main_page') }}" class="btn btn-outline-secondary">📋 Wyświetl wszystkie</a>
</div>
</div>
</div>
</div>
</div>
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js', filename='toggle_button.js') }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='select_month.js') }}"></script>
{% endblock %}
{% endblock %}

View File

@@ -1,21 +1,27 @@
{% extends 'base.html' %}
{% block title %}📊 Twoje wydatki{% endblock %}
{% block title %}Wydatki z Twoich list{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<h2 class="mb-2">Statystyki wydatków</h2>
<a href="{{ url_for('main_page') }}" class="btn btn-outline-secondary">← Powrót</a>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="showAllLists" {% if show_all %}checked{% endif %}>
<label class="form-check-label ms-2 text-white" for="showAllLists">Pokaż wszystkie publiczne listy
innych</label>
</div>
<div class="card bg-dark text-white mb-5">
<div class="card-body">
<ul class="nav nav-tabs mb-3" id="expenseTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="table-tab" data-bs-toggle="tab" data-bs-target="#tableTab" type="button"
<button class="nav-link active" id="lists-tab" data-bs-toggle="tab" data-bs-target="#listsTab" type="button"
role="tab">
📄 Tabela
📚 Listy
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="chart-tab" data-bs-toggle="tab" data-bs-target="#chartTab" type="button"
role="tab">
@@ -25,32 +31,80 @@
</ul>
<div class="tab-content" id="expenseTabsContent">
<!-- Tabela -->
<div class="tab-pane fade show active" id="tableTab" role="tabpanel">
<!-- 📚 LISTY -->
<div class="tab-pane fade show active" id="listsTab" role="tabpanel">
<div class="card bg-dark text-white mb-4">
<div class="card-body">
{% if expense_table %}
<div class="row g-4">
{% for row in expense_table %}
<div class="col-12 col-sm-6 col-lg-4">
<div class="card bg-dark text-white border border-secondary h-100 shadow-sm">
<div class="card-body">
<h5 class="card-title text-truncate" title="{{ row.title }}">{{ row.title }}</h5>
<p class="mb-1">💸 <strong>{{ '%.2f'|format(row.amount) }} PLN</strong></p>
<p class="mb-0">📅 {{ row.added_at.strftime('%Y-%m-%d') }}</p>
</div>
</div>
</div>
{% endfor %}
<div class="d-flex flex-wrap gap-2 mb-3">
<button class="btn btn-outline-light btn-sm range-btn" data-range="day">🗓️ Dzień</button>
<button class="btn btn-outline-light btn-sm range-btn" data-range="week">📆 Tydzień</button>
<button class="btn btn-outline-light btn-sm range-btn active" data-range="month">📅 Miesiąc</button>
<button class="btn btn-outline-light btn-sm range-btn" data-range="year">📈 Rok</button>
<button class="btn btn-outline-light btn-sm range-btn" data-range="all">🌐 Wszystko</button>
</div>
{% else %}
<div class="alert alert-info text-center mb-0">Brak wydatków do wyświetlenia.</div>
{% endif %}
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="onlyWithExpenses">
<label class="form-check-label ms-2 text-white" for="onlyWithExpenses">Pokaż tylko listy z
wydatkami</label>
</div>
<div class="input-group input-group-sm mb-3 w-100" style="max-width: 570px;">
<span class="input-group-text bg-secondary text-white border-secondary">Od</span>
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="customStart">
<span class="input-group-text bg-secondary text-white border-secondary">Do</span>
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="customEnd">
<button class="btn btn-outline-success" id="applyCustomRange">📊 Zastosuj zakres</button>
</div>
<div class="d-flex justify-content-end mb-2">
<button id="toggleAllCheckboxes" class="btn btn-outline-light btn-sm">
✅ Zaznacz wszystkie
</button>
</div>
<div class="table-responsive">
<table class="table table-dark table-striped align-middle">
<thead>
<tr>
<th></th>
<th>Nazwa listy</th>
<th>Data</th>
<th>Wydatki (PLN)</th>
</tr>
</thead>
<tbody id="listsTableBody">
{% for list in lists_data %}
<tr data-date="{{ list.created_at.strftime('%Y-%m-%d') }}"
data-week="{{ list.created_at.isocalendar()[0] }}-{{ '%02d' % list.created_at.isocalendar()[1] }}"
data-month="{{ list.created_at.strftime('%Y-%m') }}" data-year="{{ list.created_at.year }}">
<td>
<input type="checkbox" class="form-check-input list-checkbox"
data-amount="{{ '%.2f'|format(list.total_expense) }}">
</td>
<td>
<strong>{{ list.title }}</strong>
<br><small class="text-small">👤 {{ list.owner_username or '?' }}</small>
</td>
<td>{{ list.created_at.strftime('%Y-%m-%d') }}</td>
<td>{{ '%.2f'|format(list.total_expense) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<hr>
<h5 class="text-success mt-3">💰 Suma zaznaczonych: <span id="listsTotal">0.00 PLN</span></h5>
</div>
</div>
</div>
<!-- Wykres -->
<!-- 📊 WYKRES -->
<div class="tab-pane fade" id="chartTab" role="tabpanel">
<div class="card bg-dark text-white mb-4">
<div class="card-body">
@@ -59,31 +113,33 @@
</div>
</div>
<div class="d-flex flex-wrap gap-2 mb-4">
<div class="d-flex flex-wrap gap-2 mb-3">
<button class="btn btn-outline-light btn-sm range-btn active" data-range="monthly">📅 Miesięczne</button>
<button class="btn btn-outline-light btn-sm range-btn" data-range="quarterly">📆 Kwartalne</button>
<button class="btn btn-outline-light btn-sm range-btn" data-range="quarterly">📊 Kwartalne</button>
<button class="btn btn-outline-light btn-sm range-btn" data-range="halfyearly">🗓️ Półroczne</button>
<button class="btn btn-outline-light btn-sm range-btn" data-range="yearly">📈 Roczne</button>
</div>
<div class="row g-2 mb-4">
<div class="col-6 col-md-3">
<input type="date" id="startDate" class="form-control bg-dark text-white border-secondary rounded">
</div>
<div class="col-6 col-md-3">
<input type="date" id="endDate" class="form-control bg-dark text-white border-secondary rounded">
</div>
<div class="col-12 col-md-3">
<button class="btn btn-outline-light w-100" id="customRangeBtn">📊 Zakres własny</button>
</div>
<!-- Picker daty w formie input-group -->
<div class="input-group input-group-sm mb-4 w-100" style="max-width: 570px;">
<span class="input-group-text bg-secondary text-white border-secondary">Od</span>
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="startDate">
<span class="input-group-text bg-secondary text-white border-secondary">Do</span>
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="endDate">
<button class="btn btn-outline-success" id="customRangeBtn">📊 Pokaż dane z zakresu</button>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js_lib', filename='chart.js') }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='user_expenses.js') }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='user_expense_lists.js') }}"></script>
{% endblock %}