13 Commits

Author SHA1 Message Date
Mateusz Gruszczyński
cda3ad2203 progress bar and stats 2026-03-20 12:01:18 +01:00
Mateusz Gruszczyński
fd43032b55 poprawki cd 2026-03-19 15:14:21 +01:00
Mateusz Gruszczyński
4ddb48aef0 cleanup in docker 2026-03-19 09:54:03 +01:00
Mateusz Gruszczyński
616fcacb60 cleanup in docker 2026-03-19 09:36:31 +01:00
Mateusz Gruszczyński
59ec73c8b7 improvements 2026-03-18 10:26:34 +01:00
Mateusz Gruszczyński
986518b2e4 improvements 2026-03-18 10:26:25 +01:00
Mateusz Gruszczyński
f02d3b8085 fixes more 2026-03-17 13:06:31 +01:00
Mateusz Gruszczyński
3347df1911 fixes 2026-03-17 12:55:59 +01:00
Mateusz Gruszczyński
a299783a6c more changes 2026-03-17 11:49:36 +01:00
Mateusz Gruszczyński
14a544c9c4 some fixes 2026-03-15 22:10:33 +01:00
Mateusz Gruszczyński
ad5dbcc24b small fixes in css 2026-03-15 14:01:54 +01:00
Mateusz Gruszczyński
3a57f2f1d7 refactor next push 2026-03-14 23:17:05 +01:00
Mateusz Gruszczyński
a16798553e refactor part 1 2026-03-13 23:55:17 +01:00
109 changed files with 14519 additions and 7673 deletions

33
API_OPIS.txt Normal file
View File

@@ -0,0 +1,33 @@
API aplikacji Lista Zakupów
Autoryzacja:
- Authorization: Bearer TWOJ_TOKEN
- albo X-API-Token: TWOJ_TOKEN
Token ma jednocześnie dwa ograniczenia:
1. zakresy (scopes), np. expenses:read, lists:read, templates:read
2. dozwolone endpointy
Dostępne endpointy:
- GET /api/ping
Test poprawności tokenu.
- GET /api/expenses/latest?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD&list_id=ID&owner_id=ID&limit=50
Zwraca ostatnie wydatki wraz z metadanymi listy i właściciela.
- GET /api/expenses/summary?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD&list_id=ID&owner_id=ID
Zwraca sumę wydatków, liczbę rekordów i agregację po listach.
- GET /api/lists?owner_id=ID&limit=50
Zwraca listy z podstawowymi metadanymi.
- GET /api/lists/<id>/expenses?limit=50
Zwraca wydatki przypisane do konkretnej listy.
- GET /api/templates?owner_id=ID
Zwraca aktywne szablony.
Uwagi:
- limit odpowiedzi jest przycinany do max_limit ustawionego na tokenie
- daty przekazuj w formacie YYYY-MM-DD
- endpoint musi być zaznaczony na tokenie, samo posiadanie zakresu nie wystarczy

30
CLI_OPIS.txt Normal file
View File

@@ -0,0 +1,30 @@
Komendy CLI
===========
Admini
-------
flask admins list
flask admins create <username> <password> [--admin/--user]
flask admins promote <username|id>
flask admins demote <username|id>
flask admins set-password <username|id> <password>
Listy
-----
flask lists copy-schedule --source-list-id 12 --when "2026-03-20 18:30"
flask lists copy-schedule --source-list-id 12 --when "2026-03-20 18:30" --owner admin
flask lists copy-schedule --source-list-id 12 --when "2026-03-20 18:30" --title "Zakupy piatkowe"
Zasady dzialania
----------------
- copy-schedule tworzy nowa liste na podstawie istniejacej
- kopiuje pozycje i przypisane kategorie
- ustawia nowy created_at na wartosc z parametru --when
- gdy lista byla tymczasowa i miala expires_at, termin wygasniecia jest przesuwany o ten sam odstep czasu
- wydatki i paragony nie sa kopiowane
SZABLONY I HISTORIA:
- Historia zmian listy jest widoczna w widoku listy właściciela.
- Szablon można utworzyć z panelu admina lub z poziomu listy właściciela.
- Admin może szybko utworzyć listę z szablonu i zduplikować listę jednym kliknięciem.

View File

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

View File

@@ -10,6 +10,8 @@ Prosta aplikacja webowa do zarządzania listami zakupów z obsługą użytkownik
- Archiwizacja i udostępnianie list (publiczne/prywatne)
- Statystyki wydatków z podziałem na okresy, statystyki dla użytkowników
- Panel administracyjny (statystyki, produkty, paragony, zarządzanie, użytkowmicy)
- Tokeny API administratora i endpoint do pobierania ostatnich wydatków
- Ujednolicony UI formularzy, tabel i przycisków oraz drobne usprawnienia UX
## Wymagania
@@ -85,4 +87,8 @@ DB_PORT=5432
DB_NAME=myapp
DB_USER=user
DB_PASSWORD=pass
```
```
## CLI
Opis komend administracyjnych znajduje sie w pliku `CLI_OPIS.txt`.

4723
app.py

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
#!/bin/bash
set -e
# --- Wczytaj zmienne z .env ---
if [[ -f .env ]]; then
set -a
source .env
@@ -9,8 +8,8 @@ if [[ -f .env ]]; then
fi
APP_PORT="${APP_PORT:-8080}"
PROFILE=$1
COMPOSE_FILE="docker/compose.yml"
if [[ -z "$PROFILE" ]]; then
echo "Użycie: $0 {pgsql|mysql|sqlite}"
@@ -19,9 +18,9 @@ fi
echo "Zatrzymuję kontenery aplikacji i bazy..."
if [[ "$PROFILE" == "sqlite" ]]; then
docker compose stop
docker compose -f "$COMPOSE_FILE" stop
else
docker compose --profile "$PROFILE" stop
docker compose -f "$COMPOSE_FILE" --profile "$PROFILE" stop
fi
echo "Pobieram najnowszy kod z repozytorium..."
@@ -35,9 +34,9 @@ git rev-parse --short HEAD > version.txt
echo "Buduję i uruchamiam kontenery..."
if [[ "$PROFILE" == "sqlite" ]]; then
docker compose up -d --build
docker compose -f "$COMPOSE_FILE" up -d --build
else
DB_ENGINE="$PROFILE" docker compose --profile "$PROFILE" up -d --build
DB_ENGINE="$PROFILE" docker compose -f "$COMPOSE_FILE" --profile "$PROFILE" up -d --build
fi
echo "Gotowe! Wersja aplikacji: $(cat version.txt)"
echo "Gotowe! Wersja aplikacji: $(cat version.txt)"

28
docker/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
FROM python:3.14-trixie
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
tesseract-ocr \
tesseract-ocr-pol \
libglib2.0-0 \
libsm6 \
libxrender1 \
libxext6 \
poppler-utils \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY docker/requirements.txt /app/requirements.txt
RUN pip install --upgrade pip
RUN pip install --no-cache-dir -r requirements.txt
COPY . /app
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
#EXPOSE 8000
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -0,0 +1,28 @@
FROM python:3.14-slim-trixie
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
tesseract-ocr \
tesseract-ocr-pol \
libglib2.0-0 \
libsm6 \
libxrender1 \
libxext6 \
poppler-utils \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY docker/requirements.txt /app/requirements.txt
RUN pip install --upgrade pip
RUN pip install --no-cache-dir -r requirements.txt
COPY . /app
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
#EXPOSE 8000
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -0,0 +1,27 @@
FROM python:3.14-slim-trixie
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
tesseract-ocr \
tesseract-ocr-pol \
libglib2.0-0 \
libsm6 \
libxrender1 \
libxext6 \
poppler-utils \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY docker/requirements-stable.txt /app/requirements.txt
RUN pip install --upgrade pip
RUN pip install --no-cache-dir -r requirements.txt
COPY . /app
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
#EXPOSE 8000
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -1,14 +1,11 @@
services:
app:
build: .
build:
context: ..
dockerfile: docker/Dockerfile.debian-stable-slim
container_name: lista-zakupow-app
expose:
- "${APP_PORT:-8000}"
# temporary
#ports:
# - "9281:${APP_PORT:-8000}"
healthcheck:
test:
[
@@ -22,11 +19,11 @@ services:
retries: 3
start_period: 10s
env_file:
- .env
- ../.env
volumes:
- .:/app
- ./uploads:/app/uploads
- ./instance:/app/instance
- ../:/app
- ../uploads:/app/uploads
- ../instance:/app/instance
networks:
- lista-zakupow_network
restart: unless-stopped
@@ -40,7 +37,7 @@ services:
ports:
- "${APP_PORT:-8000}:80"
volumes:
- ./deploy/varnish/default.vcl:/etc/varnish/default.vcl:ro
- ../deploy/varnish/default.vcl:/etc/varnish/default.vcl:ro
environment:
- VARNISH_SIZE=256m
networks:
@@ -56,7 +53,7 @@ services:
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: 89o38kUX5T4C
volumes:
- ./db/mysql:/var/lib/mysql
- ../db/mysql:/var/lib/mysql
restart: unless-stopped
networks:
- lista-zakupow_network
@@ -71,7 +68,7 @@ services:
POSTGRES_PASSWORD: ${DB_PASSWORD}
PGDATA: /var/lib/postgresql
volumes:
- ./db/pgsql/:/var/lib/postgresql
- ../db/pgsql:/var/lib/postgresql
networks:
- lista-zakupow_network
restart: unless-stopped
@@ -79,4 +76,4 @@ services:
networks:
lista-zakupow_network:
driver: bridge
driver: bridge

View File

@@ -0,0 +1,21 @@
bcrypt==5.0.0
cryptography==46.0.5
Flask==3.1.3
Flask-Compress==1.23
Flask-Login==0.6.3
Flask-Session==0.8.0
Flask-SocketIO==5.6.1
Flask-SQLAlchemy==3.1.1
flask-talisman==1.1.0
gevent==25.9.1
gevent-websocket==0.10.1
opencv-python-headless>=4.12.0.88
pdf2image==1.17.0
pillow==12.1.1
pillow_heif==1.3.0
psutil==7.2.2
psycopg2-binary==2.9.11
PyMySQL==1.1.2
pytesseract==0.3.13
SQLAlchemy==2.0.48
Werkzeug==3.1.6

11
shopping_app/__init__.py Normal file
View File

@@ -0,0 +1,11 @@
from .app_setup import app, db, socketio, login_manager, APP_PORT, DEBUG_MODE, static_bp
from . import models # noqa: F401
from . import helpers # noqa: F401
app.register_blueprint(static_bp)
from . import web # noqa: F401
from . import routes_main # noqa: F401
from . import routes_secondary # noqa: F401
from . import routes_admin # noqa: F401
from . import sockets # noqa: F401
__all__ = ["app", "db", "socketio", "login_manager", "APP_PORT", "DEBUG_MODE"]

127
shopping_app/app_setup.py Normal file
View File

@@ -0,0 +1,127 @@
from .deps import *
app = Flask(__name__)
app.config.from_object(Config)
csp_policy = (
{
"default-src": "'self'",
"script-src": "'self' 'unsafe-inline'",
"style-src": "'self' 'unsafe-inline'",
"img-src": "'self' data:",
"connect-src": "'self'",
}
if app.config.get("ENABLE_CSP", True)
else None
)
permissions_policy = {"browsing-topics": "()"} if app.config.get("ENABLE_PP") else None
talisman_kwargs = {
"force_https": False,
"strict_transport_security": app.config.get("ENABLE_HSTS", True),
"frame_options": "DENY" if app.config.get("ENABLE_XFO", True) else None,
"permissions_policy": permissions_policy,
"content_security_policy": csp_policy,
"x_content_type_options": app.config.get("ENABLE_XCTO", True),
"strict_transport_security_include_subdomains": False,
}
referrer_policy = app.config.get("REFERRER_POLICY")
if referrer_policy:
talisman_kwargs["referrer_policy"] = referrer_policy
effective_headers = {
k: v
for k, v in talisman_kwargs.items()
if k != "referrer_policy" and v not in (None, False)
}
if effective_headers:
from flask_talisman import Talisman
talisman = Talisman(
app,
session_cookie_secure=app.config.get("SESSION_COOKIE_SECURE", True),
**talisman_kwargs,
)
print("[TALISMAN] Włączony z nagłówkami:", list(effective_headers.keys()))
else:
print("[TALISMAN] Pominięty — wszystkie nagłówki security wyłączone.")
register_heif_opener()
SQLALCHEMY_ECHO = True
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp", "heic", "pdf"}
SYSTEM_PASSWORD = app.config.get("SYSTEM_PASSWORD")
DEFAULT_ADMIN_USERNAME = app.config.get("DEFAULT_ADMIN_USERNAME")
DEFAULT_ADMIN_PASSWORD = app.config.get("DEFAULT_ADMIN_PASSWORD")
UPLOAD_FOLDER = app.config.get("UPLOAD_FOLDER")
AUTHORIZED_COOKIE_VALUE = app.config.get("AUTHORIZED_COOKIE_VALUE")
AUTH_COOKIE_MAX_AGE = app.config.get("AUTH_COOKIE_MAX_AGE")
HEALTHCHECK_TOKEN = app.config.get("HEALTHCHECK_TOKEN")
SESSION_TIMEOUT_MINUTES = int(app.config.get("SESSION_TIMEOUT_MINUTES"))
SESSION_COOKIE_SECURE = app.config.get("SESSION_COOKIE_SECURE")
APP_PORT = int(app.config.get("APP_PORT"))
app.config["COMPRESS_ALGORITHM"] = ["zstd", "br", "gzip", "deflate"]
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(minutes=SESSION_TIMEOUT_MINUTES)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
DEBUG_MODE = app.config.get("DEBUG_MODE", False)
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
db_uri = app.config.get("SQLALCHEMY_DATABASE_URI", "")
if db_uri.startswith("sqlite:///"):
sqlite_path = db_uri.replace("sqlite:///", "", 1)
sqlite_dir = os.path.dirname(sqlite_path)
if sqlite_dir:
os.makedirs(sqlite_dir, exist_ok=True)
failed_login_attempts = defaultdict(deque)
MAX_ATTEMPTS = 10
TIME_WINDOW = 60 * 60
WEBP_SAVE_PARAMS = {
"format": "WEBP",
"lossless": False,
"method": 6,
"quality": 95,
}
def read_commit(filename="version.txt", root_path=None):
base = root_path or os.path.dirname(os.path.abspath(__file__))
path = os.path.join(base, filename)
if not os.path.exists(path):
return None
try:
commit = open(path, "r", encoding="utf-8").read().strip()
return commit[:12] if commit else None
except Exception:
return None
def get_file_md5(path):
try:
digest = hashlib.md5()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
digest.update(chunk)
return digest.hexdigest()[:12]
except Exception:
return "dev"
commit = read_commit("version.txt", root_path=os.path.dirname(os.path.dirname(__file__))) or "dev"
APP_VERSION = commit
app.config["APP_VERSION"] = APP_VERSION
db = SQLAlchemy(app)
socketio = SocketIO(app, async_mode="gevent")
login_manager = LoginManager(app)
login_manager.login_view = "login"
app.config["SESSION_TYPE"] = "sqlalchemy"
app.config["SESSION_SQLALCHEMY"] = db
Session(app)
compress = Compress()
compress.init_app(app)
static_bp = Blueprint("static_bp", __name__)
active_users = {}
def utcnow():
return datetime.now(timezone.utc)
app_start_time = utcnow()

39
shopping_app/deps.py Normal file
View File

@@ -0,0 +1,39 @@
import os
import secrets
import time
import mimetypes
import sys
import platform
import psutil
import hashlib
import re
import traceback
import bcrypt
import colorsys
from pillow_heif import register_heif_opener
from datetime import datetime, timedelta, UTC, timezone
from urllib.parse import urlparse, urlunparse, urlencode
from flask import (
Flask, render_template, redirect, url_for, request, flash, Blueprint,
send_from_directory, abort, session, jsonify, g, render_template_string
)
from flask_sqlalchemy import SQLAlchemy
from flask_login import (
LoginManager, UserMixin, login_user, login_required, logout_user, current_user
)
from flask_compress import Compress
from flask_socketio import SocketIO, emit, join_room
from config import Config
from PIL import Image, ExifTags, ImageFilter, ImageOps
from werkzeug.middleware.proxy_fix import ProxyFix
from sqlalchemy import func, extract, inspect, or_, case, text, and_, literal
from sqlalchemy.orm import joinedload, load_only, aliased
from collections import defaultdict, deque
from functools import wraps
from flask_session import Session
from types import SimpleNamespace
from pdf2image import convert_from_bytes
from typing import Sequence, Any
import pytesseract
from pytesseract import Output
import logging

1525
shopping_app/helpers.py Normal file

File diff suppressed because it is too large Load Diff

216
shopping_app/models.py Normal file
View File

@@ -0,0 +1,216 @@
from .deps import *
from .app_setup import db, 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(512), nullable=False)
is_admin = db.Column(db.Boolean, default=False)
# Tabela pośrednia
shopping_list_category = db.Table(
"shopping_list_category",
db.Column(
"shopping_list_id",
db.Integer,
db.ForeignKey("shopping_list.id"),
primary_key=True,
),
db.Column(
"category_id", db.Integer, db.ForeignKey("category.id"), primary_key=True
),
)
class Category(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False)
class ShoppingList(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(150), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
owner_id = db.Column(db.Integer, db.ForeignKey("user.id"))
owner = db.relationship("User", backref="lists", foreign_keys=[owner_id])
is_temporary = db.Column(db.Boolean, default=False)
share_token = db.Column(db.String(64), unique=True, nullable=True)
expires_at = db.Column(db.DateTime(timezone=True), nullable=True)
owner = db.relationship("User", backref="lists", lazy=True)
is_archived = db.Column(db.Boolean, default=False)
is_public = db.Column(db.Boolean, default=False)
# Relacje
items = db.relationship("Item", back_populates="shopping_list", lazy="select")
receipts = db.relationship(
"Receipt",
back_populates="shopping_list",
cascade="all, delete-orphan",
lazy="select",
)
expenses = db.relationship("Expense", back_populates="shopping_list", lazy="select")
categories = db.relationship(
"Category",
secondary=shopping_list_category,
backref=db.backref("shopping_lists", lazy="dynamic"),
)
class Item(db.Model):
id = db.Column(db.Integer, primary_key=True)
list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id"))
name = db.Column(db.String(150), nullable=False)
# added_at = db.Column(db.DateTime, default=datetime.utcnow)
added_at = db.Column(db.DateTime, default=utcnow)
added_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
added_by_user = db.relationship(
"User", backref="added_items", lazy="joined", foreign_keys=[added_by]
)
purchased = db.Column(db.Boolean, default=False)
purchased_at = db.Column(db.DateTime, nullable=True)
quantity = db.Column(db.Integer, default=1)
note = db.Column(db.Text, nullable=True)
not_purchased = db.Column(db.Boolean, default=False)
not_purchased_reason = db.Column(db.Text, nullable=True)
position = db.Column(db.Integer, default=0)
shopping_list = db.relationship("ShoppingList", back_populates="items")
class SuggestedProduct(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(150), unique=True, nullable=False)
usage_count = db.Column(db.Integer, default=0)
class Expense(db.Model):
id = db.Column(db.Integer, primary_key=True)
list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id"))
amount = db.Column(db.Float, nullable=False)
added_at = db.Column(db.DateTime, default=datetime.utcnow)
receipt_filename = db.Column(db.String(255), nullable=True)
shopping_list = db.relationship("ShoppingList", back_populates="expenses")
class Receipt(db.Model):
id = db.Column(db.Integer, primary_key=True)
list_id = db.Column(
db.Integer,
db.ForeignKey("shopping_list.id", ondelete="CASCADE"),
nullable=False,
)
filename = db.Column(db.String(255), nullable=False)
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
filesize = db.Column(db.Integer, nullable=True)
file_hash = db.Column(db.String(64), nullable=True, unique=True)
uploaded_by = db.Column(db.Integer, db.ForeignKey("user.id"))
version_token = db.Column(db.String(32), nullable=True)
shopping_list = db.relationship("ShoppingList", back_populates="receipts")
uploaded_by_user = db.relationship("User", backref="uploaded_receipts")
class ListPermission(db.Model):
__tablename__ = "list_permission"
id = db.Column(db.Integer, primary_key=True)
list_id = db.Column(
db.Integer,
db.ForeignKey("shopping_list.id", ondelete="CASCADE"),
nullable=False,
)
user_id = db.Column(
db.Integer,
db.ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
__table_args__ = (db.UniqueConstraint("list_id", "user_id", name="uq_list_user"),)
ShoppingList.permitted_users = db.relationship(
"User",
secondary="list_permission",
backref=db.backref("permitted_lists", lazy="dynamic"),
lazy="dynamic",
)
class AppSetting(db.Model):
key = db.Column(db.String(64), primary_key=True)
value = db.Column(db.Text, nullable=True)
class ApiToken(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False)
token_hash = db.Column(db.String(64), unique=True, nullable=False, index=True)
token_prefix = db.Column(db.String(18), nullable=False)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
last_used_at = db.Column(db.DateTime, nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
scopes = db.Column(db.String(255), nullable=False, default="expenses:read")
allowed_endpoints = db.Column(db.String(255), nullable=False, default="/api/expenses/latest")
max_limit = db.Column(db.Integer, nullable=False, default=100)
creator = db.relationship(
"User", backref="created_api_tokens", lazy="joined", foreign_keys=[created_by]
)
class ListTemplate(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(150), nullable=False)
description = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
created_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
creator = db.relationship("User", backref="list_templates", lazy="joined")
items = db.relationship(
"ListTemplateItem",
back_populates="template",
cascade="all, delete-orphan",
lazy="select",
order_by="ListTemplateItem.position.asc()",
)
class ListTemplateItem(db.Model):
id = db.Column(db.Integer, primary_key=True)
template_id = db.Column(db.Integer, db.ForeignKey("list_template.id", ondelete="CASCADE"), nullable=False)
name = db.Column(db.String(150), nullable=False)
quantity = db.Column(db.Integer, default=1)
note = db.Column(db.Text, nullable=True)
position = db.Column(db.Integer, default=0)
template = db.relationship("ListTemplate", back_populates="items")
class ListActivityLog(db.Model):
id = db.Column(db.Integer, primary_key=True)
list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id", ondelete="CASCADE"), nullable=False, index=True)
actor_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
actor_name = db.Column(db.String(150), nullable=False, default="System")
action = db.Column(db.String(64), nullable=False)
item_name = db.Column(db.String(150), nullable=True)
details = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False, index=True)
shopping_list = db.relationship("ShoppingList", backref=db.backref("activity_logs", lazy="dynamic", cascade="all, delete-orphan"))
actor = db.relationship("User", backref="list_activity_logs", lazy="joined")
class CategoryColorOverride(db.Model):
id = db.Column(db.Integer, primary_key=True)
category_id = db.Column(
db.Integer, db.ForeignKey("category.id"), unique=True, nullable=False
)
color_hex = db.Column(db.String(7), nullable=False) # "#rrggbb"

1443
shopping_app/routes_admin.py Normal file

File diff suppressed because it is too large Load Diff

880
shopping_app/routes_main.py Normal file
View File

@@ -0,0 +1,880 @@
from .deps import *
from .app_setup import *
from .models import *
from .helpers import *
@app.route("/")
def main_page():
perm_subq = (
user_permission_subq(current_user.id) if current_user.is_authenticated else None
)
now = datetime.now(timezone.utc)
month_param = request.args.get("m", None)
start = end = None
if month_param in (None, ""):
# domyślnie: bieżący miesiąc
month_str = now.strftime("%Y-%m")
start = datetime(now.year, now.month, 1, tzinfo=timezone.utc)
end = (start + timedelta(days=31)).replace(day=1)
elif month_param == "all":
month_str = "all"
start = end = None
else:
month_str = month_param
try:
year, month = map(int, month_str.split("-"))
start = datetime(year, month, 1, tzinfo=timezone.utc)
end = (start + timedelta(days=31)).replace(day=1)
except ValueError:
# jeśli m ma zły format pokaż wszystko
month_str = "all"
start = end = None
def date_filter(query):
if start and end:
query = query.filter(
ShoppingList.created_at >= start, ShoppingList.created_at < end
)
return query
if current_user.is_authenticated:
user_lists = (
date_filter(
ShoppingList.query.filter(
ShoppingList.owner_id == current_user.id,
ShoppingList.is_archived == False,
(ShoppingList.expires_at == None) | (ShoppingList.expires_at > now),
)
)
.order_by(ShoppingList.created_at.desc())
.all()
)
archived_lists = (
ShoppingList.query.filter_by(owner_id=current_user.id, is_archived=True)
.order_by(ShoppingList.created_at.desc())
.all()
)
# publiczne cudze + udzielone mi (po list_permission)
public_lists = (
date_filter(
ShoppingList.query.filter(
ShoppingList.owner_id != current_user.id,
ShoppingList.is_archived == False,
(ShoppingList.expires_at == None) | (ShoppingList.expires_at > now),
or_(
ShoppingList.is_public == True,
ShoppingList.id.in_(perm_subq),
),
)
)
.order_by(ShoppingList.created_at.desc())
.all()
)
accessible_lists = public_lists # alias do szablonu: publiczne + udostępnione
else:
user_lists = []
archived_lists = []
public_lists = (
date_filter(
ShoppingList.query.filter(
ShoppingList.is_public == True,
(ShoppingList.expires_at == None) | (ShoppingList.expires_at > now),
ShoppingList.is_archived == False,
)
)
.order_by(ShoppingList.created_at.desc())
.all()
)
accessible_lists = public_lists # dla gościa = tylko publiczne
# Zakres miesięcy do selektora
if current_user.is_authenticated:
visible_lists_query = ShoppingList.query.filter(
or_(
ShoppingList.owner_id == current_user.id,
ShoppingList.is_public == True,
ShoppingList.id.in_(perm_subq),
)
)
else:
visible_lists_query = ShoppingList.query.filter(ShoppingList.is_public == True)
month_options = get_active_months_query(visible_lists_query)
# Statystyki dla wszystkich widocznych sekcji
all_lists = user_lists + accessible_lists + archived_lists
all_ids = [l.id for l in all_lists]
if all_ids:
stats = (
db.session.query(
Item.list_id,
func.count(Item.id).label("total_count"),
func.sum(case((((Item.purchased == True) & (Item.not_purchased == False)), 1), else_=0)).label(
"purchased_count"
),
func.sum(case((Item.not_purchased == True, 1), else_=0)).label(
"not_purchased_count"
),
)
.filter(Item.list_id.in_(all_ids))
.group_by(Item.list_id)
.all()
)
stats_map = {
s.list_id: (
s.total_count or 0,
s.purchased_count or 0,
s.not_purchased_count or 0,
)
for s in stats
}
latest_expenses_map = dict(
db.session.query(
Expense.list_id, func.coalesce(func.sum(Expense.amount), 0)
)
.filter(Expense.list_id.in_(all_ids))
.group_by(Expense.list_id)
.all()
)
for l in all_lists:
total_count, purchased_count, not_purchased_count = stats_map.get(
l.id, (0, 0, 0)
)
l.total_count = total_count
l.purchased_count = purchased_count
l.not_purchased_count = not_purchased_count
l.total_expense = latest_expenses_map.get(l.id, 0)
l.category_badges = [
{"name": c.name, "color": category_color_for(c)} for c in l.categories
]
else:
for l in all_lists:
l.total_count = 0
l.purchased_count = 0
l.not_purchased_count = 0
l.total_expense = 0
l.category_badges = []
def build_progress_summary(lists_):
total_lists = len(lists_)
total_products = sum(getattr(l, "total_count", 0) or 0 for l in lists_)
purchased_products = sum(getattr(l, "purchased_count", 0) or 0 for l in lists_)
not_purchased_products = sum(getattr(l, "not_purchased_count", 0) or 0 for l in lists_)
total_expense = float(sum((getattr(l, "total_expense", 0) or 0) for l in lists_))
completion_percent = (
(purchased_products / total_products) * 100 if total_products > 0 else 0
)
return {
"list_count": total_lists,
"total_products": total_products,
"purchased_products": purchased_products,
"not_purchased_products": not_purchased_products,
"remaining_products": max(total_products - purchased_products - not_purchased_products, 0),
"total_expense": round(total_expense, 2),
"completion_percent": completion_percent,
}
user_lists_summary = build_progress_summary(user_lists)
accessible_lists_summary = build_progress_summary(accessible_lists)
expiring_lists = get_expiring_lists_for_user(current_user.id) if current_user.is_authenticated else []
templates = (ListTemplate.query.filter_by(is_active=True, created_by=current_user.id).order_by(ListTemplate.name.asc()).all() if current_user.is_authenticated else [])
return render_template(
"main.html",
user_lists=user_lists,
public_lists=public_lists,
accessible_lists=accessible_lists,
archived_lists=archived_lists,
now=now,
timedelta=timedelta,
month_options=month_options,
selected_month=month_str,
expiring_lists=expiring_lists,
templates=templates,
user_lists_summary=user_lists_summary,
accessible_lists_summary=accessible_lists_summary,
)
@app.route("/system-auth", methods=["GET", "POST"])
def system_auth():
if (
current_user.is_authenticated
or request.cookies.get("authorized") == AUTHORIZED_COOKIE_VALUE
):
flash("Jesteś już zalogowany lub autoryzowany.", "info")
return redirect(url_for("main_page"))
ip = request.access_route[0]
next_page = request.args.get("next") or url_for("main_page")
if is_ip_blocked(ip):
flash(
"Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.",
"danger",
)
return render_template("system_auth.html"), 403
if request.method == "POST":
if request.form["password"] == SYSTEM_PASSWORD:
reset_failed_attempts(ip)
resp = redirect(next_page)
return set_authorized_cookie(resp)
else:
register_failed_attempt(ip)
if is_ip_blocked(ip):
flash(
"Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.",
"danger",
)
return render_template("system_auth.html"), 403
remaining = attempts_remaining(ip)
flash(f"Nieprawidłowe hasło. Pozostało {remaining} prób.", "warning")
return render_template("system_auth.html")
@app.route("/edit_my_list/<int:list_id>", methods=["GET", "POST"])
@login_required
def edit_my_list(list_id):
# --- Pobranie listy i weryfikacja właściciela ---
l = db.session.get(ShoppingList, list_id)
if l is None:
abort(404)
if l.owner_id != current_user.id:
abort(403, description="Nie jesteś właścicielem tej listy.")
# Dane do widoku
receipts = (
Receipt.query.filter_by(list_id=list_id)
.order_by(Receipt.uploaded_at.desc())
.all()
)
categories = Category.query.order_by(Category.name.asc()).all()
selected_categories_ids = {c.id for c in l.categories}
next_page = request.args.get("next") or request.referrer
wants_json = (
"application/json" in (request.headers.get("Accept") or "")
or request.headers.get("X-Requested-With") == "fetch"
)
if request.method == "POST":
action = request.form.get("action")
# --- Nadanie dostępu (grant) ---
if action == "grant":
grant_username = (request.form.get("grant_username") or "").strip().lower()
if not grant_username:
if wants_json:
return jsonify(ok=False, error="empty"), 400
flash("Podaj nazwę użytkownika do nadania dostępu.", "danger")
return redirect(next_page or request.url)
u = User.query.filter(func.lower(User.username) == grant_username).first()
if not u:
if wants_json:
return jsonify(ok=False, error="not_found"), 404
flash("Użytkownik nie istnieje.", "danger")
return redirect(next_page or request.url)
if u.id == current_user.id:
if wants_json:
return jsonify(ok=False, error="owner"), 409
flash("Jesteś właścicielem tej listy.", "info")
return redirect(next_page or request.url)
exists = (
db.session.query(ListPermission.id)
.filter(
ListPermission.list_id == l.id,
ListPermission.user_id == u.id,
)
.first()
)
if not exists:
db.session.add(ListPermission(list_id=l.id, user_id=u.id))
db.session.commit()
if wants_json:
return jsonify(ok=True, user={"id": u.id, "username": u.username})
flash(f"Nadano dostęp użytkownikowi „{u.username}”.", "success")
else:
if wants_json:
return jsonify(ok=False, error="exists"), 409
flash("Ten użytkownik już ma dostęp.", "info")
return redirect(next_page or request.url)
# --- Odebranie dostępu (revoke) ---
revoke_user_id = request.form.get("revoke_user_id")
if revoke_user_id:
try:
uid = int(revoke_user_id)
except ValueError:
if wants_json:
return jsonify(ok=False, error="bad_id"), 400
flash("Błędny identyfikator użytkownika.", "danger")
return redirect(next_page or request.url)
ListPermission.query.filter_by(list_id=l.id, user_id=uid).delete()
db.session.commit()
if wants_json:
return jsonify(ok=True, removed_user_id=uid)
flash("Odebrano dostęp użytkownikowi.", "success")
return redirect(next_page or request.url)
# --- Przywracanie z archiwum ---
if "unarchive" in request.form:
l.is_archived = False
db.session.commit()
if wants_json:
return jsonify(ok=True, unarchived=True)
flash(f"Lista „{l.title}” została przywrócona.", "success")
return redirect(next_page or request.url)
# --- Główny zapis pól formularza ---
move_to_month = request.form.get("move_to_month")
if move_to_month:
try:
year, month = map(int, move_to_month.split("-"))
l.created_at = datetime(year, month, 1, tzinfo=timezone.utc)
if not wants_json:
flash(
f"Zmieniono datę utworzenia listy na {l.created_at.strftime('%Y-%m-%d')}",
"success",
)
except ValueError:
if not wants_json:
flash(
"Nieprawidłowy format miesiąca — zignorowano zmianę miesiąca.",
"danger",
)
new_title = (request.form.get("title") or "").strip()
is_public = "is_public" in request.form
is_temporary = "is_temporary" in request.form
is_archived = "is_archived" in request.form
expires_date = request.form.get("expires_date")
expires_time = request.form.get("expires_time")
if not new_title:
if wants_json:
return jsonify(ok=False, error="title_empty"), 400
flash("Podaj poprawny tytuł", "danger")
return redirect(next_page or request.url)
l.title = new_title
l.is_public = is_public
l.is_temporary = is_temporary
l.is_archived = is_archived
if expires_date and expires_time:
try:
combined = f"{expires_date} {expires_time}"
expires_dt = datetime.strptime(combined, "%Y-%m-%d %H:%M")
l.expires_at = expires_dt.replace(tzinfo=timezone.utc)
except ValueError:
if wants_json:
return jsonify(ok=False, error="bad_expiry"), 400
flash("Błędna data lub godzina wygasania", "danger")
return redirect(next_page or request.url)
else:
l.expires_at = None
# Kategorie (używa Twojej pomocniczej funkcji)
update_list_categories_from_form(l, request.form)
db.session.commit()
if wants_json:
return jsonify(ok=True, saved=True)
flash("Zaktualizowano dane listy", "success")
return redirect(next_page or request.url)
# GET: użytkownicy z dostępem
permitted_users = (
db.session.query(User)
.join(ListPermission, ListPermission.user_id == User.id)
.where(ListPermission.list_id == l.id)
.order_by(User.username.asc())
.all()
)
all_usernames = [
u.username
for u in User.query.filter(User.id != current_user.id)
.order_by(func.lower(User.username).asc())
.limit(300)
.all()
]
return render_template(
"edit_my_list.html",
list=l,
receipts=receipts,
categories=categories,
selected_categories=selected_categories_ids,
permitted_users=permitted_users,
all_usernames=all_usernames,
)
@app.route("/edit_my_list/<int:list_id>/suggestions", methods=["GET"])
@login_required
def edit_my_list_suggestions(list_id: int):
# Weryfikacja listy i właściciela (prywatność)
l = db.session.get(ShoppingList, list_id)
if l is None:
abort(404)
if l.owner_id != current_user.id:
abort(403, description="Nie jesteś właścicielem tej listy.")
q = (request.args.get("q") or "").strip().lower()
# Historia nadawań uprawnień przez tego właściciela (po wszystkich jego listach)
subq = (
db.session.query(
ListPermission.user_id.label("uid"),
func.count(ListPermission.id).label("grant_count"),
func.max(ListPermission.id).label("last_grant_id"),
)
.join(ShoppingList, ShoppingList.id == ListPermission.list_id)
.filter(ShoppingList.owner_id == current_user.id)
.group_by(ListPermission.user_id)
.subquery()
)
query = (
db.session.query(User.username, subq.c.grant_count, subq.c.last_grant_id)
.outerjoin(subq, subq.c.uid == User.id)
.filter(User.id != current_user.id)
)
if q:
query = query.filter(func.lower(User.username).like(f"{q}%"))
rows = (
query.order_by(
func.coalesce(subq.c.grant_count, 0).desc(),
func.coalesce(subq.c.last_grant_id, 0).desc(),
func.lower(User.username).asc(),
)
.limit(20)
.all()
)
return jsonify({"users": [r.username for r in rows]})
@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)
delete_receipts_for_list(list_id)
Item.query.filter_by(list_id=list_id).delete()
Expense.query.filter_by(list_id=list_id).delete()
db.session.delete(l)
db.session.commit()
flash("Lista została usunięta", "success")
return redirect(url_for("main_page"))
@app.route("/toggle_visibility/<int:list_id>", methods=["GET", "POST"])
@login_required
def toggle_visibility(list_id):
l = db.session.get(ShoppingList, list_id)
if l is None:
abort(404)
if l.owner_id != current_user.id:
if request.is_json or request.method == "POST":
return {"error": "Unauthorized"}, 403
flash("Nie masz uprawnień do tej listy", "danger")
return redirect(url_for("main_page"))
l.is_public = not l.is_public
db.session.commit()
share_url = f"{request.url_root}share/{l.share_token}"
if request.is_json or request.method == "POST":
return {"is_public": l.is_public, "share_url": share_url}
if l.is_public:
flash("Lista została udostępniona publicznie", "success")
else:
flash("Lista została ukryta przed gośćmi", "info")
return redirect(url_for("main_page"))
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username_input = request.form["username"].lower()
user = User.query.filter(func.lower(User.username) == username_input).first()
if user and check_password(user.password_hash, request.form["password"]):
session.permanent = True
login_user(user)
session.modified = True
flash("Zalogowano pomyślnie", "success")
return redirect(url_for("main_page"))
flash("Nieprawidłowy login lub hasło", "danger")
return render_template("login.html")
@app.route("/logout")
@login_required
def logout():
logout_user()
flash("Wylogowano pomyślnie", "success")
return redirect(url_for("main_page"))
@app.route("/create", methods=["POST"])
@login_required
def create_list():
title = request.form.get("title")
is_temporary = request.form.get("temporary") == "1"
token = generate_share_token(8)
expires_at = (
datetime.now(timezone.utc) + timedelta(days=7) if is_temporary else None
)
new_list = ShoppingList(
title=title,
owner_id=current_user.id,
is_temporary=is_temporary,
share_token=token,
expires_at=expires_at,
)
db.session.add(new_list)
db.session.commit()
log_list_activity(new_list.id, 'list_created', actor=current_user, actor_name=current_user.username, details='Utworzono listę ręcznie')
db.session.commit()
flash("Utworzono nową listę", "success")
return redirect(url_for("view_list", list_id=new_list.id))
@app.route("/list/<int:list_id>")
@login_required
def view_list(list_id):
shopping_list = db.session.get(ShoppingList, list_id)
if not shopping_list:
abort(404)
is_owner = current_user.id == shopping_list.owner_id
if not is_owner:
flash(
"Nie jesteś właścicielem listy, przekierowano do widoku publicznego.",
"warning",
)
if current_user.is_admin:
flash(
"W celu modyfikacji listy, przejdź do panelu administracyjnego.", "info"
)
return redirect(url_for("shared_list", token=shopping_list.share_token))
shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id)
total_count = len(items)
purchased_count = len([i for i in items if i.purchased])
percent = (purchased_count / total_count * 100) if total_count > 0 else 0
for item in items:
if item.added_by != shopping_list.owner_id:
item.added_by_display = (
item.added_by_user.username if item.added_by_user else "?"
)
else:
item.added_by_display = None
shopping_list.category_badges = [
{"name": c.name, "color": category_color_for(c)}
for c in shopping_list.categories
]
# Wszystkie kategorie (do selecta)
categories = Category.query.order_by(Category.name.asc()).all()
selected_categories_ids = {c.id for c in shopping_list.categories}
# Najczęściej używane kategorie właściciela (top N)
popular_categories = (
db.session.query(Category)
.join(
shopping_list_category,
shopping_list_category.c.category_id == Category.id,
)
.join(
ShoppingList,
ShoppingList.id == shopping_list_category.c.shopping_list_id,
)
.filter(ShoppingList.owner_id == current_user.id)
.group_by(Category.id)
.order_by(func.count(ShoppingList.id).desc(), func.lower(Category.name).asc())
.limit(6)
.all()
)
# Użytkownicy z uprawnieniami do listy
permitted_users = (
db.session.query(User)
.join(ListPermission, ListPermission.user_id == User.id)
.filter(ListPermission.list_id == shopping_list.id)
.order_by(User.username.asc())
.all()
)
activity_logs = (
ListActivityLog.query.filter_by(list_id=list_id)
.order_by(ListActivityLog.created_at.desc(), ListActivityLog.id.desc())
.limit(20)
.all()
)
all_usernames = [
u.username
for u in User.query.filter(User.id != current_user.id)
.order_by(func.lower(User.username).asc())
.limit(300)
.all()
]
return render_template(
"list.html",
list=shopping_list,
items=items,
receipts=receipts,
total_count=total_count,
purchased_count=purchased_count,
percent=percent,
expenses=expenses,
total_expense=total_expense,
is_share=False,
is_owner=is_owner,
categories=categories,
selected_categories=selected_categories_ids,
permitted_users=permitted_users,
popular_categories=popular_categories,
activity_logs=activity_logs,
action_label=action_label,
all_usernames=all_usernames,
)
@app.route("/list/<int:list_id>/settings", methods=["POST"])
@login_required
def list_settings(list_id):
# Uprawnienia: właściciel
l = db.session.get(ShoppingList, list_id)
if l is None:
abort(404)
if l.owner_id != current_user.id:
abort(403, description="Brak uprawnień do ustawień tej listy.")
next_page = request.form.get("next") or url_for("view_list", list_id=list_id)
wants_json = (
"application/json" in (request.headers.get("Accept") or "")
or request.headers.get("X-Requested-With") == "fetch"
)
action = request.form.get("action")
# 1) Ustawienie kategorii (pojedynczy wybór z list.html -> modal kategorii)
if action == "set_category":
cid = request.form.get("category_id")
if cid in (None, "", "none"):
# usunięcie kategorii lub brak zmiany w zależności od Twojej logiki
l.categories = []
db.session.commit()
if wants_json:
return jsonify(ok=True, saved=True)
flash("Zapisano kategorię.", "success")
return redirect(next_page)
try:
cid = int(cid)
except (TypeError, ValueError):
if wants_json:
return jsonify(ok=False, error="bad_category"), 400
flash("Błędna kategoria.", "danger")
return redirect(next_page)
c = db.session.get(Category, cid)
if not c:
if wants_json:
return jsonify(ok=False, error="bad_category"), 400
flash("Błędna kategoria.", "danger")
return redirect(next_page)
# Jeśli jeden wybór zastąp listę kategorii jedną:
l.categories = [c]
db.session.commit()
if wants_json:
return jsonify(ok=True, saved=True)
flash("Zapisano kategorię.", "success")
return redirect(next_page)
# 2) Nadanie dostępu (akceptuj 'grant_access' i 'grant')
if action in ("grant_access", "grant"):
grant_username = (request.form.get("grant_username") or "").strip().lower()
if not grant_username:
if wants_json:
return jsonify(ok=False, error="empty_username"), 400
flash("Podaj nazwę użytkownika.", "danger")
return redirect(next_page)
# Szukamy użytkownika po username (case-insensitive)
u = User.query.filter(func.lower(User.username) == grant_username).first()
if not u:
if wants_json:
return jsonify(ok=False, error="not_found"), 404
flash("Użytkownik nie istnieje.", "danger")
return redirect(next_page)
# Właściciel już ma dostęp
if u.id == l.owner_id:
if wants_json:
return jsonify(ok=False, error="owner"), 409
flash("Jesteś właścicielem tej listy.", "info")
return redirect(next_page)
# Czy już ma dostęp?
exists = (
db.session.query(ListPermission.id)
.filter(ListPermission.list_id == l.id, ListPermission.user_id == u.id)
.first()
)
if exists:
if wants_json:
return jsonify(ok=False, error="exists"), 409
flash("Ten użytkownik już ma dostęp.", "info")
return redirect(next_page)
# Zapis uprawnienia
db.session.add(ListPermission(list_id=l.id, user_id=u.id))
db.session.commit()
if wants_json:
# Zwracamy usera, żeby JS mógł dokleić token bez odświeżania
return jsonify(ok=True, user={"id": u.id, "username": u.username})
flash(f"Nadano dostęp użytkownikowi „{u.username}”.", "success")
return redirect(next_page)
# 3) Odebranie dostępu (po polu revoke_user_id, nie po action)
revoke_uid = request.form.get("revoke_user_id")
if revoke_uid:
try:
uid = int(revoke_uid)
except (TypeError, ValueError):
if wants_json:
return jsonify(ok=False, error="bad_user_id"), 400
flash("Błędny identyfikator użytkownika.", "danger")
return redirect(next_page)
# Nie pozwalaj usunąć właściciela
if uid == l.owner_id:
if wants_json:
return jsonify(ok=False, error="cannot_revoke_owner"), 400
flash("Nie można odebrać dostępu właścicielowi.", "danger")
return redirect(next_page)
ListPermission.query.filter_by(list_id=l.id, user_id=uid).delete()
db.session.commit()
if wants_json:
return jsonify(ok=True, removed_user_id=uid)
flash("Odebrano dostęp użytkownikowi.", "success")
return redirect(next_page)
# 4) Nieznana akcja
if wants_json:
return jsonify(ok=False, error="unknown_action"), 400
flash("Nieznana akcja.", "danger")
return redirect(next_page)
@app.route('/my-templates', methods=['GET', 'POST'])
@login_required
def my_templates():
if request.method == 'POST':
action = (request.form.get('action') or 'create_manual').strip()
if action == 'create_manual':
name = (request.form.get('name') or '').strip()
description = (request.form.get('description') or '').strip()
raw_items = (request.form.get('items_text') or '').splitlines()
if not name:
flash('Podaj nazwę szablonu.', 'danger')
return redirect(url_for('my_templates'))
template = ListTemplate(name=name, description=description, created_by=current_user.id, is_active=True)
db.session.add(template)
db.session.flush()
pos = 1
for line in raw_items:
line = line.strip()
if not line:
continue
qty = 1
item_name = line
match = re.match(r'^(.*?)(?:\s+[xX](\d+))?$', line)
if match:
item_name = (match.group(1) or '').strip() or line
if match.group(2):
qty = max(1, int(match.group(2)))
db.session.add(ListTemplateItem(template_id=template.id, name=item_name, quantity=qty, position=pos))
pos += 1
db.session.commit()
flash(f'Utworzono szablon „{template.name}”.', 'success')
return redirect(url_for('my_templates'))
elif action == 'delete':
template = ListTemplate.query.options(joinedload(ListTemplate.items)).get_or_404(request.form.get('template_id', type=int))
if template.created_by != current_user.id and not current_user.is_admin:
abort(403)
db.session.delete(template)
db.session.commit()
flash(f'Usunięto szablon „{template.name}”.', 'warning')
return redirect(url_for('my_templates'))
templates = ListTemplate.query.options(joinedload(ListTemplate.items)).filter_by(created_by=current_user.id, is_active=True).order_by(ListTemplate.created_at.desc(), ListTemplate.id.desc()).all()
source_lists = ShoppingList.query.filter_by(owner_id=current_user.id, is_archived=False).order_by(ShoppingList.created_at.desc()).limit(100).all()
return render_template('my_templates.html', templates=templates, source_lists=source_lists)
@app.route('/templates/<int:template_id>/instantiate', methods=['POST'])
@login_required
def instantiate_template(template_id):
template = ListTemplate.query.get_or_404(template_id)
if not template_is_accessible_to_user(template, current_user):
abort(403)
title = (request.form.get('title') or '').strip() or None
new_list = create_list_from_template(template, owner=current_user, title=title)
log_list_activity(new_list.id, 'template_created', actor=current_user, details=f'Utworzono z szablonu: {template.name}')
db.session.commit()
flash(f'Utworzono listę z szablonu „{template.name}”.', 'success')
return redirect(url_for('view_list', list_id=new_list.id))
@app.route('/templates/create-from-list/<int:list_id>', methods=['POST'])
@login_required
def create_template_from_user_list(list_id):
source_list = ShoppingList.query.options(joinedload(ShoppingList.items)).get_or_404(list_id)
if source_list.owner_id != current_user.id and not current_user.is_admin:
abort(403)
name = (request.form.get('template_name') or '').strip() or f'{source_list.title} - szablon'
description = (request.form.get('description') or '').strip() or f'Szablon utworzony z listy {source_list.title}'
template = create_template_from_list(source_list, created_by=current_user.id, name=name, description=description)
flash(f'Utworzono szablon „{template.name}”.', 'success')
return redirect(url_for('my_templates'))

View File

@@ -0,0 +1,740 @@
from .deps import *
from .app_setup import *
from .models import *
from .helpers import *
@app.route("/expenses")
@login_required
def expenses():
start_date_str = request.args.get("start_date")
end_date_str = request.args.get("end_date")
category_id = request.args.get("category_id", type=str)
show_all = request.args.get("show_all", "true").lower() == "true"
now = datetime.now(timezone.utc)
visible_clause = visible_lists_clause_for_expenses(
user_id=current_user.id, include_shared=show_all, now_dt=now
)
lists_q = ShoppingList.query.filter(*visible_clause)
if start_date_str and end_date_str:
try:
start = datetime.strptime(start_date_str, "%Y-%m-%d")
end = datetime.strptime(end_date_str, "%Y-%m-%d") + timedelta(days=1)
lists_q = lists_q.filter(
ShoppingList.created_at >= start,
ShoppingList.created_at < end,
)
except ValueError:
flash("Błędny zakres dat", "danger")
if category_id:
if category_id == "none":
lists_q = lists_q.filter(~ShoppingList.categories.any())
else:
try:
cid = int(category_id)
lists_q = lists_q.join(
shopping_list_category,
shopping_list_category.c.shopping_list_id == ShoppingList.id,
).filter(shopping_list_category.c.category_id == cid)
except (TypeError, ValueError):
pass
lists_filtered = (
lists_q.options(
joinedload(ShoppingList.owner), joinedload(ShoppingList.categories)
)
.order_by(ShoppingList.created_at.desc())
.all()
)
list_ids = [l.id for l in lists_filtered] or [-1]
expenses = (
Expense.query.options(
joinedload(Expense.shopping_list).joinedload(ShoppingList.owner),
joinedload(Expense.shopping_list).joinedload(ShoppingList.categories),
)
.filter(Expense.list_id.in_(list_ids))
.order_by(Expense.added_at.desc())
.all()
)
totals_rows = (
db.session.query(
ShoppingList.id.label("lid"),
func.coalesce(func.sum(Expense.amount), 0).label("total_expense"),
)
.select_from(ShoppingList)
.filter(ShoppingList.id.in_(list_ids))
.outerjoin(Expense, Expense.list_id == ShoppingList.id)
.group_by(ShoppingList.id)
.all()
)
totals_map = {row.lid: float(row.total_expense or 0) for row in totals_rows}
categories = (
Category.query.join(
shopping_list_category, shopping_list_category.c.category_id == Category.id
)
.join(
ShoppingList, ShoppingList.id == shopping_list_category.c.shopping_list_id
)
.filter(ShoppingList.id.in_(list_ids))
.distinct()
.order_by(Category.name.asc())
.all()
)
categories.append(SimpleNamespace(id="none", name="Bez kategorii"))
expense_table = [
{
"title": (e.shopping_list.title if e.shopping_list else "Nieznana"),
"amount": e.amount,
"added_at": e.added_at,
}
for e in expenses
]
lists_data = [
{
"id": l.id,
"title": l.title,
"created_at": l.created_at,
"total_expense": totals_map.get(l.id, 0.0),
"owner_username": l.owner.username if l.owner else "?",
"categories": [c.id for c in l.categories],
}
for l in lists_filtered
]
return render_template(
"expenses.html",
expense_table=expense_table,
lists_data=lists_data,
categories=categories,
selected_category=category_id,
show_all=show_all,
)
@app.route("/expenses_data")
@login_required
def expenses_data():
range_type = request.args.get("range", "monthly")
start_date = request.args.get("start_date")
end_date = request.args.get("end_date")
show_all = request.args.get("show_all", "true").lower() == "true"
category_id = request.args.get("category_id")
by_category = request.args.get("by_category", "false").lower() == "true"
if not start_date or not end_date:
sd, ed, bucket = resolve_range(range_type)
if sd and ed:
start_date = sd
end_date = ed
range_type = bucket
if by_category:
result = get_total_expenses_grouped_by_category(
show_all=show_all,
range_type=range_type,
start_date=start_date,
end_date=end_date,
user_id=current_user.id,
category_id=category_id,
)
else:
result = get_total_expenses_grouped_by_list_created_at(
user_only=False,
admin=False,
show_all=show_all,
range_type=range_type,
start_date=start_date,
end_date=end_date,
user_id=current_user.id,
category_id=category_id,
)
if "error" in result:
return jsonify({"error": result["error"]}), 400
return jsonify(result)
@app.route("/api/expenses/latest")
@api_token_required
@require_api_scope('expenses:read')
def api_latest_expenses():
start_date_str = (request.args.get("start_date") or "").strip() or None
end_date_str = (request.args.get("end_date") or "").strip() or None
list_id = request.args.get("list_id", type=int)
owner_id = request.args.get("owner_id", type=int)
limit = request.args.get("limit", default=50, type=int) or 50
token_limit = getattr(g, 'api_token', None).max_limit if getattr(g, 'api_token', None) else 500
limit = max(1, min(limit, int(token_limit or 500), 500))
try:
start_date, end_date = parse_api_date_range(start_date_str, end_date_str)
except ValueError as exc:
return jsonify({"ok": False, "error": "bad_request", "message": str(exc)}), 400
filter_query = Expense.query.join(ShoppingList, ShoppingList.id == Expense.list_id)
if start_date:
filter_query = filter_query.filter(Expense.added_at >= start_date)
if end_date:
filter_query = filter_query.filter(Expense.added_at < end_date)
if list_id:
filter_query = filter_query.filter(Expense.list_id == list_id)
if owner_id:
filter_query = filter_query.filter(ShoppingList.owner_id == owner_id)
total_count = filter_query.with_entities(func.count(Expense.id)).scalar() or 0
total_amount = float(filter_query.with_entities(func.coalesce(func.sum(Expense.amount), 0)).scalar() or 0)
expenses = (
filter_query.options(
joinedload(Expense.shopping_list).joinedload(ShoppingList.owner),
joinedload(Expense.shopping_list).joinedload(ShoppingList.categories),
)
.order_by(Expense.added_at.desc(), Expense.id.desc())
.limit(limit)
.all()
)
items = []
for expense in expenses:
shopping_list = expense.shopping_list
owner = shopping_list.owner if shopping_list else None
items.append(
{
"expense_id": expense.id,
"amount": round(float(expense.amount or 0), 2),
"added_at": format_dt_for_api(expense.added_at),
"receipt_filename": expense.receipt_filename,
"list": {
"id": shopping_list.id if shopping_list else None,
"title": shopping_list.title if shopping_list else None,
"created_at": format_dt_for_api(shopping_list.created_at if shopping_list else None),
"is_archived": bool(shopping_list.is_archived) if shopping_list else None,
"is_public": bool(shopping_list.is_public) if shopping_list else None,
"categories": [c.name for c in shopping_list.categories] if shopping_list else [],
},
"owner": {
"id": owner.id if owner else None,
"username": owner.username if owner else None,
},
}
)
return jsonify(
{
"ok": True,
"filters": {
"start_date": start_date_str,
"end_date": end_date_str,
"list_id": list_id,
"owner_id": owner_id,
"limit": limit,
},
"meta": {
"returned_count": len(items),
"total_count": int(total_count),
"total_amount": round(total_amount, 2),
"token_name": g.api_token.name,
"token_prefix": g.api_token.token_prefix,
},
"items": items,
}
)
@app.route("/api/ping")
@api_token_required
def api_ping():
return jsonify({"ok": True, "message": "token accepted", "token_name": g.api_token.name, "token_prefix": g.api_token.token_prefix})
@app.route("/api/expenses/summary")
@api_token_required
@require_api_scope('expenses:read')
def api_expenses_summary():
start_date_str = (request.args.get("start_date") or "").strip() or None
end_date_str = (request.args.get("end_date") or "").strip() or None
list_id = request.args.get("list_id", type=int)
owner_id = request.args.get("owner_id", type=int)
try:
start_date, end_date = parse_api_date_range(start_date_str, end_date_str)
except ValueError as exc:
return jsonify({"ok": False, "error": "bad_request", "message": str(exc)}), 400
query = Expense.query.join(ShoppingList, ShoppingList.id == Expense.list_id)
if start_date:
query = query.filter(Expense.added_at >= start_date)
if end_date:
query = query.filter(Expense.added_at < end_date)
if list_id:
query = query.filter(Expense.list_id == list_id)
if owner_id:
query = query.filter(ShoppingList.owner_id == owner_id)
total_count = int(query.with_entities(func.count(Expense.id)).scalar() or 0)
total_amount = float(query.with_entities(func.coalesce(func.sum(Expense.amount), 0)).scalar() or 0)
by_list = (
query.with_entities(ShoppingList.id, ShoppingList.title, func.count(Expense.id), func.coalesce(func.sum(Expense.amount), 0))
.group_by(ShoppingList.id, ShoppingList.title)
.order_by(func.coalesce(func.sum(Expense.amount), 0).desc(), ShoppingList.id.desc())
.limit(100)
.all()
)
return jsonify({
"ok": True,
"filters": {"start_date": start_date_str, "end_date": end_date_str, "list_id": list_id, "owner_id": owner_id},
"meta": {"total_count": total_count, "total_amount": round(total_amount, 2)},
"lists": [{"id": row[0], "title": row[1], "expense_count": int(row[2] or 0), "total_amount": round(float(row[3] or 0), 2)} for row in by_list],
})
@app.route("/api/lists")
@api_token_required
@require_api_scope('lists:read')
def api_lists():
owner_id = request.args.get("owner_id", type=int)
limit = request.args.get("limit", default=50, type=int) or 50
token_limit = getattr(g, 'api_token', None).max_limit if getattr(g, 'api_token', None) else 500
limit = max(1, min(limit, int(token_limit or 500), 500))
query = ShoppingList.query.options(joinedload(ShoppingList.owner), joinedload(ShoppingList.categories)).order_by(ShoppingList.created_at.desc(), ShoppingList.id.desc())
if owner_id:
query = query.filter(ShoppingList.owner_id == owner_id)
rows = query.limit(limit).all()
return jsonify({
"ok": True,
"items": [{
"id": row.id,
"title": row.title,
"created_at": format_dt_for_api(row.created_at),
"owner": {"id": row.owner.id if row.owner else None, "username": row.owner.username if row.owner else None},
"is_temporary": bool(row.is_temporary),
"expires_at": format_dt_for_api(row.expires_at),
"is_archived": bool(row.is_archived),
"is_public": bool(row.is_public),
"categories": [c.name for c in row.categories],
} for row in rows],
})
@app.route("/api/lists/<int:list_id>/expenses")
@api_token_required
@require_api_scope('lists:read')
def api_list_expenses(list_id):
limit = request.args.get("limit", default=50, type=int) or 50
token_limit = getattr(g, 'api_token', None).max_limit if getattr(g, 'api_token', None) else 500
limit = max(1, min(limit, int(token_limit or 500), 500))
shopping_list = ShoppingList.query.options(joinedload(ShoppingList.owner), joinedload(ShoppingList.categories)).get_or_404(list_id)
rows = Expense.query.filter_by(list_id=list_id).order_by(Expense.added_at.desc(), Expense.id.desc()).limit(limit).all()
return jsonify({
"ok": True,
"list": {
"id": shopping_list.id,
"title": shopping_list.title,
"owner": {"id": shopping_list.owner.id if shopping_list.owner else None, "username": shopping_list.owner.username if shopping_list.owner else None},
"categories": [c.name for c in shopping_list.categories],
},
"items": [{"expense_id": row.id, "amount": round(float(row.amount or 0), 2), "added_at": format_dt_for_api(row.added_at), "receipt_filename": row.receipt_filename} for row in rows],
})
@app.route("/api/templates")
@api_token_required
@require_api_scope('templates:read')
def api_templates():
query = ListTemplate.query.options(joinedload(ListTemplate.creator), joinedload(ListTemplate.items)).filter_by(is_active=True)
owner_id = request.args.get("owner_id", type=int)
if owner_id:
query = query.filter(ListTemplate.created_by == owner_id)
rows = query.order_by(ListTemplate.created_at.desc(), ListTemplate.id.desc()).limit(100).all()
return jsonify({
"ok": True,
"items": [{
"id": row.id,
"name": row.name,
"description": row.description,
"created_at": format_dt_for_api(row.created_at),
"owner": {"id": row.creator.id if row.creator else None, "username": row.creator.username if row.creator else None},
"items_count": len(row.items),
"items": [{"name": item.name, "quantity": item.quantity, "note": item.note} for item in row.items],
} for row in rows],
})
@app.route("/share/<token>")
# @app.route("/guest-list/<int:list_id>")
@app.route("/shared/<int:list_id>")
def shared_list(token=None, list_id=None):
now = datetime.now(timezone.utc)
if token:
shopping_list = ShoppingList.query.filter_by(share_token=token).first_or_404()
expires_at = shopping_list.expires_at
if expires_at and expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
# jeśli lista wygasła zablokuj (spójne z resztą aplikacji)
if shopping_list.is_temporary and expires_at and expires_at <= now:
flash("Link wygasł.", "warning")
return redirect(url_for("main_page"))
list_id = shopping_list.id
# jeśli zalogowany i nie jest właścicielem — auto-przypisz stałe uprawnienie
if current_user.is_authenticated and current_user.id != shopping_list.owner_id:
exists = (
db.session.query(ListPermission.id)
.filter(
ListPermission.list_id == shopping_list.id,
ListPermission.user_id == current_user.id,
)
.first()
)
if not exists:
db.session.add(
ListPermission(list_id=shopping_list.id, user_id=current_user.id)
)
db.session.commit()
else:
shopping_list = ShoppingList.query.get_or_404(list_id)
expires_at = shopping_list.expires_at
if expires_at and expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if shopping_list.is_temporary and expires_at and expires_at <= now:
flash("Ta lista wygasła.", "warning")
return redirect(url_for("main_page"))
is_allowed = shopping_list.is_public
if current_user.is_authenticated:
is_allowed = is_allowed or shopping_list.owner_id == current_user.id or (
db.session.query(ListPermission.id)
.filter(
ListPermission.list_id == shopping_list.id,
ListPermission.user_id == current_user.id,
)
.first()
is not None
)
if not is_allowed:
flash("Ta lista nie jest publicznie dostępna.", "warning")
return redirect(url_for("main_page"))
total_expense = get_total_expense_for_list(list_id)
shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id)
shopping_list.category_badges = [
{"name": c.name, "color": category_color_for(c)}
for c in shopping_list.categories
]
for item in items:
if item.added_by != shopping_list.owner_id:
item.added_by_display = (
item.added_by_user.username if item.added_by_user else "?"
)
else:
item.added_by_display = None
return render_template(
"list_share.html",
list=shopping_list,
items=items,
receipts=receipts,
expenses=expenses,
total_expense=total_expense,
is_share=True,
)
@app.route("/copy/<int:list_id>")
@login_required
def copy_list(list_id):
original = ShoppingList.query.get_or_404(list_id)
token = generate_share_token(8)
new_list = ShoppingList(
title=original.title + " (Kopia)", owner_id=current_user.id, share_token=token
)
db.session.add(new_list)
db.session.commit()
original_items = Item.query.filter_by(list_id=original.id).all()
for item in original_items:
copy_item = Item(list_id=new_list.id, name=item.name)
db.session.add(copy_item)
db.session.commit()
flash("Skopiowano listę", "success")
return redirect(url_for("view_list", list_id=new_list.id))
@app.route("/suggest_products")
def suggest_products():
query = request.args.get("q", "")
suggestions = []
if query:
suggestions = (
SuggestedProduct.query.filter(SuggestedProduct.name.ilike(f"%{query}%"))
.limit(5)
.all()
)
return {"suggestions": [s.name for s in suggestions]}
@app.route("/all_products")
def all_products():
sort = request.args.get("sort", "popularity")
limit = request.args.get("limit", type=int) or 100
offset = request.args.get("offset", type=int) or 0
products_from_items = db.session.query(
func.lower(func.trim(Item.name)).label("normalized_name"),
func.min(Item.name).label("display_name"),
func.count(func.distinct(Item.list_id)).label("count"),
).group_by(func.lower(func.trim(Item.name)))
products_from_suggested = (
db.session.query(
func.lower(func.trim(SuggestedProduct.name)).label("normalized_name"),
func.min(SuggestedProduct.name).label("display_name"),
db.literal(1).label("count"),
)
.filter(
~func.lower(func.trim(SuggestedProduct.name)).in_(
db.session.query(func.lower(func.trim(Item.name)))
)
)
.group_by(func.lower(func.trim(SuggestedProduct.name)))
)
union_q = products_from_items.union_all(products_from_suggested).subquery()
final_q = db.session.query(
union_q.c.normalized_name,
union_q.c.display_name,
func.sum(union_q.c.count).label("count"),
).group_by(union_q.c.normalized_name, union_q.c.display_name)
if sort == "alphabetical":
final_q = final_q.order_by(func.lower(union_q.c.display_name).asc())
else:
final_q = final_q.order_by(
func.sum(union_q.c.count).desc(), func.lower(union_q.c.display_name).asc()
)
total_count = (
db.session.query(func.count()).select_from(final_q.subquery()).scalar()
)
products = final_q.offset(offset).limit(limit).all()
out = [{"name": row.display_name, "count": row.count} for row in products]
return jsonify({"products": out, "total_count": total_count})
@app.route("/upload_receipt/<int:list_id>", methods=["POST"])
@login_required
def upload_receipt(list_id):
l = db.session.get(ShoppingList, list_id)
file = request.files.get("receipt")
if not file or file.filename == "":
return receipt_error("Nie wybrano pliku")
if not allowed_file(file.filename):
return receipt_error("Niedozwolony format pliku")
file_bytes = file.read()
file.seek(0)
file_hash = hashlib.sha256(file_bytes).hexdigest()
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:
if file.filename.lower().endswith(".pdf"):
file.seek(0)
save_pdf_as_webp(file, file_path)
else:
save_resized_image(file, file_path)
except ValueError as e:
return receipt_error(str(e))
try:
new_receipt = Receipt(
list_id=list_id,
filename=webp_filename,
filesize=os.path.getsize(file_path),
uploaded_at=now,
file_hash=file_hash,
uploaded_by=current_user.id,
version_token=generate_version_token(),
)
db.session.add(new_receipt)
db.session.commit()
except Exception as e:
return receipt_error(f"Błąd zapisu do bazy: {str(e)}")
if request.is_json or request.headers.get("X-Requested-With") == "XMLHttpRequest":
url = (
url_for("uploaded_file", filename=webp_filename)
+ f"?v={new_receipt.version_token or '0'}"
)
socketio.emit("receipt_added", {"url": url}, to=str(list_id))
return jsonify({"success": True, "url": url})
flash("Wgrano paragon", "success")
return redirect(request.referrer or url_for("main_page"))
@app.route("/uploads/<filename>")
def uploaded_file(filename):
response = send_from_directory(app.config["UPLOAD_FOLDER"], filename)
response.headers["Cache-Control"] = app.config["UPLOADS_CACHE_CONTROL"]
response.headers.pop("Content-Disposition", None)
mime, _ = mimetypes.guess_type(filename)
if mime:
response.headers["Content-Type"] = mime
return response
@app.route("/reorder_items", methods=["POST"])
@login_required
def reorder_items():
data = request.get_json()
list_id = data.get("list_id")
order = data.get("order")
for index, item_id in enumerate(order):
item = db.session.get(Item, item_id)
if item and item.list_id == list_id:
item.position = index
db.session.commit()
socketio.emit(
"items_reordered", {"list_id": list_id, "order": order}, to=str(list_id)
)
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)
recalculate_filesizes(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:
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("/user_crop_receipt", methods=["POST"])
@login_required
def crop_receipt_user():
receipt_id = request.form.get("receipt_id")
file = request.files.get("cropped_image")
receipt = Receipt.query.get_or_404(receipt_id)
list_obj = ShoppingList.query.get_or_404(receipt.list_id)
if list_obj.owner_id != current_user.id and not current_user.is_admin:
return jsonify(success=False, error="Brak dostępu"), 403
result = handle_crop_receipt(receipt_id, file)
return jsonify(result)

622
shopping_app/sockets.py Normal file
View File

@@ -0,0 +1,622 @@
import click
from .deps import *
from .app_setup import *
from .models import *
from .helpers import *
from flask import render_template_string
@app.route('/admin/debug-socket')
@login_required
@admin_required
def debug_socket():
return render_template_string('''
<!DOCTYPE html>
<html>
<head>
<title>Socket Debug</title>
<script src="{{ url_for('static_bp.serve_js_lib', filename='socket.io.min.js') }}?v={{ APP_VERSION }}"></script>
<style>
body { font-family: monospace; background: #1e1e1e; color: #fff; padding: 20px; }
#log { height: 400px; overflow-y: scroll; background: #2d2d2d; padding: 15px; border-radius: 8px; margin: 10px 0; white-space: pre-wrap; }
button { background: #007bff; color: white; border: none; padding: 10px 20px; margin: 5px; border-radius: 5px; cursor: pointer; }
button:hover { background: #0056b3; }
.status { font-size: 18px; font-weight: bold; margin: 10px 0; }
.connected { color: #28a745; }
.disconnected { color: #dc3545; }
</style>
</head>
<body>
<h1>Socket.IO Debug Tool</h1>
<div id="status" class="status disconnected">Rozlaczony</div>
<div id="info">
Transport: <span id="transport">-</span> |
Ping: <span id="ping">-</span>ms |
SID: <span id="sid">-</span>
</div>
<button onclick="connect()">Polacz</button>
<button onclick="disconnect()">Rozlacz</button>
<button onclick="emitTest()">Emit Test</button>
<button onclick="forcePolling()">Force Polling</button>
<h3>Logi:</h3>
<div id="log"></div>
<script>
let socket;
let logLines = 0;
let isPollingOnly = true;
function log(msg, color = '#fff') {
const logEl = document.getElementById('log');
const time = new Date().toLocaleTimeString();
logEl.innerHTML += `[${time}] ${msg}\n`;
logEl.scrollTop = logEl.scrollHeight;
logLines++;
if (logLines > 200) {
const lines = logEl.innerHTML.split('\\n');
logEl.innerHTML = lines.slice(-200).join('\\n');
logLines = 200;
}
}
function updateStatus(connected) {
const status = document.getElementById('status');
status.textContent = connected ? 'Polaczony' : 'Rozlaczony';
status.className = `status ${connected ? 'connected' : 'disconnected'}`;
}
function connect() {
if (socket) {
socket.disconnect();
socket = null;
}
const transports = isPollingOnly ? ['polling'] : ['polling', 'websocket'];
log(`Polaczenie z: ${transports.join(', ')}`);
socket = io('', {
transports: transports,
timeout: 20000,
autoConnect: false,
forceNew: true
});
socket.on('connect', function() {
log('CONNECTED OK');
updateStatus(true);
try {
const transport = socket.io.engine.transport.name;
document.getElementById('transport').textContent = transport;
document.getElementById('sid').textContent = socket.id.substring(0,8) + '...';
} catch(e) {
log('Transport info error: ' + e.message);
}
socket.emit('requestfulllist', {listid: 1});
});
socket.on('disconnect', function(reason) {
log('DISCONNECTED: ' + reason);
updateStatus(false);
});
socket.on('connect_error', function(err) {
log('CONNECT ERROR: ' + err.message + ' (' + (err.type || 'unknown') + ')');
});
socket.onAny(function(event, ...args) {
log('RECV ' + event + ': ' + JSON.stringify(args).substring(0,100));
});
socket.connect();
}
function disconnect() {
if (socket) {
socket.disconnect();
socket = null;
}
}
function emitTest() {
if (!socket || !socket.connected) {
log('Niepolaczony!');
return;
}
const now = Date.now();
socket.emit('pingtest', now);
log('SENT pingtest ' + now);
}
function forcePolling() {
isPollingOnly = !isPollingOnly;
log('Polling only: ' + isPollingOnly);
connect();
}
// STATUS check co 30s
setInterval(function() {
if (socket && socket.connected) {
const transport = socket.io.engine ? socket.io.engine.transport.name : 'unknown';
log('STATUS OK: ' + transport + ' | SID: ' + (socket.id ? socket.id.substring(0,8) : 'none'));
emitTest();
} else {
log('STATUS: Offline');
}
}, 30000);
// Start
connect();
</script>
</body>
</html>
''')
# =========================================================================================
# SOCKET.IO
# =========================================================================================
@socketio.on("delete_item")
def handle_delete_item(data):
# item = Item.query.get(data["item_id"])
item = db.session.get(Item, data["item_id"])
if item:
list_id = item.list_id
log_list_activity(list_id, 'item_deleted', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość')
db.session.delete(item)
db.session.commit()
emit("item_deleted", {"item_id": item.id}, to=str(item.list_id))
purchased_count, total_count, percent = get_progress(list_id)
emit(
"progress_updated",
{
"purchased_count": purchased_count,
"total_count": total_count,
"percent": percent,
},
to=str(list_id),
)
@socketio.on("edit_item")
def handle_edit_item(data):
item = db.session.get(Item, data["item_id"])
new_name = data["new_name"]
new_quantity = data.get("new_quantity", item.quantity)
if item and new_name.strip():
item.name = new_name.strip()
try:
new_quantity = int(new_quantity)
if new_quantity < 1:
new_quantity = 1
except:
new_quantity = 1
item.quantity = new_quantity
db.session.commit()
emit(
"item_edited",
{"item_id": item.id, "new_name": item.name, "new_quantity": item.quantity},
to=str(item.list_id),
)
@socketio.on("join_list")
def handle_join(data):
global active_users
room = str(data["room"])
username = data.get("username", "Gość")
join_room(room)
if room not in active_users:
active_users[room] = set()
active_users[room].add(username)
shopping_list = db.session.get(ShoppingList, int(data["room"]))
list_title = shopping_list.title if shopping_list else "Twoja lista"
emit("user_joined", {"username": username}, to=room)
emit("user_list", {"users": list(active_users[room])}, to=room)
emit("joined_confirmation", {"room": room, "list_title": list_title})
@socketio.on("disconnect")
def handle_disconnect(sid):
global active_users
username = current_user.username if current_user.is_authenticated else "Gość"
for room, users in active_users.items():
if username in users:
users.remove(username)
emit("user_left", {"username": username}, to=room)
emit("user_list", {"users": list(users)}, to=room)
@socketio.on("add_item")
def handle_add_item(data):
list_id = data["list_id"]
name = data["name"].strip()
quantity = data.get("quantity", 1)
list_obj = db.session.get(ShoppingList, list_id)
if not list_obj:
return
try:
quantity = int(quantity)
if quantity < 1:
quantity = 1
except:
quantity = 1
existing_item = Item.query.filter(
Item.list_id == list_id,
func.lower(Item.name) == name.lower(),
Item.not_purchased == False,
).first()
if existing_item:
existing_item.quantity += quantity
db.session.commit()
emit(
"item_edited",
{
"item_id": existing_item.id,
"new_name": existing_item.name,
"new_quantity": existing_item.quantity,
},
to=str(list_id),
)
else:
max_position = (
db.session.query(func.max(Item.position))
.filter_by(list_id=list_id)
.scalar()
)
if max_position is None:
max_position = 0
user_id = current_user.id if current_user.is_authenticated else None
user_name = current_user.username if current_user.is_authenticated else "Gość"
new_item = Item(
list_id=list_id,
name=name,
quantity=quantity,
position=max_position + 1,
added_by=user_id,
)
db.session.add(new_item)
if not SuggestedProduct.query.filter(
func.lower(SuggestedProduct.name) == name.lower()
).first():
new_suggestion = SuggestedProduct(name=name)
db.session.add(new_suggestion)
log_list_activity(list_id, 'item_added', item_name=new_item.name, actor=current_user if current_user.is_authenticated else None, actor_name=user_name, details=f'ilość: {new_item.quantity}')
db.session.commit()
emit(
"item_added",
{
"id": new_item.id,
"name": new_item.name,
"quantity": new_item.quantity,
"added_by": user_name,
"added_by_id": user_id,
"owner_id": list_obj.owner_id,
},
to=str(list_id),
include_self=True,
)
purchased_count, total_count, percent = get_progress(list_id)
emit(
"progress_updated",
{
"purchased_count": purchased_count,
"total_count": total_count,
"percent": percent,
},
to=str(list_id),
)
@socketio.on("check_item")
def handle_check_item(data):
item = db.session.get(Item, data["item_id"])
if item:
item.purchased = True
item.purchased_at = datetime.now(UTC)
item.not_purchased = False
item.not_purchased_reason = None
log_list_activity(item.list_id, 'item_checked', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość')
db.session.commit()
purchased_count, total_count, percent = get_progress(item.list_id)
emit("item_checked", {"item_id": item.id}, to=str(item.list_id))
emit(
"progress_updated",
{
"purchased_count": purchased_count,
"total_count": total_count,
"percent": percent,
},
to=str(item.list_id),
)
@socketio.on("uncheck_item")
def handle_uncheck_item(data):
item = db.session.get(Item, data["item_id"])
if item:
item.purchased = False
item.purchased_at = None
log_list_activity(item.list_id, 'item_unchecked', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość')
db.session.commit()
purchased_count, total_count, percent = get_progress(item.list_id)
emit("item_unchecked", {"item_id": item.id}, to=str(item.list_id))
emit(
"progress_updated",
{
"purchased_count": purchased_count,
"total_count": total_count,
"percent": percent,
},
to=str(item.list_id),
)
@socketio.on("request_full_list")
def handle_request_full_list(data):
list_id = data["list_id"]
shopping_list = db.session.get(ShoppingList, list_id)
if not shopping_list:
return
owner_id = shopping_list.owner_id
items = (
Item.query.options(joinedload(Item.added_by_user))
.filter_by(list_id=list_id)
.order_by(Item.position.asc())
.all()
)
items_data = []
for item in items:
items_data.append(
{
"id": item.id,
"name": item.name,
"quantity": item.quantity,
"purchased": item.purchased if not item.not_purchased else False,
"not_purchased": item.not_purchased,
"not_purchased_reason": item.not_purchased_reason,
"note": item.note or "",
"added_by": item.added_by_user.username if item.added_by_user else None,
"added_by_id": item.added_by_user.id if item.added_by_user else None,
"owner_id": owner_id,
}
)
emit("full_list", {"items": items_data}, to=request.sid)
@socketio.on("update_note")
def handle_update_note(data):
item_id = data["item_id"]
note = data["note"]
item = Item.query.get(item_id)
if item:
item.note = note
db.session.commit()
emit("note_updated", {"item_id": item_id, "note": note}, to=str(item.list_id))
@socketio.on("add_expense")
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
)
db.session.add(new_expense)
log_list_activity(list_id, 'expense_added', item_name=None, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość', details=f'kwota: {float(amount):.2f} PLN')
db.session.commit()
total = (
db.session.query(func.sum(Expense.amount)).filter_by(list_id=list_id).scalar()
or 0
)
emit("expense_added", {"amount": amount, "total": total}, to=str(list_id))
@socketio.on("mark_not_purchased")
def handle_mark_not_purchased(data):
item = db.session.get(Item, data["item_id"])
reason = data.get("reason", "")
if item:
item.not_purchased = True
item.not_purchased_reason = reason
item.purchased = False
item.purchased_at = None
log_list_activity(item.list_id, 'item_marked_not_purchased', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość', details=reason or None)
db.session.commit()
emit(
"item_marked_not_purchased",
{"item_id": item.id, "reason": reason},
to=str(item.list_id),
)
@socketio.on("unmark_not_purchased")
def handle_unmark_not_purchased(data):
item = db.session.get(Item, data["item_id"])
if item:
item.not_purchased = False
item.purchased = False
item.purchased_at = None
item.not_purchased_reason = None
log_list_activity(item.list_id, 'item_unmarked_not_purchased', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość')
db.session.commit()
emit("item_unmarked_not_purchased", {"item_id": item.id}, to=str(item.list_id))
@app.cli.command("db_info")
def create_db():
with app.app_context():
inspector = inspect(db.engine)
actual_tables = inspector.get_table_names()
table_count = len(actual_tables)
record_total = 0
with db.engine.connect() as conn:
for table in actual_tables:
try:
count = conn.execute(text(f"SELECT COUNT(*) FROM {table}")).scalar()
record_total += count
except Exception:
pass
print("\nStruktura bazy danych jest poprawna.")
print(f"Silnik: {db.engine.name}")
print(f"Liczba tabel: {table_count}")
print(f"Łączna liczba rekordów: {record_total}")
if __name__ == "__main__":
logging.basicConfig(level=logging.DEBUG if DEBUG_MODE else logging.INFO)
socketio.run(app, host="0.0.0.0", port=APP_PORT, debug=False)
@app.cli.group("admins")
def admins_cli():
"""Zarzadzanie kontami administratorow z CLI."""
@admins_cli.command("list")
def admins_list_command():
with app.app_context():
users = User.query.order_by(User.username.asc()).all()
if not users:
click.echo('Brak uzytkownikow.')
return
for user in users:
role = 'admin' if user.is_admin else 'user'
click.echo(f"{user.id} {user.username} {role}")
@admins_cli.command("create")
@click.argument("username")
@click.argument("password")
@click.option("--admin/--user", "make_admin", default=True, show_default=True, help="Utworz konto admina albo zwyklego uzytkownika.")
def admins_create_command(username, password, make_admin):
with app.app_context():
user, created, _ = create_or_update_admin_user(username, password=password, make_admin=make_admin, update_password=False)
status = 'Utworzono' if created else 'Istnieje juz'
click.echo(f"{status} konto: id={user.id}, username={user.username}, admin={user.is_admin}")
@admins_cli.command("promote")
@click.argument("username")
def admins_promote_command(username):
with app.app_context():
user = resolve_user_identifier(username)
if not user:
raise click.ClickException('Nie znaleziono uzytkownika.')
user.is_admin = True
db.session.commit()
click.echo(f"Uzytkownik {user.username} ma teraz uprawnienia admina.")
@admins_cli.command("demote")
@click.argument("username")
def admins_demote_command(username):
with app.app_context():
user = resolve_user_identifier(username)
if not user:
raise click.ClickException('Nie znaleziono uzytkownika.')
user.is_admin = False
db.session.commit()
click.echo(f"Uzytkownik {user.username} nie jest juz adminem.")
@admins_cli.command("set-password")
@click.argument("username")
@click.argument("password")
def admins_set_password_command(username, password):
with app.app_context():
user = resolve_user_identifier(username)
if not user:
raise click.ClickException('Nie znaleziono uzytkownika.')
user.password_hash = hash_password(password)
db.session.commit()
click.echo(f"Zmieniono haslo dla {user.username}.")
@app.cli.group("lists")
def lists_cli():
"""Operacje CLI na listach zakupowych."""
@lists_cli.command("copy-schedule")
@click.option("--source-list-id", required=True, type=int, help="ID listy zrodlowej.")
@click.option("--when", "when_value", required=True, help="Nowa data utworzenia listy: YYYY-MM-DD lub YYYY-MM-DD HH:MM")
@click.option("--owner", "owner_value", default=None, help="Nowy wlasciciel: username albo ID. Domyslnie wlasciciel oryginalu.")
@click.option("--title", default=None, help="Nowy tytul listy. Domyslnie taki sam jak w oryginale.")
def lists_copy_schedule_command(source_list_id, when_value, owner_value, title):
with app.app_context():
source_list = ShoppingList.query.options(joinedload(ShoppingList.items), joinedload(ShoppingList.categories)).get(source_list_id)
if not source_list:
raise click.ClickException('Nie znaleziono listy zrodlowej.')
try:
scheduled_for = parse_cli_datetime(when_value)
except ValueError as exc:
raise click.ClickException(str(exc))
owner = None
if owner_value:
owner = resolve_user_identifier(owner_value)
if not owner:
raise click.ClickException('Nie znaleziono docelowego wlasciciela.')
new_list = duplicate_list_for_schedule(source_list, scheduled_for=scheduled_for, owner=owner, title=title)
click.echo(
f"Utworzono kopie listy: nowa_id={new_list.id}, tytul={new_list.title}, created_at={new_list.created_at.isoformat()}"
)

View File

@@ -0,0 +1,95 @@
import os
import sys
import platform
import socket
from datetime import datetime
import psutil
try:
from sqlalchemy import text
except Exception:
text = None
def mb(x):
return int(x / 1024 / 1024)
def get_db_type(app):
uri = app.config.get("SQLALCHEMY_DATABASE_URI") or app.config.get("DATABASE_URL", "")
if not uri:
return "NONE"
if uri.startswith("sqlite"):
return "SQLite"
if uri.startswith("mysql"):
return "MySQL"
if uri.startswith("postgresql"):
return "PostgreSQL"
return "OTHER"
def print_startup_info(app):
host = os.getenv("HOST", "127.0.0.1")
port = int(os.getenv("PORT", "8000"))
rules = list(app.url_map.iter_rules())
cpu = psutil.cpu_percent(interval=0.2)
ram = psutil.virtual_memory()
proc = psutil.Process(os.getpid())
db_type = get_db_type(app)
print("\n" + "="*52)
print(" APP START")
print("="*52)
# SYSTEM
print("\n[ SYSTEM ]")
print(f"Time : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"OS : {platform.system()} {platform.release()} ({platform.machine()})")
print(f"Python : {sys.version.split()[0]}")
print(f"Host : {socket.gethostname()}")
# SERVER
print("\n[ SERVER ]")
print(f"Bind : {host}:{port}")
print(f"URL : http://127.0.0.1:{port}")
# APP
print("\n[ APP ]")
print(f"Name : {app.name}")
print(f"Mode : {'DEV' if app.debug else 'PROD'}")
print(f"Debug : {app.debug}")
# RESOURCES
print("\n[ RESOURCES ]")
print(f"CPU : {cpu:>5.1f}%")
print(f"RAM : {ram.percent:>5.1f}% ({mb(ram.used)} / {mb(ram.total)} MB)")
print(f"PROC : {mb(proc.memory_info().rss)} MB")
# DATABASE
print("\n[ DATABASE ]")
print(f"Type : {db_type}")
# SECURITY
print("\n[ SECURITY ]")
print(f"Secret : {'OK' if app.config.get('SECRET_KEY') else 'MISSING'}")
print(f"Talis : {'OFF' if app.config.get('TALISMAN_DISABLED') else 'ON'}")
# HEALTH
print("\n[ HEALTH ]")
print(f"Uploads: {'OK' if os.path.exists('uploads') else 'MISS'}")
print(f"Static : {'OK' if os.path.exists(app.static_folder) else 'MISS'}")
# ROUTES
print("\n[ ROUTES ]")
print(f"Total : {len(rules)}")
# STATUS
print("\n[ STATUS ]")
print("READY")
print("="*52 + "\n")

File diff suppressed because it is too large Load Diff

View File

@@ -29,7 +29,7 @@
async function postAction(postUrl, nextPath, params) {
const form = new FormData();
for (const [k, v] of Object.entries(params)) form.set(k, v);
form.set('next', nextPath); // dla trybu HTML fallback
form.set('next', nextPath);
try {
const res = await fetch(postUrl, {
@@ -61,13 +61,16 @@
const suggestUrl = box.dataset.suggestUrl || '';
const grantAction = box.dataset.grantAction || 'grant';
const revokeField = box.dataset.revokeField || 'revoke_user_id';
const listId = box.dataset.listId || '';
const tokensBox = $('.tokens', box);
const input = $('.access-input', box);
const addBtn = $('.access-add', box);
// współdzielony datalist do sugestii
let datalist = $('#userHintsGeneric');
let datalist = null;
const existingListId = input?.getAttribute('list');
if (existingListId) datalist = document.getElementById(existingListId);
if (!datalist) datalist = $('#userHintsGeneric');
if (!datalist) {
datalist = document.createElement('datalist');
datalist.id = 'userHintsGeneric';
@@ -79,25 +82,32 @@
const parseUserText = (txt) => unique((txt || '').split(/[\s,;]+/g).map(s => s.trim().replace(/^@/, '').toLowerCase()).filter(Boolean));
const debounce = (fn, ms = 200) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
// Sugestie (GET JSON)
const renderHints = (users = []) => { datalist.innerHTML = users.slice(0, 20).map(u => `<option value="${u}">@${u}</option>`).join(''); };
const initialOptions = Array.from(datalist.querySelectorAll('option')).map(o => o.value).filter(Boolean);
const renderHints = (users = []) => {
const merged = unique([...(users || []), ...initialOptions]).slice(0, 20);
datalist.innerHTML = merged.map(u => `<option value="${u}"></option>`).join('');
};
renderHints(initialOptions);
let acCtrl = null;
const fetchHints = debounce(async (q) => {
if (!suggestUrl) return;
try {
acCtrl?.abort();
acCtrl = new AbortController();
const res = await fetch(`${suggestUrl}?q=${encodeURIComponent(q || '')}`, { credentials: 'same-origin', signal: acCtrl.signal });
const normalized = String(q || '').trim().replace(/^@/, '');
const res = await fetch(`${suggestUrl}?q=${encodeURIComponent(normalized)}`, { credentials: 'same-origin', signal: acCtrl.signal });
if (!res.ok) return renderHints([]);
const data = await res.json().catch(() => ({ users: [] }));
renderHints(data.users || []);
} catch { renderHints([]); }
} catch {
renderHints(initialOptions);
}
}, 200);
input?.addEventListener('focus', () => fetchHints(input.value));
input?.addEventListener('input', () => fetchHints(input.value));
// Revoke (klik w token)
box.addEventListener('click', async (e) => {
const btn = e.target.closest('.token');
if (!btn || !box.contains(btn)) return;
@@ -107,7 +117,7 @@
if (!userId) return toast('Brak identyfikatora użytkownika.', 'danger');
btn.disabled = true; btn.classList.add('disabled');
const res = await postAction(postUrl, nextPath, { [revokeField]: userId });
const res = await postAction(postUrl, nextPath, { action: 'revoke', target_list_id: listId, [revokeField]: userId });
if (res.ok) {
btn.remove();
@@ -124,7 +134,6 @@
}
});
// Grant (wiele loginów, bez przeładowania strony)
async function addUsers() {
const users = parseUserText(input?.value);
if (!users?.length) return toast('Podaj co najmniej jednego użytkownika', 'warning');
@@ -136,10 +145,9 @@
let okCount = 0, failCount = 0, appended = 0;
for (const u of users) {
const res = await postAction(postUrl, nextPath, { action: grantAction, grant_username: u });
const res = await postAction(postUrl, nextPath, { action: grantAction, target_list_id: listId, grant_username: u });
if (res.ok) {
okCount++;
// jeśli backend odda JSON z userem dolep token live
if (res.data?.user) {
appendToken(box, res.data.user);
appended++;
@@ -156,9 +164,7 @@
if (okCount) toast(`Dodano dostęp: ${okCount} użytkownika`, 'success');
if (failCount) toast(`Błędy przy dodawaniu: ${failCount}`, 'danger');
// fallback: jeśli nic nie dolepiliśmy (brak JSON), odśwież, by zobaczyć nowe tokeny
if (okCount && appended === 0) {
// opóźnij minimalnie, by toast mignął
setTimeout(() => location.reload(), 400);
}
}

View File

@@ -0,0 +1,114 @@
(function () {
const form = document.getElementById("settings-form");
const resetAllBtn = document.getElementById("reset-all");
if (!form) return;
function getCard(input) {
return input.closest(".settings-category-card");
}
function getAutoHex(input) {
const autoHex = (input.dataset.auto || "").trim();
return autoHex ? autoHex.toUpperCase() : "#000000";
}
function setOverrideState(input, enabled) {
const card = getCard(input);
const flag = card?.querySelector('.override-enabled');
const badge = card?.querySelector('[data-role="override-status"]');
input.dataset.hasOverride = enabled ? "1" : "0";
if (flag) flag.value = enabled ? "1" : "0";
if (badge) {
badge.textContent = enabled ? "Nadpisany" : "Domyślny";
badge.classList.toggle('text-bg-info', enabled);
badge.classList.toggle('text-bg-secondary', !enabled);
}
}
function updatePreview(input) {
const card = getCard(input);
if (!card) return;
const hexAutoEl = card.querySelector('.hex-auto');
const hexEffEl = card.querySelector('.hex-effective');
const barAuto = card.querySelector('.bar[data-kind="auto"]');
const barEff = card.querySelector('.bar[data-kind="effective"]');
const autoHex = getAutoHex(input);
const effectiveHex = ((input.value || autoHex).trim() || autoHex).toUpperCase();
const hasOverride = input.dataset.hasOverride === '1';
if (barAuto) barAuto.style.backgroundColor = autoHex;
if (hexAutoEl) hexAutoEl.textContent = autoHex;
if (barEff) barEff.style.backgroundColor = effectiveHex;
if (hexEffEl) hexEffEl.textContent = effectiveHex;
setOverrideState(input, hasOverride);
}
function applyDefaultVisual(input, keepOverride) {
input.value = getAutoHex(input);
setOverrideState(input, !!keepOverride);
updatePreview(input);
}
form.querySelectorAll('.use-default').forEach((btn) => {
btn.addEventListener('click', () => {
const input = form.querySelector(`#${btn.dataset.target}`);
if (!input) return;
applyDefaultVisual(input, true);
});
});
form.querySelectorAll('.reset-one').forEach((btn) => {
btn.addEventListener('click', () => {
const input = form.querySelector(`#${btn.dataset.target}`);
if (!input) return;
applyDefaultVisual(input, false);
});
});
resetAllBtn?.addEventListener('click', () => {
form.querySelectorAll('input[type="color"].category-color').forEach((input) => {
applyDefaultVisual(input, false);
});
});
form.querySelectorAll('input[type="color"].category-color').forEach((input) => {
updatePreview(input);
input.addEventListener('input', () => {
setOverrideState(input, true);
updatePreview(input);
});
input.addEventListener('change', () => {
setOverrideState(input, true);
updatePreview(input);
});
});
(function () {
const slider = document.getElementById('ocr_sensitivity');
const badge = document.getElementById('ocr_sens_badge');
const value = document.getElementById('ocr_sens_value');
if (!slider || !badge || !value) return;
function labelFor(v) {
v = Number(v);
if (v <= 3) return 'Niski';
if (v <= 7) return 'Średni';
return 'Wysoki';
}
function clsFor(v) {
v = Number(v);
if (v <= 3) return 'sens-low';
if (v <= 7) return 'sens-mid';
return 'sens-high';
}
function update() {
value.textContent = `(${slider.value})`;
badge.textContent = labelFor(slider.value);
badge.classList.remove('sens-low', 'sens-mid', 'sens-high');
badge.classList.add(clsFor(slider.value));
}
slider.addEventListener('input', update);
slider.addEventListener('change', update);
update();
})();
})();

View File

@@ -0,0 +1,270 @@
document.addEventListener('DOMContentLoaded', function () {
enhancePasswordFields();
observePasswordFields();
enhanceSearchableTables();
wireCopyButtons();
wireUnsavedWarnings();
enhanceMobileTables();
wireAdminNavToggle();
initResponsiveCategoryBadges();
});
function initPasswordField(input) {
if (!input || input.dataset.uiPasswordReady === '1') return;
if (input.closest('[data-ui-skip-toggle="true"]')) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ui-password-toggle';
btn.setAttribute('aria-label', 'Pokaż lub ukryj hasło');
btn.setAttribute('aria-pressed', 'false');
btn.title = 'Pokaż hasło';
btn.innerHTML = '<span aria-hidden="true">👁</span>';
const syncState = function () {
const visible = input.type === 'text';
btn.innerHTML = visible ? '<span aria-hidden="true">🙈</span>' : '<span aria-hidden="true">👁</span>';
btn.classList.toggle('is-active', visible);
btn.setAttribute('aria-pressed', visible ? 'true' : 'false');
btn.title = visible ? 'Ukryj hasło' : 'Pokaż hasło';
};
btn.addEventListener('click', function () {
const selectionStart = input.selectionStart;
const selectionEnd = input.selectionEnd;
input.type = input.type === 'password' ? 'text' : 'password';
syncState();
input.focus({ preventScroll: true });
if (typeof selectionStart === 'number' && typeof selectionEnd === 'number') {
try {
input.setSelectionRange(selectionStart, selectionEnd);
} catch (err) {}
}
});
const parent = input.parentElement;
if (parent && parent.classList.contains('input-group')) {
parent.classList.add('ui-password-group');
if (!parent.querySelector(':scope > .ui-password-toggle')) {
parent.appendChild(btn);
}
} else {
const wrapper = document.createElement('div');
wrapper.className = 'input-group ui-password-group';
input.parentNode.insertBefore(wrapper, input);
wrapper.appendChild(input);
wrapper.appendChild(btn);
}
input.dataset.uiPasswordReady = '1';
syncState();
}
function enhancePasswordFields(root) {
const scope = root && root.querySelectorAll ? root : document;
if (scope.matches && scope.matches('input[type="password"]')) {
initPasswordField(scope);
}
scope.querySelectorAll('input[type="password"]').forEach(initPasswordField);
}
function observePasswordFields() {
if (window.__uiPasswordObserverReady) return;
const observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
mutation.addedNodes.forEach(function (node) {
if (!(node instanceof HTMLElement)) return;
enhancePasswordFields(node);
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
window.__uiPasswordObserverReady = true;
}
function enhanceSearchableTables() {
if (document.getElementById('search-table')) return;
const tables = document.querySelectorAll('table.sortable, table[data-searchable="true"]');
tables.forEach(function (table, index) {
if (table.dataset.uiSearchReady === '1') return;
const tbody = table.tBodies[0];
if (!tbody) return;
const rows = Array.from(tbody.querySelectorAll('tr'));
if (rows.length < 6) return;
const toolbar = document.createElement('div');
toolbar.className = 'table-toolbar';
toolbar.innerHTML = [
'<div class="input-group input-group-sm table-toolbar__search">',
' <span class="input-group-text">🔎</span>',
' <input type="search" class="form-control" placeholder="Filtruj tabelę…" aria-label="Filtruj tabelę">',
' <button type="button" class="btn btn-outline-light">Wyczyść</button>',
'</div>',
'<div class="table-toolbar__meta text-secondary small">',
' <span class="table-toolbar__count"></span>',
'</div>'
].join('');
const input = toolbar.querySelector('input');
const clearBtn = toolbar.querySelector('button');
const count = toolbar.querySelector('.table-toolbar__count');
function updateTableFilter() {
const query = (input.value || '').trim().toLowerCase();
let visible = 0;
rows.forEach(function (row) {
const rowText = row.innerText.toLowerCase();
const match = !query || rowText.includes(query);
row.style.display = match ? '' : 'none';
if (match) visible += 1;
});
count.textContent = 'Widoczne: ' + visible + ' / ' + rows.length;
}
input.addEventListener('input', updateTableFilter);
clearBtn.addEventListener('click', function () {
input.value = '';
updateTableFilter();
input.focus();
});
const container = table.closest('.table-responsive') || table;
container.parentNode.insertBefore(toolbar, container);
updateTableFilter();
table.dataset.uiSearchReady = '1';
});
}
function wireCopyButtons() {
document.querySelectorAll('[data-copy-target]').forEach(function (button) {
if (button.dataset.uiCopyReady === '1') return;
button.dataset.uiCopyReady = '1';
button.addEventListener('click', async function () {
const target = document.querySelector(button.dataset.copyTarget);
if (!target) return;
const text = target.value || target.textContent || '';
try {
await navigator.clipboard.writeText(text.trim());
const original = button.textContent;
button.textContent = '✅ Skopiowano';
setTimeout(function () {
button.textContent = original;
}, 1800);
} catch (err) {
console.warn('Copy failed', err);
}
});
});
}
function wireUnsavedWarnings() {
const trackedForms = Array.from(document.querySelectorAll('form[data-unsaved-warning="true"]'));
if (!trackedForms.length) return;
trackedForms.forEach(function (form) {
if (form.dataset.uiUnsavedReady === '1') return;
form.dataset.uiUnsavedReady = '1';
form.dataset.uiDirty = '0';
const markDirty = function () {
form.dataset.uiDirty = '1';
form.classList.add('is-dirty');
};
form.addEventListener('input', markDirty);
form.addEventListener('change', markDirty);
form.addEventListener('submit', function () {
form.dataset.uiDirty = '0';
form.classList.remove('is-dirty');
});
});
window.addEventListener('beforeunload', function (event) {
const hasDirty = trackedForms.some(function (form) {
return form.dataset.uiDirty === '1';
});
if (!hasDirty) return;
event.preventDefault();
event.returnValue = '';
});
}
function enhanceMobileTables() {
document.querySelectorAll('table').forEach(function (table) {
if (table.dataset.mobileLabelsReady === '1') return;
const headers = Array.from(table.querySelectorAll('thead th')).map(function (th) {
return (th.innerText || '').trim();
});
if (!headers.length) return;
table.querySelectorAll('tbody tr').forEach(function (row) {
Array.from(row.children).forEach(function (cell, index) {
if (!cell.dataset.label && headers[index]) {
cell.dataset.label = headers[index];
}
});
});
table.dataset.mobileLabelsReady = '1';
});
}
function wireAdminNavToggle() {
const toggle = document.querySelector('[data-admin-nav-toggle]');
const nav = document.querySelector('[data-admin-nav-body]');
if (!toggle || !nav) return;
toggle.addEventListener('click', function () {
const expanded = toggle.getAttribute('aria-expanded') === 'true';
toggle.setAttribute('aria-expanded', expanded ? 'false' : 'true');
nav.classList.toggle('is-open', !expanded);
});
}
function initResponsiveCategoryBadges() {
const headings = Array.from(document.querySelectorAll('[data-mobile-list-heading]'));
if (!headings.length) return;
const update = function () {
const isMobile = window.matchMedia('(max-width: 575.98px)').matches;
headings.forEach(function (heading) {
const title = heading.querySelector('[data-mobile-list-title]');
const group = heading.querySelector('[data-mobile-category-group]');
if (!title || !group) return;
group.classList.remove('is-compact');
if (!isMobile || !group.children.length) return;
const headingWidth = Math.ceil(heading.getBoundingClientRect().width);
if (!headingWidth) return;
const titleRect = title.getBoundingClientRect();
const groupRect = group.getBoundingClientRect();
const titleWidth = Math.ceil(titleRect.width);
const groupWidth = Math.ceil(group.scrollWidth);
const wrapped = groupRect.top - titleRect.top > 4;
const needsCompact = wrapped || (titleWidth + groupWidth > headingWidth);
group.classList.toggle('is-compact', needsCompact);
});
};
let resizeTimer = null;
window.addEventListener('resize', function () {
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(update, 60);
});
if (typeof ResizeObserver === 'function') {
const observer = new ResizeObserver(update);
headings.forEach(function (heading) {
observer.observe(heading);
});
}
update();
}

View File

@@ -76,7 +76,7 @@ document.addEventListener("DOMContentLoaded", function () {
}
// porzucenie zakresu
document.querySelectorAll("#chartTab .range-btn").forEach(b => b.classList.remove("active"));
document.querySelectorAll("#chartTab .chart-range-btn").forEach(b => b.classList.remove("active"));
reloadRespectingSplit();
});
@@ -90,9 +90,9 @@ document.addEventListener("DOMContentLoaded", function () {
});
// ——— Predefiniowane zakresy pod wykresem ———
document.querySelectorAll("#chartTab .range-btn").forEach((btn) => {
document.querySelectorAll("#chartTab .chart-range-btn").forEach((btn) => {
btn.addEventListener("click", function () {
document.querySelectorAll("#chartTab .range-btn").forEach((b) => b.classList.remove("active"));
document.querySelectorAll("#chartTab .chart-range-btn").forEach((b) => b.classList.remove("active"));
this.classList.add("active");
const r = this.getAttribute("data-range"); // last30days/currentmonth/monthly/quarterly/halfyearly/yearly

View File

@@ -25,16 +25,15 @@ document.addEventListener("DOMContentLoaded", () => {
}
checkbox.disabled = true;
row.classList.add('opacity-50');
row.classList.add('opacity-50', 'is-pending');
// Dodaj spinner tylko jeśli nie ma
let existingSpinner = row.querySelector('.spinner-border');
let existingSpinner = row.querySelector('.shopping-item-spinner');
if (!existingSpinner) {
const spinner = document.createElement('span');
spinner.className = 'spinner-border spinner-border-sm ms-2';
spinner.className = 'shopping-item-spinner spinner-border spinner-border-sm';
spinner.setAttribute('role', 'status');
spinner.setAttribute('aria-hidden', 'true');
checkbox.parentElement.appendChild(spinner);
row.appendChild(spinner);
}
});
});

View File

@@ -1,7 +1,7 @@
document.addEventListener('DOMContentLoaded', () => {
const checkboxes = document.querySelectorAll('.list-checkbox');
const totalEl = document.getElementById('listsTotal');
const filterButtons = document.querySelectorAll('.range-btn');
const filterButtons = document.querySelectorAll('#listsTab .table-range-btn');
const rows = document.querySelectorAll('#listsTableBody tr');
const categoryButtons = document.querySelectorAll('.category-filter');
const applyCustomBtn = document.getElementById('applyCustomRange');
@@ -136,7 +136,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (initialLoad) {
filterByLast30Days();
} else {
const activeRange = document.querySelector('.range-btn.active');
const activeRange = document.querySelector('#listsTab .table-range-btn.active');
if (activeRange) filterByRange(activeRange.dataset.range);
}
applyExpenseFilter();
@@ -158,7 +158,7 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
initialLoad = false;
document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('#listsTab .table-range-btn').forEach(b => b.classList.remove('active'));
filterByCustomRange(startStr, endStr);
applyExpenseFilter();
applyCategoryFilter();

View File

@@ -4,7 +4,7 @@ function updateItemState(itemId, isChecked) {
checkbox.checked = isChecked;
checkbox.disabled = false;
const li = checkbox.closest('li');
li.classList.remove('opacity-50', 'bg-light', 'text-dark', 'bg-success', 'text-white');
li.classList.remove('opacity-50', 'is-pending', 'bg-light', 'text-dark', 'bg-success', 'text-white', 'bg-warning', 'item-not-checked');
if (isChecked) {
li.classList.add('bg-success', 'text-white');
@@ -12,8 +12,7 @@ function updateItemState(itemId, isChecked) {
li.classList.add('item-not-checked');
}
const sp = li.querySelector('.spinner-border');
if (sp) sp.remove();
li.querySelectorAll('.shopping-item-spinner, .spinner-border').forEach(sp => sp.remove());
}
updateProgressBar();
applyHidePurchased();
@@ -87,22 +86,51 @@ function deleteItem(id) {
}
function editItem(id, oldName, oldQuantity) {
const newName = prompt('Podaj nową nazwę (lub zostaw starą):', oldName);
if (newName === null) return;
const finalName = String(oldName ?? '').trim();
let newQuantity = parseInt(oldQuantity, 10);
const newQuantityStr = prompt('Podaj nową ilość:', oldQuantity);
if (newQuantityStr === null) return;
if (!finalName) {
showToast('Nazwa produktu nie może być pusta.', 'warning');
return;
}
const finalName = newName.trim() !== '' ? newName.trim() : oldName;
let newQuantity = parseInt(newQuantityStr);
if (isNaN(newQuantity) || newQuantity < 1) {
newQuantity = oldQuantity;
newQuantity = 1;
}
socket.emit('edit_item', { item_id: id, new_name: finalName, new_quantity: newQuantity });
}
function openEditItemModal(event, id, oldName, oldQuantity) {
if (event && typeof event.stopPropagation === 'function') {
event.stopPropagation();
}
const modalEl = document.getElementById('editItemModal');
const idInput = document.getElementById('editItemId');
const nameInput = document.getElementById('editItemName');
const quantityInput = document.getElementById('editItemQuantity');
if (!modalEl || !idInput || !nameInput || !quantityInput || typeof bootstrap === 'undefined') {
editItem(id, oldName, oldQuantity);
return;
}
idInput.value = id;
nameInput.value = String(oldName ?? '').trim();
const parsedQuantity = parseInt(oldQuantity, 10);
quantityInput.value = !isNaN(parsedQuantity) && parsedQuantity > 0 ? parsedQuantity : 1;
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
setTimeout(() => {
nameInput.focus();
nameInput.select();
}, 150);
}
function submitExpense(listId) {
const amountInput = document.getElementById('expenseAmount');
const amount = parseFloat(amountInput.value);
@@ -257,6 +285,17 @@ function showToast(message, type = 'primary') {
setTimeout(() => { toast.remove(); }, 1750);
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function isListDifferent(oldItems, newItems) {
if (oldItems.length !== newItems.length) return true;
@@ -271,96 +310,103 @@ function isListDifferent(oldItems, newItems) {
return false;
}
function renderItem(item, isShare = window.IS_SHARE, showEditOnly = false) {
function renderItem(item, isShare = window.IS_SHARE, optionsOrShowEditOnly = false) {
const options = (typeof optionsOrShowEditOnly === 'object' && optionsOrShowEditOnly !== null)
? optionsOrShowEditOnly
: { showEditOnly: !!optionsOrShowEditOnly };
const showEditOnly = !!options.showEditOnly;
const temporaryShareUndo = !!options.temporaryShareUndo;
const countdownSeconds = Math.max(0, parseInt(options.countdownSeconds, 10) || 15);
const li = document.createElement('li');
li.id = `item-${item.id}`;
li.dataset.name = item.name.toLowerCase();
li.className = `list-group-item d-flex justify-content-between align-items-center flex-wrap clickable-item ${item.purchased ? 'bg-success text-white'
li.dataset.name = String(item.name || '').toLowerCase();
li.dataset.isShare = isShare ? 'true' : 'false';
li.className = `list-group-item shopping-item-row clickable-item ${item.purchased ? 'bg-success text-white'
: item.not_purchased ? 'bg-warning text-dark'
: 'item-not-checked'
}`;
const isOwner = window.IS_OWNER === true || window.IS_OWNER === 'true';
const allowEdit = !isShare || showEditOnly || isOwner;
const isArchived = window.IS_ARCHIVED === true || window.IS_ARCHIVED === 'true';
const safeName = escapeHtml(item.name || '');
const nameForEdit = JSON.stringify(String(item.name || ''));
const quantity = Number.isInteger(item.quantity) ? item.quantity : parseInt(item.quantity, 10) || 1;
const quantityBadge = quantity > 1
? `<span class="badge rounded-pill bg-secondary">x${quantity}</span>`
: '';
let quantityBadge = '';
if (item.quantity && item.quantity > 1) {
quantityBadge = `<span class="badge bg-secondary">x${item.quantity}</span>`;
const canEditListItem = !isShare;
const canShowShareActions = isShare && !showEditOnly && !temporaryShareUndo;
const canMarkNotPurchased = !item.not_purchased && !isArchived;
const checkboxHtml = `<input id="checkbox-${item.id}" class="large-checkbox" type="checkbox" ${item.purchased ? 'checked' : ''} ${(item.not_purchased || isArchived) ? 'disabled' : ''}>`;
const infoParts = [];
if (item.note) {
infoParts.push(`<span class="text-danger">[ <b>${escapeHtml(item.note)}</b> ]</span>`);
}
if (item.not_purchased_reason) {
infoParts.push(`<span class="text-dark">[ <b>Powód: ${escapeHtml(item.not_purchased_reason)}</b> ]</span>`);
}
const addedByDisplay = item.added_by_display;
if (addedByDisplay) {
infoParts.push(`<span class="text-info">[ Dodał/a: <b>${escapeHtml(addedByDisplay)}</b> ]</span>`);
}
const infoHtml = infoParts.length
? `<span class="info-line small" id="info-${item.id}">${infoParts.join(' ')}</span>`
: '';
const iconBtn = 'btn btn-outline-light btn-sm shopping-action-btn';
const wideBtn = 'btn btn-outline-light btn-sm shopping-action-btn shopping-action-btn--wide';
let actionButtons = '';
if (canEditListItem) {
const dragHandleButton = window.isSorting
? `<button type="button" class="${iconBtn} drag-handle" title="Przesuń produkt" aria-label="Przesuń produkt" ${isArchived ? 'disabled' : ''}>☰</button>`
: '';
actionButtons += `
${dragHandleButton}
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick='openEditItemModal(event, ${item.id}, ${JSON.stringify(String(item.name || ''))}, ${quantity})'`}></button>
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick="deleteItem(${item.id})"`}>🗑</button>`;
}
let checkboxOrIcon = item.not_purchased
? `<span class="ms-1 block-icon">🚫</span>`
: `<input id="checkbox-${item.id}" class="large-checkbox" type="checkbox" ${item.purchased ? 'checked' : ''}>`;
let noteHTML = item.note
? `<small class="text-danger ms-4">[ <b>${item.note}</b> ]</small>` : '';
let reasonHTML = item.not_purchased_reason
? `<small class="text-dark ms-4">[ <b>Powód: ${item.not_purchased_reason}</b> ]</small>` : '';
let dragHandle = window.isSorting ? `<span class="drag-handle me-2 text-danger" style="cursor: grab;">☰</span>` : '';
let left = `
<div class="d-flex align-items-center gap-2 flex-grow-1">
${dragHandle}
${checkboxOrIcon}
<span id="name-${item.id}" class="text-white">${item.name} ${quantityBadge}</span>
${noteHTML}
${reasonHTML}
</div>`;
let rightButtons = '';
// ✏️ i 🗑️ — tylko jeśli nie jesteśmy w trybie /share lub jesteśmy w 15s (tymczasowo) lub jesteśmy właścicielem
if (allowEdit) {
rightButtons += `
<button type="button" class="btn btn-outline-light"
onclick="editItem(${item.id}, '${item.name.replace(/'/g, "\\'")}', ${item.quantity || 1})">
</button>
<button type="button" class="btn btn-outline-light"
onclick="deleteItem(${item.id})">
🗑
</button>`;
}
// ✅ Jeśli element jest oznaczony jako niekupiony — pokaż "Przywróć"
if (item.not_purchased) {
rightButtons += `
<button type="button" class="btn btn-outline-light me-auto"
onclick="unmarkNotPurchased(${item.id})">
Przywróć
</button>`;
actionButtons += `
<button type="button" class="${wideBtn}" ${isArchived ? 'disabled' : `onclick="unmarkNotPurchased(${item.id})"`}> Przywróć</button>`;
} else if (!isShare || canShowShareActions || isOwner) {
actionButtons += `
<button type="button" class="${iconBtn}" ${canMarkNotPurchased ? `onclick="markNotPurchasedModal(event, ${item.id})"` : 'disabled'}></button>`;
}
// ⚠️ tylko jeśli NIE jest oznaczony jako niekupiony i nie jesteśmy w 15s
if (!item.not_purchased && (isOwner || (isShare && !showEditOnly))) {
rightButtons += `
<button type="button" class="btn btn-outline-light"
onclick="markNotPurchasedModal(event, ${item.id})">
</button>`;
if (temporaryShareUndo) {
actionButtons += `
<button type="button" class="${iconBtn} shopping-action-btn--countdown" disabled data-countdown-for="${item.id}">${countdownSeconds}s</button>
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick='openEditItemModal(event, ${item.id}, ${nameForEdit}, ${quantity})'`}></button>
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick="deleteItem(${item.id})"`}>🗑</button>`;
} else if (canShowShareActions) {
actionButtons += `
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick="openNoteModal(event, ${item.id})"`}>📝</button>`;
}
// 📝 tylko jeśli jesteśmy w /share i nie jesteśmy w 15s
if (isShare && !showEditOnly && !isOwner) {
rightButtons += `
<button type="button" class="btn btn-outline-light"
onclick="openNoteModal(event, ${item.id})">
📝
</button>`;
}
li.innerHTML = `${left}<div class="btn-group btn-group-sm" role="group">${rightButtons}</div>`;
if (item.added_by && item.owner_id && item.added_by_id && item.added_by_id !== item.owner_id) {
const infoEl = document.createElement('small');
infoEl.className = 'text-info ms-4';
infoEl.innerHTML = `[Dodał/a: <b>${item.added_by}</b>]`;
li.querySelector('.d-flex.align-items-center')?.appendChild(infoEl);
}
li.innerHTML = `
<div class="shopping-item-main">
${checkboxHtml}
<div class="shopping-item-content">
<div class="shopping-item-head">
<div class="shopping-item-text">
<span id="name-${item.id}" class="shopping-item-name text-white">${safeName}</span>
${quantityBadge}
${infoHtml}
</div>
<div class="list-item-actions shopping-item-actions" role="group">
${actionButtons}
</div>
</div>
</div>
</div>`;
return li;
}

View File

@@ -0,0 +1,22 @@
(function () {
const $=(s,r=document)=>r.querySelector(s); const $$=(s,r=document)=>Array.from(r.querySelectorAll(s));
const filterInput=$('#listFilter'),filterCount=$('#filterCount'),selectAll=$('#selectAll'),bulkTokens=$('#bulkTokens'),bulkInput=$('#bulkUsersInput'),bulkBtn=$('#bulkAddBtn');
const unique=arr=>Array.from(new Set(arr));
const parseUserText=txt=>unique((txt||'').split(/[\s,;]+/g).map(s=>s.trim().replace(/^@/,'').toLowerCase()).filter(Boolean));
const selectedListIds=()=>$$('.row-check:checked').map(ch=>ch.dataset.listId);
const visibleRows=()=>$$('#listsTable tbody tr').filter(r=>r.style.display!=='none');
function applyFilter(){const q=(filterInput?.value||'').trim().toLowerCase();let shown=0;$$('#listsTable tbody tr').forEach(tr=>{const hay=`${tr.dataset.id||''} ${tr.dataset.title||''} ${tr.dataset.owner||''}`;const ok=!q||hay.includes(q);tr.style.display=ok?'':'none';if(ok) shown++;});if(filterCount) filterCount.textContent=shown?`Widoczne: ${shown}`:'Brak wyników';}
filterInput?.addEventListener('input',applyFilter);applyFilter();
selectAll?.addEventListener('change',()=>{visibleRows().forEach(tr=>{const cb=tr.querySelector('.row-check'); if(cb) cb.checked=selectAll.checked;});});
$$('.copy-share').forEach(btn=>btn.addEventListener('click',async()=>{const url=btn.dataset.url;try{await navigator.clipboard.writeText(url);}catch{const ta=Object.assign(document.createElement('textarea'),{value:url});document.body.appendChild(ta);ta.select();document.execCommand('copy');ta.remove();}showToast('Skopiowano link udostępnienia','success');}));
function addGlobalToken(username){if(!username) return;const exists=$(`.user-token[data-user="${username}"]`,bulkTokens);if(exists) return;const token=document.createElement('span');token.className='badge rounded-pill text-bg-secondary user-token';token.dataset.user=username;token.innerHTML=`@${username} <button type="button" class="btn btn-sm btn-link p-0 ms-1 text-white">✕</button>`;token.querySelector('button').addEventListener('click',()=>token.remove());bulkTokens.appendChild(token);}
bulkInput?.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();parseUserText(bulkInput.value).forEach(addGlobalToken);bulkInput.value='';}});
bulkInput?.addEventListener('change',()=>{parseUserText(bulkInput.value).forEach(addGlobalToken);bulkInput.value='';});
let hintCtrl=null;
function renderBulkHints(users){const dl=$('#userHints'); if(!dl) return; dl.innerHTML=(users||[]).slice(0,20).map(u=>`<option value="${u}"></option>`).join('');}
async function fetchBulkHints(q=''){const normalized=String(q||'').trim().replace(/^@/,'');try{hintCtrl?.abort();hintCtrl=new AbortController();const res=await fetch(`/admin/user-suggestions?q=${encodeURIComponent(normalized)}`,{credentials:'same-origin',signal:hintCtrl.signal});if(!res.ok) return renderBulkHints([]);const data=await res.json().catch(()=>({users:[]}));renderBulkHints(data.users||[]);}catch(e){renderBulkHints([]);}}
bulkInput?.addEventListener('focus',()=>fetchBulkHints(bulkInput.value));
bulkInput?.addEventListener('input',()=>fetchBulkHints(bulkInput.value));
async function bulkGrant(){const lists=selectedListIds(), users=$$('.user-token',bulkTokens).map(t=>t.dataset.user);if(!lists.length) return showToast('Zaznacz przynajmniej jedną listę','warning');if(!users.length) return showToast('Dodaj przynajmniej jednego użytkownika','warning');bulkBtn.disabled=true;bulkBtn.textContent='Pracuję…';const url=location.pathname+location.search;let ok=0,fail=0;for(const lid of lists){for(const u of users){const form=new FormData();form.set('action','grant');form.set('target_list_id',lid);form.set('grant_username',u);try{const res=await fetch(url,{method:'POST',body:form,credentials:'same-origin',headers:{'Accept':'application/json','X-Requested-With':'fetch'}});if(res.ok) ok++; else fail++;}catch{fail++;}}}bulkBtn.disabled=false;bulkBtn.textContent=' Nadaj dostęp';showToast(`Gotowe. Sukcesy: ${ok}${fail?`, błędy: ${fail}`:''}`,fail?'danger':'success');if(ok) location.reload();}
bulkBtn?.addEventListener('click',bulkGrant);
})();

View File

@@ -13,7 +13,7 @@ function toggleEmptyPlaceholder() {
const li = document.createElement('li');
li.id = 'empty-placeholder';
li.className = 'list-group-item bg-dark text-secondary text-center w-100';
li.textContent = 'Brak produktów w tej liście.';
li.textContent = 'Brak produktów w tej liście.';
list.appendChild(li);
} else if (hasRealItems && placeholder) {
placeholder.remove();
@@ -88,15 +88,15 @@ function setupList(listId, username) {
}
e.target.disabled = true;
li.classList.add('opacity-50');
li.classList.add('opacity-50', 'is-pending');
let existingSpinner = li.querySelector('.spinner-border');
let existingSpinner = li.querySelector('.shopping-item-spinner');
if (!existingSpinner) {
const spinner = document.createElement('span');
spinner.className = 'spinner-border spinner-border-sm ms-2';
spinner.className = 'shopping-item-spinner spinner-border spinner-border-sm';
spinner.setAttribute('role', 'status');
spinner.setAttribute('aria-hidden', 'true');
e.target.parentElement.appendChild(spinner);
li.appendChild(spinner);
}
}
}
@@ -139,45 +139,48 @@ function setupList(listId, username) {
note: ''
};
const li = renderItem(item, false, true); // ← tryb 15s
const isOwnFreshShareItem = Boolean(
window.IS_SHARE &&
data.added_by &&
window.CURRENT_LIST_USERNAME &&
String(data.added_by) === String(window.CURRENT_LIST_USERNAME)
);
const li = renderItem(
item,
window.IS_SHARE,
isOwnFreshShareItem ? { temporaryShareUndo: true, countdownSeconds: 15 } : false
);
document.getElementById('items').appendChild(li);
toggleEmptyPlaceholder();
updateProgressBar();
if (window.IS_SHARE) {
const countdownId = `countdown-${data.id}`;
const countdownBtn = document.createElement('button');
countdownBtn.type = 'button';
countdownBtn.className = 'btn btn-outline-warning';
countdownBtn.id = countdownId;
countdownBtn.disabled = true;
countdownBtn.textContent = '15s';
const btnGroup = li.querySelector('.btn-group');
if (btnGroup) {
btnGroup.prepend(countdownBtn);
}
if (isOwnFreshShareItem) {
let seconds = 15;
const intervalId = setInterval(() => {
const el = document.getElementById(countdownId);
if (el) {
seconds--;
el.textContent = `${seconds}s`;
if (seconds <= 0) {
el.remove();
clearInterval(intervalId);
}
} else {
const currentItem = document.getElementById(`item-${data.id}`);
const countdownEl = currentItem?.querySelector(`[data-countdown-for="${data.id}"]`);
if (!currentItem || !countdownEl) {
clearInterval(intervalId);
return;
}
seconds -= 1;
if (seconds <= 0) {
clearInterval(intervalId);
return;
}
countdownEl.textContent = `${seconds}s`;
}, 1000);
setTimeout(() => {
clearInterval(intervalId);
const existing = document.getElementById(`item-${data.id}`);
if (existing) {
const updated = renderItem(item, true);
existing.replaceWith(updated);
existing.replaceWith(renderItem(item, window.IS_SHARE));
}
}, 15000);
}
@@ -203,7 +206,7 @@ function setupList(listId, username) {
const progressTitle = document.getElementById('progress-title');
if (progressTitle) {
progressTitle.textContent = `📊 Postęp listy — ${data.purchased_count}/${data.total_count} kupionych (${Math.round(data.percent)}%)`;
progressTitle.textContent = `Postęp listy — ${data.purchased_count}/${data.total_count} kupionych (${Math.round(data.percent)}%)`;
}
});
@@ -218,7 +221,7 @@ function setupList(listId, username) {
window.currentItems[idx].name = data.new_name;
window.currentItems[idx].quantity = data.new_quantity;
const newItem = renderItem(window.currentItems[idx], true);
const newItem = renderItem(window.currentItems[idx], window.IS_SHARE);
const oldItem = document.getElementById(`item-${data.item_id}`);
if (oldItem && newItem) {
oldItem.replaceWith(newItem);
@@ -234,6 +237,7 @@ function setupList(listId, username) {
// --- WAŻNE: zapisz dane do reconnect ---
window.LIST_ID = listId;
window.usernameForReconnect = username;
window.CURRENT_LIST_USERNAME = username;
}

View File

@@ -126,6 +126,9 @@ socket.on('full_list', function (data) {
window.currentItems = data.items;
updateListSmoothly(data.items);
if (typeof window.syncSortModeUI === 'function') {
window.syncSortModeUI();
}
toggleEmptyPlaceholder();
if (didReceiveFirstFullList && isDifferent) {

View File

@@ -1,32 +1,62 @@
let sortable = null;
let isSorting = false;
window.isSorting = false;
function syncSortModeUI() {
const active = !!window.isSorting;
const btn = document.getElementById('sort-toggle-btn');
const itemsContainer = document.getElementById('items');
document.body.classList.toggle('sorting-active', active);
if (btn) {
if (active) {
btn.textContent = '✔️ Zakończ sortowanie';
btn.classList.remove('btn-outline-warning');
btn.classList.add('btn-outline-success');
} else {
btn.textContent = '✳️ Zmień kolejność';
btn.classList.remove('btn-outline-success');
btn.classList.add('btn-outline-warning');
}
}
if (itemsContainer && window.currentItems) {
updateListSmoothly(window.currentItems);
}
document.querySelectorAll('.drag-handle').forEach(handle => {
handle.hidden = !active;
handle.setAttribute('aria-hidden', active ? 'false' : 'true');
});
}
function enableSortMode() {
if (isSorting) return;
isSorting = true;
window.isSorting = true;
localStorage.setItem('sortModeEnabled', 'true');
if (window.isSorting) return;
const itemsContainer = document.getElementById('items');
const listId = window.LIST_ID;
if (!itemsContainer || !listId) return;
if (window.currentItems) {
updateListSmoothly(window.currentItems);
}
window.isSorting = true;
syncSortModeUI();
setTimeout(() => {
if (sortable) sortable.destroy();
if (!window.isSorting) return;
if (sortable) {
sortable.destroy();
sortable = null;
}
sortable = Sortable.create(itemsContainer, {
animation: 150,
handle: '.drag-handle',
ghostClass: 'drag-ghost',
filter: 'input, button',
filter: 'input, button:not(.drag-handle)',
preventOnFilter: false,
onEnd: () => {
const order = Array.from(itemsContainer.children)
.map(li => parseInt(li.id.replace('item-', '')))
.map(li => parseInt(li.id.replace('item-', ''), 10))
.filter(id => !isNaN(id));
fetch('/reorder_items', {
@@ -37,16 +67,14 @@ function enableSortMode() {
showToast('Zapisano nową kolejność', 'success');
if (window.currentItems) {
window.currentItems = order.map(id =>
window.currentItems.find(item => item.id === id)
);
window.currentItems = order
.map(id => window.currentItems.find(item => item.id === id))
.filter(Boolean);
updateListSmoothly(window.currentItems);
}
});
}
});
updateSortButtonUI(true);
}, 50);
}
@@ -56,39 +84,22 @@ function disableSortMode() {
sortable = null;
}
isSorting = false;
localStorage.removeItem('sortModeEnabled');
window.isSorting = false;
if (window.currentItems) {
updateListSmoothly(window.currentItems);
}
updateSortButtonUI(false);
syncSortModeUI();
}
function toggleSortMode() {
isSorting ? disableSortMode() : enableSortMode();
}
function updateSortButtonUI(active) {
const btn = document.getElementById('sort-toggle-btn');
if (!btn) return;
if (active) {
btn.textContent = '✔️ Zakończ sortowanie';
btn.classList.remove('btn-outline-warning');
btn.classList.add('btn-outline-success');
if (window.isSorting) {
disableSortMode();
} else {
btn.textContent = '✳️ Zmień kolejność';
btn.classList.remove('btn-outline-success');
btn.classList.add('btn-outline-warning');
}
}
document.addEventListener('DOMContentLoaded', () => {
const wasSorting = localStorage.getItem('sortModeEnabled') === 'true';
if (wasSorting) {
enableSortMode();
}
}
window.toggleSortMode = toggleSortMode;
window.syncSortModeUI = syncSortModeUI;
document.addEventListener('DOMContentLoaded', () => {
window.isSorting = false;
syncSortModeUI();
});

View File

@@ -0,0 +1,30 @@
document.addEventListener("DOMContentLoaded", function () {
const toggleBtn = document.getElementById("tempToggle");
const hiddenInput = document.getElementById("temporaryHidden");
if (!toggleBtn || !hiddenInput) return;
if (typeof bootstrap !== "undefined") {
new bootstrap.Tooltip(toggleBtn);
}
function updateToggle(isActive) {
toggleBtn.classList.toggle("is-active", isActive);
toggleBtn.textContent = isActive ? "Tymczasowa ✔" : "Tymczasowa";
toggleBtn.setAttribute("aria-pressed", isActive ? "true" : "false");
toggleBtn.setAttribute("title", isActive
? "Lista tymczasowa będzie ważna przez 7 dni"
: "Po zaznaczeniu lista będzie ważna tylko 7 dni");
}
let active = toggleBtn.getAttribute("data-active") === "1";
hiddenInput.value = active ? "1" : "0";
updateToggle(active);
toggleBtn.addEventListener("click", function (event) {
event.preventDefault();
active = !active;
toggleBtn.setAttribute("data-active", active ? "1" : "0");
hiddenInput.value = active ? "1" : "0";
updateToggle(active);
});
});

View File

Before

Width:  |  Height:  |  Size: 280 B

After

Width:  |  Height:  |  Size: 280 B

View File

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,31 @@
{% set total_count = total_count or 0 %}
{% set purchased_count = purchased_count or 0 %}
{% set not_purchased_count = not_purchased_count or 0 %}
{% set accounted_count = purchased_count + not_purchased_count %}
{% set percent = (purchased_count / total_count * 100) if total_count > 0 else 0 %}
{% set purchased_percent = (purchased_count / total_count * 100) if total_count > 0 else 0 %}
{% set not_purchased_percent = (not_purchased_count / total_count * 100) if total_count > 0 else 0 %}
{% set remaining_count = (total_count - accounted_count) if total_count > accounted_count else 0 %}
{% set remaining_percent = (remaining_count / total_count * 100) if total_count > 0 else 100 %}
<div class="main-list-progress-wrap mt-2">
<div class="main-list-progress progress progress-dark progress-thin position-relative"
aria-label="Postęp listy {{ purchased_count }} z {{ total_count }} kupionych">
<div class="progress-bar bg-success" role="progressbar"
style="width: {{ purchased_percent }}%"
aria-valuemin="0" aria-valuemax="100"></div>
<div class="progress-bar bg-warning" role="progressbar"
style="width: {{ not_purchased_percent }}%"
aria-valuemin="0" aria-valuemax="100"></div>
<div class="progress-bar bg-transparent" role="progressbar"
style="width: {{ remaining_percent }}%"
aria-valuemin="0" aria-valuemax="100"></div>
<span class="progress-label main-list-progress__label small fw-bold {% if percent < 51 %}text-white{% else %}text-dark{% endif %}">
Produkty: {{ purchased_count }}/{{ total_count }} ({{ percent|round(0) }}%)
{% if total_expense > 0 %} — 💸 {{ '%.2f'|format(total_expense) }} PLN{% endif %}
</span>
</div>
</div>

View File

@@ -0,0 +1,18 @@
<div class="card bg-secondary bg-opacity-10 text-white mb-4 admin-shortcuts">
<div class="card-body p-2">
<div class="d-md-none mb-2">
<button type="button" class="btn btn-outline-light w-100" data-admin-nav-toggle aria-expanded="false">☰ Menu admina</button>
</div>
<div class="d-flex flex-wrap gap-2" data-admin-nav-body>
<a href="{{ url_for('admin_panel') }}" class="btn btn-sm {% if request.endpoint == 'admin_panel' %}btn-success{% else %}btn-outline-light{% endif %}">📊 Dashboard</a>
<a href="{{ url_for('list_users') }}" class="btn btn-sm {% if request.endpoint == 'list_users' %}btn-success{% else %}btn-outline-light{% endif %}">👥 Użytkownicy</a>
<a href="{{ url_for('admin_receipts') }}" class="btn btn-sm {% if request.endpoint == 'admin_receipts' %}btn-success{% else %}btn-outline-light{% endif %}">📸 Paragony</a>
<a href="{{ url_for('list_products') }}" class="btn btn-sm {% if request.endpoint == 'list_products' %}btn-success{% else %}btn-outline-light{% endif %}">🛍️ Produkty</a>
<a href="{{ url_for('admin_edit_categories') }}" class="btn btn-sm {% if request.endpoint == 'admin_edit_categories' %}btn-success{% else %}btn-outline-light{% endif %}">🗂 Kategorie</a>
<a href="{{ url_for('admin_lists_access') }}" class="btn btn-sm {% if request.endpoint == 'admin_lists_access' %}btn-success{% else %}btn-outline-light{% endif %}">🔐 Uprawnienia</a>
<a href="{{ url_for('admin_api_tokens') }}" class="btn btn-sm {% if request.endpoint in ['admin_api_tokens', 'admin_api_docs'] %}btn-success{% else %}btn-outline-light{% endif %}">🔑 Tokeny API</a>
<a href="{{ url_for('admin_templates') }}" class="btn btn-sm {% if request.endpoint == 'admin_templates' %}btn-success{% else %}btn-outline-light{% endif %}">🧩 Szablony</a>
<a href="{{ url_for('admin_settings') }}" class="btn btn-sm {% if request.endpoint == 'admin_settings' %}btn-success{% else %}btn-outline-light{% endif %}">⚙️ Ustawienia</a>
</div>
</div>
</div>

View File

@@ -2,23 +2,16 @@
{% block title %}Panel administratora{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<h2 class="mb-2">⚙️ Panel administratora</h2>
<div class="admin-page-head mb-4">
<div>
<h2 class="mb-2">⚙️ Panel administratora</h2>
<p class="text-secondary mb-0">Wgląd w użytkowników, listy, paragony, wydatki i ustawienia aplikacji.</p>
</div>
<a href="{{ url_for('main_page') }}" class="btn btn-outline-secondary">← Powrót do strony głównej</a>
</div>
<div class="card bg-secondary bg-opacity-10 text-white mb-4">
<div class="card-body p-2">
<div class="d-flex flex-wrap gap-2">
<a href="{{ url_for('list_users') }}" class="btn btn-outline-light btn-sm">👥 Użytkownicy</a>
<a href="{{ url_for('admin_receipts') }}" class="btn btn-outline-light btn-sm">📸 Paragony</a>
<a href="{{ url_for('list_products') }}" class="btn btn-outline-light btn-sm">🛍️ Produkty</a>
<a href="{{ url_for('admin_edit_categories') }}" class="btn btn-outline-light btn-sm">🗂 Kategorie</a>
<a href="{{ url_for('admin_lists_access') }}" class="btn btn-outline-light btn-sm">🔐 Uprawnienia</a>
<a href="{{ url_for('admin_settings') }}" class="btn btn-outline-light btn-sm">⚙️ Ustawienia</a>
</div>
</div>
</div>
{% include 'admin/_nav.html' %}
<div class="row g-3 mb-4">
<!-- Statystyki liczbowe -->
@@ -158,7 +151,7 @@
</div>
</div>
<div class="card bg-dark text-white mb-5">
{% if expiring_lists %}<div class="alert alert-warning mb-4"><div class="fw-semibold mb-2">⏰ Listy tymczasowe wygasające w ciągu 24h</div><ul class="mb-0 ps-3">{% for l in expiring_lists %}<li>#{{ l.id }} {{ l.title }} — {{ l.owner.username if l.owner else '—' }} — {{ l.expires_at.strftime('%Y-%m-%d %H:%M') }}</li>{% endfor %}</ul></div>{% endif %}<div class="card bg-dark text-white mb-5">
<div class="card-body">
{# panel wyboru miesiąca zawsze widoczny #}
@@ -243,10 +236,10 @@
{% for e in enriched_lists %}
{% set l = e.list %}
<tr>
<td><input type="checkbox" name="list_ids" value="{{ l.id }}"></td>
<td><input type="checkbox" name="list_ids" value="{{ l.id }}" class="table-select-checkbox"></td>
<td>{{ l.id }}</td>
<td class="fw-bold align-middle">
<a href="{{ url_for('view_list', list_id=l.id) }}" class="text-white">{{ l.title }}</a>
<a href="{{ url_for('view_list', list_id=l.id) }}" class="text-white">{{ l.title }}</a>{% if l.is_temporary and l.expires_at %}<div class="small text-warning mt-1">wygasa: {{ l.expires_at.strftime('%Y-%m-%d %H:%M') }}</div>{% endif %}
{% if l.categories %}
<span class="ms-1 text-info" data-bs-toggle="tooltip"
title="{{ l.categories | map(attribute='name') | join(', ') }}">
@@ -295,13 +288,9 @@
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="{{ url_for('edit_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light"
title="Edytuj">✏</a>
<button type="button" class="btn btn-sm btn-outline-light preview-btn" data-list-id="{{ l.id }}"
title="Podgląd produktów">
👁️
</button>
<div class="d-flex flex-wrap gap-2">
<a href="{{ url_for('edit_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light" title="Edytuj">✏️</a>
<button type="button" class="btn btn-sm btn-outline-light preview-btn" data-list-id="{{ l.id }}" title="Podgląd produktów">👁</button>
</div>
</td>
</tr>
@@ -352,7 +341,7 @@
checkboxes.forEach(cb => cb.checked = this.checked);
});
</script>
<script src="{{ url_for('static_bp.serve_js', filename='preview_list_modal.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'preview_list_modal.js') }}"></script>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,161 @@
{% extends 'base.html' %}
{% block title %}Tokeny API{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4 gap-2">
<div>
<h2 class="mb-2">🔑 Tokeny API</h2>
<p class="text-secondary mb-0">Administrator może utworzyć wiele tokenów, ograniczyć ich zakres i endpointy oraz w każdej chwili je wyłączyć albo usunąć.</p>
</div>
<div class="d-flex gap-2 flex-wrap">
<a href="{{ url_for('admin_api_docs') }}" class="btn btn-outline-light" target="_blank">📄 Zobacz opis API</a>
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
</div>
</div>
{% include 'admin/_nav.html' %}
{% if latest_plain_token %}
<div class="alert alert-success border-success mb-4" role="alert">
<div class="d-flex flex-column gap-2">
<div><strong>Nowy token:</strong> {{ latest_api_token_name or 'API' }}</div>
<div class="input-group">
<input type="text" id="latestApiToken" class="form-control" readonly value="{{ latest_plain_token }}">
<button type="button" class="btn btn-outline-light" data-copy-target="#latestApiToken">📋 Kopiuj</button>
</div>
<div class="small text-warning">Pełna wartość jest widoczna tylko teraz. Po odświeżeniu zostanie ukryta.</div>
</div>
</div>
{% endif %}
<div class="card bg-dark text-white">
<div class="card-body">
<h5 class="mb-3"> Utwórz token</h5>
<form method="post" data-unsaved-warning="true" class="stack-form">
<input type="hidden" name="action" value="create">
<div class="row g-4">
<div class="col-xl-4">
<label for="name" class="form-label">Nazwa tokenu</label>
<input type="text" id="name" name="name" class="form-control" placeholder="np. integracja ERP / Power BI" required>
<div class="form-text">Nazwij token tak, aby było wiadomo do czego służy.</div>
</div>
<div class="col-sm-6 col-xl-4">
<label class="form-label d-block">Zakresy</label>
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="scope_expenses_read" name="scope_expenses_read" checked><label class="form-check-label" for="scope_expenses_read">Odczyt wydatków</label></div>
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="scope_lists_read" name="scope_lists_read" checked><label class="form-check-label" for="scope_lists_read">Odczyt list i wydatków list</label></div>
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="scope_templates_read" name="scope_templates_read"><label class="form-check-label" for="scope_templates_read">Odczyt szablonów</label></div>
</div>
<div class="col-sm-6 col-xl-4">
<label class="form-label d-block">Dozwolone endpointy</label>
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="allow_ping" name="allow_ping" checked><label class="form-check-label" for="allow_ping">/api/ping</label></div>
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="allow_latest_expenses" name="allow_latest_expenses" checked><label class="form-check-label" for="allow_latest_expenses">/api/expenses/latest</label></div>
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="allow_expenses_summary" name="allow_expenses_summary" checked><label class="form-check-label" for="allow_expenses_summary">/api/expenses/summary</label></div>
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="allow_lists" name="allow_lists" checked><label class="form-check-label" for="allow_lists">/api/lists oraz /api/lists/&lt;id&gt;/expenses</label></div>
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="allow_templates" name="allow_templates"><label class="form-check-label" for="allow_templates">/api/templates</label></div>
</div>
<div class="col-sm-6 col-xl-3">
<label for="max_limit" class="form-label">Maksymalny limit rekordów</label>
<input type="number" id="max_limit" name="max_limit" min="1" max="500" value="100" class="form-control">
</div>
<div class="col-sm-6 col-xl-3 d-flex align-items-end">
<button type="submit" class="btn btn-success w-100">🔑 Wygeneruj token</button>
</div>
</div>
</form>
</div>
</div>
<div class="card bg-dark text-white mt-4">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<h5 class="mb-0">📘 Dokumentacja API</h5>
<a href="{{ url_for('admin_api_docs') }}" class="btn btn-sm btn-outline-light" target="_blank">Otwórz TXT</a>
</div>
<div class="small text-secondary mb-3">Autoryzacja: <code>Authorization: Bearer TWOJ_TOKEN</code> lub <code>X-API-Token</code>. Endpoint i zakres muszą być jednocześnie dozwolone na tokenie. Parametr <code>limit</code> jest przycinany do wartości ustawionej w tokenie.</div>
<div class="table-responsive admin-table-responsive admin-table-responsive--full">
<table class="table table-dark align-middle table-sm keep-horizontal">
<thead>
<tr><th>Metoda</th><th>Endpoint</th><th>Wymagany zakres</th><th>Opis</th></tr>
</thead>
<tbody>
{% for row in api_examples %}
<tr>
<td><code>{{ row.method }}</code></td>
<td><code class="api-chip api-chip--wrap">{{ row.path }}</code></td>
<td><code class="api-chip">{{ row.scope }}</code></td>
<td>{{ row.description }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="card bg-dark text-white mt-4">
<div class="card-body">
<div class="admin-page-head mb-3">
<h5 class="mb-0">📋 Aktywne i historyczne tokeny</h5>
<span class="badge rounded-pill bg-secondary">{{ api_tokens|length }} szt.</span>
</div>
<div class="table-responsive admin-table-responsive admin-table-responsive--wide">
<table class="table table-dark align-middle sortable keep-horizontal" data-searchable="true">
<thead>
<tr>
<th>Nazwa</th>
<th>Prefix</th>
<th>Status</th>
<th>Zakres</th>
<th>Endpointy</th>
<th>Max limit</th>
<th>Utworzono</th>
<th>Ostatnie użycie</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for token in api_tokens %}
<tr>
<td>
<div class="fw-semibold text-break">{{ token.name }}</div>
<div class="small text-secondary">Autor: {{ token.creator.username if token.creator else '—' }}</div>
</td>
<td><code class="api-chip">{{ token.token_prefix }}…</code></td>
<td>{% if token.is_active %}<span class="badge rounded-pill bg-success">Aktywny</span>{% else %}<span class="badge rounded-pill bg-secondary">Wyłączony</span>{% endif %}</td>
<td><code class="api-chip api-chip--wrap">{{ token.scopes or '—' }}</code></td>
<td><code class="api-chip api-chip--wrap">{{ token.allowed_endpoints or '—' }}</code></td>
<td>{{ token.max_limit or '—' }}</td>
<td>{{ token.created_at.strftime('%Y-%m-%d %H:%M') if token.created_at else '—' }}</td>
<td>{{ token.last_used_at.strftime('%Y-%m-%d %H:%M') if token.last_used_at else 'Jeszcze nie użyto' }}</td>
<td>
<div class="d-flex flex-wrap gap-2">
{% if token.is_active %}
<form method="post" class="d-inline">
<input type="hidden" name="action" value="deactivate">
<input type="hidden" name="token_id" value="{{ token.id }}">
<button type="submit" class="btn btn-sm btn-outline-warning">⏸ Wyłącz</button>
</form>
{% else %}
<form method="post" class="d-inline">
<input type="hidden" name="action" value="activate">
<input type="hidden" name="token_id" value="{{ token.id }}">
<button type="submit" class="btn btn-sm btn-outline-success">▶ Włącz</button>
</form>
{% endif %}
<form method="post" class="d-inline" onsubmit="return confirm('Usunąć ten token API?')">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="token_id" value="{{ token.id }}">
<button type="submit" class="btn btn-sm btn-outline-danger">🗑 Usuń</button>
</form>
</div>
</td>
</tr>
{% else %}
<tr><td colspan="9" class="text-center text-secondary py-4">Brak tokenów API.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@@ -9,6 +9,8 @@
</div>
</div>
{% include 'admin/_nav.html' %}
<div class="card bg-dark text-white mb-5">
<div class="card-body">
<div class="alert alert-warning border-warning text-dark" role="alert">
@@ -16,10 +18,10 @@
wydatków.
</div>
<form method="post" id="mass-edit-form">
<form method="post" id="mass-edit-form" data-unsaved-warning="true">
<div class="card bg-dark text-white mb-4">
<div class="card-body p-0">
<div class="table-responsive">
<div class="table-responsive admin-table-responsive admin-table-responsive--full">
<table class="table table-dark align-middle sortable mb-0">
<thead class="position-sticky top-0 bg-dark">
<tr>
@@ -88,8 +90,7 @@
</div>
</div>
{# Fallback ukryty przez JS #}
<button type="submit" class="btn btn-sm btn-outline-light" id="fallback-save-btn">💾 Zapisz zmiany</button>
<div class="d-flex justify-content-end mt-3"><button type="submit" class="btn btn-sm btn-outline-light">💾 Zapisz wszystkie zmiany</button></div>
</form>
</div>
</div>
@@ -145,7 +146,6 @@
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js', filename='preview_list_modal.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='categories_select_admin.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='categories_autosave.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'preview_list_modal.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'categories_select_admin.js') }}"></script>
{% endblock %}

View File

@@ -7,10 +7,12 @@
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
</div>
{% include 'admin/_nav.html' %}
<div class="card bg-secondary bg-opacity-10 text-white mb-5">
<div class="card-body">
<h4 class="card-title">📄 Podstawowe informacje</h4>
<form method="post" class="mt-3">
<form method="post" class="mt-3" data-unsaved-warning="true">
<input type="hidden" name="action" value="save">
<!-- Nazwa listy -->
@@ -43,20 +45,20 @@
<!-- Statusy -->
<div class="mb-4">
<label class="form-label">⚙️ Statusy listy</label>
<div class="d-flex flex-wrap gap-3">
<div class="form-check form-switch">
<div class="switch-grid">
<div class="form-check form-switch app-switch">
<input class="form-check-input" type="checkbox" id="archived" name="archived" {% if list.is_archived
%}checked{% endif %}>
<label class="form-check-label" for="archived">📦 Archiwalna</label>
</div>
<div class="form-check form-switch">
<div class="form-check form-switch app-switch">
<input class="form-check-input" type="checkbox" id="public" name="public" {% if list.is_public %}checked{%
endif %}>
<label class="form-check-label" for="public">🌐 Publiczna</label>
</div>
<div class="form-check form-switch">
<div class="form-check form-switch app-switch">
<input class="form-check-input" type="checkbox" id="temporary" name="temporary" {% if list.is_temporary
%}checked{% endif %}>
<label class="form-check-label" for="temporary">⏳ Tymczasowa (podaj date i godzine wygasania)</label>
@@ -301,5 +303,5 @@
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js', filename='select.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'select.js') }}"></script>
{% endblock %}

View File

@@ -7,6 +7,8 @@
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
</div>
{% include 'admin/_nav.html' %}
<div class="card bg-secondary bg-opacity-10 text-white mb-4">
<div class="card-body">
@@ -40,7 +42,7 @@
<span class="badge rounded-pill bg-info">{{ total_items }} produktów</span>
</div>
<div class="card-body p-0">
<table class="table table-dark align-middle sortable">
<table class="table table-dark align-middle sortable keep-horizontal">
<thead>
<tr>
<th>ID</th>
@@ -99,7 +101,7 @@
</div>
<div class="card-body p-0">
{% set item_names = items | map(attribute='name') | map('lower') | list %}
<table class="table table-dark align-middle sortable">
<table class="table table-dark align-middle sortable keep-horizontal">
<thead>
<tr>
<th>ID</th>
@@ -168,8 +170,8 @@
</div>
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js', filename='product_suggestion.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='table_search.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'product_suggestion.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'table_search.js') }}"></script>
{% endblock %}
{% endblock %}

View File

@@ -3,8 +3,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-3">
<h2 class="mb-2">🔐{% if list_id %} Zarządzanie dostępem listy #{{ list_id }}{% else %} Zarządzanie dostępem do list
{% endif %}</h2>
<h2 class="mb-2">🔐{% if list_id %} Dostęp do listy #{{ list_id }}{% else %} Zarządzanie dostępem do list{% endif %}</h2>
<div class="d-flex gap-2">
{% if list_id %}
<a href="{{ url_for('admin_lists_access') }}" class="btn btn-outline-light">Powrót do wszystkich list</a>
@@ -13,12 +12,14 @@
</div>
</div>
{% include 'admin/_nav.html' %}
<!-- STICKY ACTION BAR -->
<div id="bulkBar" class="position-sticky top-0 z-3 mb-3" style="backdrop-filter: blur(6px);">
<div class="card bg-dark border-secondary shadow-sm">
<div class="card-body py-2 d-flex flex-wrap align-items-center gap-3">
<div class="d-flex align-items-center gap-2">
<input id="selectAll" class="form-check-input" type="checkbox" />
<div class="d-flex align-items-center gap-2 w-100">
<input id="selectAll" class="form-check-input table-select-checkbox" type="checkbox" />
<label for="selectAll" class="form-check-label">Zaznacz wszystko</label>
</div>
@@ -36,7 +37,7 @@
<div class="flex-grow-1">
<div class="input-group input-group-sm">
<input id="bulkUsersInput" class="form-control bg-dark text-white border-secondary"
placeholder="Podaj użytkowników (po przecinku lub enterach)" list="userHints">
placeholder="Podaj użytkowników (po przecinku lub enterach)" list="userHints" autocomplete="off">
<button id="bulkAddBtn" class="btn btn-outline-light" type="button"> Nadaj dostęp</button>
</div>
<div id="bulkTokens" class="d-flex flex-wrap gap-2 mt-2"></div>
@@ -47,15 +48,14 @@
<!-- HINTS -->
<datalist id="userHints"></datalist>
<datalist id="userHints">
{% for username in all_usernames %}<option value="{{ username }}"></option>{% endfor %}
</datalist>
<div class="card bg-dark text-white mb-5">
<div class="card-body">
<form id="statusForm" method="post">
<input type="hidden" name="action" value="save_changes">
<div class="table-responsive">
<table class="table table-dark align-middle sortable" id="listsTable">
<div class="table-responsive admin-table-responsive admin-table-responsive--wide">
<table class="table table-dark align-middle sortable lists-access-table" id="listsTable">
<thead class="align-middle">
<tr>
<th scope="col" style="width:36px;"></th>
@@ -63,9 +63,8 @@
<th scope="col">Nazwa listy</th>
<th scope="col">Właściciel</th>
<th scope="col">Utworzono</th>
<th scope="col">Statusy</th>
<th scope="col">Udostępnianie</th>
<th scope="col" style="min-width: 340px;">Uprawnienia</th>
<th scope="col" style="min-width: 420px;">Uprawnienia</th>
</tr>
</thead>
<tbody>
@@ -73,8 +72,7 @@
<tr data-id="{{ l.id }}" data-title="{{ l.title|lower }}"
data-owner="{{ (l.owner.username if l.owner else '-')|lower }}">
<td>
<input class="row-check form-check-input" type="checkbox" data-list-id="{{ l.id }}">
<input type="hidden" name="visible_ids" value="{{ l.id }}">
<input class="row-check form-check-input table-select-checkbox" type="checkbox" data-list-id="{{ l.id }}">
</td>
<td class="text-nowrap">{{ l.id }}</td>
@@ -92,29 +90,10 @@
</td>
<td class="text-nowrap">{{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }}</td>
<td style="min-width: 230px;">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="pub_{{ l.id }}" name="is_public_{{ l.id }}" {% if
l.is_public %}checked{% endif %}>
<label class="form-check-label" for="pub_{{ l.id }}">🌐 Publiczna</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="tmp_{{ l.id }}" name="is_temporary_{{ l.id }}" {%
if l.is_temporary %}checked{% endif %}>
<label class="form-check-label" for="tmp_{{ l.id }}">⏳ Tymczasowa</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="arc_{{ l.id }}" name="is_archived_{{ l.id }}" {%
if l.is_archived %}checked{% endif %}>
<label class="form-check-label" for="arc_{{ l.id }}">📦 Archiwalna</label>
</div>
</td>
<td style="min-width: 260px;">
{% if l.share_token %}
{% set share_url = url_for('shared_list', token=l.share_token, _external=True) %}
<div class="d-flex align-items-center gap-2">
<div class="d-flex align-items-center gap-2 w-100">
<div class="flex-grow-1 text-truncate mono small" title="{{ share_url }}">{{ share_url }}</div>
<button class="btn btn-sm btn-outline-secondary copy-share" type="button" data-url="{{ share_url }}"
aria-label="Kopiuj link">📋</button>
@@ -123,12 +102,12 @@
{% if l.is_public %}Lista widoczna publicznie{% else %}Dostęp przez link / uprawnienia{% endif %}
</div>
{% else %}
<div class="text-warning small">Brak tokenu</div>
<div class="text-warning small w-100 d-block">Brak tokenu</div>
{% endif %}
</td>
<td>
<div class="access-editor" data-list-id="{{ l.id }}">
<div class="access-editor" data-list-id="{{ l.id }}" data-post-url="{{ request.path }}{{ ('?page=' ~ page ~ "&per_page=" ~ per_page) if not list_id else "" }}" data-suggest-url="{{ url_for('admin_user_suggestions') }}" data-next="{{ request.full_path if request.query_string else request.path }}">
<!-- Tokeny z uprawnieniami -->
<div class="d-flex flex-wrap gap-2 mb-2 tokens">
{% for u in permitted_by_list.get(l.id, []) %}
@@ -146,11 +125,11 @@
<div class="input-group input-group-sm">
<input type="text"
class="form-control form-control-sm bg-dark text-white border-secondary access-input"
placeholder="Dodaj @użytkownika (wiele: przecinki/enter)" list="userHints"
placeholder="Dodaj użytkownika (wiele: przecinki/enter)" list="userHints" autocomplete="off"
aria-label="Dodaj użytkowników">
<button type="button" class="btn btn-sm btn-outline-light access-add"> Dodaj</button>
<button type="button" class="btn btn-sm btn-outline-light access-add">💾 Zapisz dostęp</button>
</div>
<div class="text-secondary small mt-1">Kliknij token, aby odebrać dostęp.</div>
<div class="text-secondary small mt-1">Kliknij token, aby odebrać dostęp. Zmiana zapisuje się od razu.</div>
</div>
</td>
@@ -158,17 +137,13 @@
{% endfor %}
{% if lists|length == 0 %}
<tr>
<td colspan="8" class="text-center py-4">Brak list do wyświetlenia</td>
<td colspan="7" class="text-center py-4">Brak list do wyświetlenia</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<div class="mt-3 d-flex justify-content-end">
<button type="submit" class="btn btn-sm btn-outline-light">💾 Zapisz zmiany statusów</button>
</div>
</form>
</div>
</div>
@@ -206,6 +181,7 @@
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js', filename='lists_access.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'access_users.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'lists_access.js') }}"></script>
{% endblock %}

View File

@@ -46,6 +46,8 @@
</div>
</div>
{% include 'admin/_nav.html' %}
<div class="card bg-secondary bg-opacity-10 text-white mb-5">
<div class="card-body">
<div class="row g-3">
@@ -222,8 +224,8 @@
endpoint: "/admin/crop_receipt"
};
</script>
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop_logic.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'receipt_crop.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'receipt_crop_logic.js') }}"></script>
{% endblock %}
{% endblock %}

View File

@@ -7,7 +7,9 @@
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
</div>
<form method="post" id="settings-form">
{% include 'admin/_nav.html' %}
<form method="post" id="settings-form" data-unsaved-warning="true">
<div class="card bg-dark text-white mb-4">
<div class="card-header border-0">
<strong>🔎 OCR — słowa kluczowe i czułość</strong>
@@ -64,34 +66,44 @@
{% set hex_auto = auto_colors[c.id] %}
{% set hex_effective = effective_colors[c.id] %}
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label d-block mb-2">{{ c.name }}</label>
<div class="settings-category-card h-100">
<div class="settings-category-header mb-2">
<label class="form-label d-block mb-0 settings-category-name" for="color_{{ c.id }}">{{ c.name }}</label>
<span class="badge settings-override-badge {{ 'text-bg-info' if hex_override else 'text-bg-secondary' }}" data-role="override-status">
{{ 'Nadpisany' if hex_override else 'Domyślny' }}
</span>
</div>
<div class="input-group">
<input
type="color"
class="form-control form-control-color category-color"
name="color_{{ c.id }}"
value="{{ hex_override or '' }}"
data-auto="{{ hex_auto }}"
{% if not hex_override %}data-empty="1"{% endif %}
aria-label="Kolor kategorii {{ c.name }}"
>
<input type="hidden" name="override_enabled_{{ c.id }}" value="{{ '1' if hex_override else '0' }}" class="override-enabled">
<div class="btn-group" role="group" aria-label="Akcje koloru">
<button type="button"
class="btn btn-outline-light btn-sm reset-one"
data-target="color_{{ c.id }}">
🔄 Reset
</button>
<button type="button"
class="btn btn-outline-light btn-sm use-default"
data-target="color_{{ c.id }}">
🎯 Przywróć domyślny
</button>
</div>
</div>
<div class="settings-color-controls">
<input
type="color"
class="form-control form-control-color category-color"
id="color_{{ c.id }}"
name="color_{{ c.id }}"
value="{{ hex_effective }}"
data-auto="{{ hex_auto }}"
data-effective="{{ hex_effective }}"
data-has-override="{{ '1' if hex_override else '0' }}"
aria-label="Kolor kategorii {{ c.name }}"
>
<div class="color-indicators mt-2">
<div class="settings-color-actions" role="group" aria-label="Akcje koloru">
<button type="button"
class="btn btn-outline-light btn-sm reset-one"
data-target="color_{{ c.id }}">
🔄 Wyczyść nadpisanie
</button>
<button type="button"
class="btn btn-outline-light btn-sm use-default"
data-target="color_{{ c.id }}">
🎯 Ustaw kolor domyślny
</button>
</div>
</div>
<div class="color-indicators mt-3">
<div class="indicator">
<span class="badge text-bg-dark me-2">Efektywny</span>
<span class="bar" data-kind="effective" style="background-color: {{ hex_effective }};"></span>
@@ -102,6 +114,7 @@
<span class="bar" data-kind="auto" style="background-color: {{ hex_auto }};"></span>
<span class="hex hex-auto ms-2">{{ hex_auto|upper }}</span>
</div>
</div>
</div>
</div>
{% endfor %}
@@ -140,6 +153,6 @@
{% endblock %}
{% block scripts %}
<link rel="stylesheet" href="{{ url_for('static_bp.serve_css', filename='admin_settings.css') }}?v={{ APP_VERSION }}">
<script src="{{ url_for('static_bp.serve_js', filename='admin_settings.js') }}?v={{ APP_VERSION }}"></script>
<link rel="stylesheet" href="{{ static_asset_url('static_bp.serve_css', 'admin_settings.css') }}">
<script src="{{ static_asset_url('static_bp.serve_js', 'admin_settings.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,64 @@
{% extends 'base.html' %}
{% block title %}Szablony list{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4 gap-2">
<div>
<h2 class="mb-2">🧩 Szablony list</h2>
<p class="text-secondary mb-0">Szablony są niezależne od zwykłych list i mogą służyć do szybkiego tworzenia nowych list.</p>
</div>
</div>
{% include 'admin/_nav.html' %}
<div class="row g-4">
<div class="col-lg-5">
<div class="card bg-dark text-white mb-4"><div class="card-body">
<h5 class="mb-3"> Nowy szablon ręcznie</h5>
<form method="post" class="stack-form">
<input type="hidden" name="action" value="create_manual">
<div class="mb-3"><label class="form-label">Nazwa</label><input type="text" name="name" class="form-control" required></div>
<div class="mb-3"><label class="form-label">Opis</label><textarea name="description" class="form-control" rows="2"></textarea></div>
<div class="mb-3"><label class="form-label">Produkty</label><textarea name="items_text" class="form-control" rows="8" placeholder="Mleko x2&#10;Chleb&#10;Jajka x10"></textarea><div class="form-text">Każdy produkt w osobnej linii. Ilość opcjonalnie po „x”.</div></div>
<button type="submit" class="btn btn-success w-100">Utwórz szablon</button>
</form>
</div></div>
<div class="card bg-dark text-white"><div class="card-body">
<h5 class="mb-3">📋 Utwórz z istniejącej listy</h5>
<form method="post" class="stack-form">
<input type="hidden" name="action" value="create_from_list">
<div class="mb-3"><label class="form-label">Lista źródłowa</label><select name="source_list_id" class="form-select" required>{% for l in source_lists %}<option value="{{ l.id }}">#{{ l.id }} — {{ l.title }}</option>{% endfor %}</select></div>
<div class="mb-3"><label class="form-label">Nazwa szablonu</label><input type="text" name="template_name" class="form-control"></div>
<div class="mb-3"><label class="form-label">Opis</label><textarea name="description" class="form-control" rows="2"></textarea></div>
<button type="submit" class="btn btn-outline-light w-100">Utwórz z listy</button>
</form>
</div></div>
</div>
<div class="col-lg-7">
<div class="card bg-dark text-white"><div class="card-body">
<div class="admin-page-head mb-3"><h5 class="mb-0">Wszystkie szablony użytkowników</h5><span class="badge rounded-pill bg-secondary">{{ templates|length }} szt.</span></div>
<div class="table-responsive">
<table class="table table-dark align-middle keep-horizontal">
<thead><tr><th>Nazwa</th><th>Produkty</th><th>Status</th><th>Autor</th><th>Akcje</th></tr></thead>
<tbody>
{% for template in templates %}
<tr>
<td><div class="fw-semibold">{{ template.name }}</div><div class="small text-secondary">{{ template.description or 'Bez opisu' }}</div></td>
<td>{{ template.items|length }}</td>
<td>{% if template.is_active %}<span class="badge rounded-pill bg-success">Aktywny</span>{% else %}<span class="badge rounded-pill bg-secondary">Wyłączony</span>{% endif %}</td>
<td>{{ template.creator.username if template.creator else '—' }}</td>
<td>
<div class="d-flex flex-wrap gap-2">
<form method="post"><input type="hidden" name="action" value="instantiate"><input type="hidden" name="template_id" value="{{ template.id }}"><button class="btn btn-sm btn-outline-primary" type="submit"> Utwórz listę</button></form>
<form method="post"><input type="hidden" name="action" value="toggle"><input type="hidden" name="template_id" value="{{ template.id }}"><button class="btn btn-sm btn-outline-warning" type="submit">{% if template.is_active %}⏸ Wyłącz{% else %}▶ Włącz{% endif %}</button></form>
<form method="post" onsubmit="return confirm('Usunąć szablon?')"><input type="hidden" name="action" value="delete"><input type="hidden" name="template_id" value="{{ template.id }}"><button class="btn btn-sm btn-outline-danger" type="submit">🗑 Usuń</button></form>
</div>
</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-center text-secondary py-4">Brak szablonów użytkowników.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div></div>
</div>
</div>
{% endblock %}

View File

@@ -7,6 +7,8 @@
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
</div>
{% include 'admin/_nav.html' %}
<!-- Formularz dodawania nowego użytkownika -->
<div class="card bg-secondary bg-opacity-10 text-white mb-4">
<div class="card-body">
@@ -20,8 +22,10 @@
</div>
<div class="col-md-4">
<label for="password" class="form-label text-white-50">Hasło</label>
<input type="password" id="password" name="password"
class="form-control bg-dark text-white border-secondary rounded" placeholder="min. 6 znaków" required>
<div class="input-group ui-password-group">
<input type="password" id="password" name="password"
class="form-control bg-dark text-white border-secondary rounded" placeholder="min. 6 znaków" required>
</div>
</div>
<div class="col-md-4 d-grid">
<button type="submit" class="btn btn-outline-light"> Dodaj użytkownika</button>
@@ -34,7 +38,7 @@
<div class="card bg-dark text-white mb-5">
<div class="card-body">
<table class="table table-dark align-middle sortable">
<table class="table table-dark align-middle sortable keep-horizontal">
<thead>
<tr>
<th>ID</th>
@@ -103,8 +107,10 @@
</div>
<div class="modal-body">
<p id="resetUsernameLabel">Dla użytkownika: <strong></strong></p>
<input type="password" name="password" placeholder="Nowe hasło"
class="form-control bg-dark text-white border-secondary rounded" required>
<div class="input-group ui-password-group">
<input type="password" name="password" placeholder="Nowe hasło"
class="form-control bg-dark text-white border-secondary rounded" required>
</div>
</div>
<div class="modal-footer border-0">
<button type="submit" class="btn btn-sm btn-outline-light w-100">💾 Zapisz nowe hasło</button>
@@ -115,7 +121,7 @@
</div>
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js', filename='user_management.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'user_management.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Live Lista Zakupów{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('favicon') }}">
<link href="{{ static_asset_url('static_bp.serve_css_lib', 'bootstrap.min.css') }}" rel="stylesheet">
<link href="{{ static_asset_url('static_bp.serve_css', 'style.css') }}" rel="stylesheet">
{% set exclude_paths = ['/system-auth'] %}
{% if (exclude_paths | select("in", request.path) | list | length == 0)
and has_authorized_cookie
and not is_blocked %}
<link href="{{ static_asset_url('static_bp.serve_css_lib', 'glightbox.min.css') }}" rel="stylesheet">
<link href="{{ static_asset_url('static_bp.serve_css_lib', 'sort_table.min.css') }}" rel="stylesheet">
{% endif %}
{% set substrings_cropper = ['/admin/receipts', '/edit_my_list'] %}
{% if substrings_cropper | select("in", request.path) | list | length > 0 %}
<link href="{{ static_asset_url('static_bp.serve_css_lib', 'cropper.min.css') }}" rel="stylesheet">
{% endif %}
{% set substrings_tomselect = ['/edit_my_list', '/admin/edit_list', '/admin/edit_categories'] %}
{% if substrings_tomselect | select("in", request.path) | list | length > 0 %}
<link href="{{ static_asset_url('static_bp.serve_css_lib', 'tom-select.bootstrap5.min.css') }}" rel="stylesheet">
{% endif %}
</head>
<body class="app-body endpoint-{{ (request.endpoint or 'unknown')|replace('.', '-') }}{% if current_user.is_authenticated %} is-authenticated{% endif %}{% if request.path.startswith('/admin') %} is-admin-area{% endif %}">
<div class="app-backdrop"></div>
<header class="app-header sticky-top">
<nav class="navbar navbar-expand-lg app-navbar">
<div class="container-xxl px-3 px-lg-4 gap-2">
<a class="navbar-brand app-brand" href="{{ url_for('main_page') }}">
<span class="app-brand__icon">🛒</span>
<span>
<span class="app-brand__title">Lista</span>
<span class="app-brand__accent">Zakupów</span>
</span>
</a>
<div class="app-navbar__meta order-lg-2 ms-auto ms-lg-0">
{% if has_authorized_cookie and not is_blocked %}
{% if current_user.is_authenticated %}
<div class="app-user-chip">
<span class="app-user-chip__label">Zalogowany</span>
<span class="badge rounded-pill text-bg-success">{{ current_user.username }}</span>
</div>
{% else %}
<div class="app-user-chip app-user-chip--guest">
<span class="app-user-chip__label">Tryb</span>
<span class="badge rounded-pill text-bg-info">gość</span>
</div>
{% endif %}
{% endif %}
</div>
<div class="d-lg-none app-navbar__meta app-navbar__meta--mobile ms-auto">
{% if has_authorized_cookie and not is_blocked %}
{% if current_user.is_authenticated %}
<div class="app-user-chip app-user-chip--mobile">
<span class="app-user-chip__label">Zalogowany</span>
<span class="badge rounded-pill text-bg-success">{{ current_user.username }}</span>
</div>
{% else %}
<div class="app-user-chip app-user-chip--guest app-user-chip--mobile">
<span class="app-user-chip__label">Tryb</span>
<span class="badge rounded-pill text-bg-info">gość</span>
</div>
{% endif %}
{% endif %}
</div>
<div class="dropdown d-lg-none app-mobile-menu">
<button class="btn app-navbar-toggler app-mobile-menu__toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false" aria-label="Otwórz menu">
<span class="navbar-toggler-icon"></span>
</button>
<div class="dropdown-menu dropdown-menu-end app-mobile-menu__panel">
{% if not is_blocked and request.endpoint and request.endpoint != 'system_auth' %}
{% if current_user.is_authenticated %}
{% if current_user.is_admin %}
<a href="{{ url_for('admin_panel') }}" class="dropdown-item app-mobile-menu__item">⚙️ <span>Panel</span></a>
{% endif %}
<a href="{{ url_for('expenses') }}" class="dropdown-item app-mobile-menu__item">📊 <span>Wydatki</span></a>
<a href="{{ url_for('my_templates') }}" class="dropdown-item app-mobile-menu__item">🧩 <span>Szablony</span></a>
<a href="{{ url_for('logout') }}" class="dropdown-item app-mobile-menu__item">🚪 <span>Wyloguj</span></a>
{% else %}
<a href="{{ url_for('login') }}" class="dropdown-item app-mobile-menu__item">🔑 <span>Zaloguj</span></a>
{% endif %}
{% endif %}
</div>
</div>
<div class="d-none d-lg-flex justify-content-end order-lg-3" id="appNavbarMenu">
<div class="app-navbar__actions">
{% if not is_blocked and request.endpoint and request.endpoint != 'system_auth' %}
{% if current_user.is_authenticated %}
{% if current_user.is_admin %}
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-light btn-sm app-nav-action">⚙️ <span>Panel</span></a>
{% endif %}
<a href="{{ url_for('expenses') }}" class="btn btn-outline-light btn-sm app-nav-action">📊 <span>Wydatki</span></a>
<a href="{{ url_for('my_templates') }}" class="btn btn-outline-light btn-sm app-nav-action">🧩 <span>Szablony</span></a>
<a href="{{ url_for('logout') }}" class="btn btn-outline-light btn-sm app-nav-action">🚪 <span>Wyloguj</span></a>
{% else %}
<a href="{{ url_for('login') }}" class="btn btn-success btn-sm app-nav-action">🔑 <span>Zaloguj</span></a>
{% endif %}
{% endif %}
</div>
</div>
</div>
</nav>
</header>
<main class="app-main">
<div class="container-xxl px-2 px-md-3 px-xl-4">
{% block before_content %}{% endblock %}
<div class="app-content-frame">
{% block content %}{% endblock %}
</div>
</div>
</main>
<div id="toast-container" class="toast-container position-fixed bottom-0 end-0 p-3"></div>
<footer class="app-footer text-center text-secondary small">
<div class="container-xxl px-3 px-lg-4">
<div class="app-footer__inner">
<p class="mb-1">© 2025 <strong>linuxiarz.pl</strong></p>
<p class="mb-1">
<a href="https://git.linuxiarz.pl/gru/lista_zakupowa_live" target="_blank" class="link-success text-decoration-none">
source code
</a>
</p>
<div class="small">v{{ APP_VERSION }}</div>
</div>
</div>
</footer>
<script src="{{ static_asset_url('static_bp.serve_js_lib', 'bootstrap.bundle.min.js') }}"></script>
{% if not is_blocked %}
<script>
document.addEventListener('DOMContentLoaded', function () {
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.forEach(function (el) {
new bootstrap.Tooltip(el);
});
{% with messages = get_flashed_messages(with_categories = true) %}
{% for category, message in messages %}
{% set cat = 'info' if not category else ('danger' if category == 'error' else category) %}
{% if message == 'Please log in to access this page.' %}
showToast("Aby uzyskać dostęp do tej strony, musisz być zalogowany.", "danger");
{% else %}
showToast({{ message|tojson }}, "{{ cat }}");
{% endif %}
{% endfor %}
{% endwith %}
});
</script>
{% if request.endpoint != 'system_auth' %}
<script src="{{ static_asset_url('static_bp.serve_js_lib', 'glightbox.min.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js_lib', 'socket.io.min.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js_lib', 'sort_table.min.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'functions.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'live.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'sockets.js') }}"></script>
{% endif %}
<script src="{{ static_asset_url('static_bp.serve_js', 'toasts.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'app_ui.js') }}"></script>
<script>
if (typeof GLightbox === 'function') {
let lightbox = GLightbox({ selector: '.glightbox' });
}
</script>
{% set substrings = ['/admin/receipts', '/edit_my_list'] %}
{% if substrings | select("in", request.path) | list | length > 0 %}
<script src="{{ static_asset_url('static_bp.serve_js_lib', 'cropper.min.js') }}"></script>
{% endif %}
{% set substrings = ['/edit_my_list', '/admin/edit_list', '/admin/edit_categories'] %}
{% if substrings | select("in", request.path) | list | length > 0 %}
<script src="{{ static_asset_url('static_bp.serve_js_lib', 'tom-select.complete.min.js') }}"></script>
{% endif %}
{% endif %}
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -1,14 +1,17 @@
{% extends 'base.html' %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<h2>Edytuj listę: <strong>{{ list.title }}</strong></h2>
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h2 class="mb-2">Edytuj listę: <strong>{{ list.title }}</strong></h2>
<p class="text-secondary mb-0">Zmień ustawienia, kategorię, ważność i udostępnianie listy.</p>
</div>
<a href="{{ url_for('main_page') }}" class="btn btn-outline-secondary">← Powrót</a>
</div>
<div class="card bg-secondary bg-opacity-10 text-white mb-5">
<div class="card-body">
<form method="post">
<form method="post" data-unsaved-warning="true" class="stack-form">
<!-- Nazwa listy -->
<div class="mb-3">
@@ -20,20 +23,20 @@
<!-- Statusy listy -->
<div class="mb-4">
<label class="form-label">⚙️ Statusy listy</label>
<div class="d-flex flex-wrap gap-3">
<div class="form-check form-switch">
<div class="switch-grid">
<div class="form-check form-switch form-check-spaced form-switch-compact app-switch">
<input class="form-check-input" type="checkbox" id="public" name="is_public" {% if list.is_public
%}checked{% endif %}>
<label class="form-check-label" for="public">🌐 Publiczna (czyli mogą zobaczyć goście)</label>
</div>
<div class="form-check form-switch">
<div class="form-check form-switch form-check-spaced form-switch-compact app-switch">
<input class="form-check-input" type="checkbox" id="temporary" name="is_temporary" {% if list.is_temporary
%}checked{% endif %}>
<label class="form-check-label" for="temporary">⏳ Tymczasowa (ustaw date wygasania)</label>
</div>
<div class="form-check form-switch">
<div class="form-check form-switch form-check-spaced form-switch-compact app-switch">
<input class="form-check-input" type="checkbox" id="archived" name="is_archived" {% if list.is_archived
%}checked{% endif %}>
<label class="form-check-label" for="archived">📦 Archiwalna</label>
@@ -95,6 +98,10 @@
</div>
<!-- DOSTĘP DO LISTY -->
<datalist id="userHintsOwner">
{% for username in all_usernames %}<option value="{{ username }}"></option>{% endfor %}
</datalist>
<div class="mb-3">
<label class="form-label">👥 Użytkownicy z dostępem</label>
@@ -118,10 +125,10 @@
<!-- Dodawanie (wiele: przecinki/enter) + prywatne podpowiedzi -->
<div class="input-group input-group-sm">
<input type="text" class="access-input form-control form-control-sm bg-dark text-white border-secondary"
placeholder="Dodaj @użytkownika (wiele: przecinki/enter)" aria-label="Dodaj użytkowników">
<button type="button" class="access-add btn btn-sm btn-outline-light"> Dodaj</button>
placeholder="Dodaj użytkownika (wiele: przecinki/enter)" list="userHintsOwner" autocomplete="off" aria-label="Dodaj użytkowników">
<button type="button" class="access-add btn btn-sm btn-outline-light">💾 Zapisz dostęp</button>
</div>
<div class="text-secondary small mt-1">Kliknij token, aby odebrać dostęp.</div>
<div class="text-secondary small mt-1">Kliknij token, aby odebrać dostęp. Możesz wpisać kilka loginów oddzielonych przecinkiem.</div>
</div>
</div>
@@ -253,9 +260,9 @@
endpoint: "/user_crop_receipt"
};
</script>
<script src="{{ url_for('static_bp.serve_js', filename='confirm_delete.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop_logic.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='select.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='access_users.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'confirm_delete.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'receipt_crop.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'receipt_crop_logic.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'select.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'access_users.js') }}"></script>
{% endblock %}

View File

@@ -2,9 +2,10 @@
{% block title %}Błąd {{ code }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<section class="text-center mb-4">
<h2 class="mb-2">{{ code }} — {{ title }}</h2>
</div>
<p class="text-secondary mb-0">Nie udało się wyświetlić żądanej strony.</p>
</section>
<div class="card bg-dark text-white">
<div class="card-body">

View File

@@ -2,15 +2,18 @@
{% block title %}Wydatki z Twoich list{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<h2 class="mb-2">Statystyki wydatków</h2>
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h2 class="mb-2">Statystyki wydatków</h2>
<p class="text-secondary mb-0">Analiza kosztów list w czasie, z podziałem na zakresy i kategorie.</p>
</div>
<a href="{{ url_for('main_page') }}" class="btn btn-outline-secondary">← Powrót</a>
</div>
<div class="card bg-secondary bg-opacity-10 text-white mb-5">
<div class="card-body">
<div class="d-flex justify-content-center mb-3">
<div class="form-check form-switch">
<div class="form-check form-switch app-switch">
<input class="form-check-input" type="checkbox" id="showAllLists" {% if show_all %}checked{% endif %}>
<label class="form-check-label ms-2 text-white" for="showAllLists">
Uwzględnij listy udostępnione dla mnie i publiczne
@@ -58,18 +61,18 @@
<div class="tab-pane fade show active" id="listsTab" role="tabpanel">
<div class="card bg-dark text-white mb-4">
<div class="card-body">
<div class="d-flex flex-wrap gap-2 mb-3 justify-content-center">
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-light range-btn" data-range="day">🗓️ Dzień</button>
<button class="btn btn-outline-light range-btn" data-range="week">📆 Tydzień</button>
<button class="btn btn-outline-light range-btn active" data-range="month">📅 Miesiąc</button>
<button class="btn btn-outline-light range-btn" data-range="year">📈 Rok</button>
<button class="btn btn-outline-light range-btn" data-range="all">🌐 Wszystko</button>
<div class="d-flex flex-wrap gap-2 mb-3 justify-content-center expenses-range-toolbar expenses-table-toolbar">
<div class="btn-group btn-group-sm expenses-range-group" role="group">
<button class="btn btn-outline-light range-btn table-range-btn" data-range="day">🗓️ Dzień</button>
<button class="btn btn-outline-light range-btn table-range-btn" data-range="week">📆 Tydzień</button>
<button class="btn btn-outline-light range-btn table-range-btn active" data-range="month">📅 Miesiąc</button>
<button class="btn btn-outline-light range-btn table-range-btn" data-range="year">📈 Rok</button>
<button class="btn btn-outline-light range-btn table-range-btn" data-range="all">🌐 Wszystko</button>
</div>
</div>
<div class="d-flex justify-content-center mb-3">
<div class="input-group input-group-sm w-100" style="max-width: 570px;">
<div class="input-group input-group-sm w-100 expenses-date-range" style="max-width: 570px;">
<span class="input-group-text bg-secondary text-white border-secondary">Od</span>
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1"
id="customStart">
@@ -166,20 +169,20 @@
</div>
</div>
<div class="d-flex flex-wrap gap-2 mb-3 justify-content-center">
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-light range-btn" data-range="last30days">🗓️ Ostatnie 30
<div class="d-flex flex-wrap gap-2 mb-3 justify-content-center expenses-range-toolbar expenses-chart-toolbar">
<div class="btn-group btn-group-sm expenses-range-group" role="group">
<button class="btn btn-outline-light range-btn chart-range-btn" data-range="last30days">🗓️ Ostatnie 30
dni</button>
<button class="btn btn-outline-light range-btn" data-range="currentmonth">📅 Bieżący miesiąc</button>
<button class="btn btn-outline-light range-btn" data-range="monthly">📆 Miesięczne</button>
<button class="btn btn-outline-light range-btn" data-range="quarterly">📊 Kwartalne</button>
<button class="btn btn-outline-light range-btn" data-range="halfyearly">🗓️ Półroczne</button>
<button class="btn btn-outline-light range-btn" data-range="yearly">📈 Roczne</button>
<button class="btn btn-outline-light range-btn chart-range-btn" data-range="currentmonth">📅 Bieżący miesiąc</button>
<button class="btn btn-outline-light range-btn chart-range-btn" data-range="monthly">📆 Miesięczne</button>
<button class="btn btn-outline-light range-btn chart-range-btn" data-range="quarterly">📊 Kwartalne</button>
<button class="btn btn-outline-light range-btn chart-range-btn" data-range="halfyearly">🗓️ Półroczne</button>
<button class="btn btn-outline-light range-btn chart-range-btn" data-range="yearly">📈 Roczne</button>
</div>
</div>
<div class="d-flex justify-content-center mb-4">
<div class="input-group input-group-sm w-100" style="max-width: 570px;">
<div class="input-group input-group-sm w-100 expenses-date-range" style="max-width: 570px;">
<span class="input-group-text bg-secondary text-white border-secondary">Od</span>
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="startDate">
<span class="input-group-text bg-secondary text-white border-secondary">Do</span>
@@ -210,13 +213,13 @@
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js_lib', filename='chart.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='show_all_expense.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='expense_chart.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='expense_table.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='expense_tab.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='select_all_table.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='chart_controls.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='modal_chart.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='download_chart.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js_lib', 'chart.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'show_all_expense.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'expense_chart.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'expense_table.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'expense_tab.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'select_all_table.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'chart_controls.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'modal_chart.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'download_chart.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,494 @@
{% extends 'base.html' %}
{% block title %}Lista: {{ list.title }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap">
<h2 class="mb-2">
Lista: <strong>{{ list.title }}</strong>
{% if list.is_archived %}
<span class="badge rounded-pill bg-secondary ms-2">(Archiwalna)</span>
{% endif %}
{% if list.category_badges %}
{% for cat in list.category_badges %}
<span class="badge rounded-pill rounded-pill text-dark ms-1" style="background-color: {{ cat.color }};
font-size: 0.75rem;
opacity: 0.85;">
{{ cat.name }}
</span>
{% endfor %}
<!-- PRZYCISK DO MODALA KATEGORII -->
<button class="btn btn-sm btn-outline-light ms-2" data-bs-toggle="modal" data-bs-target="#categoriesModal">
✏️ Zmień kategorie
</button>
{% else %}
<!-- ZAMIAST LINKU: OTWARCIE MODALA KATEGORII -->
<button class="btn btn-sm btn-outline-light ms-2" data-bs-toggle="modal" data-bs-target="#categoriesModal">
Dodaj kategorię
</button>
{% endif %}
</h2>
</div>
<a href="{{ request.url_root }}share/{{ list.share_token }}" class="btn btn-outline-primary btn-sm w-100 mb-3" {% if not
list.is_public %}disabled{% endif %}>
✅ Otwórz tryb odznaczania
</a>
<div id="share-card" class="card bg-secondary bg-opacity-10 text-white mb-4">
<div class="card-body">
<div class="mb-2">
<strong id="share-header">
{% if list.is_public %}🔗 Udostępnij link (lista publiczna){% else %}🔗 Udostępnij link (widoczna przez link /
uprawnienia){% endif %}
</strong>
<span id="share-url" class="badge rounded-pill bg-secondary text-wrap" style="font-size: 0.7rem;">
{{ request.url_root }}share/{{ list.share_token }}
</span>
</div>
<div class="d-flex flex-column flex-md-row gap-2">
<button id="copyBtn" class="btn btn-outline-success btn-sm flex-fill"
onclick="copyLink('{{ request.url_root }}share/{{ list.share_token }}')">
📋 Skopiuj / Udostępnij
</button>
<button id="toggleVisibilityBtn" class="btn btn-outline-light btn-sm flex-fill"
onclick="toggleVisibility({{ list.id }})">
{% if list.is_public %}🙈 Ustaw niepubliczną{% else %}🐵 Uczyń publiczną{% endif %}
</button>
<!-- ZAMIAST LINKU: OTWARCIE MODALA NADAWANIA DOSTĘPU -->
<button class="btn btn-outline-primary btn-sm flex-fill" data-bs-toggle="modal"
data-bs-target="#grantAccessModal">
Nadaj dostęp
</button>
</div>
</div>
</div>
<!-- Progress bar (dynamic) -->
<h5 id="progress-title" class="mb-2">
Postęp listy —
<span id="purchased-count">{{ purchased_count }}</span>/<span id="total-count">{{ total_count }}</span> kupionych
(<span id="percent-value">{{ percent|int }}</span>%)
</h5>
<div class="progress progress-dark position-relative">
<div id="progress-bar-purchased" class="progress-bar bg-success" role="progressbar" data-bs-toggle="tooltip"
title="Kupione produkty"></div>
<div id="progress-bar-not-purchased" class="progress-bar bg-warning" role="progressbar" data-bs-toggle="tooltip"
title="Oznaczone jako niekupione"></div>
<div id="progress-bar-remaining" class="progress-bar bg-transparent" role="progressbar" data-bs-toggle="tooltip"
title="Pozostałe do kupienia"></div>
<span id="progress-label" class="progress-label small fw-bold"></span>
</div>
<br>
{% if total_expense > 0 %}
<div id="total-expense2" class="text-success fw-bold mb-3">
💸 Łącznie wydano: {{ '%.2f'|format(total_expense) }} PLN
</div>
{% else %}
<div id="total-expense2" class="text-success fw-bold mb-3">
💸 Łącznie wydano: 0.00 PLN
</div>
{% endif %}
<div class="list-toolbar d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<button id="sort-toggle-btn" class="btn btn-sm btn-outline-warning list-toolbar__sort" onclick="toggleSortMode()">✳️ Zmień kolejność</button>
<div class="form-check form-switch form-check-spaced app-switch hide-purchased-switch hide-purchased-switch--minimal hide-purchased-switch--right">
<input class="form-check-input" type="checkbox" id="hidePurchasedToggle">
<label class="form-check-label ms-2" for="hidePurchasedToggle">Ukryj zaznaczone</label>
</div>
</div>
<ul id="items" class="list-group mb-3" data-is-share="{{ 'true' if is_share else 'false' }}">
{% for item in items %}
<li data-name="{{ item.name|lower }}" id="item-{{ item.id }}"
class="list-group-item shopping-item-row clickable-item
{% 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="shopping-item-main">
<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 %}>
<div class="shopping-item-content">
<div class="shopping-item-head">
<div class="shopping-item-text">
<span id="name-{{ item.id }}" class="shopping-item-name text-white">{{ item.name }}</span>
{% if item.quantity and item.quantity > 1 %}
<span class="badge rounded-pill bg-secondary">x{{ item.quantity }}</span>
{% endif %}
{% set info_parts = [] %}
{% if item.note %}{% set _ = info_parts.append('<span class="text-danger">[ <b>' ~ item.note ~ '</b> ]</span>') %}{% endif %}
{% if item.not_purchased_reason %}{% set _ = info_parts.append('<span class="text-dark">[ <b>Powód: ' ~ item.not_purchased_reason ~ '</b> ]</span>') %}{% endif %}
{% if item.added_by_display %}{% set _ = info_parts.append('<span class="text-info">[ Dodał/a: <b>' ~ item.added_by_display ~ '</b> ]</span>') %}{% endif %}
{% if info_parts %}
<span class="info-line small" id="info-{{ item.id }}">{{ info_parts | join(' ') | safe }}</span>
{% endif %}
</div>
<div class="list-item-actions shopping-item-actions" role="group">
{% if not is_share %}
<button type="button" class="btn btn-outline-light btn-sm shopping-action-btn" {% if list.is_archived %}disabled{% else
%}onclick='openEditItemModal(event, {{ item.id }}, {{ item.name|tojson }}, {{ item.quantity or 1 }})' {% endif %}>✏️</button>
<button type="button" class="btn btn-outline-light btn-sm shopping-action-btn" {% if list.is_archived %}disabled{% else
%}onclick="deleteItem({{ item.id }})" {% endif %}>🗑️</button>
{% endif %}
{% if item.not_purchased %}
<button type="button" class="btn btn-outline-light btn-sm shopping-action-btn shopping-action-btn--wide" {% if list.is_archived %}disabled{% else
%}onclick="unmarkNotPurchased({{ item.id }})" {% endif %}>✅ Przywróć</button>
{% elif not item.not_purchased %}
<button type="button" class="btn btn-outline-light btn-sm shopping-action-btn" {% if list.is_archived %}disabled{% else
%}onclick="markNotPurchasedModal(event, {{ item.id }})" {% endif %}>⚠️</button>
{% endif %}
</div>
</div>
</div>
</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.</li>
{% endfor %}
</ul>
<div class="modal fade" id="editItemModal" tabindex="-1" aria-labelledby="editItemModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-white">
<form id="editItemForm">
<div class="modal-header">
<h5 class="modal-title" id="editItemModalLabel">Edytuj produkt</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body">
<input type="hidden" id="editItemId">
<div class="mb-3">
<label for="editItemName" class="form-label">Nazwa produktu</label>
<input type="text" id="editItemName" class="form-control bg-dark text-white border-secondary" maxlength="255" required>
</div>
<div>
<label for="editItemQuantity" class="form-label">Ilość</label>
<input type="number" id="editItemQuantity" class="form-control bg-dark text-white border-secondary" min="1" step="1" required>
</div>
</div>
<div class="modal-footer justify-content-end">
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline-light" data-bs-dismiss="modal">❌ Anuluj</button>
<button type="submit" class="btn btn-sm btn-outline-light"><span class="shopping-btn-icon" aria-hidden="true">💾</span><span class="shopping-btn-label">Zapisz</span></button>
</div>
</div>
</form>
</div>
</div>
</div>
{% if not list.is_archived %}
<div class="shopping-entry-card mb-3" aria-label="Sekcja dodawania produktu">
<div class="shopping-entry-card__label"> Dodaj produkt</div>
<div class="shopping-entry-card__hint">Wpisz nazwę produktu i ilość, potem kliknij Dodaj.</div>
<div class="input-group mb-0 shopping-compact-input-group shopping-product-input-group">
<input type="text" id="newItem" name="name"
class="form-control bg-dark text-white border-secondary shopping-product-name-input"
placeholder="Dodaj produkt" required>
<input type="number" id="newQuantity" name="quantity"
class="form-control bg-dark text-white border-secondary shopping-qty-input"
placeholder="Ilość" min="1" value="1">
<button type="button"
class="btn btn-outline-success share-submit-btn shopping-compact-submit"
onclick="addItem({{ list.id }})">
<span class="shopping-btn-icon" aria-hidden="true"></span>
<span class="shopping-btn-label">Dodaj</span>
</button>
</div>
</div>
<div class="list-quick-actions mb-3" aria-label="Szybkie akcje listy">
<div class="list-quick-actions__header">
<div>
<div class="list-quick-actions__eyebrow">Szybkie akcje</div>
<div class="list-quick-actions__title">Dodawanie i zapis listy</div>
</div>
<div class="list-quick-actions__hint">Najczęściej używane akcje pod ręką.</div>
</div>
<div class="list-quick-actions__grid">
<button class="btn btn-outline-light list-quick-actions__action list-quick-actions__action--primary" data-bs-toggle="modal" data-bs-target="#massAddModal" type="button">
<span class="list-quick-actions__icon" aria-hidden="true"></span>
<span class="list-quick-actions__content">
<span class="list-quick-actions__label">Dodaj produkty masowo</span>
<span class="list-quick-actions__desc">Wklej kilka pozycji naraz i uzupełnij listę szybciej.</span>
</span>
</button>
<form method="post" action="{{ url_for('create_template_from_user_list', list_id=list.id) }}" class="list-quick-actions__form">
<input type="hidden" name="template_name" value="{{ list.title }} - szablon">
<button type="submit" class="btn btn-outline-primary list-quick-actions__action list-quick-actions__action--secondary">
<span class="list-quick-actions__icon" aria-hidden="true">🧩</span>
<span class="list-quick-actions__content">
<span class="list-quick-actions__label">Zapisz jako szablon</span>
<span class="list-quick-actions__desc">Zachowaj układ tej listy i użyj go ponownie.</span>
</span>
</button>
</form>
</div>
</div>
{% endif %}
{% if activity_logs %}
<div class="card bg-dark text-white mb-4">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center gap-2 flex-wrap mb-2">
<h5 class="mb-0">🕘 Historia zmian listy</h5>
<button class="btn btn-sm btn-outline-light" type="button" data-bs-toggle="collapse" data-bs-target="#activityHistory" aria-expanded="false" aria-controls="activityHistory">Pokaż / ukryj</button>
</div>
<div class="small text-secondary mb-3">Rozwiń aby zobaczyć | Zdarzeń: {{ activity_logs|length }}</div>
<div class="collapse" id="activityHistory">
<div class="table-responsive">
<table class="table table-dark table-sm align-middle">
<thead><tr><th>Kiedy</th><th>Kto</th><th>Akcja</th><th>Produkt / szczegóły</th></tr></thead>
<tbody>
{% for log in activity_logs %}
<tr>
<td>{{ log.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>{{ log.actor_name }}</td>
<td>{{ action_label(log.action) }}</td>
<td>{{ log.item_name or log.details or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endif %}
{% set receipt_pattern = 'list_' ~ list.id %}
<div class="card bg-dark text-white border-secondary shadow-sm mt-4">
<div class="card-body">
<div class="d-flex flex-column flex-lg-row align-items-lg-center justify-content-between gap-2 mb-3">
<div>
<h5 class="mb-1">📄 Paragony dodane do tej listy</h5>
<p class="text-secondary small mb-0">
Tutaj możesz wygodnie przejrzeć wszystkie paragony przypisane do tej listy.
</p>
</div>
<div class="d-flex flex-wrap gap-2">
<span class="badge rounded-pill bg-secondary">{{ receipts|length }} plik{% if receipts|length != 1 %}i{% endif %}</span>
<span class="badge rounded-pill bg-info text-dark">Tylko podgląd</span>
</div>
</div>
<div class="border border-secondary rounded-3 p-3">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<h6 class="mb-0">📸 Galeria paragonów</h6>
{% if receipts %}
<span class="text-secondary small">Kliknij miniaturę, aby otworzyć podgląd</span>
{% endif %}
</div>
<div class="row g-3" id="receiptGallery">
{% if receipts %}
{% for r in receipts %}
<div class="col-6 col-md-4 col-xl-3">
<a href="{{ url_for('uploaded_file', filename=r.filename) }}?v={{ r.version_token or '0' }}" class="glightbox text-decoration-none"
data-gallery="receipt-gallery">
<div class="card bg-black border-secondary h-100 overflow-hidden shadow-sm">
<img src="{{ url_for('uploaded_file', filename=r.filename) }}?v={{ r.version_token or '0' }}"
class="card-img-top img-fluid" style="height: 180px; object-fit: cover;" alt="Paragon {{ loop.index }}">
<div class="card-body p-2">
<div class="small text-truncate text-secondary">Paragon {{ loop.index }}</div>
</div>
</div>
</a>
</div>
{% endfor %}
{% else %}
<div class="col-12">
<div class="alert alert-info text-center mb-0" role="alert">
Brak wgranych paragonów do tej listy
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- MODAL: KATEGORIA (pojedynczy wybór) -->
<div class="modal fade" id="categoriesModal" tabindex="-1" aria-labelledby="categoriesModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title" id="categoriesModalLabel">Ustaw kategorię</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<form method="post" action="{{ url_for('list_settings', list_id=list.id) }}">
<div class="modal-body">
{% if popular_categories %}
<div class="mb-3">
<div class="small text-secondary mb-1">Najczęściej używane:</div>
<div class="d-flex flex-wrap gap-2">
{% for cat in popular_categories %}
<button type="button" class="btn btn-sm btn-outline-light category-suggestion" data-cat-id="{{ cat.id }}">
{{ cat.name }}
</button>
{% endfor %}
<button type="button" class="btn btn-sm btn-outline-secondary category-suggestion" data-cat-id="">
brak
</button>
</div>
</div>
{% endif %}
<div class="mb-4">
<label for="category_id" class="form-label">🏷️ Kategoria listy</label>
<select id="category_id" name="category_id"
class="form-select tom-dark bg-dark text-white border-secondary rounded">
<option value=""> brak </option>
{% for cat in categories %}
<option value="{{ cat.id }}" {% if cat.id in selected_categories %}selected{% endif %}>
{{ cat.name }}
</option>
{% endfor %}
</select>
</div>
<input type="hidden" name="action" value="set_category">
<input type="hidden" name="next" value="{{ url_for('view_list', list_id=list.id) }}">
</div>
<div class="modal-footer justify-content-end">
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline-light" data-bs-dismiss="modal">❌ Anuluj</button>
<button type="submit" class="btn btn-sm btn-outline-light"><span class="shopping-btn-icon" aria-hidden="true">💾</span><span class="shopping-btn-label">Zapisz</span></button>
</div>
</div>
</form>
</div>
</div>
</div>
<datalist id="userHintsOwner">
{% for username in all_usernames %}<option value="{{ username }}"></option>{% endfor %}
</datalist>
<!-- MODAL: NADAWANIE DOSTĘPU -->
<div class="modal fade" id="grantAccessModal" tabindex="-1" aria-labelledby="grantAccessModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title" id="grantAccessModalLabel">Nadaj dostęp użytkownikom</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body">
<div class="access-editor border rounded p-2 bg-dark"
data-post-url="{{ url_for('list_settings', list_id=list.id) }}"
data-suggest-url="{{ url_for('edit_my_list_suggestions', list_id=list.id) }}"
data-next="{{ url_for('view_list', list_id=list.id) }}" data-list-id="{{ list.id }}"
data-grant-action="grant_access" data-revoke-field="revoke_user_id">
<!-- Tokeny aktualnie uprawnionych -->
<div class="tokens d-flex flex-wrap gap-2 mb-2">
{% for u in permitted_users %}
<button type="button" class="btn btn-sm btn-outline-secondary rounded-pill token" data-user-id="{{ u.id }}"
data-username="{{ u.username }}" title="Kliknij, aby odebrać dostęp">
@{{ u.username }} <span aria-hidden="true">×</span>
</button>
{% endfor %}
{% if not permitted_users or permitted_users|length == 0 %}
<span class="no-perms text-warning small">Brak dodanych uprawnień.</span>
{% endif %}
</div>
<!-- Dodawanie wielu na raz + podpowiedzi prywatne -->
<div class="input-group input-group-sm">
<input type="text" class="access-input form-control form-control-sm bg-dark text-white border-secondary"
placeholder="Dodaj @użytkownika (wiele: przecinki/enter)" list="userHintsOwner" autocomplete="off" aria-label="Dodaj użytkowników">
<button type="button" class="access-add btn btn-sm btn-outline-light"><span class="shopping-btn-icon" aria-hidden="true"></span><span class="shopping-btn-label">Dodaj</span></button>
</div>
<div class="text-secondary small mt-1">Kliknij token, aby odebrać dostęp.</div>
</div>
</div>
<div class="modal-footer justify-content-end">
<button type="button" class="btn btn-sm btn-outline-light" data-bs-dismiss="modal">Zamknij</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="massAddModal" tabindex="-1" aria-labelledby="massAddModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title" id="massAddModalLabel">
Masowe dodawanie produktów
<span id="massAddProductStats" class="badge rounded-pill bg-primary ms-2"></span>
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body">
<div id="sort-bar" class="mb-2"></div>
<div class="mb-2"><span id="product-count" class="badge rounded-pill bg-primary ms-2"></span></div>
<ul id="mass-add-list" class="list-group"></ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-light btn-sm w-100" data-bs-dismiss="modal">Zamknij</button>
</div>
</div>
</div>
</div>
{% block scripts %}
<script src="{{ static_asset_url('static_bp.serve_js_lib', 'Sortable.min.js') }}"></script>
<script>
const isShare = document.getElementById('items').dataset.isShare === 'true';
window.IS_SHARE = isShare;
window.LIST_ID = {{ list.id }};
window.IS_ARCHIVED = {{ 'true' if list.is_archived else 'false' }};
window.IS_OWNER = {{ 'true' if is_owner else 'false' }};
</script>
<script src="{{ static_asset_url('static_bp.serve_js', 'mass_add.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'receipt_upload.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'sort_mode.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'access_users.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'category_modal.js') }}"></script>
<script>
setupList({{ list.id }}, '{{ current_user.username if current_user.is_authenticated else 'Gość' }}');
document.addEventListener('DOMContentLoaded', function () {
const editItemForm = document.getElementById('editItemForm');
if (!editItemForm) return;
editItemForm.addEventListener('submit', function (event) {
event.preventDefault();
const itemId = parseInt(document.getElementById('editItemId').value, 10);
const itemName = document.getElementById('editItemName').value;
const itemQuantity = document.getElementById('editItemQuantity').value;
editItem(itemId, itemName, itemQuantity);
const modalEl = document.getElementById('editItemModal');
const modal = bootstrap.Modal.getInstance(modalEl);
if (modal) {
modal.hide();
}
});
});
</script>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,335 @@
{% extends 'base.html' %}
{% block title %}Lista: {{ list.title }}{% endblock %}
{% block content %}
<div class="list-header-toolbar d-flex justify-content-between align-items-start mb-3 flex-wrap gap-2">
<h2 class="mb-0">
🛍️ {{ list.title }}
{% if list.is_archived %}
<span class="badge rounded-pill bg-secondary ms-2">(Archiwalna)</span>
{% endif %}
{% if total_expense > 0 %}
<span id="total-expense1" class="badge rounded-pill bg-success ms-2">
💸 {{ '%.2f'|format(total_expense) }} PLN
</span>
{% else %}
<span id="total-expense" class="badge rounded-pill bg-secondary ms-2" style="display: none;">
💸 0.00 PLN
</span>
{% endif %}
{% if list.category_badges %}
{% for cat in list.category_badges %}
<span class="badge rounded-pill rounded-pill text-dark ms-1" style="background-color: {{ cat.color }};
font-size: 0.75rem;
opacity: 0.85;">
{{ cat.name }}
</span>
{% endfor %}
{% endif %}
</h2>
<div class="list-toolbar list-toolbar--share d-flex justify-content-end align-items-center gap-2 share-page-toolbar">
<div class="form-check form-switch form-check-spaced app-switch hide-purchased-switch hide-purchased-switch--minimal hide-purchased-switch--right">
<input class="form-check-input" type="checkbox" id="hidePurchasedToggle">
<label class="form-check-label ms-2" for="hidePurchasedToggle">Ukryj zaznaczone</label>
</div>
</div>
</div>
<ul id="items" class="list-group mb-3" data-is-share="{{ 'true' if is_share else 'false' }}">
{% for item in items %}
<li data-name="{{ item.name|lower }}" id="item-{{ item.id }}"
class="list-group-item shopping-item-row clickable-item
{% if item.purchased %}bg-success text-white{% elif item.not_purchased %}bg-warning text-dark{% else %}item-not-checked{% endif %}">
<div class="shopping-item-main">
<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 %}>
<div class="shopping-item-content">
<div class="shopping-item-head">
<div class="shopping-item-text">
<span id="name-{{ item.id }}" class="shopping-item-name text-white">{{ item.name }}</span>
{% if item.quantity and item.quantity > 1 %}
<span class="badge rounded-pill bg-secondary">x{{ item.quantity }}</span>
{% endif %}
{% set info_parts = [] %}
{% if item.note %}{% set _ = info_parts.append('<span class="text-danger">[ <b>' ~ item.note ~ '</b> ]</span>') %}{% endif %}
{% if item.not_purchased_reason %}{% set _ = info_parts.append('<span class="text-dark">[ <b>Powód: ' ~ item.not_purchased_reason ~ '</b> ]</span>') %}{% endif %}
{% if item.added_by_display %}{% set _ = info_parts.append('<span class="text-info">[ Dodał/a: <b>' ~ item.added_by_display ~ '</b> ]</span>') %}{% endif %}
{% if info_parts %}
<span class="info-line small" id="info-{{ item.id }}">{{ info_parts | join(' ') | safe }}</span>
{% endif %}
</div>
<div class="list-item-actions shopping-item-actions" role="group">
{% if item.not_purchased %}
<button type="button" class="btn btn-outline-light btn-sm shopping-action-btn shopping-action-btn--wide" {% if list.is_archived %}disabled{% else %}
onclick="unmarkNotPurchased({{ item.id }})" {% endif %}>
✅ Przywróć
</button>
{% else %}
<button type="button" class="btn btn-outline-light btn-sm shopping-action-btn" {% if list.is_archived %}disabled{% else %}
onclick="markNotPurchasedModal(event, {{ item.id }})" {% endif %}>
⚠️
</button>
{% endif %}
<button type="button" class="btn btn-outline-light btn-sm shopping-action-btn" {% if list.is_archived %}disabled{% else %}
onclick="openNoteModal(event, {{ item.id }})" {% endif %}>
📝
</button>
</div>
</div>
</div>
</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.
</li>
{% endfor %}
</ul>
{% if not list.is_archived %}
<div class="shopping-entry-card mb-3" aria-label="Sekcja dodawania produktu">
<div class="shopping-entry-card__label"> Dodaj produkt</div>
<div class="shopping-entry-card__hint">Wpisz nazwę produktu i ilość, potem kliknij Dodaj.</div>
<div class="input-group mb-0 shopping-compact-input-group shopping-product-input-group">
<input id="newItem" class="form-control bg-dark text-white border-secondary shopping-product-name-input" placeholder="Dodaj produkt" {% if
not current_user.is_authenticated %}disabled{% endif %}>
<input id="newQuantity" type="number" class="form-control bg-dark text-white border-secondary shopping-qty-input" placeholder="Ilość"
min="1" value="1" {% if not current_user.is_authenticated %}disabled{% endif %}>
<button onclick="addItem({{ list.id }})" class="btn btn-outline-success share-submit-btn shopping-compact-submit" {% if not
current_user.is_authenticated %}disabled{% endif %}><span class="shopping-btn-icon" aria-hidden="true"></span><span class="shopping-btn-label">Dodaj</span></button>
</div>
</div>
{% endif %}
{% if not list.is_archived %}
<div class="shopping-entry-card shopping-entry-card--expense mb-3" aria-label="Sekcja dodawania wydatku">
<div class="shopping-entry-card__label d-flex justify-content-between align-items-center">
<span>💰 Dodaj wydatek</span>
<span class="badge rounded-pill bg-success" id="total-expense2">
💸 Łączna suma: {{ '%.2f'|format(total_expense) }} PLN
</span>
</div>
<div class="shopping-entry-card__hint">Wpisz kwotę wydatku i kliknij Zapisz.</div>
<div class="input-group mb-0 shopping-compact-input-group shopping-expense-input-group">
<input id="expenseAmount" type="number" step="0.01" min="0"
class="form-control bg-dark text-white border-secondary shopping-expense-amount-input"
placeholder="Kwota (PLN)">
<button onclick="submitExpense({{ list.id }})"
class="btn btn-outline-primary share-submit-btn share-submit-btn--expense shopping-compact-submit">
<span class="shopping-btn-icon" aria-hidden="true">💾</span>
<span class="shopping-btn-label">Zapisz</span>
</button>
</div>
</div>
{% endif %}
<p id="total-expense2" style="display: none;">
<b>💸 Łącznie wydano:</b> {{ '%.2f'|format(total_expense) }} PLN
</p>
<button id="toggleReceiptBtn" class="btn btn-outline-light mb-3 w-100 w-md-auto d-block mx-auto" type="button"
data-bs-toggle="collapse" data-bs-target="#receiptSection" aria-expanded="false" aria-controls="receiptSection">
📄 Pokaż sekcję paragonów
</button>
<div class="collapse px-2 px-md-4" id="receiptSection">
{% set receipt_pattern = 'list_' ~ list.id %}
<div class="receipt-section-stack d-flex flex-column gap-3 mt-3">
<div class="card bg-dark text-white border-secondary shadow-sm">
<div class="card-body">
<div class="d-flex flex-column flex-lg-row align-items-lg-center justify-content-between gap-2 mb-3">
<div>
<h5 class="mb-1">📄 Paragony</h5>
<p class="text-secondary small mb-0">
Przeglądaj dodane paragony, wrzucaj nowe i rozliczaj je przez OCR.
</p>
</div>
<div class="d-flex flex-wrap gap-2">
<span class="badge rounded-pill bg-secondary">{{ receipts|length }} plik{% if receipts|length != 1 %}i{% endif %}</span>
{% if list.is_archived %}
<span class="badge rounded-pill bg-secondary">Lista archiwalna</span>
{% elif current_user.is_authenticated %}
<span class="badge rounded-pill bg-success">Możesz dodawać</span>
{% else %}
<span class="badge rounded-pill bg-warning text-dark">Tylko podgląd</span>
{% endif %}
</div>
</div>
<div class="row g-3 align-items-stretch">
<div class="col-12 col-xl-7">
<div class="border border-secondary rounded-3 p-3 h-100">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<h6 class="mb-0">📸 Dodane paragony</h6>
{% if receipts %}
<span class="text-secondary small">Kliknij miniaturę, aby otworzyć podgląd</span>
{% endif %}
</div>
<div class="row g-3" id="receiptGallery">
{% if receipts %}
{% for r in receipts %}
<div class="col-6 col-md-4 text-center">
<a href="{{ url_for('uploaded_file', filename=r.filename) }}?v={{ r.version_token or '0' }}" class="glightbox text-decoration-none"
data-gallery="receipt-gallery">
<div class="card bg-black border-secondary h-100 overflow-hidden shadow-sm">
<img src="{{ url_for('uploaded_file', filename=r.filename) }}?v={{ r.version_token or '0' }}"
class="card-img-top img-fluid" style="height: 180px; object-fit: cover;" alt="Paragon {{ loop.index }}">
<div class="card-body p-2">
<div class="small text-truncate text-secondary">Paragon {{ loop.index }}</div>
</div>
</div>
</a>
</div>
{% endfor %}
{% else %}
<div class="col-12">
<div class="alert alert-info text-center mb-0" role="alert">
Brak wgranych paragonów do tej listy
</div>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-12 col-xl-5">
<div class="d-flex flex-column gap-3 h-100">
<div class="border border-secondary rounded-3 p-3 bg-black bg-opacity-25 {% if not receipts %}d-none{% endif %}" id="receiptAnalysisBlock">
<div class="d-flex justify-content-between align-items-start gap-2 flex-wrap mb-2">
<div>
<h6 class="mb-1">🔍 Analiza paragonów (OCR)</h6>
<p class="text-small text-secondary mb-0">
System spróbuje automatycznie rozpoznać kwoty. Sprawdź wynik i kliknij „Dodaj”, aby dopisać wydatek.
</p>
</div>
</div>
{% if current_user.is_authenticated %}
<button id="analyzeBtn" class="btn btn-sm btn-outline-light mb-3 w-100 w-sm-auto">
🔍 Zleć analizę OCR
</button>
{% else %}
<div class="alert alert-warning mb-3">
⚠️ Tylko zalogowani użytkownicy mogą zlecać analizę OCR.
</div>
{% endif %}
<div id="analysisResults" class="mt-2"></div>
</div>
{% if not list.is_archived and current_user.is_authenticated %}
<div class="border border-secondary rounded-3 p-3 h-100">
<h6 class="mb-1">📤 Dodaj nowy paragon</h6>
<p class="text-secondary small mb-3">Możesz dodać zdjęcie z aparatu, z galerii albo plik PDF.</p>
<form id="receiptForm" action="{{ url_for('upload_receipt', list_id=list.id) }}" method="post"
enctype="multipart/form-data" class="text-center">
<div class="d-grid gap-2">
<label for="cameraInput" id="cameraBtn"
class="btn btn-outline-light w-100 py-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="cameraInput">
<label for="galleryInput" id="galleryBtn"
class="btn btn-outline-light w-100 py-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">
<label for="pdfInput" id="pdfBtn"
class="btn btn-outline-light w-100 py-2 d-flex align-items-center justify-content-center gap-2">
📄 Dodaj PDF
</label>
<input type="file" name="receipt" accept="application/pdf" class="d-none" id="pdfInput">
</div>
<div id="progressContainer" class="progress progress-dark rounded-3 overflow-hidden shadow-sm mt-3"
style="height: 20px; display: none;">
<div id="progressBar" class="progress-bar bg-success fw-bold text-white text-center" role="progressbar"
style="width: 0%;">0%</div>
</div>
<div id="receiptUploadFeedback" class="mt-3"></div>
</form>
</div>
{% elif list.is_archived %}
<div class="border border-secondary rounded-3 p-3 bg-black bg-opacity-25">
<h6 class="mb-1">📤 Dodawanie zablokowane</h6>
<p class="text-secondary small mb-0">Ta lista jest archiwalna, więc nie można już dodawać nowych paragonów.</p>
</div>
{% elif not current_user.is_authenticated %}
<div class="border border-secondary rounded-3 p-3 bg-black bg-opacity-25">
<h6 class="mb-1">🔐 Dodawanie wymaga logowania</h6>
<p class="text-secondary small mb-0">Zaloguj się, aby dodawać paragony i uruchamiać analizę OCR.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal notatki -->
<div class="modal fade" id="noteModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title">Dodaj notatkę</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<form id="noteForm" onsubmit="submitNote(event)">
<div class="modal-body">
<textarea id="noteText" class="form-control" rows="4" placeholder="Np. 'Promocja 2+2'"></textarea>
</div>
<div class="modal-footer">
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-light btn-sm" data-bs-dismiss="modal">❌ Anuluj</button>
<button type="submit" class="btn btn-outline-light btn-sm"><span class="shopping-btn-icon" aria-hidden="true">💾</span><span class="shopping-btn-label">Zapisz</span></button>
</div>
</div>
</form>
</div>
</div>
</div>
{% block scripts %}
<script>
const isShare = document.getElementById('items').dataset.isShare === 'true';
window.IS_SHARE = isShare;
window.LIST_ID = {{ list.id }};
window.IS_ARCHIVED = {{ 'true' if list.is_archived else 'false' }};
window.IS_OWNER = {{ 'true' if (current_user.is_authenticated and list.user_id == current_user.id) else 'false' }};
if (typeof isSorting === 'undefined') {
var isSorting = false;
}
</script>
<script src="{{ static_asset_url('static_bp.serve_js_lib', 'Sortable.min.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'notes.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'clickable_row.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'receipt_section.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'receipt_upload.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'receipt_analysis.js') }}"></script>
<script>
setupList({{ list.id }}, '{{ current_user.username if current_user.is_authenticated else 'Gość' }}');
</script>
{% endblock %}
{% endblock %}

View File

@@ -1,9 +1,10 @@
{% extends 'base.html' %}
{% block title %}Logowanie{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<section class="text-center mb-4">
<h2 class="mb-2">🔒 Logowanie</h2>
</div>
<p class="text-secondary mb-0">Zaloguj się, aby tworzyć, edytować i współdzielić listy zakupów.</p>
</section>
<div class="card bg-dark text-white">
<div class="card-body">
@@ -13,8 +14,10 @@
class="form-control bg-dark text-white border-secondary rounded" required>
</div>
<div class="mb-3">
<input type="password" name="password" placeholder="Hasło"
class="form-control bg-dark text-white border-secondary rounded" required>
<div class="input-group ui-password-group">
<input type="password" name="password" placeholder="Hasło"
class="form-control bg-dark text-white border-secondary rounded" required>
</div>
</div>
<button type="submit" class="btn btn-success w-100">🔑 Zaloguj</button>
</form>

View File

@@ -0,0 +1,326 @@
{% extends 'base.html' %}
{% block title %}Twoje listy zakupów{% endblock %}
{% block content %}
{% if not current_user.is_authenticated %}
<div class="alert alert-info text-center" role="alert">
Nie jesteś zalogowany/a. Możesz przeglądać tylko listy publiczne.
</div>
{% endif %}
{% if current_user.is_authenticated %}
{% if expiring_lists %}
<div class="alert alert-warning mb-4" role="alert">
<div class="fw-semibold mb-2">⏰ Wygasające listy tymczasowe w ciągu 24h</div>
<ul class="mb-0 ps-3">
{% for l in expiring_lists %}
<li><a class="link-dark fw-semibold" href="{{ url_for('view_list', list_id=l.id) }}">{{ l.title }}</a> — {{ l.expires_at.strftime('%Y-%m-%d %H:%M') }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<section class="mb-4">
<div class="card bg-secondary bg-opacity-10 text-white mb-4">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-3">
<div>
<h2 class="mb-2">Twoje centrum list zakupowych</h2>
<p class="text-secondary mb-0">Stwórz nową liste</p>
</div>
</div>
<form action="{{ url_for('create_list') }}" method="post">
<div class="input-group mb-3 create-list-input-group">
<input type="text" name="title" id="title" placeholder="Wprowadź nazwę nowej listy" required
class="form-control bg-dark text-white border-secondary">
<button type="button" class="btn btn-outline-secondary create-list-temp-toggle" id="tempToggle" data-active="0"
data-bs-toggle="tooltip" data-bs-placement="top" title="Po zaznaczeniu lista będzie ważna tylko 7 dni">
Tymczasowa
</button>
<input type="hidden" name="temporary" id="temporaryHidden" value="0">
</div>
<button type="submit" class="btn btn-success w-100"> Utwórz nową listę</button>
</form>
</div>
</div>
</section>
{% endif %}
{% set month_names = ["styczeń","luty","marzec","kwiecień","maj","czerwiec","lipiec","sierpień","wrzesień","październik","listopad","grudzień"] %}
<div class="d-none d-md-flex justify-content-end align-items-center flex-wrap gap-2 mb-3">
<label for="monthSelect" class="text-white small mb-0">📅 Wybierz miesiąc:</label>
<select id="monthSelect" class="form-select form-select-sm bg-dark text-white border-secondary" style="min-width: 180px;">
{% for m in month_options %}
{% set year, month = m.split('-') %}
<option value="{{ m }}" {% if selected_month==m %}selected{% endif %}>
{{ month_names[month|int - 1] }} {{ year }}
</option>
{% endfor %}
<option value="all" {% if selected_month=='all' %}selected{% endif %}>
Wyświetl wszystko
</option>
</select>
</div>
<div class="d-md-none mb-3">
<button class="btn btn-outline-light w-100" data-bs-toggle="modal" data-bs-target="#monthPickerModal">
📅 Wybierz miesiąc
</button>
</div>
{% macro render_summary_panel(title, summary, accent='success') -%}
<div class="col-12 col-lg-6">
<div class="main-summary-card h-100">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-2">
<div>
<div class="main-summary-card__eyebrow">Postęp</div>
<h4 class="main-summary-card__title mb-0">{{ title }}</h4>
</div>
<span class="badge rounded-pill text-bg-dark border border-secondary-subtle">Listy: {{ summary.list_count }}</span>
</div>
{% with total_count=summary.total_products, purchased_count=summary.purchased_products, not_purchased_count=summary.not_purchased_products, total_expense=summary.total_expense %}
{% include '_list_progress.html' %}
{% endwith %}
<div class="main-summary-stats mt-3">
<div class="main-summary-stat">
<span class="main-summary-stat__label">Kupione</span>
<strong>{{ summary.purchased_products }}</strong>
</div>
<div class="main-summary-stat">
<span class="main-summary-stat__label">Niekupione</span>
<strong>{{ summary.not_purchased_products }}</strong>
</div>
<div class="main-summary-stat">
<span class="main-summary-stat__label">Nieoznaczone</span>
<strong>{{ summary.remaining_products }}</strong>
</div>
<div class="main-summary-stat">
<span class="main-summary-stat__label">Wydatki</span>
<strong>{{ '%.2f'|format(summary.total_expense) }} PLN</strong>
</div>
</div>
</div>
</div>
{%- endmacro %}
{% if current_user.is_authenticated %}
<div class="d-flex justify-content-end align-items-center flex-wrap gap-2 mb-3">
<button class="btn btn-sm btn-outline-light" type="button" data-bs-toggle="collapse" data-bs-target="#mainStatsCollapse" aria-expanded="false" aria-controls="mainStatsCollapse">
📈 Statystyki
</button>
<button type="button" class="btn btn-sm btn-outline-light" data-bs-toggle="modal" data-bs-target="#archivedModal">
🗄️ Zarchiwizowane
</button>
</div>
<div class="collapse mb-4" id="mainStatsCollapse">
<div class="row g-3">
{{ render_summary_panel('Twoje listy', user_lists_summary) }}
{{ render_summary_panel('Udostępnione i publiczne', accessible_lists_summary, 'info') }}
</div>
</div>
<h3 class="mt-4 d-flex justify-content-between align-items-center flex-wrap">
Twoje listy
</h3>
{% if user_lists %}
<ul class="list-group mb-4">
{% for l in user_lists %}
{% set purchased_count = l.purchased_count %}
{% set total_count = l.total_count %}
{% set percent = (purchased_count / total_count * 100) if total_count > 0 else 0 %}
<li class="list-group-item bg-dark text-white">
<div class="main-list-row">
<div class="list-main-meta">
<div class="fw-bold list-main-title mobile-list-heading" data-mobile-list-heading>
{% if l.is_temporary and l.expires_at %}<span class="badge rounded-pill bg-warning text-dark me-2">⏰ {{ l.expires_at.strftime('%Y-%m-%d %H:%M') }}</span>{% endif %}
<a class="list-main-title__link text-white text-decoration-none" href="{{ url_for('view_list', list_id=l.id) }}">
<span data-mobile-list-title>{{ l.title }}</span>
<span class="d-none d-sm-inline"> (Autor: Ty)</span>
</a>
{% if l.category_badges %}
<span class="mobile-category-badges" data-mobile-category-group>
{% for cat in l.category_badges %}
<span class="badge rounded-pill text-dark ms-1 fw-semibold mobile-category-badge"
style="background-color: {{ cat.color }}; font-size: 0.7rem; opacity: 0.9; padding: 0.3em 0.6em;"
title="{{ cat.name }}" aria-label="Kategoria: {{ cat.name }}">
<span class="mobile-category-badge__text">{{ cat.name }}</span>
<span class="mobile-category-badge__dot"></span>
</span>
{% endfor %}
</span>
{% endif %}
</div>
</div>
<div class="btn-group btn-group-compact list-main-actions" role="group">
<a href="{{ url_for('view_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center" data-bs-toggle="tooltip" title="Otwórz">
📂 <span class="btn-text ms-1 d-none d-sm-inline">Otwórz</span>
</a>
{% if l.share_token %}<a href="{{ url_for('shared_list', token=l.share_token) }}" class="btn btn-sm btn-outline-light d-flex align-items-center" data-bs-toggle="tooltip" title="Odznaczaj">
✏️ <span class="btn-text ms-1 d-none d-sm-inline">Odznaczaj</span>
</a>{% endif %}
<a href="{{ url_for('copy_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center" data-bs-toggle="tooltip" title="Kopiuj">
📋 <span class="btn-text ms-1 d-none d-sm-inline">Kopiuj</span>
</a>
<a href="{{ url_for('toggle_visibility', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center" data-bs-toggle="tooltip" title="{% if l.is_public %}Ukryj{% else %}Odkryj{% endif %}">
{% if l.is_public %}🙈 <span class="btn-text ms-1 d-none d-sm-inline">Ukryj</span>{% else %}🐵 <span class="btn-text ms-1 d-none d-sm-inline">Odkryj</span>{% endif %}
</a>
<a href="{{ url_for('edit_my_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center" data-bs-toggle="tooltip" title="Ustawienia">
⚙️ <span class="btn-text ms-1 d-none d-sm-inline">Ustawienia</span>
</a>
</div>
</div>
{% with total_count=total_count, purchased_count=purchased_count, not_purchased_count=l.not_purchased_count, total_expense=l.total_expense %}
{% include '_list_progress.html' %}
{% endwith %}
</li>
{% endfor %}
</ul>
{% else %}
<p><span class="badge rounded-pill bg-secondary opacity-75">Nie utworzono żadnej listy</span></p>
{% endif %}
{% else %}
<div class="mb-3">
<button class="btn btn-sm btn-outline-light" type="button" data-bs-toggle="collapse" data-bs-target="#mainStatsCollapse" aria-expanded="false" aria-controls="mainStatsCollapse">
📈 Statystyki
</button>
</div>
<div class="collapse mb-4" id="mainStatsCollapse">
<div class="row g-3">
<div class="col-12">
{{ render_summary_panel('Publiczne listy innych użytkowników', accessible_lists_summary, 'info') }}
</div>
</div>
</div>
{% endif %}
<h3 class="mt-4">
{% if current_user.is_authenticated %}
Udostępnione i publiczne listy innych użytkowników
{% else %}
Publiczne listy innych użytkowników
{% endif %}
</h3>
{% set lists_to_show = accessible_lists %}
{% if lists_to_show %}
<ul class="list-group">
{% for l in lists_to_show %}
{% set purchased_count = l.purchased_count %}
{% set total_count = l.total_count %}
{% set percent = (purchased_count / total_count * 100) if total_count > 0 else 0 %}
<li class="list-group-item bg-dark text-white">
<div class="main-list-row">
<div class="list-main-meta">
<div class="fw-bold list-main-title mobile-list-heading" data-mobile-list-heading>
{% if l.is_temporary and l.expires_at %}<span class="badge rounded-pill bg-warning text-dark me-2">⏰ {{ l.expires_at.strftime('%Y-%m-%d %H:%M') }}</span>{% endif %}
<a class="fw-bold list-main-title__link text-white text-decoration-none" href="{{ url_for('shared_list', list_id=l.id) }}">
<span data-mobile-list-title>{{ l.title }}</span>
<span class="d-none d-sm-inline"> (Autor: {{ l.owner.username if l.owner else '—' }})</span>
</a>
{% if l.category_badges %}
<span class="mobile-category-badges" data-mobile-category-group>
{% for cat in l.category_badges %}
<span class="badge rounded-pill text-dark ms-1 fw-semibold mobile-category-badge"
style="background-color: {{ cat.color }}; font-size: 0.7rem; opacity: 0.9; padding: 0.3em 0.6em;"
title="{{ cat.name }}" aria-label="Kategoria: {{ cat.name }}">
<span class="mobile-category-badge__text">{{ cat.name }}</span>
<span class="mobile-category-badge__dot"></span>
</span>
{% endfor %}
</span>
{% endif %}
</div>
</div>
<div class="btn-group btn-group-compact list-main-actions" role="group">
<a href="{{ url_for('shared_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center" data-bs-toggle="tooltip" title="Odznaczaj">
✏️ <span class="btn-text ms-1 d-none d-sm-inline">Odznaczaj</span>
</a>
</div>
</div>
{% with total_count=total_count, purchased_count=purchased_count, not_purchased_count=l.not_purchased_count, total_expense=l.total_expense %}
{% include '_list_progress.html' %}
{% endwith %}
</li>
{% endfor %}
</ul>
{% else %}
<p><span class="badge rounded-pill bg-secondary opacity-75">Brak list 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">
<div class="modal-header">
<h5 class="modal-title" id="archivedModalLabel">Zarchiwizowane listy</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body">
{% if archived_lists %}
<ul class="list-group">
{% for l in archived_lists %}
<li class="list-group-item bg-dark text-white d-flex justify-content-between align-items-center flex-wrap">
<span>{{ l.title }}</span>
<form action="{{ url_for('edit_my_list', list_id=l.id) }}" method="post" class="d-contents">
<input type="hidden" name="unarchive" value="1">
<button type="submit" class="btn btn-sm btn-outline-light">
♻️ Przywróć
</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<div class="alert alert-info text-center" role="alert">
Nie masz żadnych zarchiwizowanych list
</div>
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Zamknij</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="monthPickerModal" tabindex="-1" aria-labelledby="monthPickerModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title">📅 Wybierz miesiąc</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body">
<div class="d-grid gap-2">
{% for m in month_options %}
{% set year, month = m.split('-') %}
<a href="{{ url_for('main_page', m=m) }}"
class="btn btn-outline-light {% if selected_month == m %}active{% endif %}">
{{ month_names[month|int - 1] }} {{ year }}
</a>
{% endfor %}
<a href="{{ url_for('main_page', m='all') }}"
class="btn btn-outline-secondary {% if selected_month == 'all' %}active{% endif %}">
📋 Wyświetl wszystkie
</a>
</div>
</div>
</div>
</div>
</div>
{% block scripts %}
<script src="{{ static_asset_url('static_bp.serve_js', 'toggle_button.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'select_month.js') }}"></script>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,58 @@
{% extends 'base.html' %}
{% block title %}Moje szablony{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4 gap-2">
<div><h2 class="mb-1">🧩 Moje szablony</h2><p class="text-secondary mb-0">Każdy użytkownik zarządza własnymi szablonami niezależnie od panelu admina.</p></div>
{% if current_user.is_admin %}<a href="{{ url_for('admin_templates') }}" class="btn btn-outline-secondary">Panel admina</a>{% endif %}
</div>
<div class="row g-4">
<div class="col-lg-5">
<div class="card bg-dark text-white mb-4"><div class="card-body">
<h5 class="mb-3"> Nowy szablon ręcznie</h5>
<form method="post" data-unsaved-warning="true" class="stack-form">
<input type="hidden" name="action" value="create_manual">
<div class="mb-3"><label class="form-label">Nazwa</label><input type="text" name="name" class="form-control" required></div>
<div class="mb-3"><label class="form-label">Opis</label><textarea name="description" class="form-control" rows="2"></textarea></div>
<div class="mb-3"><label class="form-label">Produkty</label><textarea name="items_text" class="form-control" rows="8" placeholder="Mleko x2
Chleb
Jajka x10"></textarea><div class="form-text">Każda linia to osobny produkt. Ilość opcjonalnie przez xN.</div></div>
<button class="btn btn-success w-100" type="submit">Utwórz szablon</button>
</form>
</div></div>
<div class="card bg-dark text-white"><div class="card-body">
<h5 class="mb-3">📋 Utwórz z istniejącej listy</h5>
<form method="post" action="{{ url_for('create_template_from_user_list', list_id=0) }}" onsubmit="this.action=this.action.replace('/0','/' + this.querySelector('[name=source_list_id]').value);">
<div class="mb-3"><label class="form-label">Lista źródłowa</label><select name="source_list_id" class="form-select" required>{% for l in source_lists %}<option value="{{ l.id }}">#{{ l.id }} — {{ l.title }}</option>{% endfor %}</select></div>
<div class="mb-3"><label class="form-label">Nazwa szablonu</label><input type="text" name="template_name" class="form-control"></div>
<div class="mb-3"><label class="form-label">Opis</label><textarea name="description" class="form-control" rows="2"></textarea></div>
<button class="btn btn-outline-success w-100" type="submit">Zapisz z listy</button>
</form>
</div></div>
</div>
<div class="col-lg-7">
<div class="card bg-dark text-white"><div class="card-body">
<div class="admin-page-head mb-3"><h5 class="mb-0">Aktywne szablony</h5><span class="badge rounded-pill bg-secondary">{{ templates|length }} szt.</span></div>
<div class="table-responsive">
<table class="table table-dark align-middle" data-searchable="true">
<thead><tr><th>Nazwa</th><th>Opis</th><th>Produkty</th><th>Akcje</th></tr></thead>
<tbody>
{% for template in templates %}
<tr>
<td><div class="fw-semibold">{{ template.name }}</div><div class="small text-secondary">{{ template.created_at.strftime('%Y-%m-%d %H:%M') if template.created_at else '' }}</div></td>
<td>{{ template.description or '—' }}</td>
<td>{{ template.items|length }}</td>
<td><div class="d-flex flex-wrap gap-2">
<form method="post" action="{{ url_for('instantiate_template', template_id=template.id) }}" class="d-inline"><button class="btn btn-sm btn-outline-success" type="submit"> Utwórz listę</button></form>
<form method="post" onsubmit="return confirm('Usunąć szablon?')" class="d-inline"><input type="hidden" name="action" value="delete"><input type="hidden" name="template_id" value="{{ template.id }}"><button class="btn btn-sm btn-outline-danger" type="submit">🗑 Usuń</button></form>
</div></td>
</tr>
{% else %}
<tr><td colspan="4" class="text-center text-secondary py-4">Brak własnych szablonów.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div></div>
</div>
</div>
{% endblock %}

View File

@@ -2,16 +2,19 @@
{% block title %}Wymagane hasło główne{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<section class="text-center mb-4">
<h2 class="mb-2">🔑 Podaj hasło główne</h2>
</div>
<p class="text-secondary mb-0">Dostęp do aplikacji jest chroniony dodatkowym hasłem wejściowym.</p>
</section>
<div class="card bg-dark text-white">
<div class="card-body">
<form method="post">
<div class="mb-3">
<input type="password" name="password" placeholder="Hasło"
class="form-control bg-dark text-white border-secondary rounded" required>
<div class="input-group ui-password-group">
<input type="password" name="password" placeholder="Hasło"
class="form-control bg-dark text-white border-secondary rounded" required>
</div>
</div>
<button type="submit" class="btn btn-success w-100">🔓 Wejdź</button>
</form>

1
shopping_app/uploads Symbolic link
View File

@@ -0,0 +1 @@
../uploads

Some files were not shown because too many files have changed in this diff Show More