47 Commits

Author SHA1 Message Date
gru
7f68b1647e Merge pull request 'pgsql_mysql_docker' (#4) from pgsql_mysql_docker into master
Reviewed-on: #4
2025-07-24 10:03:32 +02:00
Mateusz Gruszczyński
6f7d0069cc poprawki w compose i kodzie 2025-07-24 09:56:30 +02:00
Mateusz Gruszczyński
a68aa031bb poprawki w compose i kodzie 2025-07-24 09:54:18 +02:00
root
730330cba9 remove firebird 2025-07-23 23:50:06 +02:00
Mateusz Gruszczyński
5a898c5b7a usprawnienia w panelu 2025-07-23 13:50:22 +02:00
Mateusz Gruszczyński
74ae7642e5 usprawnienia w panelu 2025-07-23 13:46:57 +02:00
root
111a63d3af wsparie dla mysql/pgsql/firebird/sqlite 2025-07-23 10:57:13 +02:00
Mateusz Gruszczyński
57a3866ec8 inne bazy z opcjach 2025-07-23 09:30:27 +02:00
gru
48f1841649 Merge pull request 'ocr' (#3) from ocr into master
Reviewed-on: #3
2025-07-23 08:34:44 +02:00
gru
0d9e56dfa1 Update templates/admin/receipts.html 2025-07-23 08:34:35 +02:00
Mateusz Gruszczyński
d899672a2b przeliczenie wielkosci plikow 2025-07-22 22:21:10 +02:00
Mateusz Gruszczyński
03d4370c8a przeliczenie wielkosci plikow 2025-07-22 22:17:17 +02:00
Mateusz Gruszczyński
f30cd0f2fe stopka 2025-07-22 22:10:53 +02:00
Mateusz Gruszczyński
4ec33569a0 stopka 2025-07-22 22:04:17 +02:00
Mateusz Gruszczyński
1ab1b36811 usprawnieni i funkcje oraz zabezpieczenia 2025-07-22 21:56:37 +02:00
Mateusz Gruszczyński
dea0309cfd croper do paragonów 2025-07-22 15:15:03 +02:00
Mateusz Gruszczyński
22bc8bd01d user moze edytowac paragony 2025-07-22 14:36:06 +02:00
Mateusz Gruszczyński
78fcdce327 obracanie zdjęcia fix 2025-07-22 14:19:24 +02:00
Mateusz Gruszczyński
258d111133 start kontenera z systemem 2025-07-22 12:35:34 +02:00
Mateusz Gruszczyński
cc1dad0d7d ocr usprawnienia 2025-07-22 11:29:20 +02:00
Mateusz Gruszczyński
db6f70349e ocr usprawnienia 2025-07-22 11:28:11 +02:00
Mateusz Gruszczyński
a44a61c718 ocr usprawnienia 2025-07-22 11:23:00 +02:00
Mateusz Gruszczyński
aa865baf3b restore analiza 2025-07-21 15:54:28 +02:00
Mateusz Gruszczyński
a84b130822 uprawnienia ocr i uploadu 2025-07-21 15:50:46 +02:00
Mateusz Gruszczyński
983114575d uprawnienia ocr i uploadu 2025-07-21 15:50:35 +02:00
Mateusz Gruszczyński
955196dd92 uprawnienia ocr i uploadu 2025-07-21 14:12:50 +02:00
Mateusz Gruszczyński
8ae9068ffa OCR 2025-07-21 12:08:01 +02:00
gru
a3d47eb368 Update templates/admin/edit_list.html 2025-07-20 23:05:38 +02:00
gru
b0095c3b97 Update templates/admin/receipts.html 2025-07-20 23:05:10 +02:00
Mateusz Gruszczyński
98f22e0bd1 nowe opcje w paragonacch 2025-07-20 22:08:55 +02:00
Mateusz Gruszczyński
62939a9e9a nowe opcje w paragonacch 2025-07-20 22:08:25 +02:00
Mateusz Gruszczyński
ae89f55446 webp support 2025-07-20 17:34:53 +02:00
Mateusz Gruszczyński
3ebb364322 webp support 2025-07-20 17:34:21 +02:00
Mateusz Gruszczyński
470cd32745 webp support 2025-07-20 16:50:26 +02:00
Mateusz Gruszczyński
1f609b6dba dropbne poprawki w js 2025-07-20 10:36:58 +02:00
Mateusz Gruszczyński
f71697b6db python libheif 2025-07-19 23:04:11 +02:00
Mateusz Gruszczyński
6dc712f76e python libheif 2025-07-19 22:59:17 +02:00
Mateusz Gruszczyński
69b1e9495f python libheif 2025-07-19 22:56:38 +02:00
Mateusz Gruszczyński
114bf5c047 upload z zjec z galerii + prettycode 2025-07-19 22:53:49 +02:00
Mateusz Gruszczyński
d8233cb6e5 upload z zjec z galerii + prettycode 2025-07-19 22:19:51 +02:00
Mateusz Gruszczyński
7a9042ffb2 upload z zjec z galerii + prettycode 2025-07-19 22:18:23 +02:00
Mateusz Gruszczyński
1df8e44e4d upload z zjec z galerii + prettycode 2025-07-19 22:16:21 +02:00
Mateusz Gruszczyński
c09edd04b0 upload z zjec z galerii + prettycode 2025-07-19 22:07:58 +02:00
Mateusz Gruszczyński
115d15a055 uxowe zmiany 2025-07-18 22:32:00 +02:00
gru
65a09b2305 Merge pull request 'funkcja_niekupione' (#2) from funkcja_niekupione into master
Reviewed-on: #2
2025-07-18 22:07:28 +02:00
gru
d48654f5b6 Merge branch 'master' into funkcja_niekupione 2025-07-18 22:07:06 +02:00
gru
1bc96a1979 Merge pull request 'ukrycie_zaznaczonych' (#1) from ukrycie_zaznaczonych into master
Reviewed-on: #1
2025-07-12 23:39:35 +02:00
39 changed files with 2109 additions and 658 deletions

BIN
.app.py.swp Normal file

Binary file not shown.

View File

@@ -23,4 +23,31 @@ AUTH_COOKIE_MAX_AGE=86400
HEALTHCHECK_TOKEN=alamapsaikota123
# sesja zalogowanego usera (domyślnie 7 dni)
SESSION_TIMEOUT_MINUTES=10080
SESSION_TIMEOUT_MINUTES=10080
# 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`)
# 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_PORT=5432
DB_NAME=myapp
DB_USER=user
DB_PASSWORD=pass

4
.gitignore vendored
View File

@@ -5,4 +5,6 @@ env
__pycache__
instance/
uploads/
.DS_Store
.DS_Store
db/*
*.swp

View File

@@ -4,6 +4,17 @@ FROM python:3.13-slim
# Ustawiamy katalog roboczy
WORKDIR /app
# Zależności systemowe do OCR, obrazów, tesseract i języka PL
RUN apt-get update && apt-get install -y --no-install-recommends \
tesseract-ocr \
tesseract-ocr-pol \
libglib2.0-0 \
libsm6 \
libxrender1 \
libxext6 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Kopiujemy wymagania
COPY requirements.txt requirements.txt

270
_tools/add_products.py Normal file
View File

@@ -0,0 +1,270 @@
import urllib.request
import json
from app import db, SuggestedProduct, app
CATEGORIES = {
"Przyprawa": [
"przyprawa",
"pieprz",
"sól",
"bazylia",
"oregano",
"papryka",
"majeranek",
"czosnek",
"tymianek",
"rozmaryn",
"kolendra",
"curry",
"imbir",
"goździki",
"chili",
"koper",
"kminek",
"liść laurowy",
"ziele angielskie",
"kurkuma",
"musztarda",
"chrzan",
],
"Mięso": [
"kurczak",
"piersi z kurczaka",
"udka z kurczaka",
"wołowina",
"mielona wołowina",
"wieprzowina",
"schab",
"łopatka",
"szynka",
"boczek",
"indyk",
"filet z indyka",
"gulasz",
"pasztet",
"karkówka",
"żeberka",
"kiełbasa",
"parówki",
"salami",
"kabanos",
],
"Ryba i owoce morza": [
"łosoś",
"dorsz",
"mintaj",
"makrela",
"pstrąg",
"karp",
"śledź",
"tuńczyk",
"morszczuk",
"sardynka",
"szproty",
"anchois",
"tilapia",
"sandacz",
"halibut",
"sum",
"flądra",
"ostrobok",
"paluszki rybne",
"konserwa rybna",
],
"Nabiał": [
"mleko",
"jogurt",
"ser żółty",
"ser biały",
"twaróg",
"śmietana",
"masło",
"kefir",
"maślanka",
"serek wiejski",
"serek topiony",
"mozzarella",
"feta",
"parmezan",
"gouda",
"emmental",
"ser pleśniowy",
"ser homogenizowany",
"serek mascarpone",
"ser ricotta",
],
"Warzywo": [
"pomidor",
"ogórek",
"marchew",
"cebula",
"sałata",
"papryka",
"ziemniak",
"kapusta",
"brokuł",
"kalafior",
"cukinia",
"bakłażan",
"szpinak",
"rukola",
"seler",
"por",
"burak",
"dynia",
"rzodkiewka",
"fasola",
],
"Owoc": [
"jabłko",
"banan",
"gruszka",
"truskawka",
"winogrono",
"malina",
"borówka",
"czereśnia",
"wiśnia",
"brzoskwinia",
"nektaryna",
"śliwka",
"ananas",
"mango",
"kiwi",
"cytryna",
"limonka",
"pomarańcza",
"mandarynka",
"grejpfrut",
],
"Pieczywo i zboża": [
"chleb",
"bułka",
"bagietka",
"kajzerka",
"pumpernikiel",
"chleb razowy",
"chleb żytni",
"tost",
"grahamka",
"croissant",
"tortilla",
"pizza",
"pierogi",
"ryż",
"makaron",
"kasza jaglana",
"kasza gryczana",
"owsianka",
"płatki kukurydziane",
"musli",
],
"Słodycze i przekąski": [
"czekolada",
"baton",
"ciastko",
"wafel",
"lody",
"cukierek",
"żelki",
"herbatnik",
"paluszki",
"chipsy",
"orzeszki",
"popcorn",
"krakersy",
"ciasto",
"muffin",
"pączek",
"drożdżówka",
"babeczka",
"piernik",
"beza",
],
"Napoje": [
"woda",
"sok jabłkowy",
"sok pomarańczowy",
"sok multiwitamina",
"cola",
"pepsi",
"napój gazowany",
"kawa",
"herbata",
"piwo",
"wino czerwone",
"wino białe",
"tonik",
"lemoniada",
"napój izotoniczny",
"kompot",
"napój mleczny",
"maślanka pitna",
"koktajl owocowy",
"nektar",
],
"Tłuszcze i oleje": [
"oliwa",
"olej rzepakowy",
"olej słonecznikowy",
"masło klarowane",
"margaryna",
"smalec",
"masło orzechowe",
"tłuszcz kokosowy",
"olej lniany",
"olej z pestek winogron",
"olej sezamowy",
"olej ryżowy",
"olej z awokado",
"olej kukurydziany",
"olej arachidowy",
"olej palmowy",
"olej konopny",
"olej sojowy",
"olej dyniowy",
"olej z orzechów włoskich",
],
"Dania gotowe": [
"pizza",
"hamburger",
"hot dog",
"zupa",
"gulasz",
"pierogi ruskie",
"pierogi z mięsem",
"lasagne",
"sałatka warzywna",
"kanapka",
"wrap",
"tortilla",
"zapiekanka",
"sushi",
"falafel",
"kebab",
"pyzy",
"kluski śląskie",
"kotlet schabowy",
"gołąbki",
],
}
produkty = []
for category, names in CATEGORIES.items():
for name in names:
produkty.append((category, name.lower().strip()))
print(f"Przygotowano {len(produkty)} produktów do dodania.")
with app.app_context():
dodane = 0
for category, name in produkty:
full_name = f"{category}: {name}"
if not SuggestedProduct.query.filter_by(name=full_name).first():
prod = SuggestedProduct(name=full_name)
db.session.add(prod)
dodane += 1
db.session.commit()
print(f"Dodano {dodane} produktów do bazy.")

View File

@@ -0,0 +1,47 @@
import os
from datetime import datetime
from app import db, app, Receipt
def extract_list_id(filename):
if filename.startswith("list_"):
parts = filename.split("_", 2)
if len(parts) >= 2 and parts[1].isdigit():
return int(parts[1])
return None
def migrate_missing_receipts():
with app.app_context():
folder = app.config["UPLOAD_FOLDER"]
files = os.listdir(folder)
added = 0
skipped = 0
for file in files:
if not file.endswith(".webp"):
continue
list_id = extract_list_id(file)
if list_id is None:
print(f"Pominięto (brak list_id): {file}")
continue
exists = Receipt.query.filter_by(list_id=list_id, filename=file).first()
if exists:
skipped += 1
continue
new_receipt = Receipt(
list_id=list_id, filename=file, uploaded_at=datetime.utcnow()
)
db.session.add(new_receipt)
added += 1
print(f"📄 {file} dodany do Receipt (list_id={list_id})")
db.session.commit()
print(f"\n✅ Dodano: {added}, pominięto (już były): {skipped}")
if __name__ == "__main__":
migrate_missing_receipts()

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 pgsql-db psql -U lista -d lista
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,61 @@
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
from sqlalchemy import create_engine, MetaData
from sqlalchemy.orm import sessionmaker
from config import Config
from dotenv import load_dotenv
load_dotenv()
# Źródło: SQLite
sqlite_engine = create_engine("sqlite:///instance/shopping.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"\n➡️ 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 = ["user", "shopping_list", "item", "expense", "receipt", "suggested_product"]
for table in tables:
migrate_table(table)
print("\n🎉 Migracja zakończona pomyślnie.")
if __name__ == "__main__":
main()

83
_tools/migrate_to_webp.py Normal file
View File

@@ -0,0 +1,83 @@
import os
from datetime import datetime
from PIL import Image
from app import db, app, Receipt
ALLOWED_EXTS = ("jpg", "jpeg", "png", "gif", "heic")
UPLOAD_FOLDER = None
def convert_to_webp(input_path, output_path):
try:
image = Image.open(input_path).convert("RGB")
image.save(output_path, "WEBP", quality=85)
return True
except Exception as e:
print(f"Błąd konwersji {input_path}: {e}")
return False
def extract_list_id(filename):
if filename.startswith("list_"):
parts = filename.split("_", 2)
if len(parts) >= 2 and parts[1].isdigit():
return int(parts[1])
return None
def migrate():
global UPLOAD_FOLDER
with app.app_context():
UPLOAD_FOLDER = app.config["UPLOAD_FOLDER"]
files = os.listdir(UPLOAD_FOLDER)
created = 0
skipped = 0
existing = 0
for file in files:
ext = file.rsplit(".", 1)[-1].lower()
if ext not in ALLOWED_EXTS:
continue
list_id = extract_list_id(file)
if list_id is None:
print(f"Pominięto (brak list_id): {file}")
continue
src_path = os.path.join(UPLOAD_FOLDER, file)
base = os.path.splitext(file)[0]
webp_filename = base + ".webp"
dst_path = os.path.join(UPLOAD_FOLDER, webp_filename)
if os.path.exists(dst_path):
print(f"Pominięto (webp istnieje): {webp_filename}")
skipped += 1
continue
if convert_to_webp(src_path, dst_path):
os.remove(src_path)
r = Receipt.query.filter_by(
list_id=list_id, filename=webp_filename
).first()
if r:
print(f"Już istnieje w Receipt: {webp_filename}")
existing += 1
continue
new_receipt = Receipt(
list_id=list_id,
filename=webp_filename,
uploaded_at=datetime.utcnow(),
)
db.session.add(new_receipt)
created += 1
print(f"{file}{webp_filename} + zapis do Receipt")
db.session.commit()
print(f"\nNowe wpisy: {created}")
print(f"Pominięte (webp istniało): {skipped}")
print(f"Duplikaty w bazie: {existing}")
if __name__ == "__main__":
migrate()

View File

@@ -0,0 +1,44 @@
import os
from datetime import datetime
from app import app, db, Receipt
def update_missing_receipt_fields():
with app.app_context():
folder = app.config["UPLOAD_FOLDER"]
updated = 0
receipts = Receipt.query.filter(
(Receipt.filesize == None)
| (Receipt.filesize == 0)
| (Receipt.uploaded_at == None)
).all()
for r in receipts:
path = os.path.join(folder, r.filename)
if not os.path.exists(path):
print(f"Brak pliku: {r.filename}")
continue
changed = False
if not r.filesize:
r.filesize = os.path.getsize(path)
changed = True
print(f"{r.filename} → filesize: {r.filesize} B")
if not r.uploaded_at:
timestamp = os.path.getmtime(path)
r.uploaded_at = datetime.fromtimestamp(timestamp)
changed = True
print(f"{r.filename} → uploaded_at: {r.uploaded_at}")
if changed:
updated += 1
db.session.commit()
print(f"\nZaktualizowano {updated} rekordów.")
if __name__ == "__main__":
update_missing_receipt_fields()

View File

@@ -1,87 +0,0 @@
import urllib.request
import json
from app import db, SuggestedProduct, app
CATEGORIES = {
"Przyprawa": [
"przyprawa", "pieprz", "sól", "bazylia", "oregano", "papryka", "majeranek", "czosnek",
"tymianek", "rozmaryn", "kolendra", "curry", "imbir", "goździki", "chili", "koper",
"kminek", "liść laurowy", "ziele angielskie", "kurkuma", "musztarda", "chrzan"
],
"Mięso": [
"kurczak", "piersi z kurczaka", "udka z kurczaka", "wołowina", "mielona wołowina",
"wieprzowina", "schab", "łopatka", "szynka", "boczek", "indyk", "filet z indyka",
"gulasz", "pasztet", "karkówka", "żeberka", "kiełbasa", "parówki", "salami", "kabanos"
],
"Ryba i owoce morza": [
"łosoś", "dorsz", "mintaj", "makrela", "pstrąg", "karp", "śledź", "tuńczyk",
"morszczuk", "sardynka", "szproty", "anchois", "tilapia", "sandacz", "halibut",
"sum", "flądra", "ostrobok", "paluszki rybne", "konserwa rybna"
],
"Nabiał": [
"mleko", "jogurt", "ser żółty", "ser biały", "twaróg", "śmietana", "masło",
"kefir", "maślanka", "serek wiejski", "serek topiony", "mozzarella", "feta",
"parmezan", "gouda", "emmental", "ser pleśniowy", "ser homogenizowany",
"serek mascarpone", "ser ricotta"
],
"Warzywo": [
"pomidor", "ogórek", "marchew", "cebula", "sałata", "papryka", "ziemniak",
"kapusta", "brokuł", "kalafior", "cukinia", "bakłażan", "szpinak", "rukola",
"seler", "por", "burak", "dynia", "rzodkiewka", "fasola"
],
"Owoc": [
"jabłko", "banan", "gruszka", "truskawka", "winogrono", "malina", "borówka",
"czereśnia", "wiśnia", "brzoskwinia", "nektaryna", "śliwka", "ananas",
"mango", "kiwi", "cytryna", "limonka", "pomarańcza", "mandarynka", "grejpfrut"
],
"Pieczywo i zboża": [
"chleb", "bułka", "bagietka", "kajzerka", "pumpernikiel", "chleb razowy",
"chleb żytni", "tost", "grahamka", "croissant", "tortilla", "pizza",
"pierogi", "ryż", "makaron", "kasza jaglana", "kasza gryczana", "owsianka",
"płatki kukurydziane", "musli"
],
"Słodycze i przekąski": [
"czekolada", "baton", "ciastko", "wafel", "lody", "cukierek", "żelki",
"herbatnik", "paluszki", "chipsy", "orzeszki", "popcorn", "krakersy",
"ciasto", "muffin", "pączek", "drożdżówka", "babeczka", "piernik", "beza"
],
"Napoje": [
"woda", "sok jabłkowy", "sok pomarańczowy", "sok multiwitamina", "cola",
"pepsi", "napój gazowany", "kawa", "herbata", "piwo", "wino czerwone",
"wino białe", "tonik", "lemoniada", "napój izotoniczny", "kompot",
"napój mleczny", "maślanka pitna", "koktajl owocowy", "nektar"
],
"Tłuszcze i oleje": [
"oliwa", "olej rzepakowy", "olej słonecznikowy", "masło klarowane",
"margaryna", "smalec", "masło orzechowe", "tłuszcz kokosowy",
"olej lniany", "olej z pestek winogron", "olej sezamowy",
"olej ryżowy", "olej z awokado", "olej kukurydziany", "olej arachidowy",
"olej palmowy", "olej konopny", "olej sojowy", "olej dyniowy", "olej z orzechów włoskich"
],
"Dania gotowe": [
"pizza", "hamburger", "hot dog", "zupa", "gulasz", "pierogi ruskie",
"pierogi z mięsem", "lasagne", "sałatka warzywna", "kanapka",
"wrap", "tortilla", "zapiekanka", "sushi", "falafel", "kebab",
"pyzy", "kluski śląskie", "kotlet schabowy", "gołąbki"
]
}
produkty = []
for category, names in CATEGORIES.items():
for name in names:
produkty.append((category, name.lower().strip()))
print(f"Przygotowano {len(produkty)} produktów do dodania.")
with app.app_context():
dodane = 0
for category, name in produkty:
full_name = f"{category}: {name}"
if not SuggestedProduct.query.filter_by(name=full_name).first():
prod = SuggestedProduct(name=full_name)
db.session.add(prod)
dodane += 1
db.session.commit()
print(f"Dodano {dodane} produktów do bazy.")

View File

@@ -37,3 +37,17 @@ ALTER TABLE item ADD COLUMN not_purchased BOOLEAN DEFAULT 0;
# funkcja sortowania
ALTER TABLE item ADD COLUMN position INTEGER DEFAULT 0;
# migracja paragonów do nowej tabeli
CREATE TABLE receipt (
id INTEGER PRIMARY KEY AUTOINCREMENT,
list_id INTEGER NOT NULL,
filename TEXT NOT NULL,
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (list_id) REFERENCES shopping_list(id)
);
ALTER TABLE receipt ADD COLUMN filesize INTEGER;
# unikanie identycznych plikow
ALTER TABLE receipt ADD COLUMN file_hash TEXT

684
app.py
View File

@@ -6,6 +6,10 @@ import mimetypes
import sys
import platform
import psutil
import hashlib
import re
from pillow_heif import register_heif_opener
from datetime import datetime, timedelta, UTC, timezone
@@ -38,17 +42,24 @@ from flask_compress import Compress
from flask_socketio import SocketIO, emit, join_room
from werkzeug.security import generate_password_hash, check_password_hash
from config import Config
from PIL import Image
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
from sqlalchemy import func, extract, inspect
from collections import defaultdict, deque
from functools import wraps
# OCR
from collections import Counter
import pytesseract
from pytesseract import Output
app = Flask(__name__)
app.config.from_object(Config)
register_heif_opener() # pillow_heif dla HEIC
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp", "heic"}
SQLALCHEMY_ECHO = True
SYSTEM_PASSWORD = app.config.get("SYSTEM_PASSWORD", "changeme")
@@ -89,10 +100,13 @@ def utcnow():
return datetime.now(timezone.utc)
app_start_time = utcnow()
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(150), unique=True, nullable=False)
password_hash = db.Column(db.String(150), nullable=False)
password_hash = db.Column(db.String(512), nullable=False)
is_admin = db.Column(db.Boolean, default=False)
@@ -141,9 +155,18 @@ class Expense(db.Model):
list = db.relationship("ShoppingList", backref="expenses", lazy=True)
class Receipt(db.Model):
id = db.Column(db.Integer, primary_key=True)
list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id"), nullable=False)
filename = db.Column(db.String(255), nullable=False)
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
shopping_list = db.relationship("ShoppingList", backref="receipts", lazy=True)
filesize = db.Column(db.Integer, nullable=True)
file_hash = db.Column(db.String(64), nullable=True, unique=True)
with app.app_context():
db.create_all()
from werkzeug.security import generate_password_hash
admin = User.query.filter_by(is_admin=True).first()
username = app.config.get("DEFAULT_ADMIN_USERNAME", "admin")
@@ -213,16 +236,16 @@ def allowed_file(filename):
def get_list_details(list_id):
shopping_list = ShoppingList.query.get_or_404(list_id)
items = Item.query.filter_by(list_id=list_id).order_by(Item.position.asc()).all()
receipt_pattern = f"list_{list_id}"
all_files = os.listdir(app.config["UPLOAD_FOLDER"])
receipt_files = [f for f in all_files if receipt_pattern in f]
expenses = Expense.query.filter_by(list_id=list_id).all()
total_expense = sum(e.amount for e in expenses)
receipts = Receipt.query.filter_by(list_id=list_id).all()
receipt_files = [r.filename for r in receipts]
return shopping_list, items, receipt_files, expenses, total_expense
def generate_share_token(length=8):
"""Generuje token do udostępniania. Parametr `length` to liczba znaków (domyślnie 4)."""
return secrets.token_hex(length // 2)
@@ -242,10 +265,31 @@ def enrich_list_data(l):
return l
def save_resized_image(file, path: str, max_size=(2000, 2000)):
img = Image.open(file)
img.thumbnail(max_size)
img.save(path)
def save_resized_image(file, path):
try:
# Otwórz i sprawdź poprawność pliku
image = Image.open(file)
image.verify()
file.seek(0)
image = Image.open(file)
except Exception:
raise ValueError("Nieprawidłowy plik graficzny")
try:
# Automatyczna rotacja według EXIF (np. zdjęcia z telefonu)
image = ImageOps.exif_transpose(image)
except Exception:
pass # ignorujemy, jeśli EXIF jest uszkodzony lub brak
try:
image.thumbnail((2000, 2000))
image = image.convert("RGB")
image.info.clear()
new_path = path.rsplit(".", 1)[0] + ".webp"
image.save(new_path, format="WEBP", quality=100, method=0)
except Exception as e:
raise ValueError(f"Błąd podczas przetwarzania obrazu: {e}")
def redirect_with_flash(
@@ -284,6 +328,174 @@ def delete_receipts_for_list(list_id):
print(f"Nie udało się usunąć pliku {filename}: {e}")
def _receipt_error(message):
if request.is_json or request.headers.get("X-Requested-With") == "XMLHttpRequest":
return jsonify({"success": False, "error": message}), 400
flash(message, "danger")
return redirect(request.referrer or url_for("main_page"))
def rotate_receipt_by_id(receipt_id):
receipt = Receipt.query.get_or_404(receipt_id)
old_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename)
if not os.path.exists(old_path):
raise FileNotFoundError("Plik nie istnieje")
image = Image.open(old_path)
rotated = image.rotate(-90, expand=True)
new_filename = generate_new_receipt_filename(receipt.list_id)
new_path = os.path.join(app.config["UPLOAD_FOLDER"], new_filename)
rotated.save(new_path, format="WEBP", quality=100)
os.remove(old_path)
receipt.filename = new_filename
db.session.commit()
return receipt
def delete_receipt_by_id(receipt_id):
receipt = Receipt.query.get_or_404(receipt_id)
filepath = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename)
if os.path.exists(filepath):
os.remove(filepath)
db.session.delete(receipt)
db.session.commit()
return receipt
def generate_new_receipt_filename(list_id):
timestamp = datetime.now().strftime("%Y%m%d_%H%M")
random_part = secrets.token_hex(3)
return f"list_{list_id}_{timestamp}_{random_part}.webp"
############# OCR ###########################
def preprocess_image_for_tesseract(image):
image = ImageOps.autocontrast(image)
image = image.point(lambda x: 0 if x < 150 else 255) # mocniejsza binarizacja
image = image.resize(
(image.width * 2, image.height * 2), Image.BICUBIC
) # większe powiększenie
return 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(
r"""
\b(
[5s]u[mn][aąo0]? |
razem |
zap[łl][aąo0]ty |
do\s+zap[łl][aąo0]ty |
kwota |
płatno[śćs] |
warto[śćs] |
total |
amount
)\b
""",
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)
for match in matches:
try:
val = float(match.replace(" ", "").replace(",", "."))
if 0.1 <= val <= 100000:
candidates.append((val, line))
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
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())
]
if preferred:
max_val = max(preferred, key=lambda x: x[0])[0]
return round(max_val, 2), lines
if candidates:
max_val = max([val for val, _ in candidates])
return round(max_val, 2), lines
data = pytesseract.image_to_data(
image, lang="pol", config="--psm 4", output_type=Output.DICT
)
font_candidates = []
for i in range(len(data["text"])):
word = data["text"][i].strip()
if not 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
if font_candidates:
best = max(font_candidates, key=lambda x: x[1])
return round(best[0], 2), lines
return 0.0, lines
############# END OCR #######################
# zabezpieczenie logowani do systemu - błędne hasła
def is_ip_blocked(ip):
now = time.time()
@@ -425,7 +637,11 @@ def forbidden(e):
"errors.html",
code=403,
title="Brak dostępu",
message="Nie masz uprawnień do wyświetlenia tej strony.",
message=(
e.description
if e.description
else "Nie masz uprawnień do wyświetlenia tej strony."
),
),
403,
)
@@ -566,12 +782,18 @@ def toggle_archive_list(list_id):
@app.route("/edit_my_list/<int:list_id>", methods=["GET", "POST"])
@login_required
def edit_my_list(list_id):
receipts = (
Receipt.query.filter_by(list_id=list_id)
.order_by(Receipt.uploaded_at.desc())
.all()
)
l = db.session.get(ShoppingList, list_id)
if l is None:
abort(404)
if l.owner_id != current_user.id:
return redirect_with_flash("Nie masz uprawnień do tej listy", "danger")
abort(403, description="Nie jesteś właścicielem tej listy.")
if request.method == "POST":
new_title = request.form.get("title", "").strip()
@@ -608,12 +830,17 @@ def edit_my_list(list_id):
flash("Zaktualizowano dane listy", "success")
return redirect(url_for("main_page"))
return render_template("edit_my_list.html", list=l)
return render_template("edit_my_list.html", list=l, receipts=receipts)
@app.route("/delete_user_list/<int:list_id>", methods=["POST"])
@login_required
def delete_user_list(list_id):
l = db.session.get(ShoppingList, list_id)
if l is None or l.owner_id != current_user.id:
abort(403, description="Nie jesteś właścicielem tej listy.")
l = db.session.get(ShoppingList, list_id)
if l is None or l.owner_id != current_user.id:
abort(403)
@@ -864,9 +1091,11 @@ def all_products():
top_products_query = top_products_query.filter(
SuggestedProduct.name.ilike(f"%{query}%")
)
top_products = (
top_products_query.order_by(
SuggestedProduct.usage_count.desc(), SuggestedProduct.name.asc()
SuggestedProduct.name.asc(), # musi być pierwsze
SuggestedProduct.usage_count.desc(),
)
.distinct(SuggestedProduct.name)
.limit(20)
@@ -896,77 +1125,67 @@ def all_products():
return {"allproducts": unique_names}
""" @app.route('/upload_receipt/<int:list_id>', methods=['POST'])
def upload_receipt(list_id):
if 'receipt' not in request.files:
flash('Brak pliku', 'danger')
return redirect(request.referrer)
file = request.files['receipt']
if file.filename == '':
flash('Nie wybrano pliku', 'danger')
return redirect(request.referrer)
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
file_path = os.path.join(app.config['UPLOAD_FOLDER'], f"list_{list_id}_{filename}")
save_resized_image(file, file_path)
flash('Wgrano paragon', 'success')
return redirect(request.referrer)
flash('Niedozwolony format pliku', 'danger')
return redirect(request.referrer) """
@app.route("/upload_receipt/<int:list_id>", methods=["POST"])
@login_required
def upload_receipt(list_id):
l = db.session.get(ShoppingList, list_id)
# if l is None or l.owner_id != current_user.id:
# return _receipt_error("Nie masz uprawnień do tej listy.")
if "receipt" not in request.files:
if (
request.is_json
or request.headers.get("X-Requested-With") == "XMLHttpRequest"
):
return jsonify({"success": False, "message": "Brak pliku"}), 400
flash("Brak pliku", "danger")
return redirect(request.referrer)
return _receipt_error("Brak pliku")
file = request.files["receipt"]
if file.filename == "":
if (
request.is_json
or request.headers.get("X-Requested-With") == "XMLHttpRequest"
):
return jsonify({"success": False, "message": "Nie wybrano pliku"}), 400
flash("Nie wybrano pliku", "danger")
return redirect(request.referrer)
return _receipt_error("Nie wybrano pliku")
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
full_filename = f"list_{list_id}_{filename}"
file_path = os.path.join(app.config["UPLOAD_FOLDER"], full_filename)
file_bytes = file.read()
file.seek(0)
file_hash = hashlib.sha256(file_bytes).hexdigest()
save_resized_image(file, file_path)
existing = Receipt.query.filter_by(file_hash=file_hash).first()
if existing:
return _receipt_error("Taki plik już istnieje")
now = datetime.now(timezone.utc)
timestamp = now.strftime("%Y%m%d_%H%M")
random_part = secrets.token_hex(3)
webp_filename = f"list_{list_id}_{timestamp}_{random_part}.webp"
file_path = os.path.join(app.config["UPLOAD_FOLDER"], webp_filename)
try:
save_resized_image(file, file_path)
except ValueError as e:
return _receipt_error(str(e))
filesize = os.path.getsize(file_path) if os.path.exists(file_path) else None
uploaded_at = datetime.now(timezone.utc)
new_receipt = Receipt(
list_id=list_id,
filename=webp_filename,
filesize=filesize,
uploaded_at=uploaded_at,
file_hash=file_hash,
)
db.session.add(new_receipt)
db.session.commit()
if (
request.is_json
or request.headers.get("X-Requested-With") == "XMLHttpRequest"
):
url = url_for("uploaded_file", filename=full_filename)
url = url_for("uploaded_file", filename=webp_filename)
socketio.emit("receipt_added", {"url": url}, to=str(list_id))
return jsonify({"success": True, "url": url})
flash("Wgrano paragon", "success")
return redirect(request.referrer)
return redirect(request.referrer or url_for("main_page"))
if request.is_json or request.headers.get("X-Requested-With") == "XMLHttpRequest":
return jsonify({"success": False, "message": "Niedozwolony format pliku"}), 400
flash("Niedozwolony format pliku", "danger")
return redirect(request.referrer)
return _receipt_error("Niedozwolony format pliku")
@app.route("/uploads/<filename>")
@@ -1001,6 +1220,95 @@ def reorder_items():
return jsonify(success=True)
@app.route("/rotate_receipt/<int:receipt_id>")
@login_required
def rotate_receipt_user(receipt_id):
receipt = Receipt.query.get_or_404(receipt_id)
list_obj = ShoppingList.query.get_or_404(receipt.list_id)
if not (current_user.is_admin or current_user.id == list_obj.owner_id):
flash("Brak uprawnień do tej operacji", "danger")
return redirect(url_for("main_page"))
try:
rotate_receipt_by_id(receipt_id)
flash("Obrócono paragon", "success")
except FileNotFoundError:
flash("Plik nie istnieje", "danger")
except Exception as e:
flash(f"Błąd przy obracaniu: {str(e)}", "danger")
return redirect(request.referrer or url_for("main_page"))
@app.route("/delete_receipt/<int:receipt_id>")
@login_required
def delete_receipt_user(receipt_id):
receipt = Receipt.query.get_or_404(receipt_id)
list_obj = ShoppingList.query.get_or_404(receipt.list_id)
if not (current_user.is_admin or current_user.id == list_obj.owner_id):
flash("Brak uprawnień do tej operacji", "danger")
return redirect(url_for("main_page"))
try:
delete_receipt_by_id(receipt_id)
flash("Paragon usunięty", "success")
except Exception as e:
flash(f"Błąd przy usuwaniu pliku: {str(e)}", "danger")
return redirect(request.referrer or url_for("main_page"))
# OCR
@app.route("/lists/<int:list_id>/analyze", methods=["POST"])
@login_required
def analyze_receipts_for_list(list_id):
receipt_objs = Receipt.query.filter_by(list_id=list_id).all()
existing_expenses = {
e.receipt_filename
for e in Expense.query.filter_by(list_id=list_id).all()
if e.receipt_filename
}
results = []
total = 0.0
for receipt in receipt_objs:
filepath = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename)
if not os.path.exists(filepath):
continue
try:
raw_image = Image.open(filepath).convert("RGB")
image = preprocess_image_for_tesseract(raw_image)
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 = []
already_added = receipt.filename in existing_expenses
results.append(
{
"id": receipt.id,
"filename": receipt.filename,
"amount": round(value, 2),
"debug_text": lines,
"already_added": already_added,
}
)
if not already_added:
total += value
return jsonify({"results": results, "total": round(total, 2)})
@app.route("/admin")
@login_required
@admin_required
@@ -1080,6 +1388,32 @@ def admin_panel():
process = psutil.Process(os.getpid())
app_mem = process.memory_info().rss // (1024 * 1024) # MB
# Engine info
db_engine = db.engine
db_info = {
"engine": db_engine.name,
"version": getattr(db_engine.dialect, "server_version_info", None),
"url": str(db_engine.url).split("?")[0],
}
# Tabele
inspector = inspect(db_engine)
table_count = len(inspector.get_table_names())
# Rekordy (szybkie zliczenie)
record_total = (
db.session.query(func.count(User.id)).scalar()
+ db.session.query(func.count(ShoppingList.id)).scalar()
+ db.session.query(func.count(Item.id)).scalar()
+ db.session.query(func.count(Receipt.id)).scalar()
+ db.session.query(func.count(Expense.id)).scalar()
)
# Uptime
uptime_minutes = int(
(datetime.now(timezone.utc) - app_start_time).total_seconds() // 60
)
return render_template(
"admin/admin_panel.html",
user_count=user_count,
@@ -1095,6 +1429,10 @@ def admin_panel():
python_version=sys.version,
system_info=platform.platform(),
app_memory=f"{app_mem} MB",
db_info=db_info,
table_count=table_count,
record_total=record_total,
uptime_minutes=uptime_minutes,
)
@@ -1194,42 +1532,100 @@ def delete_user(user_id):
@login_required
@admin_required
def admin_receipts(id):
all_files = os.listdir(app.config["UPLOAD_FOLDER"])
image_files = [f for f in all_files if allowed_file(f)]
if id == "all":
filtered_files = image_files
else:
try:
try:
if id == "all":
receipts = Receipt.query.order_by(Receipt.uploaded_at.desc()).all()
else:
list_id = int(id)
receipt_prefix = f"list_{list_id}_"
filtered_files = [f for f in image_files if f.startswith(receipt_prefix)]
except ValueError:
flash("Nieprawidłowe ID listy.", "danger")
return redirect(url_for("admin_panel"))
receipts = (
Receipt.query.filter_by(list_id=list_id)
.order_by(Receipt.uploaded_at.desc())
.all()
)
except ValueError:
flash("Nieprawidłowe ID listy.", "danger")
return redirect(url_for("admin_panel"))
return render_template(
"admin/receipts.html",
image_files=filtered_files,
upload_folder=app.config["UPLOAD_FOLDER"],
)
return render_template("admin/receipts.html", receipts=receipts)
@app.route("/admin/delete_receipt/<filename>")
@app.route("/admin/rotate_receipt/<int:receipt_id>")
@login_required
@admin_required
def delete_receipt(filename):
file_path = os.path.join(app.config["UPLOAD_FOLDER"], filename)
if os.path.exists(file_path):
os.remove(file_path)
flash("Plik usunięty", "success")
else:
def rotate_receipt(receipt_id):
try:
rotate_receipt_by_id(receipt_id)
flash("Obrócono paragon", "success")
except FileNotFoundError:
flash("Plik nie istnieje", "danger")
except Exception as e:
flash(f"Błąd przy obracaniu: {str(e)}", "danger")
next_url = request.args.get("next")
if next_url:
return redirect(next_url)
return redirect(url_for("admin_receipts"))
return redirect(request.referrer or url_for("admin_receipts", id="all"))
@app.route("/admin/delete_receipt/<int:receipt_id>")
@login_required
@admin_required
def delete_receipt(receipt_id):
try:
delete_receipt_by_id(receipt_id)
flash("Paragon usunięty", "success")
except Exception as e:
flash(f"Błąd przy usuwaniu pliku: {str(e)}", "danger")
return redirect(request.referrer or url_for("admin_receipts", id="all"))
@app.route("/admin/rename_receipt/<int:receipt_id>")
@login_required
@admin_required
def rename_receipt(receipt_id):
receipt = Receipt.query.get_or_404(receipt_id)
old_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename)
if not os.path.exists(old_path):
flash("Plik nie istnieje", "danger")
return redirect(request.referrer)
new_filename = generate_new_receipt_filename(receipt.list_id)
new_path = os.path.join(app.config["UPLOAD_FOLDER"], new_filename)
try:
os.rename(old_path, new_path)
receipt.filename = new_filename
db.session.commit()
flash("Zmieniono nazwę pliku", "success")
except Exception as e:
flash(f"Błąd przy zmianie nazwy: {str(e)}", "danger")
return redirect(request.referrer or url_for("admin_receipts", id="all"))
@app.route("/admin/generate_receipt_hash/<int:receipt_id>")
@login_required
@admin_required
def generate_receipt_hash(receipt_id):
receipt = Receipt.query.get_or_404(receipt_id)
if receipt.file_hash:
flash("Hash już istnieje", "info")
return redirect(request.referrer)
file_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename)
if not os.path.exists(file_path):
flash("Plik nie istnieje", "danger")
return redirect(request.referrer)
try:
with open(file_path, "rb") as f:
file_hash = hashlib.sha256(f.read()).hexdigest()
receipt.file_hash = file_hash
db.session.commit()
flash("Hash wygenerowany", "success")
except Exception as e:
flash(f"Błąd przy generowaniu hasha: {e}", "danger")
return redirect(request.referrer)
@app.route("/admin/delete_selected_lists", methods=["POST"])
@@ -1267,9 +1663,11 @@ def edit_list(list_id):
db.session.query(Item).filter_by(list_id=list_id).order_by(Item.id.desc()).all()
)
receipt_pattern = f"list_{list_id}_"
all_files = os.listdir(app.config["UPLOAD_FOLDER"])
receipts = [f for f in all_files if f.startswith(receipt_pattern)]
receipts = (
Receipt.query.filter_by(list_id=list_id)
.order_by(Receipt.uploaded_at.desc())
.all()
)
if request.method == "POST":
action = request.form.get("action")
@@ -1412,6 +1810,21 @@ def edit_list(list_id):
flash("Nie znaleziono produktu", "danger")
return redirect(url_for("edit_list", list_id=list_id))
elif action == "edit_quantity":
item = db.session.get(Item, request.form.get("item_id"))
if item and item.list_id == list_id:
try:
new_quantity = int(request.form.get("quantity"))
if new_quantity > 0:
item.quantity = new_quantity
db.session.commit()
flash("Zmieniono ilość produktu", "success")
except ValueError:
flash("Nieprawidłowa ilość", "danger")
else:
flash("Nie znaleziono produktu", "danger")
return redirect(url_for("edit_list", list_id=list_id))
return render_template(
"admin/edit_list.html",
list=l,
@@ -1635,6 +2048,65 @@ def demote_user(user_id):
return redirect(url_for("list_users"))
@app.route("/admin/crop_receipt", methods=["POST"])
@login_required
@admin_required
def crop_receipt():
receipt_id = request.form.get("receipt_id")
file = request.files.get("cropped_image")
if not receipt_id or not file:
return jsonify(success=False, error="Brak danych")
receipt = Receipt.query.get_or_404(receipt_id)
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)
if os.path.exists(old_path):
os.remove(old_path)
receipt.filename = new_filename
db.session.commit()
return jsonify(success=True)
except Exception as e:
return jsonify(success=False, error=str(e))
@app.route("/admin/recalculate_filesizes")
@login_required
@admin_required
def recalculate_filesizes():
updated = 0
not_found = 0
unchanged = 0
receipts = Receipt.query.all()
for r in receipts:
filepath = os.path.join(app.config["UPLOAD_FOLDER"], r.filename)
if os.path.exists(filepath):
real_size = os.path.getsize(filepath)
if r.filesize != real_size:
r.filesize = real_size
updated += 1
else:
unchanged += 1
else:
not_found += 1
db.session.commit()
flash(
f"Zaktualizowano: {updated}, bez zmian: {unchanged}, brak pliku: {not_found}",
"success",
)
return redirect(url_for("admin_receipts", id="all"))
@app.route("/healthcheck")
def healthcheck():
header_token = request.headers.get("X-Internal-Check")
@@ -1907,8 +2379,18 @@ def handle_update_note(data):
def handle_add_expense(data):
list_id = data["list_id"]
amount = data["amount"]
receipt_filename = data.get("receipt_filename")
if receipt_filename:
existing = Expense.query.filter_by(
list_id=list_id, receipt_filename=receipt_filename
).first()
if existing:
return
new_expense = Expense(
list_id=list_id, amount=amount, receipt_filename=receipt_filename
)
new_expense = Expense(list_id=list_id, amount=amount)
db.session.add(new_expense)
db.session.commit()

View File

@@ -1,14 +1,32 @@
import os
basedir = os.path.abspath(os.path.dirname(__file__))
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY', 'D8pceNZ8q%YR7^7F&9wAC2')
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', 'sqlite:///shopping.db')
SECRET_KEY = os.environ.get("SECRET_KEY", "D8pceNZ8q%YR7^7F&9wAC2")
DB_ENGINE = os.environ.get("DB_ENGINE", "sqlite").lower()
if DB_ENGINE == "sqlite":
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(basedir, 'instance', 'shopping.db')}"
elif DB_ENGINE == "pgsql":
SQLALCHEMY_DATABASE_URI = f"postgresql://{os.environ['DB_USER']}:{os.environ['DB_PASSWORD']}@{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']}@{os.environ['DB_HOST']}:{os.environ.get('DB_PORT', 3306)}/{os.environ['DB_NAME']}"
else:
raise ValueError("Nieobsługiwany typ bazy danych.")
SQLALCHEMY_TRACK_MODIFICATIONS = False
SYSTEM_PASSWORD = os.environ.get('SYSTEM_PASSWORD', 'admin')
DEFAULT_ADMIN_USERNAME = os.environ.get('DEFAULT_ADMIN_USERNAME', 'admin')
DEFAULT_ADMIN_PASSWORD = os.environ.get('DEFAULT_ADMIN_PASSWORD', 'admin123')
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', 'uploads')
AUTHORIZED_COOKIE_VALUE = os.environ.get('AUTHORIZED_COOKIE_VALUE', 'cookievalue')
AUTH_COOKIE_MAX_AGE = int(os.environ.get('AUTH_COOKIE_MAX_AGE', 86400))
HEALTHCHECK_TOKEN = os.environ.get('HEALTHCHECK_TOKEN', 'alamapsaikota1234')
SESSION_TIMEOUT_MINUTES = int(os.environ.get('SESSION_TIMEOUT_MINUTES', 10080))
SYSTEM_PASSWORD = os.environ.get("SYSTEM_PASSWORD", "admin")
DEFAULT_ADMIN_USERNAME = os.environ.get("DEFAULT_ADMIN_USERNAME", "admin")
DEFAULT_ADMIN_PASSWORD = os.environ.get("DEFAULT_ADMIN_PASSWORD", "admin123")
UPLOAD_FOLDER = os.environ.get("UPLOAD_FOLDER", "uploads")
AUTHORIZED_COOKIE_VALUE = os.environ.get("AUTHORIZED_COOKIE_VALUE", "cookievalue")
try:
AUTH_COOKIE_MAX_AGE = int(os.environ.get("AUTH_COOKIE_MAX_AGE", "86400") or "86400")
except ValueError:
AUTH_COOKIE_MAX_AGE = 86400
HEALTHCHECK_TOKEN = os.environ.get("HEALTHCHECK_TOKEN", "alamapsaikota1234")
try:
SESSION_TIMEOUT_MINUTES = int(os.environ.get("SESSION_TIMEOUT_MINUTES", "10080") or "10080")
except ValueError:
SESSION_TIMEOUT_MINUTES = 10080

View File

@@ -1,13 +1,28 @@
#!/bin/bash
set -e
echo "Zatrzymuję i usuwam stare kontenery..."
docker compose down --rmi all
PROFILE=$1
if [[ -z "$PROFILE" ]]; then
echo "Uzycie: $0 {pgsql|mysql|sqlite}"
exit 1
fi
echo "Zatrzymuje kontenery aplikacji i bazy..."
if [[ "$PROFILE" == "sqlite" ]]; then
docker compose stop
else
docker compose --profile "$PROFILE" stop
fi
echo "Pobieram najnowszy kod z repozytorium..."
git pull
echo "Buduję obrazy i uruchamiam kontenery..."
docker compose up -d --build
echo "Buduje i uruchamiam kontenery..."
if [[ "$PROFILE" == "sqlite" ]]; then
docker compose up -d --build
else
DB_ENGINE="$PROFILE" docker compose --profile "$PROFILE" up -d --build
fi
echo "Gotowe!"

View File

@@ -10,17 +10,41 @@ services:
timeout: 10s
retries: 3
start_period: 10s
environment:
- FLASK_APP=app.py
- FLASK_ENV=production
- SECRET_KEY=${SECRET_KEY}
- SYSTEM_PASSWORD=${SYSTEM_PASSWORD}
- DEFAULT_ADMIN_USERNAME=${DEFAULT_ADMIN_USERNAME}
- DEFAULT_ADMIN_PASSWORD=${DEFAULT_ADMIN_PASSWORD}
- UPLOAD_FOLDER=${UPLOAD_FOLDER}
- AUTHORIZED_COOKIE_VALUE=${AUTHORIZED_COOKIE_VALUE}
- AUTH_COOKIE_MAX_AGE=${AUTH_COOKIE_MAX_AGE}
- HEALTHCHECK_TOKEN=${HEALTHCHECK_TOKEN}
- SESSION_TIMEOUT_MINUTES=${SESSION_TIMEOUT_MINUTES}
env_file:
- .env
volumes:
- .:/app
- ./uploads:/app/uploads
- ./instance:/app/instance
restart: unless-stopped
pgsql:
image: postgres:17
container_name: pgsql-db
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- ./db/pgsql:/var/lib/postgresql/data
#ports:
# - ":5432:5432"
restart: unless-stopped
hostname: db
profiles: ["pgsql"]
mysql:
image: mysql:8
container_name: 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
#ports:
# - "3306:3306"
restart: unless-stopped
hostname: db
profiles: ["mysql"]

View File

@@ -6,4 +6,11 @@ Flask-Compress
eventlet
Werkzeug
Pillow
psutil
psutil
pillow-heif
pytesseract
opencv-python-headless
psycopg2-binary # pgsql
pymysql # mysql
cryptography

View File

@@ -193,20 +193,19 @@ input.form-control {
}
.info-bar-fixed {
position: fixed;
left: 0;
right: 0;
bottom: 0;
width: 100%;
color: #f8f9fa;
background-color: #212529;
border-radius: 12px 12px 0 0;
text-align: center;
padding: 10px 8px;
padding: 10px 10px;
font-size: 0.95rem;
z-index: 9999;
box-sizing: border-box;
margin-top: 2rem;
box-shadow: 0 -1px 4px rgba(0, 0, 0, 0.25);
}
@media (max-width: 768px) {
.info-bar-fixed {
position: static;
@@ -305,4 +304,10 @@ input.form-control {
.fade-out {
opacity: 0;
transition: opacity 0.5s ease;
}
@media (pointer: fine) {
.only-mobile {
display: none !important;
}
}

View File

@@ -1,31 +1,41 @@
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll('.clickable-item').forEach(item => {
item.addEventListener('click', function (e) {
if (!e.target.closest('button') && e.target.tagName.toLowerCase() !== 'input') {
const checkbox = this.querySelector('input[type="checkbox"]');
const itemsContainer = document.getElementById('items');
if (!itemsContainer) return;
if (checkbox.disabled) {
return;
}
itemsContainer.addEventListener('click', function (e) {
const row = e.target.closest('.clickable-item');
if (!row || !itemsContainer.contains(row)) return;
if (checkbox.checked) {
socket.emit('uncheck_item', { item_id: parseInt(this.id.replace('item-', ''), 10) });
} else {
socket.emit('check_item', { item_id: parseInt(this.id.replace('item-', ''), 10) });
}
// Ignoruj kliknięcia w przyciski i inputy
if (e.target.closest('button') || e.target.tagName.toLowerCase() === 'input') {
return;
}
checkbox.disabled = true;
this.classList.add('opacity-50');
const checkbox = row.querySelector('input[type="checkbox"]');
if (!checkbox || checkbox.disabled) {
return;
}
let existingSpinner = this.querySelector('.spinner-border');
if (!existingSpinner) {
const spinner = document.createElement('span');
spinner.className = 'spinner-border spinner-border-sm ms-2';
spinner.setAttribute('role', 'status');
spinner.setAttribute('aria-hidden', 'true');
checkbox.parentElement.appendChild(spinner);
}
}
});
const itemId = parseInt(row.id.replace('item-', ''), 10);
if (isNaN(itemId)) return;
if (checkbox.checked) {
socket.emit('uncheck_item', { item_id: itemId });
} else {
socket.emit('check_item', { item_id: itemId });
}
checkbox.disabled = true;
row.classList.add('opacity-50');
// Dodaj spinner tylko jeśli nie ma
let existingSpinner = row.querySelector('.spinner-border');
if (!existingSpinner) {
const spinner = document.createElement('span');
spinner.className = 'spinner-border spinner-border-sm ms-2';
spinner.setAttribute('role', 'status');
spinner.setAttribute('aria-hidden', 'true');
checkbox.parentElement.appendChild(spinner);
}
});
});

View File

@@ -123,7 +123,7 @@ function copyLink(link) {
if (navigator.share) {
navigator.share({
title: 'Udostępnij link',
text: 'Link do listy::',
text: 'Udostępniam link do listy:',
url: link
}).then(() => {
showToast('Link udostępniony!');
@@ -303,7 +303,7 @@ function updateListSmoothly(newItems) {
// Wewnętrzny HTML
li.innerHTML = `
<div class="d-flex align-items-center gap-3 flex-grow-1">
<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"

View File

@@ -124,11 +124,11 @@ function setupList(listId, username) {
summaryEl.innerHTML = `<b>💸 Łącznie wydano:</b> ${data.total.toFixed(2)} PLN`;
}
showToast(`Dodano wydatek: ${data.amount.toFixed(2)} PLN`);
showToast(`Dodano wydatek: ${data.amount.toFixed(2)} PLN`, 'info');
});
socket.on('item_added', data => {
showToast(`${data.added_by} dodał: ${data.name}`);
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}`;
@@ -138,12 +138,20 @@ function setupList(listId, username) {
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>
<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})">
✏️
@@ -155,27 +163,39 @@ function setupList(listId, username) {
</div>
`;
// góra listy
//document.getElementById('items').prepend(li);
// dół listy
document.getElementById('items').appendChild(li);
toggleEmptyPlaceholder();
// ⏳ 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);
// 🔁 Request listy po 15s
setTimeout(() => {
if (window.LIST_ID) {
socket.emit('request_full_list', { list_id: window.LIST_ID });
}
}, 15000);
});
socket.on('item_deleted', data => {
const li = document.getElementById(`item-${data.item_id}`);
if (li) {
li.remove();
}
showToast('Usunięto produkt');
showToast('Usunięto produkt z listy', 'success');
updateProgressBar();
toggleEmptyPlaceholder();
});
@@ -215,7 +235,7 @@ function setupList(listId, username) {
}
}
}
showToast('Notatka zaktualizowana!');
showToast('Notatka dodana/zaktualizowana', 'success');
});
socket.on('item_edited', data => {
@@ -227,7 +247,7 @@ function setupList(listId, username) {
}
nameSpan.innerHTML = `${data.new_name}${quantityBadge}`;
}
showToast(`Zaktualizowano produkt: ${data.new_name} (x${data.new_quantity})`);
showToast(`Zaktualizowano produkt: ${data.new_name} (x${data.new_quantity})`, 'success');
});
updateProgressBar();

View File

@@ -79,6 +79,7 @@ document.addEventListener('DOMContentLoaded', function () {
btn.onclick = () => {
const quantity = parseInt(qty.value) || 1;
socket.emit('add_item', { list_id: LIST_ID, name: name, quantity: quantity });
socket.emit('request_full_list', { list_id: LIST_ID });
};
li.appendChild(qtyWrapper);

View File

@@ -0,0 +1,99 @@
document.addEventListener("DOMContentLoaded", () => {
const analyzeBtn = document.getElementById("analyzeBtn");
if (analyzeBtn) {
analyzeBtn.addEventListener("click", () => analyzeReceipts(LIST_ID));
}
});
async function analyzeReceipts(listId) {
const resultsDiv = document.getElementById("analysisResults");
resultsDiv.innerHTML = `
<div class="text-info d-flex align-items-center gap-2">
<div class="spinner-border spinner-border-sm text-info" role="status"></div>
<span>Trwa analiza paragonów...</span>
</div>`;
const start = performance.now();
try {
const res = await fetch(`/lists/${listId}/analyze`, { method: "POST" });
const data = await res.json();
const duration = ((performance.now() - start) / 1000).toFixed(2);
let html = `<div class="card bg-dark text-white border-secondary p-3">`;
html += `<p><b>📊 Łącznie wykryto:</b> ${data.total.toFixed(2)} PLN</p>`;
html += `<p class="text-secondary"><small>⏱ Czas analizy OCR: ${duration} sek.</small></p>`;
data.results.forEach((r, i) => {
const disabled = r.already_added ? "disabled" : "";
const inputStyle = "form-control d-inline-block bg-dark text-white border-light rounded";
const inputField = `<input type="number" id="amount-${i}" value="${r.amount}" step="0.01" class="${inputStyle}" style="width: 120px;" ${disabled}>`;
const button = r.already_added
? `<span class="badge bg-primary ms-2">✅ Dodano</span>`
: `<button id="add-btn-${i}" onclick="emitExpense(${i})" class="btn btn-sm btn-outline-success ms-2"> Dodaj</button>`;
html += `
<div class="mb-2 d-flex align-items-center gap-2 flex-wrap">
<span class="text-light flex-grow-1">${r.filename}</span>
${inputField}
${button}
</div>`;
});
if (data.results.length > 1) {
html += `<button id="addAllBtn" onclick="emitAllExpenses(${data.results.length})" class="btn btn-success mt-3 w-100"> Dodaj wszystkie</button>`;
}
html += `</div>`;
resultsDiv.innerHTML = html;
window._ocr_results = data.results;
} catch (err) {
resultsDiv.innerHTML = `<div class="text-danger">❌ Wystąpił błąd podczas analizy.</div>`;
console.error(err);
}
}
function emitExpense(i) {
const r = window._ocr_results[i];
const val = parseFloat(document.getElementById(`amount-${i}`).value);
const btn = document.getElementById(`add-btn-${i}`);
if (!isNaN(val) && val > 0) {
socket.emit('add_expense', {
list_id: LIST_ID,
amount: val,
receipt_filename: r.filename
});
document.getElementById(`amount-${i}`).disabled = true;
if (btn) {
btn.disabled = true;
btn.classList.remove('btn-outline-success');
btn.classList.add('btn-success');
btn.textContent = '✅ Dodano';
}
}
}
function emitAllExpenses(n) {
const btnAll = document.getElementById('addAllBtn');
if (btnAll) {
btnAll.disabled = true;
btnAll.innerHTML = `<span class="spinner-border spinner-border-sm me-2" role="status"></span>Dodawanie...`;
}
for (let i = 0; i < n; i++) {
setTimeout(() => emitExpense(i), i * 150);
}
setTimeout(() => {
if (btnAll) {
btnAll.innerHTML = '✅ Wszystko dodano';
btnAll.classList.remove('btn-success');
btnAll.classList.add('btn-outline-success');
}
}, n * 150 + 300);
}

63
static/js/receipt_crop.js Normal file
View File

@@ -0,0 +1,63 @@
let cropper;
let currentReceiptId;
document.addEventListener("DOMContentLoaded", function () {
const cropModal = document.getElementById("cropModal");
const cropImage = document.getElementById("cropImage");
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;
if (cropper) {
cropper.destroy();
cropper = null;
}
image.onload = () => {
cropper = new Cropper(image, {
viewMode: 1,
autoCropArea: 1,
responsive: true,
background: false,
zoomable: true,
movable: true,
dragMode: 'move',
minContainerHeight: 400,
minContainerWidth: 400,
});
};
});
document.getElementById("saveCrop").addEventListener("click", function () {
if (!cropper) return;
cropper.getCroppedCanvas().toBlob(function (blob) {
const formData = new FormData();
formData.append("receipt_id", currentReceiptId);
formData.append("cropped_image", blob);
fetch("/admin/crop_receipt", {
method: "POST",
body: formData,
})
.then((res) => res.json())
.then((data) => {
if (data.success) {
showToast("Zapisano przycięty paragon", "success");
setTimeout(() => location.reload(), 1500);
} else {
showToast("Błąd: " + (data.error || "Nieznany"), "danger");
}
})
.catch((err) => {
showToast("Błąd sieci", "danger");
console.error(err);
});
}, "image/webp");
});
});

View File

@@ -3,29 +3,28 @@ window.receiptToastShown = window.receiptToastShown || false;
if (!window.receiptUploaderInitialized) {
document.addEventListener("DOMContentLoaded", function () {
const form = document.getElementById("receiptForm");
const input = document.getElementById("receiptInput");
const gallery = document.getElementById("receiptGallery");
const inputCamera = document.getElementById("cameraInput");
const inputGallery = document.getElementById("galleryInput");
const galleryBtn = document.getElementById("galleryBtn");
const galleryBtnText = document.getElementById("galleryBtnText");
const cameraBtn = document.getElementById("cameraBtn");
const progressContainer = document.getElementById("progressContainer");
const progressBar = document.getElementById("progressBar");
const fileLabel = document.getElementById("fileLabel");
const gallery = document.getElementById("receiptGallery");
if (!form || !input || !gallery) return;
if (!form || !inputCamera || !inputGallery || !gallery) return;
// Zmiana labela po wyborze pliku
if (input && fileLabel) {
input.addEventListener("change", function () {
if (input.files.length > 0) {
fileLabel.textContent = input.files[0].name;
} else {
fileLabel.textContent = "Wybierz zdjęcie paragonu";
}
});
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
if (galleryBtnText) galleryBtnText.textContent = " Dodaj paragon";
}
form.addEventListener("submit", function (e) {
e.preventDefault();
const file = input.files[0];
function handleFileUpload(inputElement) {
const file = inputElement.files[0];
if (!file) {
showToast("Nie wybrano pliku!", "warning");
return;
@@ -56,31 +55,30 @@ if (!window.receiptUploaderInitialized) {
progressContainer.style.display = "none";
progressBar.style.width = "0%";
progressBar.textContent = "";
input.value = "";
if (fileLabel) {
fileLabel.textContent = "Wybierz zdjęcie paragonu";
}
inputElement.value = "";
window.receiptToastShown = false;
};
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
try {
const res = JSON.parse(xhr.responseText);
if (res.success && res.url) {
if (xhr.status === 200 && res.success && res.url) {
fetch(window.location.href)
.then(response => response.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const newGallery = doc.getElementById("receiptGallery");
if (newGallery) {
gallery.innerHTML = newGallery.innerHTML;
lightbox.destroy();
lightbox = GLightbox({
selector: '.glightbox'
});
if (typeof lightbox !== "undefined") {
lightbox.destroy();
}
lightbox = GLightbox({ selector: ".glightbox" });
if (!window.receiptToastShown) {
showToast("Wgrano paragon", "success");
@@ -89,16 +87,21 @@ if (!window.receiptUploaderInitialized) {
}
});
} else {
showToast(res.message || "Błąd podczas wgrywania.", "danger");
const errorMessage = res.error || res.message || "Błąd podczas wgrywania.";
showToast(errorMessage, "danger");
}
} else {
showToast("Błąd serwera. Spróbuj ponownie.", "danger");
} catch (err) {
showToast("Błąd serwera (nieprawidłowa odpowiedź).", "danger");
}
}
};
xhr.send(formData);
});
}
inputCamera?.addEventListener("change", () => handleFileUpload(inputCamera));
inputGallery?.addEventListener("change", () => handleFileUpload(inputGallery));
});
window.receiptUploaderInitialized = true;

View File

@@ -46,7 +46,7 @@ socket.on('connect', function () {
});
socket.on('disconnect', function (reason) {
showToast('Utracono połączenie z serwerem...', 'warning');
//showToast('Utracono połączenie z serwerem...', 'warning');
disableCheckboxes(true);
});

9
static/lib/css/cropper.min.css vendored Normal file
View File

@@ -0,0 +1,9 @@
/*!
* Cropper.js v1.6.2
* https://fengyuanchen.github.io/cropperjs
*
* Copyright 2015-present Chen Fengyuan
* Released under the MIT license
*
* Date: 2024-04-21T07:43:02.731Z
*/.cropper-container{-webkit-touch-callout:none;direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed}

10
static/lib/js/cropper.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -75,76 +75,81 @@
</div>
</div>
<div class="card bg-dark text-white mb-5">
<div class="card-body">
<h3 class="mt-4">📄 Wszystkie listy zakupowe</h3>
<form method="post" action="{{ url_for('delete_selected_lists') }}">
<div class="table-responsive">
<table class="table table-dark table-striped align-middle">
<thead>
<tr>
<th><input type="checkbox" id="select-all"></th>
<th>ID</th>
<th>Tytuł</th>
<th>Status</th>
<th>Utworzono</th>
<th>Właściciel</th>
<th>Produkty</th>
<th>Wypełnienie</th>
<th>Komentarze</th>
<th>Paragony</th>
<th>Wydatki</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for e in enriched_lists %}
{% set l = e.list %}
<tr>
<td><input type="checkbox" name="list_ids" value="{{ l.id }}"></td>
<td>{{ l.id }}</td>
<td class="fw-bold">
<a href="{{ url_for('view_list', list_id=l.id) }}" class="text-white">{{ l.title }}</a>
</td>
<td>
{% if l.is_archived %}
<span class="badge bg-secondary">Archiwalna</span>
{% elif e.expired %}
<span class="badge bg-warning text-dark">Wygasła</span>
{% else %}
<span class="badge bg-success">Aktywna</span>
{% endif %}
</td>
<td>{{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }}</td>
<td>
{% if l.owner_id %}
{{ l.owner_id }} / {{ l.owner.username if l.owner else 'Brak użytkownika' }}
{% else %}
-
{% endif %}
</td>
<td>{{ e.total_count }}</td>
<td>{{ e.purchased_count }}/{{ e.total_count }} ({{ e.percent }}%)</td>
<td>{{ e.comments_count }}</td>
<td>{{ e.receipts_count }}</td>
<td>
{% if e.total_expense > 0 %}
{{ '%.2f'|format(e.total_expense) }} PLN
{% else %}
-
{% endif %}
</td>
<td class="d-flex flex-wrap gap-1">
<a href="{{ url_for('edit_list', list_id=l.id) }}" class="btn btn-sm btn-outline-primary">✏️
Edytuj</a>
<a href="{{ url_for('delete_list', list_id=l.id) }}" class="btn btn-sm btn-outline-danger">🗑️
Usuń</a>
</td>
</tr>
{% endfor %}
</tbody>
<h3 class="mt-4">📄 Wszystkie listy zakupowe</h3>
<form method="post" action="{{ url_for('delete_selected_lists') }}">
<div class="table-responsive">
<table class="table table-dark table-striped align-middle">
<thead>
<tr>
<th><input type="checkbox" id="select-all"></th>
<th>ID</th>
<th>Tytuł</th>
<th>Status</th>
<th>Utworzono</th>
<th>Właściciel</th>
<th>Produkty</th>
<th>Wypełnienie</th>
<th>Komentarze</th>
<th>Paragony</th>
<th>Wydatki</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for e in enriched_lists %}
{% set l = e.list %}
<tr>
<td><input type="checkbox" name="list_ids" value="{{ l.id }}"></td>
<td>{{ l.id }}</td>
<td class="fw-bold">
<a href="{{ url_for('view_list', list_id=l.id) }}" class="text-white">{{ l.title }}</a>
</td>
<td>
{% if l.is_archived %}
<span class="badge bg-secondary">Archiwalna</span>
{% elif e.expired %}
<span class="badge bg-warning text-dark">Wygasła</span>
{% else %}
<span class="badge bg-success">Aktywna</span>
{% endif %}
</td>
<td>{{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }}</td>
<td>
{% if l.owner_id %}
{{ l.owner_id }} / {{ l.owner.username if l.owner else 'Brak użytkownika' }}
{% else %}
-
{% endif %}
</td>
<td>{{ e.total_count }}</td>
<td>{{ e.purchased_count }}/{{ e.total_count }} ({{ e.percent }}%)</td>
<td>{{ e.comments_count }}</td>
<td>{{ e.receipts_count }}</td>
<td>
{% if e.total_expense > 0 %}
{{ '%.2f'|format(e.total_expense) }} PLN
{% else %}
-
{% endif %}
</td>
<td class="d-flex flex-wrap gap-1">
<a href="{{ url_for('edit_list', list_id=l.id) }}" class="btn btn-sm btn-outline-primary">✏️ Edytuj</a>
<a href="{{ url_for('delete_list', list_id=l.id) }}" class="btn btn-sm btn-outline-danger">🗑️ Usuń</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</table>
</div>
<button type="submit" class="btn btn-danger mt-2">🗑️ Usuń zaznaczone listy</button>
</form>
</div>
<button type="submit" class="btn btn-danger mt-2">🗑️ Usuń zaznaczone listy</button>
</form>
</div>
<div class="modal fade" id="expensesChartModal" tabindex="-1" aria-labelledby="expensesChartModalLabel"
aria-hidden="true">
@@ -193,8 +198,9 @@
{% endblock %}
<div class="info-bar-fixed">
Python: {{ python_version.split()[0] }} | {{ system_info }} | RAM app: {{ app_memory }}
Python: {{ python_version.split()[0] }} | {{ system_info }} | RAM app: {{ app_memory }} |
DB: {{ db_info.engine|upper }}{% if db_info.version %} v{{ db_info.version[0] }}{% endif %} |
Tabele: {{ table_count }} | Rekordy: {{ record_total }} |
Uptime: {{ uptime_minutes }} min
</div>
{% endblock %}

View File

@@ -65,8 +65,6 @@
</div>
</div>
<div class="mb-4">
<label class="form-label">Link do udostępnienia</label>
<input type="text" class="form-control bg-dark text-white border-secondary rounded" readonly
@@ -111,7 +109,30 @@
<tr>
<td>
<strong>{{ item.name }}</strong>
<small class="text-muted">(x{{ item.quantity }})</small>
<small class="text-small text-success">(x{{ item.quantity }})</small>
{% if item.note %}
<div class="text-info small mt-1">
<strong>Notatka:</strong> {{ item.note }}
</div>
{% endif %}
{% if item.not_purchased_reason %}
<div class="text-warning small mt-1">
<strong>Powód:</strong> {{ item.not_purchased_reason }}
</div>
{% endif %}
<form method="post" action="{{ url_for('edit_list', list_id=list.id) }}" class="mt-2">
<input type="hidden" name="action" value="edit_quantity">
<input type="hidden" name="item_id" value="{{ item.id }}">
<div class="input-group input-group-sm ">
<input type="number" name="quantity"
class="form-control bg-dark text-white border-secondary rounded-left" min="1"
value="{{ item.quantity }}">
<button type="submit" class="btn btn-outline-light">💾</button>
</div>
</form>
</td>
<td>
{% if item.purchased %}
@@ -152,12 +173,6 @@
<input type="hidden" name="item_id" value="{{ item.id }}">
<button type="submit" class="btn btn-outline-success btn-sm">✅ Przywróć na liste</button>
</form>
{% if item.not_purchased_reason %}
<div class="mt-2 text-warning small border-top pt-2">
<strong>Powód:</strong> {{ item.not_purchased_reason }}
</div>
{% endif %}
{% endif %}
</td>
@@ -192,23 +207,36 @@
</div>
<div class="row g-3">
{% for img in receipts %}
{% set file_path = upload_folder ~ '/' ~ img %}
{% set file_size = (file_path | filesizeformat) %}
{% set upload_time = (file_path | filemtime) %}
{% for r in receipts %}
<div class="col-6 col-md-4 col-lg-3">
<div class="card bg-dark text-white h-100">
<a href="{{ url_for('uploaded_file', filename=img) }}" data-lightbox="receipts" data-title="{{ img }}"
class="glightbox">
<img src="{{ url_for('uploaded_file', filename=img) }}" class="card-img-top"
<a href="{{ url_for('uploaded_file', filename=r.filename) }}" class="glightbox" data-gallery="receipts"
data-title="{{ r.filename }}">
<img src="{{ url_for('uploaded_file', filename=r.filename) }}" class="card-img-top"
style="object-fit: cover; height: 200px;">
</a>
<div class="card-body text-center">
<p class="small text-truncate mb-1">{{ img }}</p>
<p class="small mb-1">Rozmiar: {{ file_size }}</p>
<p class="small mb-1">Wgrano: {{ upload_time.strftime('%Y-%m-%d %H:%M') }}</p>
<a href="{{ url_for('delete_receipt', filename=img) }}?next={{ url_for('edit_list', list_id=list.id) }}"
class="btn btn-sm btn-outline-danger w-100">🗑️ Usuń</a>
<p class="small text-truncate mb-1">{{ r.filename }}</p>
<p class="small mb-1">Wgrano: {{ r.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</p>
{% if r.filesize and r.filesize >= 1024 * 1024 %}
<p class="small mb-1">Rozmiar: {{ (r.filesize / 1024 / 1024) | round(2) }} MB</p>
{% elif r.filesize %}
<p class="small mb-1">Rozmiar: {{ (r.filesize / 1024) | round(1) }} kB</p>
{% else %}
<p class="small mb-1 text-muted">Brak danych o rozmiarze</p>
{% endif %}
<a href="{{ url_for('rotate_receipt', receipt_id=r.id) }}"
class="btn btn-sm btn-outline-warning w-100 mb-2">🔄 Obróć o 90°</a>
<a href="{{ url_for('rename_receipt', receipt_id=r.id) }}" class="btn btn-sm btn-outline-info w-100 mb-2">✏️
Zmień nazwę</a>
{% if not r.file_hash %}
<a href="{{ url_for('generate_receipt_hash', receipt_id=r.id) }}"
class="btn btn-sm btn-outline-secondary w-100 mb-2">🔐 Generuj hash</a>
{% endif %}
<a href="{{ url_for('delete_receipt', receipt_id=r.id) }}"
class="btn btn-sm btn-outline-danger w-100 mb-2">🗑️ Usuń</a>
</div>
</div>
</div>

View File

@@ -8,95 +8,100 @@
</div>
<div class="card bg-dark text-white mb-5">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="m-0">📦 Produkty (z synchronizacją sugestii)</h4>
<span class="badge bg-secondary">{{ items|length }} produktów</span>
</div>
<div class="card-body p-0">
<table class="table table-dark table-striped align-middle m-0">
<thead>
<tr>
<th>ID</th>
<th>Nazwa</th>
<th>Dodana przez</th>
<th>Sugestia</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.id }}</td>
<td class="fw-bold">{{ item.name }}</td>
<td>
{% if item.added_by %}
{{ users_dict.get(item.added_by, 'Nieznany') }}
{% else %}
Gość
{% endif %}
</td>
<td>
{% set suggestion = suggestions_dict.get(item.name.lower()) %}
{% if suggestion %}
✅ Istnieje (ID: {{ suggestion.id }})
<button class="btn btn-sm btn-outline-danger ms-1 delete-suggestion-btn"
data-suggestion-id="{{ suggestion.id }}">🗑️ Usuń</button>
{% else %}
<button class="btn btn-sm btn-outline-primary sync-btn" data-item-id="{{ item.id }}">🔄
Synchronizuj</button>
{% endif %}
</td>
<td>
<a href="/list/{{ item.list_id }}" class="btn btn-sm btn-outline-light mb-1">📄 Zobacz listę</a>
</td>
</tr>
{% endfor %}
{% if items|length == 0 %}
<tr>
<td colspan="5" class="text-center text-muted">Brak produktów do wyświetlenia.</td>
</tr>
{% endif %}
</tbody>
</table>
<div class="card-body">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="m-0">📦 Produkty (z synchronizacją sugestii)</h4>
<span class="badge bg-secondary">{{ items|length }} produktów</span>
</div>
<div class="card-body p-0">
<table class="table table-dark table-striped align-middle m-0">
<thead>
<tr>
<th>ID</th>
<th>Nazwa</th>
<th>Dodana przez</th>
<th>Sugestia</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.id }}</td>
<td class="fw-bold">{{ item.name }}</td>
<td>
{% if item.added_by %}
{{ users_dict.get(item.added_by, 'Nieznany') }}
{% else %}
Gość
{% endif %}
</td>
<td>
{% set suggestion = suggestions_dict.get(item.name.lower()) %}
{% if suggestion %}
✅ Istnieje (ID: {{ suggestion.id }})
<button class="btn btn-sm btn-outline-danger ms-1 delete-suggestion-btn"
data-suggestion-id="{{ suggestion.id }}">🗑️ Usuń</button>
{% else %}
<button class="btn btn-sm btn-outline-primary sync-btn" data-item-id="{{ item.id }}">🔄
Synchronizuj</button>
{% endif %}
</td>
<td>
<a href="/list/{{ item.list_id }}" class="btn btn-sm btn-outline-light mb-1">📄 Zobacz listę</a>
</td>
</tr>
{% endfor %}
{% if items|length == 0 %}
<tr>
<td colspan="5" class="text-center text-muted">Brak produktów do wyświetlenia.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Tabela z samymi sugestiami -->
<div class="card bg-dark text-white">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="m-0">💡 Wszystkie sugestie (poza powiązanymi)</h4>
<span class="badge bg-secondary">{{ suggestions_dict|length }} sugestii</span>
</div>
<div class="card-body p-0">
{% set item_names = items | map(attribute='name') | map('lower') | list %}
<table class="table table-dark table-striped align-middle m-0">
<thead>
<tr>
<th>ID</th>
<th>Nazwa</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for suggestion in suggestions_dict.values() %}
{% if suggestion.name.lower() not in item_names %}
<tr>
<td>{{ suggestion.id }}</td>
<td class="fw-bold">{{ suggestion.name }}</td>
<td>
<button class="btn btn-sm btn-outline-danger delete-suggestion-btn"
data-suggestion-id="{{ suggestion.id }}">🗑️ Usuń</button>
</td>
</tr>
{% endif %}
{% endfor %}
{% if suggestions_dict|length == 0 %}
<tr>
<td colspan="3" class="text-center text-muted">Brak sugestii do wyświetlenia.</td>
</tr>
{% endif %}
</tbody>
</table>
<div class="card bg-dark text-white mb-5">
<div class="card-body">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="m-0">💡 Wszystkie sugestie (poza powiązanymi)</h4>
<span class="badge bg-secondary">{{ suggestions_dict|length }} sugestii</span>
</div>
<div class="card-body p-0">
{% set item_names = items | map(attribute='name') | map('lower') | list %}
<table class="table table-dark table-striped align-middle m-0">
<thead>
<tr>
<th>ID</th>
<th>Nazwa</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for suggestion in suggestions_dict.values() %}
{% if suggestion.name.lower() not in item_names %}
<tr>
<td>{{ suggestion.id }}</td>
<td class="fw-bold">{{ suggestion.name }}</td>
<td>
<button class="btn btn-sm btn-outline-danger delete-suggestion-btn"
data-suggestion-id="{{ suggestion.id }}">🗑️ Usuń</button>
</td>
</tr>
{% endif %}
{% endfor %}
{% if suggestions_dict|length == 0 %}
<tr>
<td colspan="3" class="text-center text-muted">Brak sugestii do wyświetlenia.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -4,42 +4,87 @@
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<h2 class="mb-2">📸 Wszystkie paragony</h2>
<a href="/admin" class="btn btn-outline-secondary">← Powrót do panelu</a>
<div>
<a href="{{ url_for('recalculate_filesizes') }}" class="btn btn-sm btn-outline-primary me-2">
🔄 Przelicz rozmiary plików
</a>
<a href="/admin" class="btn btn-outline-secondary">← Powrót do panelu</a>
</div>
</div>
<div class="row g-3">
{% for img in image_files %}
{% set list_id = img.split('_')[1] if '_' in img else None %}
{% set file_path = upload_folder ~ '/' ~ img %}
{% set file_size = (file_path | filesizeformat) %}
{% set upload_time = (file_path | filemtime) %}
<div class="col-6 col-md-4 col-lg-3">
<div class="card bg-dark text-white h-100">
<a href="{{ url_for('uploaded_file', filename=img) }}" class="glightbox" data-gallery="receipts"
data-title="{{ img }}">
<img src="{{ url_for('uploaded_file', filename=img) }}" class="card-img-top"
style="object-fit: cover; height: 200px;">
</a>
<div class="card-body text-center">
<p class="small text-truncate mb-1">{{ img }}</p>
<p class="small mb-1">Rozmiar: {{ file_size }}</p>
<p class="small mb-1">Wgrano: {{ upload_time.strftime('%Y-%m-%d %H:%M') }}</p>
{% if list_id %}
<a href="{{ url_for('edit_list', list_id=list_id|int) }}" class="btn btn-sm btn-outline-light w-100 mb-2">✏️
Edytuj listę #{{ list_id }}</a>
{% endif %}
<a href="{{ url_for('delete_receipt', filename=img) }}?next={{ request.path }}"
class="btn btn-sm btn-outline-danger w-100">🗑️ Usuń</a>
<div class="card bg-dark text-white mb-5">
<div class="card-body">
<div class="row g-3">
{% for r in receipts %}
<div class="col-6 col-md-4 col-lg-3">
<div class="card bg-dark text-white h-100">
<a href="{{ url_for('uploaded_file', filename=r.filename) }}" class="glightbox" data-gallery="receipts"
data-title="{{ r.filename }}">
<img src="{{ url_for('uploaded_file', filename=r.filename) }}" class="card-img-top"
style="object-fit: cover; height: 200px;">
</a>
<div class="card-body text-center">
<p class="small text-truncate mb-1">{{ r.filename }}</p>
<p class="small mb-1">Wgrano: {{ r.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</p>
{% if r.filesize and r.filesize >= 1024 * 1024 %}
<p class="small mb-1">Rozmiar: {{ (r.filesize / 1024 / 1024) | round(2) }} MB</p>
{% elif r.filesize %}
<p class="small mb-1">Rozmiar: {{ (r.filesize / 1024) | round(1) }} kB</p>
{% else %}
<p class="small mb-1 text-muted">Brak danych o rozmiarze</p>
{% endif %}
<a href="{{ url_for('rotate_receipt', receipt_id=r.id) }}"
class="btn btn-sm btn-outline-warning w-100 mb-2">🔄 Obróć o 90°</a>
<a href="#" class="btn btn-sm btn-outline-secondary w-100 mb-2" data-bs-toggle="modal"
data-bs-target="#cropModal" data-img-src="{{ url_for('uploaded_file', filename=r.filename) }}"
data-receipt-id="{{ r.id }}">✂️ Przytnij</a>
<a href="{{ url_for('rename_receipt', receipt_id=r.id) }}" class="btn btn-sm btn-outline-info w-100 mb-2">✏️
Zmień nazwę</a>
{% if not r.file_hash %}
<a href="{{ url_for('generate_receipt_hash', receipt_id=r.id) }}"
class="btn btn-sm btn-outline-secondary w-100 mb-2">🔐 Generuj hash</a>
{% endif %}
<a href="{{ url_for('delete_receipt', receipt_id=r.id) }}"
class="btn btn-sm btn-outline-danger w-100 mb-2">🗑️
Usuń</a>
<a href="{{ url_for('edit_list', list_id=r.list_id) }}" class="btn btn-sm btn-outline-light w-100 mb-2">✏️
Edytuj listę #{{ r.list_id }}</a>
</div>
</div>
</div>
{% endfor %}
</div>
{% if not receipts %}
<div class="alert alert-info text-center mt-4" role="alert">
Nie wgrano żadnych paragonów.
</div>
{% endif %}
</div>
</div>
<div class="modal fade" id="cropModal" tabindex="-1" aria-labelledby="cropModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title">✂️ Przycinanie paragonu</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div style="position: relative; width: 100%; height: 75vh;">
<img id="cropImage" style="max-width: 100%; max-height: 100%; display: block; margin: auto;">
</div>
<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>
</div>
</div>
{% endfor %}
</div>
{% if not image_files %}
<div class="alert alert-info text-center mt-4" role="alert">
Nie wgrano żadnych paragonów.
</div>
{% endif %}
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop.js') }}"></script>
{% endblock %}
{% endblock %}

View File

@@ -29,44 +29,47 @@
</div>
</div>
<table class="table table-dark table-striped align-middle">
<thead>
<tr>
<th>ID</th>
<th>Login</th>
<th>Rola</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.id }}</td>
<td class="fw-bold">{{ user.username }}</td>
<td>
{% if user.is_admin %}
<span class="badge bg-primary">Admin</span>
{% else %}
<span class="badge bg-secondary">Użytkownik</span>
{% endif %}
</td>
<td>
<button class="btn btn-sm btn-outline-warning me-1" data-bs-toggle="modal" data-bs-target="#resetPasswordModal"
data-user-id="{{ user.id }}" data-username="{{ user.username }}">
🔑 Ustaw hasło
</button>
{% if not user.is_admin %}
<a href="/admin/promote_user/{{ user.id }}" class="btn btn-sm btn-outline-info">⬆️ Ustaw admina</a>
{% else %}
<a href="/admin/demote_user/{{ user.id }}" class="btn btn-sm btn-outline-secondary"> Us admina</a>
{% endif %}
<a href="/admin/delete_user/{{ user.id }}" class="btn btn-sm btn-outline-danger me-1">🗑 Usuń</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="card bg-dark text-white mb-5">
<div class="card-body">
<table class="table table-dark table-striped align-middle">
<thead>
<tr>
<th>ID</th>
<th>Login</th>
<th>Rola</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.id }}</td>
<td class="fw-bold">{{ user.username }}</td>
<td>
{% if user.is_admin %}
<span class="badge bg-primary">Admin</span>
{% else %}
<span class="badge bg-secondary">Użytkownik</span>
{% endif %}
</td>
<td>
<button class="btn btn-sm btn-outline-warning me-1" data-bs-toggle="modal"
data-bs-target="#resetPasswordModal" data-user-id="{{ user.id }}" data-username="{{ user.username }}">
🔑 Ustaw hasło
</button>
{% if not user.is_admin %}
<a href="/admin/promote_user/{{ user.id }}" class="btn btn-sm btn-outline-info"> Ustaw admina</a>
{% else %}
<a href="/admin/demote_user/{{ user.id }}" class="btn btn-sm btn-outline-secondary"> Usuń admina</a>
{% endif %}
<a href="/admin/delete_user/{{ user.id }}" class="btn btn-sm btn-outline-danger me-1">🗑️ Usuń</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Modal resetowania hasła -->
<div class="modal fade" id="resetPasswordModal" tabindex="-1" aria-labelledby="resetPasswordModalLabel"
aria-hidden="true">

View File

@@ -11,6 +11,9 @@
<link href="{{ url_for('static_bp.serve_css_lib', filename='glightbox.min.css') }}" rel="stylesheet">
{% endif %}
<link href="{{ url_for('static_bp.serve_css_lib', filename='bootstrap.min.css') }}" rel="stylesheet">
{% if '/admin/' in request.path %}
<link href="{{ url_for('static_bp.serve_css_lib', filename='cropper.min.css') }}" rel="stylesheet">
{% endif %}
</head>
<body class="bg-dark text-white">
@@ -58,6 +61,12 @@
<div id="toast-container" class="toast-container position-fixed bottom-0 end-0 p-3"></div>
<footer class="text-center text-secondary small mt-5 mb-3">
<hr class="text-secondary">
<p class="mb-0">© 2025 <strong>linuxiarz.pl</strong> · <a href="https://gitea.linuxiarz.pl/gru/lista_zakupowa_live"
target="_blank" class="link-success text-decoration-none"> source code</a>
</footer>
<script src="{{ url_for('static_bp.serve_js_lib', filename='bootstrap.bundle.min.js') }}"></script>
{% if not is_blocked %}
<script>
@@ -74,9 +83,10 @@
{% endwith %}
});
</script>
{% if request.endpoint != 'system_auth' %}
<script src="{{ url_for('static_bp.serve_js_lib', filename='glightbox.min.js') }}"></script>
<script src="{{ url_for('static_bp.serve_js_lib', filename='socket.io.min.js') }}"></script>
{% if request.endpoint != 'system_auth' %}
<script src="{{ url_for('static_bp.serve_js', filename='functions.js') }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='live.js') }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='sockets.js') }}"></script>
@@ -87,6 +97,11 @@
selector: '.glightbox'
});
</script>
{% if '/admin/' in request.path %}
<script src="{{ url_for('static_bp.serve_js_lib', filename='cropper.min.js') }}"></script>
{% endif %}
{% endif %}
{% block scripts %}{% endblock %}

View File

@@ -3,59 +3,97 @@
{% block content %}
<h2>Edytuj listę: <strong>{{ list.title }}</strong></h2>
<form method="post">
<div class="mb-3">
<label for="title" class="form-label">Nazwa listy</label>
<input type="text" name="title" id="title" class="form-control bg-dark text-white border-secondary rounded"
value="{{ list.title }}" required>
</div>
<div class="card bg-dark text-white mb-5">
<div class="card-body">
<form method="post">
<div class="mb-3">
<label for="title" class="form-label">Nazwa listy</label>
<input type="text" name="title" id="title" class="form-control bg-dark text-white border-secondary rounded"
value="{{ list.title }}" required>
</div>
<div class="form-check mb-3">
<input class="form-check-input rounded" type="checkbox" name="is_public" id="is_public" {% if list.is_public
%}checked{% endif %}>
<label class="form-check-label" for="is_public">Lista publiczna</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input rounded" type="checkbox" name="is_public" id="is_public" {% if list.is_public
%}checked{% endif %}>
<label class="form-check-label" for="is_public">Lista publiczna</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input rounded" type="checkbox" name="is_temporary" id="is_temporary" {% if
list.is_temporary %}checked{% endif %}>
<label class="form-check-label" for="is_temporary">Lista tymczasowa</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input rounded" type="checkbox" name="is_temporary" id="is_temporary" {% if
list.is_temporary %}checked{% endif %}>
<label class="form-check-label" for="is_temporary">Lista tymczasowa</label>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="expires_date" class="form-label">Data wygaśnięcia</label>
<input type="date" class="form-control bg-dark text-white border-secondary rounded" id="expires_date"
name="expires_date" value="{{ list.expires_at.strftime('%Y-%m-%d') if list.expires_at else '' }}">
<div class="row mb-3">
<div class="col-md-6">
<label for="expires_date" class="form-label">Data wygaśnięcia</label>
<input type="date" class="form-control bg-dark text-white border-secondary rounded" id="expires_date"
name="expires_date" value="{{ list.expires_at.strftime('%Y-%m-%d') if list.expires_at else '' }}">
</div>
<div class="col-md-6">
<label for="expires_time" class="form-label">Godzina wygaśnięcia</label>
<input type="time" class="form-control bg-dark text-white border-secondary rounded" id="expires_time"
name="expires_time" value="{{ list.expires_at.strftime('%H:%M') if list.expires_at else '' }}">
</div>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="is_archived" id="is_archived" {% if list.is_archived
%}checked{% endif %}>
<label class="form-check-label" for="is_archived">Zarchiwizowana</label>
</div>
<div class="btn-group mt-4" role="group">
<button type="submit" class="btn btn-outline-success">Zapisz</button>
<a href="{{ url_for('main_page') }}" class="btn btn-outline-light">Anuluj</a>
</div>
</form>
{% if receipts %}
<hr class="my-4">
<h5>Paragony przypisane do tej listy</h5>
<div class="row">
{% for r in receipts %}
<div class="col-6 col-md-4 col-lg-3">
<div class="card bg-dark text-white h-100">
<a href="{{ url_for('uploaded_file', filename=r.filename) }}" class="glightbox" data-gallery="receipts"
data-title="{{ r.filename }}">
<img src="{{ url_for('uploaded_file', filename=r.filename) }}" class="card-img-top"
style="object-fit: cover; height: 200px;">
</a>
<div class="card-body text-center">
<p class="small text-truncate mb-1">{{ r.filename }}</p>
<p class="small mb-1">Wgrano: {{ r.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</p>
{% if r.filesize and r.filesize >= 1024 * 1024 %}
<p class="small mb-1">Rozmiar: {{ (r.filesize / 1024 / 1024) | round(2) }} MB</p>
{% elif r.filesize %}
<p class="small mb-1">Rozmiar: {{ (r.filesize / 1024) | round(1) }} kB</p>
{% else %}
<p class="small mb-1 text-muted">Brak danych o rozmiarze</p>
{% endif %}
<a href="{{ url_for('rotate_receipt_user', receipt_id=r.id) }}"
class="btn btn-sm btn-outline-warning w-100 mb-2">🔄 Obróć o 90°</a>
<a href="{{ url_for('delete_receipt_user', receipt_id=r.id) }}" class="btn btn-sm btn-outline-danger w-100"
onclick="return confirm('Na pewno usunąć ten paragon?')">🗑️ Usuń</a>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="col-md-6">
<label for="expires_time" class="form-label">Godzina wygaśnięcia</label>
<input type="time" class="form-control bg-dark text-white border-secondary rounded" id="expires_time"
name="expires_time" value="{{ list.expires_at.strftime('%H:%M') if list.expires_at else '' }}">
{% endif %}
<hr class="my-3">
<!-- Trigger przycisk -->
<div class="btn-group mt-4" role="group">
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteModal">
🗑️ Usuń tę listę
</button>
</div>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="is_archived" id="is_archived" {% if list.is_archived
%}checked{% endif %}>
<label class="form-check-label" for="is_archived">Zarchiwizowana</label>
</div>
<div class="btn-group mt-4" role="group">
<button type="submit" class="btn btn-outline-success">Zapisz</button>
<a href="{{ url_for('main_page') }}" class="btn btn-outline-light">Anuluj</a>
</div>
</form>
<hr class="my-3">
<!-- Trigger przycisk -->
<div class="btn-group mt-4" role="group">
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteModal">
🗑️ Usuń tę listę
</button>
</div>
<!-- MODAL -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">

View File

@@ -96,7 +96,7 @@
{% if item.purchased %}bg-success text-white{% elif item.not_purchased %}bg-warning text-dark{% else %}item-not-checked{% endif %}"
data-is-share="{{ 'true' if is_share else 'false' }}">
<div class="d-flex align-items-center gap-3 flex-grow-1">
<div class="d-flex align-items-center gap-2 flex-grow-1">
<input id="checkbox-{{ item.id }}" class="large-checkbox" type="checkbox" {% if item.purchased %}checked{% endif
%} {% if list.is_archived or item.not_purchased %}disabled{% endif %}>

View File

@@ -5,7 +5,6 @@
<h2 class="mb-2">
🛍️ {{ list.title }}
{% if list.is_archived %}
<span class="badge bg-secondary ms-2">(Archiwalna)</span>
{% endif %}
@@ -32,7 +31,7 @@
class="list-group-item d-flex justify-content-between align-items-center flex-wrap clickable-item
{% if item.purchased %}bg-success text-white{% elif item.not_purchased %}bg-warning text-dark{% else %}item-not-checked{% endif %}">
<div class="d-flex align-items-center gap-3 flex-grow-1">
<div class="d-flex align-items-center gap-2 flex-grow-1">
<input id="checkbox-{{ item.id }}" class="large-checkbox" type="checkbox" {% if item.purchased %}checked{% endif
%} {% if list.is_archived or item.not_purchased %}disabled{% endif %}>
@@ -70,8 +69,6 @@
{% endif %}
</div>
</li>
{% else %}
<li id="empty-placeholder" class="list-group-item bg-dark text-secondary text-center w-100">
Brak produktów w tej liście.
@@ -81,10 +78,12 @@
{% if not list.is_archived %}
<div class="input-group mb-2">
<input id="newItem" class="form-control bg-dark text-white border-secondary" placeholder="Dodaj produkt i ilość">
<input id="newItem" class="form-control bg-dark text-white border-secondary" placeholder="Dodaj produkt i ilość" {% if
not current_user.is_authenticated %}disabled{% endif %}>
<input id="newQuantity" type="number" class="form-control bg-dark text-white border-secondary" placeholder="Ilość"
min="1" value="1" style="max-width: 90px;">
<button onclick="addItem({{ list.id }})" class="btn btn-success rounded-end"> Dodaj</button>
min="1" value="1" style="max-width: 90px;" {% if not current_user.is_authenticated %}disabled{% endif %}>
<button onclick="addItem({{ list.id }})" class="btn btn-success rounded-end" {% if not current_user.is_authenticated
%}disabled{% endif %}> Dodaj</button>
</div>
{% endif %}
@@ -106,6 +105,25 @@
<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">
<h5>🧠 Analiza paragonów (OCR)</h5>
<p class="text-small">System spróbuje automatycznie rozpoznać kwoty z dodanych paragonów.</p>
{% if current_user.is_authenticated %}
<button id="analyzeBtn" class="btn btn-outline-info mb-3">
🔍 Zleć analizę OCR
</button>
{% 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">
@@ -125,20 +143,31 @@
{% endif %}
</div>
{% if not list.is_archived %}
{% if not list.is_archived and current_user.is_authenticated %}
<hr>
<h5>📤 Dodaj zdjęcie paragonu</h5>
<form id="receiptForm" action="{{ url_for('upload_receipt', list_id=list.id) }}" method="post"
enctype="multipart/form-data" class="text-center">
<label for="receiptInput"
class="btn btn-outline-light w-100 py-3 mb-2 d-flex align-items-center justify-content-center gap-2">
<i class="bi bi-upload"></i> 📸 <span id="fileLabel">Wybierz zdjęcie paragonu</span>
<!-- Zrób zdjęcie (tylko mobile) -->
<label for="cameraInput" id="cameraBtn"
class="btn btn-outline-light w-100 py-2 mb-2 d-flex align-items-center justify-content-center gap-2">
<i class="bi bi-camera"></i> 📸 Zrób zdjęcie
</label>
<input type="file" name="receipt" accept="image/*" capture="environment" class="d-none" id="receiptInput">
<button type="submit" class="btn btn-success w-100 mb-2"> Wgraj paragon</button>
<input type="file" name="receipt" accept="image/*" capture="environment" class="d-none" id="cameraInput">
<!-- Z galerii / Dodaj paragon -->
<label for="galleryInput" id="galleryBtn"
class="btn btn-outline-light w-100 py-2 mb-2 d-flex align-items-center justify-content-center gap-2">
<i class="bi bi-image"></i> <span id="galleryBtnText">🖼️ Z galerii</span>
</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>
<div id="receiptGallery" class="mt-3"></div>
</form>
{% endif %}
@@ -180,7 +209,7 @@
<script src="{{ url_for('static_bp.serve_js', filename='clickable_row.js') }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='receipt_section.js') }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='receipt_upload.js') }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='receipt_analysis.js') }}"></script>
<script>
setupList({{ list.id }}, '{{ current_user.username if current_user.is_authenticated else 'Gość' }}');
</script>

View File

@@ -79,7 +79,7 @@
</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>
z formularza powyżej</span></p>
{% endif %}
{% endif %}
@@ -114,6 +114,7 @@
<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">

View File

@@ -7,71 +7,76 @@
<a href="{{ url_for('main_page') }}" class="btn btn-outline-secondary">← Powrót</a>
</div>
<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"
role="tab">
📄 Tabela
</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">
📊 Wykres
</button>
</li>
</ul>
<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"
role="tab">
📄 Tabela
</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">
📊 Wykres
</button>
</li>
</ul>
<div class="tab-content" id="expenseTabsContent">
<!-- Tabela -->
<div class="tab-pane fade show active" id="tableTab" 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 class="tab-content" id="expenseTabsContent">
<!-- Tabela -->
<div class="tab-pane fade show active" id="tableTab" 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>
{% else %}
<div class="alert alert-info text-center mb-0">Brak wydatków do wyświetlenia.</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info text-center mb-0">Brak wydatków do wyświetlenia.</div>
{% endif %}
</div>
</div>
</div>
<!-- Wykres -->
<div class="tab-pane fade" id="chartTab" role="tabpanel">
<div class="card bg-dark text-white mb-4">
<div class="card-body">
<p id="chartRangeLabel" class="fw-bold mb-3">Widok: miesięczne</p>
<canvas id="expensesChart" height="120"></canvas>
</div>
</div>
<!-- Wykres -->
<div class="tab-pane fade" id="chartTab" role="tabpanel">
<div class="card bg-dark text-white mb-4">
<div class="card-body">
<p id="chartRangeLabel" class="fw-bold mb-3">Widok: miesięczne</p>
<canvas id="expensesChart" height="120"></canvas>
</div>
</div>
<div class="d-flex flex-wrap gap-2 mb-4">
<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="halfyearly">🗓️ Półroczne</button>
<button class="btn btn-outline-light btn-sm range-btn" data-range="yearly">📈 Roczne</button>
</div>
<div class="d-flex flex-wrap gap-2 mb-4">
<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="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">
</div>
<div class="col-6 col-md-3">
<input type="date" id="endDate" class="form-control bg-dark text-white border-secondary">
</div>
<div class="col-12 col-md-3">
<button class="btn btn-outline-light w-100" id="customRangeBtn">📊 Zakres własny</button>
<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>
</div>
</div>
</div>
</div>