rewrite_code #12

Merged
gru merged 17 commits from rewrite_code into master 2026-03-26 12:00:47 +01:00
110 changed files with 20434 additions and 7673 deletions

No files matched your search

+33
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
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.
-1
View File
@@ -1 +0,0 @@
deploy/app/Dockerfile
+7 -1
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`.
+6 -4717
View File
File diff suppressed because it is too large. Load diff
+6 -7
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
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"]
+28
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"]
+27
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"]
+11 -14
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
+21
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
File renamed without changes.
+11
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
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
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
File diff suppressed because it is too large. Load diff
+216
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"
File diff suppressed because it is too large. Load diff
+880
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'))
+740
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 and item.added_by != shopping_list.owner_id:
item.added_by_display = (
item.added_by_user.username if item.added_by_user else None
)
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
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()}"
)
+95
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
File diff suppressed because it is too large. Load diff
File renamed without changes.
@@ -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);
}
}
+114
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();
})();
})();
+270
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();
}
File renamed without changes.
@@ -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
@@ -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);
}
});
});
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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();
@@ -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,106 @@ 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 || (isShare ? item.added_by : '');
const addedById = item.added_by_id != null ? Number(item.added_by_id) : null;
const ownerId = item.owner_id != null ? Number(item.owner_id) : null;
const shouldShowAddedBy = !!addedByDisplay && (addedById === null || ownerId === null || addedById !== ownerId);
if (shouldShowAddedBy) {
infoParts.push(`<span class="item-added-by-meta">· 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;
}
+22
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);
})();
@@ -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;
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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) {
@@ -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();
});
File renamed without changes.
File renamed without changes.
+30
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);
});
});
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.

Before

Width:  |  Height:  |  Size: 280 B

After

Width:  |  Height:  |  Size: 280 B

File renamed without changes.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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>
+18
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>
@@ -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 %}
@@ -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 %}
@@ -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 %}
@@ -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 %}
@@ -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 %}
@@ -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 %}
@@ -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 %}
@@ -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 %}
@@ -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 %}
@@ -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 %}
+197
View File
@@ -0,0 +1,197 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#07111f">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<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> · v{{ APP_VERSION }}
</p>
</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>
@@ -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 %}
@@ -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">
@@ -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 %}
+494
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="item-added-by-meta">· 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 %}
+335
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="item-added-by-meta">· 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 %}
@@ -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>
+326
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 %}
+58
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 %}
@@ -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>
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more