Compare commits
77 Commits
v0.0.2
...
e1d1ec67c3
Author | SHA1 | Date | |
---|---|---|---|
e1d1ec67c3 | |||
a81737b2ce | |||
![]() |
452f2271cd | ||
7812209818 | |||
![]() |
04bc3773e1 | ||
1d583ad801 | |||
![]() |
c9ef1c488b | ||
c63995d750 | |||
7f68b1647e | |||
![]() |
6f7d0069cc | ||
![]() |
a68aa031bb | ||
![]() |
730330cba9 | ||
![]() |
5a898c5b7a | ||
![]() |
74ae7642e5 | ||
![]() |
111a63d3af | ||
![]() |
57a3866ec8 | ||
48f1841649 | |||
0d9e56dfa1 | |||
![]() |
d899672a2b | ||
![]() |
03d4370c8a | ||
![]() |
f30cd0f2fe | ||
![]() |
4ec33569a0 | ||
![]() |
1ab1b36811 | ||
![]() |
dea0309cfd | ||
![]() |
22bc8bd01d | ||
![]() |
78fcdce327 | ||
![]() |
258d111133 | ||
![]() |
cc1dad0d7d | ||
![]() |
db6f70349e | ||
![]() |
a44a61c718 | ||
![]() |
aa865baf3b | ||
![]() |
a84b130822 | ||
![]() |
983114575d | ||
![]() |
955196dd92 | ||
![]() |
8ae9068ffa | ||
a3d47eb368 | |||
b0095c3b97 | |||
![]() |
98f22e0bd1 | ||
![]() |
62939a9e9a | ||
![]() |
ae89f55446 | ||
![]() |
3ebb364322 | ||
![]() |
470cd32745 | ||
![]() |
1f609b6dba | ||
![]() |
f71697b6db | ||
![]() |
6dc712f76e | ||
![]() |
69b1e9495f | ||
![]() |
114bf5c047 | ||
![]() |
d8233cb6e5 | ||
![]() |
7a9042ffb2 | ||
![]() |
1df8e44e4d | ||
![]() |
c09edd04b0 | ||
![]() |
115d15a055 | ||
65a09b2305 | |||
d48654f5b6 | |||
![]() |
1c88e5c00b | ||
![]() |
69f1b4d1c8 | ||
![]() |
8c9f0f1a6a | ||
![]() |
804b80bbf5 | ||
![]() |
45290a6147 | ||
![]() |
377e592f90 | ||
![]() |
133b91073d | ||
![]() |
6431393baf | ||
![]() |
d3e50305a7 | ||
![]() |
53394469de | ||
![]() |
9dcd144b34 | ||
![]() |
4ef183e2a9 | ||
![]() |
3b94f93892 | ||
1bc96a1979 | |||
![]() |
2c6887095d | ||
![]() |
94eceb76ab | ||
![]() |
bd0f6003f5 | ||
![]() |
58e0929a4c | ||
![]() |
95c11589e2 | ||
![]() |
b590ebc6b6 | ||
![]() |
d1c8970108 | ||
![]() |
eaa5fde7a5 | ||
![]() |
78700c48c5 |
154
.env.example
154
.env.example
@@ -1,20 +1,160 @@
|
||||
# Domyślny port aplikacji
|
||||
# APP_PORT:
|
||||
# Domyślny port, na którym uruchamiana jest aplikacja Flask
|
||||
# Domyślnie: 8000
|
||||
APP_PORT=8000
|
||||
|
||||
# Klucz bezpieczeństwa Flask
|
||||
# SECRET_KEY:
|
||||
# Klucz używany przez Flask do zabezpieczenia sesji, tokenów i formularzy
|
||||
# Powinien być długi i trudny do odgadnięcia
|
||||
SECRET_KEY=supersekretnyklucz123
|
||||
|
||||
# Hasło główne do systemu
|
||||
# SYSTEM_PASSWORD:
|
||||
# Hasło główne administratora systemowego, używane np. przy inicjalizacji
|
||||
# Domyślnie: admin
|
||||
SYSTEM_PASSWORD=admin
|
||||
|
||||
# Domyślny admin (login i hasło)
|
||||
# DEFAULT_ADMIN_USERNAME:
|
||||
# Domyślna nazwa użytkownika administratora (tworzona przy starcie)
|
||||
# Domyślnie: admin
|
||||
DEFAULT_ADMIN_USERNAME=admin
|
||||
|
||||
# DEFAULT_ADMIN_PASSWORD:
|
||||
# Domyślne hasło administratora
|
||||
# Domyślnie: admin123
|
||||
DEFAULT_ADMIN_PASSWORD=admin123
|
||||
|
||||
# Katalog wgrywanych plików
|
||||
# UPLOAD_FOLDER:
|
||||
# Ścieżka (względna) do katalogu, gdzie zapisywane są wgrywane pliki
|
||||
# Domyślnie: uploads
|
||||
UPLOAD_FOLDER=uploads
|
||||
|
||||
# SESSION_TIMEOUT_MINUTES:
|
||||
# Czas bezczynności użytkownika (w minutach), po którym sesja wygasa
|
||||
# Domyślnie: 10080 (7 dni)
|
||||
SESSION_TIMEOUT_MINUTES=10080
|
||||
|
||||
# AUTH_COOKIE_MAX_AGE:
|
||||
# Czas życia ciasteczka autoryzacyjnego (w sekundach)
|
||||
# Domyślnie: 86400 (1 dzień)
|
||||
AUTH_COOKIE_MAX_AGE=86400
|
||||
|
||||
# AUTHORIZED_COOKIE_VALUE:
|
||||
# Wartość ciasteczka uprawniającego do dostępu (np. do zasobów zabezpieczonych)
|
||||
# Powinna być trudna do przewidzenia
|
||||
AUTHORIZED_COOKIE_VALUE=twoj_wlasny_hash
|
||||
|
||||
# czas zycia cookie
|
||||
AUTH_COOKIE_MAX_AGE=86400
|
||||
# SESSION_COOKIE_SECURE:
|
||||
# Określa, czy ciasteczko sesyjne (Flask session) ma mieć ustawiony atrybut "Secure".
|
||||
# Wymusza, by przeglądarka przesyłała je tylko przez HTTPS.
|
||||
# W środowisku deweloperskim (HTTP) ustaw na 0, by uniknąć błędu "secure cookie over insecure connection".
|
||||
# Zalecane: 1 w produkcji (HTTPS), 0 w dev.
|
||||
SESSION_COOKIE_SECURE=0
|
||||
|
||||
|
||||
# HEALTHCHECK_TOKEN:
|
||||
# Token wykorzystywany do sprawdzania stanu aplikacji (np. w Docker Compose)
|
||||
# Domyślnie: alamapsaikota123
|
||||
HEALTHCHECK_TOKEN=alamapsaikota123
|
||||
|
||||
# Rodzaj bazy: sqlite, pgsql, mysql
|
||||
# Mozliwe wartosci: sqlite / pgsql / mysql
|
||||
DB_ENGINE=sqlite
|
||||
|
||||
# --- Konfiguracja dla sqlite ---
|
||||
# Plik bazy bedzie utworzony automatycznie w katalogu ./instance
|
||||
# Pozostale zmienne sa ignorowane przy DB_ENGINE=sqlite
|
||||
|
||||
# --- Konfiguracja dla pgsql ---
|
||||
# Ustaw DB_ENGINE=pgsql
|
||||
# Domyslny port PostgreSQL to 5432
|
||||
# Wymaga dzialajacego serwera PostgreSQL (np. kontener `postgres`)
|
||||
|
||||
# --- Konfiguracja dla mysql ---
|
||||
# Ustaw DB_ENGINE=mysql
|
||||
# Domyslny port MySQL to 3306
|
||||
# Wymaga kontenera z MySQL i uzytkownika z dostepem do bazy
|
||||
|
||||
# Wspolne zmienne (dla pgsql, mysql)
|
||||
# DB_HOST = pgsql lub mysql zgodnie z deployem (profil w docker-compose.yml)
|
||||
|
||||
DB_HOST=pgsql
|
||||
DB_PORT=5432
|
||||
DB_NAME=myapp
|
||||
DB_USER=user
|
||||
DB_PASSWORD=pass
|
||||
|
||||
# ========================
|
||||
# Nagłówki bezpieczeństwa
|
||||
# ========================
|
||||
|
||||
# ENABLE_HSTS:
|
||||
# Wymusza HTTPS poprzez ustawienie nagłówka Strict-Transport-Security.
|
||||
# Zalecane (1) jeśli aplikacja działa za HTTPS. Ustaw 0, jeśli korzystasz z HTTP lokalnie.
|
||||
ENABLE_HSTS=1
|
||||
|
||||
# ENABLE_XFO:
|
||||
# Ustawia nagłówek X-Frame-Options: DENY, który blokuje osadzanie strony w <iframe>.
|
||||
# Chroni przed atakami typu clickjacking. Ustaw 0, jeśli celowo korzystasz z osadzania.
|
||||
ENABLE_XFO=1
|
||||
|
||||
# ENABLE_XCTO:
|
||||
# Ustawia nagłówek X-Content-Type-Options: nosniff, który zapobiega sniffowaniu MIME przez przeglądarkę.
|
||||
# Chroni przed błędną interpretacją typów plików (np. skrypt JS jako obraz). Zalecane: 1.
|
||||
ENABLE_XCTO=1
|
||||
|
||||
# ENABLE_CSP:
|
||||
# Ustawia podstawową politykę Content-Security-Policy (CSP), która ogranicza wczytywanie zasobów tylko z własnej domeny.
|
||||
# Zalecane: 1. Ustaw 0, jeśli używasz zewnętrznych skryptów lub masz problemy z WebSocketami (w CSP: connect-src 'self').
|
||||
ENABLE_CSP=1
|
||||
|
||||
# REFERRER_POLICY:
|
||||
# Ustawia nagłówek Referrer-Policy, który kontroluje, ile informacji o źródle (refererze)
|
||||
# jest przekazywane podczas nawigacji lub zapytań sieciowych.
|
||||
# Domyślnie: strict-origin-when-cross-origin — pełny URL tylko w obrębie tej samej domeny,
|
||||
# a przy przejściach między domenami tylko origin (np. https://example.com).
|
||||
# Zalecane ustawienie dla dobrej równowagi między prywatnością a funkcjonalnością.
|
||||
# Inne możliwe wartości: no-referrer, same-origin, origin, strict-origin, unsafe-url itd.
|
||||
|
||||
REFERRER_POLICY="strict-origin-when-cross-origin"
|
||||
|
||||
|
||||
# DEBUG_MODE:
|
||||
# Czy uruchomić aplikację w trybie debugowania (z konsolą błędów i autoreloaderem)
|
||||
# Domyślnie: 1
|
||||
DEBUG_MODE=1
|
||||
|
||||
# DISABLE_ROBOTS:
|
||||
# Czy zablokować indeksowanie przez roboty (serwuje robots.txt z Disallow: /)
|
||||
# Domyślnie: 0
|
||||
DISABLE_ROBOTS=0
|
||||
|
||||
|
||||
# ========================
|
||||
# Nagłówki cache
|
||||
# ========================
|
||||
|
||||
# JS_CACHE_CONTROL:
|
||||
# Nagłówki Cache-Control dla plików JS (/static/js/)
|
||||
# Domyślnie: "no-cache, no-store, must-revalidate"
|
||||
JS_CACHE_CONTROL="no-cache, no-store, must-revalidate"
|
||||
|
||||
# CSS_CACHE_CONTROL:
|
||||
# Nagłówki Cache-Control dla plików CSS (/static/css/)
|
||||
# Domyślnie: "public, max-age=3600"
|
||||
CSS_CACHE_CONTROL="public, max-age=3600"
|
||||
|
||||
# LIB_JS_CACHE_CONTROL:
|
||||
# Nagłówki Cache-Control dla bibliotek JS (/static/lib/js/)
|
||||
# Domyślnie: "public, max-age=604800"
|
||||
LIB_JS_CACHE_CONTROL="public, max-age=604800"
|
||||
|
||||
# LIB_CSS_CACHE_CONTROL:
|
||||
# Nagłówki Cache-Control dla bibliotek CSS (/static/lib/css/)
|
||||
# Domyślnie: "public, max-age=604800"
|
||||
LIB_CSS_CACHE_CONTROL="public, max-age=604800"
|
||||
|
||||
# UPLOADS_CACHE_CONTROL:
|
||||
# Nagłówki Cache-Control dla wgrywanych plików (/uploads/)
|
||||
# Domyślnie: "public, max-age=2592000, immutable"
|
||||
UPLOADS_CACHE_CONTROL="public, max-age=2592000, immutable"
|
||||
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@@ -5,4 +5,6 @@ env
|
||||
__pycache__
|
||||
instance/
|
||||
uploads/
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
db/*
|
||||
*.swp
|
11
Dockerfile
11
Dockerfile
@@ -4,6 +4,17 @@ FROM python:3.13-slim
|
||||
# Ustawiamy katalog roboczy
|
||||
WORKDIR /app
|
||||
|
||||
# Zależności systemowe do OCR, obrazów, tesseract i języka PL
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-pol \
|
||||
libglib2.0-0 \
|
||||
libsm6 \
|
||||
libxrender1 \
|
||||
libxext6 \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Kopiujemy wymagania
|
||||
COPY requirements.txt requirements.txt
|
||||
|
||||
|
87
README.md
87
README.md
@@ -1,59 +1,76 @@
|
||||
# Live Lista Zakupów
|
||||
# Aplikacja List Zakupów
|
||||
|
||||
Aplikacja webowa do współdzielonych list zakupów z obsługą wielu użytkowników, trybem współpracy w czasie rzeczywistym, panelami administracyjnymi oraz możliwością załączania paragonów.
|
||||
Prosta aplikacja webowa do zarządzania listami zakupów z obsługą użytkowników, OCR paragonów, statystykami i trybem współdzielenia.
|
||||
|
||||
## Funkcje
|
||||
## Główne funkcje
|
||||
|
||||
- Tworzenie, edycja i archiwizacja list zakupów
|
||||
- Dodawanie, edycja, usuwanie produktów i oznaczanie ich jako kupione
|
||||
- Udostępnianie list przez link (token)
|
||||
- Wgrywanie zdjęć paragonów do listy zakupów
|
||||
- Wyszukiwarka produktów i podpowiedzi
|
||||
- Komentarze do produktów
|
||||
- Panel administracyjny (zarządzanie użytkownikami, listami, paragonami)
|
||||
- Obsługa w czasie rzeczywistym (Socket.IO)
|
||||
- Logowanie i autoryzacja użytkowników
|
||||
- Systemowe hasło dostępu do aplikacji
|
||||
- Logowanie i zarządzanie użytkownikami (admin/user)
|
||||
- Tworzenie list zakupów z pozycjami i ilością
|
||||
- Wgrywanie paragonów (podstawowa obsługa OCR)
|
||||
- Archiwizacja i udostępnianie list (publiczne/prywatne)
|
||||
- Statystyki wydatków z podziałem na okresy, statystyki dla użytkowników
|
||||
- Panel administracyjny (statystyki, produkty, paragony, zarządzanie, użytkowmicy)
|
||||
|
||||
## Wymagania
|
||||
|
||||
- Docker
|
||||
- Docker Compose
|
||||
- Python 3.9+
|
||||
- Docker (opcjonalnie dla produkcji)
|
||||
|
||||
## Sposób uruchomienia z Docker Compose
|
||||
## Instalacja lokalna
|
||||
|
||||
1. **Przygotuj plik `.env` w katalogu głównym projektu** (przykład):
|
||||
1. Sklonuj repozytorium:
|
||||
|
||||
`APP_PORT=8000`
|
||||
```bash
|
||||
git https://gitea.linuxiarz.pl/gru/lista_zakupowa_live.git
|
||||
cd lista_zakupowa_live
|
||||
```
|
||||
|
||||
`SECRET_KEY=twoj_super_tajny_klucz`
|
||||
2. Utwórz i uzupełnij plik `.env` (zobacz `.env example`).
|
||||
|
||||
`SYSTEM_PASSWORD=haslo_do_aplikacji`
|
||||
3. Utwórz środowisko i zainstaluj zależności:
|
||||
|
||||
`DEFAULT_ADMIN_USERNAME=admin`
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
`DEFAULT_ADMIN_PASSWORD=admin123`
|
||||
4. Uruchom aplikację:
|
||||
|
||||
2. **Uruchom aplikację:**
|
||||
```bash
|
||||
flask --app app.py run
|
||||
```
|
||||
|
||||
Domyślnie aplikacja będzie dostępna pod adresem:
|
||||
**http://localhost:8000**
|
||||
## Deploy z Docker Compose
|
||||
|
||||
3. **Pierwsze logowanie:**
|
||||
- Po wejściu na stronę zostaniesz poproszony o podanie hasła systemowego (`SYSTEM_PASSWORD`).
|
||||
- Przy pierwszym uruchomieniu zostanie automatycznie utworzone konto administratora na podstawie zmiennych `DEFAULT_ADMIN_USERNAME` i `DEFAULT_ADMIN_PASSWORD`.
|
||||
1. Skonfiguruj `.env`.
|
||||
|
||||
2. Uruchom:
|
||||
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
Aplikacja będzie dostępna pod `http://localhost:8000`.
|
||||
|
||||
## Domyślne dane logowania
|
||||
|
||||
- **Login administratora:** `admin` (lub wartość z `DEFAULT_ADMIN_USERNAME`)
|
||||
- **Hasło administratora:** `admin123` (lub wartość z `DEFAULT_ADMIN_PASSWORD`)
|
||||
- Główne hasło systemowe: `admin`
|
||||
- Admin: `admin` / `admin123`
|
||||
|
||||
4. **Aby uruchomić aplikację w Dockerze, wykonaj następujące kroki:**
|
||||
## Konfiguracja bazy danych
|
||||
|
||||
* Przygotuj plik .env w katalogu projektu z wymaganymi zmiennymi środowiskowymi
|
||||
* Uruchom aplikację poleceniem:
|
||||
docker compose up --build
|
||||
Obsługiwane silniki: `sqlite`, `pgsql`, `mysql`.
|
||||
|
||||
---
|
||||
Ustaw `DB_ENGINE` oraz odpowiednie zmienne w `.env`:
|
||||
|
||||
Przykład dla PostgreSQL:
|
||||
|
||||
```env
|
||||
DB_ENGINE=pgsql
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
DB_NAME=myapp
|
||||
DB_USER=user
|
||||
DB_PASSWORD=pass
|
||||
```
|
270
_tools/add_products.py
Normal file
270
_tools/add_products.py
Normal file
@@ -0,0 +1,270 @@
|
||||
import urllib.request
|
||||
import json
|
||||
from app import db, SuggestedProduct, app
|
||||
|
||||
CATEGORIES = {
|
||||
"Przyprawa": [
|
||||
"przyprawa",
|
||||
"pieprz",
|
||||
"sól",
|
||||
"bazylia",
|
||||
"oregano",
|
||||
"papryka",
|
||||
"majeranek",
|
||||
"czosnek",
|
||||
"tymianek",
|
||||
"rozmaryn",
|
||||
"kolendra",
|
||||
"curry",
|
||||
"imbir",
|
||||
"goździki",
|
||||
"chili",
|
||||
"koper",
|
||||
"kminek",
|
||||
"liść laurowy",
|
||||
"ziele angielskie",
|
||||
"kurkuma",
|
||||
"musztarda",
|
||||
"chrzan",
|
||||
],
|
||||
"Mięso": [
|
||||
"kurczak",
|
||||
"piersi z kurczaka",
|
||||
"udka z kurczaka",
|
||||
"wołowina",
|
||||
"mielona wołowina",
|
||||
"wieprzowina",
|
||||
"schab",
|
||||
"łopatka",
|
||||
"szynka",
|
||||
"boczek",
|
||||
"indyk",
|
||||
"filet z indyka",
|
||||
"gulasz",
|
||||
"pasztet",
|
||||
"karkówka",
|
||||
"żeberka",
|
||||
"kiełbasa",
|
||||
"parówki",
|
||||
"salami",
|
||||
"kabanos",
|
||||
],
|
||||
"Ryba i owoce morza": [
|
||||
"łosoś",
|
||||
"dorsz",
|
||||
"mintaj",
|
||||
"makrela",
|
||||
"pstrąg",
|
||||
"karp",
|
||||
"śledź",
|
||||
"tuńczyk",
|
||||
"morszczuk",
|
||||
"sardynka",
|
||||
"szproty",
|
||||
"anchois",
|
||||
"tilapia",
|
||||
"sandacz",
|
||||
"halibut",
|
||||
"sum",
|
||||
"flądra",
|
||||
"ostrobok",
|
||||
"paluszki rybne",
|
||||
"konserwa rybna",
|
||||
],
|
||||
"Nabiał": [
|
||||
"mleko",
|
||||
"jogurt",
|
||||
"ser żółty",
|
||||
"ser biały",
|
||||
"twaróg",
|
||||
"śmietana",
|
||||
"masło",
|
||||
"kefir",
|
||||
"maślanka",
|
||||
"serek wiejski",
|
||||
"serek topiony",
|
||||
"mozzarella",
|
||||
"feta",
|
||||
"parmezan",
|
||||
"gouda",
|
||||
"emmental",
|
||||
"ser pleśniowy",
|
||||
"ser homogenizowany",
|
||||
"serek mascarpone",
|
||||
"ser ricotta",
|
||||
],
|
||||
"Warzywo": [
|
||||
"pomidor",
|
||||
"ogórek",
|
||||
"marchew",
|
||||
"cebula",
|
||||
"sałata",
|
||||
"papryka",
|
||||
"ziemniak",
|
||||
"kapusta",
|
||||
"brokuł",
|
||||
"kalafior",
|
||||
"cukinia",
|
||||
"bakłażan",
|
||||
"szpinak",
|
||||
"rukola",
|
||||
"seler",
|
||||
"por",
|
||||
"burak",
|
||||
"dynia",
|
||||
"rzodkiewka",
|
||||
"fasola",
|
||||
],
|
||||
"Owoc": [
|
||||
"jabłko",
|
||||
"banan",
|
||||
"gruszka",
|
||||
"truskawka",
|
||||
"winogrono",
|
||||
"malina",
|
||||
"borówka",
|
||||
"czereśnia",
|
||||
"wiśnia",
|
||||
"brzoskwinia",
|
||||
"nektaryna",
|
||||
"śliwka",
|
||||
"ananas",
|
||||
"mango",
|
||||
"kiwi",
|
||||
"cytryna",
|
||||
"limonka",
|
||||
"pomarańcza",
|
||||
"mandarynka",
|
||||
"grejpfrut",
|
||||
],
|
||||
"Pieczywo i zboża": [
|
||||
"chleb",
|
||||
"bułka",
|
||||
"bagietka",
|
||||
"kajzerka",
|
||||
"pumpernikiel",
|
||||
"chleb razowy",
|
||||
"chleb żytni",
|
||||
"tost",
|
||||
"grahamka",
|
||||
"croissant",
|
||||
"tortilla",
|
||||
"pizza",
|
||||
"pierogi",
|
||||
"ryż",
|
||||
"makaron",
|
||||
"kasza jaglana",
|
||||
"kasza gryczana",
|
||||
"owsianka",
|
||||
"płatki kukurydziane",
|
||||
"musli",
|
||||
],
|
||||
"Słodycze i przekąski": [
|
||||
"czekolada",
|
||||
"baton",
|
||||
"ciastko",
|
||||
"wafel",
|
||||
"lody",
|
||||
"cukierek",
|
||||
"żelki",
|
||||
"herbatnik",
|
||||
"paluszki",
|
||||
"chipsy",
|
||||
"orzeszki",
|
||||
"popcorn",
|
||||
"krakersy",
|
||||
"ciasto",
|
||||
"muffin",
|
||||
"pączek",
|
||||
"drożdżówka",
|
||||
"babeczka",
|
||||
"piernik",
|
||||
"beza",
|
||||
],
|
||||
"Napoje": [
|
||||
"woda",
|
||||
"sok jabłkowy",
|
||||
"sok pomarańczowy",
|
||||
"sok multiwitamina",
|
||||
"cola",
|
||||
"pepsi",
|
||||
"napój gazowany",
|
||||
"kawa",
|
||||
"herbata",
|
||||
"piwo",
|
||||
"wino czerwone",
|
||||
"wino białe",
|
||||
"tonik",
|
||||
"lemoniada",
|
||||
"napój izotoniczny",
|
||||
"kompot",
|
||||
"napój mleczny",
|
||||
"maślanka pitna",
|
||||
"koktajl owocowy",
|
||||
"nektar",
|
||||
],
|
||||
"Tłuszcze i oleje": [
|
||||
"oliwa",
|
||||
"olej rzepakowy",
|
||||
"olej słonecznikowy",
|
||||
"masło klarowane",
|
||||
"margaryna",
|
||||
"smalec",
|
||||
"masło orzechowe",
|
||||
"tłuszcz kokosowy",
|
||||
"olej lniany",
|
||||
"olej z pestek winogron",
|
||||
"olej sezamowy",
|
||||
"olej ryżowy",
|
||||
"olej z awokado",
|
||||
"olej kukurydziany",
|
||||
"olej arachidowy",
|
||||
"olej palmowy",
|
||||
"olej konopny",
|
||||
"olej sojowy",
|
||||
"olej dyniowy",
|
||||
"olej z orzechów włoskich",
|
||||
],
|
||||
"Dania gotowe": [
|
||||
"pizza",
|
||||
"hamburger",
|
||||
"hot dog",
|
||||
"zupa",
|
||||
"gulasz",
|
||||
"pierogi ruskie",
|
||||
"pierogi z mięsem",
|
||||
"lasagne",
|
||||
"sałatka warzywna",
|
||||
"kanapka",
|
||||
"wrap",
|
||||
"tortilla",
|
||||
"zapiekanka",
|
||||
"sushi",
|
||||
"falafel",
|
||||
"kebab",
|
||||
"pyzy",
|
||||
"kluski śląskie",
|
||||
"kotlet schabowy",
|
||||
"gołąbki",
|
||||
],
|
||||
}
|
||||
|
||||
produkty = []
|
||||
|
||||
for category, names in CATEGORIES.items():
|
||||
for name in names:
|
||||
produkty.append((category, name.lower().strip()))
|
||||
|
||||
print(f"Przygotowano {len(produkty)} produktów do dodania.")
|
||||
|
||||
with app.app_context():
|
||||
dodane = 0
|
||||
for category, name in produkty:
|
||||
full_name = f"{category}: {name}"
|
||||
if not SuggestedProduct.query.filter_by(name=full_name).first():
|
||||
prod = SuggestedProduct(name=full_name)
|
||||
db.session.add(prod)
|
||||
dodane += 1
|
||||
db.session.commit()
|
||||
|
||||
print(f"Dodano {dodane} produktów do bazy.")
|
47
_tools/add_receipt_to_list.py
Normal file
47
_tools/add_receipt_to_list.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import os
|
||||
from datetime import datetime
|
||||
from app import db, app, Receipt
|
||||
|
||||
|
||||
def extract_list_id(filename):
|
||||
if filename.startswith("list_"):
|
||||
parts = filename.split("_", 2)
|
||||
if len(parts) >= 2 and parts[1].isdigit():
|
||||
return int(parts[1])
|
||||
return None
|
||||
|
||||
|
||||
def migrate_missing_receipts():
|
||||
with app.app_context():
|
||||
folder = app.config["UPLOAD_FOLDER"]
|
||||
files = os.listdir(folder)
|
||||
added = 0
|
||||
skipped = 0
|
||||
|
||||
for file in files:
|
||||
if not file.endswith(".webp"):
|
||||
continue
|
||||
|
||||
list_id = extract_list_id(file)
|
||||
if list_id is None:
|
||||
print(f"Pominięto (brak list_id): {file}")
|
||||
continue
|
||||
|
||||
exists = Receipt.query.filter_by(list_id=list_id, filename=file).first()
|
||||
if exists:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
new_receipt = Receipt(
|
||||
list_id=list_id, filename=file, uploaded_at=datetime.utcnow()
|
||||
)
|
||||
db.session.add(new_receipt)
|
||||
added += 1
|
||||
print(f"📄 {file} dodany do Receipt (list_id={list_id})")
|
||||
|
||||
db.session.commit()
|
||||
print(f"\n✅ Dodano: {added}, pominięto (już były): {skipped}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate_missing_receipts()
|
38
_tools/db/migrate.txt
Normal file
38
_tools/db/migrate.txt
Normal file
@@ -0,0 +1,38 @@
|
||||
python3 -m venv venv_migrate
|
||||
source venv_migrate/bin/activate
|
||||
pip install sqlalchemy psycopg2-binary dotenv
|
||||
docker compose --profile pgsql up -d --build
|
||||
PYTHONPATH=. python3 _tools/db/migrate_sqlite_to_pgsql.py
|
||||
rm -rf venv_migrate
|
||||
|
||||
# reset wszystkich sekwencji w pgsql
|
||||
docker exec -it pgsql-db psql -U lista -d lista
|
||||
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT
|
||||
c.relname AS seq_name,
|
||||
t.relname AS table_name,
|
||||
a.attname AS column_name
|
||||
FROM
|
||||
pg_class c
|
||||
JOIN
|
||||
pg_depend d ON d.objid = c.oid
|
||||
JOIN
|
||||
pg_class t ON d.refobjid = t.oid
|
||||
JOIN
|
||||
pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid
|
||||
WHERE
|
||||
c.relkind = 'S'
|
||||
AND d.deptype = 'a'
|
||||
LOOP
|
||||
EXECUTE format(
|
||||
'SELECT setval(%L, COALESCE((SELECT MAX(%I) FROM %I), 1), true)',
|
||||
r.seq_name, r.column_name, r.table_name
|
||||
);
|
||||
END LOOP;
|
||||
END$$;
|
61
_tools/db/migrate_sqlite_to_pgsql.py
Normal file
61
_tools/db/migrate_sqlite_to_pgsql.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
|
||||
|
||||
from sqlalchemy import create_engine, MetaData
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from config import Config
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
# Źródło: SQLite
|
||||
sqlite_engine = create_engine("sqlite:///instance/shopping.db")
|
||||
sqlite_meta = MetaData()
|
||||
sqlite_meta.reflect(bind=sqlite_engine)
|
||||
|
||||
# Cel: PostgreSQL
|
||||
pg_engine = create_engine(Config.SQLALCHEMY_DATABASE_URI)
|
||||
pg_meta = MetaData()
|
||||
pg_meta.reflect(bind=pg_engine)
|
||||
|
||||
# Sesje
|
||||
SQLiteSession = sessionmaker(bind=sqlite_engine)
|
||||
PGSession = sessionmaker(bind=pg_engine)
|
||||
|
||||
sqlite_session = SQLiteSession()
|
||||
pg_session = PGSession()
|
||||
|
||||
def migrate_table(table_name):
|
||||
print("➡️ Używana baza docelowa:", Config.SQLALCHEMY_DATABASE_URI)
|
||||
print(f"\n➡️ Migruję tabelę: {table_name}")
|
||||
source_table = sqlite_meta.tables.get(table_name)
|
||||
target_table = pg_meta.tables.get(table_name)
|
||||
|
||||
if source_table is None or target_table is None:
|
||||
print(f"⚠️ Pominięto: {table_name} (brak w jednej z baz)")
|
||||
return
|
||||
|
||||
rows = sqlite_session.execute(source_table.select()).fetchall()
|
||||
if not rows:
|
||||
print("ℹ️ Brak danych do migracji.")
|
||||
return
|
||||
|
||||
insert_data = [dict(row._mapping) for row in rows]
|
||||
|
||||
try:
|
||||
with pg_engine.begin() as conn:
|
||||
conn.execute(target_table.delete())
|
||||
conn.execute(target_table.insert(), insert_data)
|
||||
print(f"✅ Przeniesiono: {len(rows)} rekordów")
|
||||
except Exception as e:
|
||||
print(f"❌ Błąd przy migracji {table_name}: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
tables = ["user", "shopping_list", "item", "expense", "receipt", "suggested_product"]
|
||||
for table in tables:
|
||||
migrate_table(table)
|
||||
print("\n🎉 Migracja zakończona pomyślnie.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
83
_tools/migrate_to_webp.py
Normal file
83
_tools/migrate_to_webp.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import os
|
||||
from datetime import datetime
|
||||
from PIL import Image
|
||||
from app import db, app, Receipt
|
||||
|
||||
ALLOWED_EXTS = ("jpg", "jpeg", "png", "gif", "heic")
|
||||
UPLOAD_FOLDER = None
|
||||
|
||||
|
||||
def convert_to_webp(input_path, output_path):
|
||||
try:
|
||||
image = Image.open(input_path).convert("RGB")
|
||||
image.save(output_path, "WEBP", quality=85)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Błąd konwersji {input_path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def extract_list_id(filename):
|
||||
if filename.startswith("list_"):
|
||||
parts = filename.split("_", 2)
|
||||
if len(parts) >= 2 and parts[1].isdigit():
|
||||
return int(parts[1])
|
||||
return None
|
||||
|
||||
|
||||
def migrate():
|
||||
global UPLOAD_FOLDER
|
||||
with app.app_context():
|
||||
UPLOAD_FOLDER = app.config["UPLOAD_FOLDER"]
|
||||
files = os.listdir(UPLOAD_FOLDER)
|
||||
created = 0
|
||||
skipped = 0
|
||||
existing = 0
|
||||
|
||||
for file in files:
|
||||
ext = file.rsplit(".", 1)[-1].lower()
|
||||
if ext not in ALLOWED_EXTS:
|
||||
continue
|
||||
|
||||
list_id = extract_list_id(file)
|
||||
if list_id is None:
|
||||
print(f"Pominięto (brak list_id): {file}")
|
||||
continue
|
||||
|
||||
src_path = os.path.join(UPLOAD_FOLDER, file)
|
||||
base = os.path.splitext(file)[0]
|
||||
webp_filename = base + ".webp"
|
||||
dst_path = os.path.join(UPLOAD_FOLDER, webp_filename)
|
||||
|
||||
if os.path.exists(dst_path):
|
||||
print(f"Pominięto (webp istnieje): {webp_filename}")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if convert_to_webp(src_path, dst_path):
|
||||
os.remove(src_path)
|
||||
r = Receipt.query.filter_by(
|
||||
list_id=list_id, filename=webp_filename
|
||||
).first()
|
||||
if r:
|
||||
print(f"Już istnieje w Receipt: {webp_filename}")
|
||||
existing += 1
|
||||
continue
|
||||
|
||||
new_receipt = Receipt(
|
||||
list_id=list_id,
|
||||
filename=webp_filename,
|
||||
uploaded_at=datetime.utcnow(),
|
||||
)
|
||||
db.session.add(new_receipt)
|
||||
created += 1
|
||||
print(f"{file} → {webp_filename} + zapis do Receipt")
|
||||
|
||||
db.session.commit()
|
||||
print(f"\nNowe wpisy: {created}")
|
||||
print(f"Pominięte (webp istniało): {skipped}")
|
||||
print(f"Duplikaty w bazie: {existing}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
44
_tools/update_missing_image_data.py
Normal file
44
_tools/update_missing_image_data.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import os
|
||||
from datetime import datetime
|
||||
from app import app, db, Receipt
|
||||
|
||||
|
||||
def update_missing_receipt_fields():
|
||||
with app.app_context():
|
||||
folder = app.config["UPLOAD_FOLDER"]
|
||||
updated = 0
|
||||
|
||||
receipts = Receipt.query.filter(
|
||||
(Receipt.filesize == None)
|
||||
| (Receipt.filesize == 0)
|
||||
| (Receipt.uploaded_at == None)
|
||||
).all()
|
||||
|
||||
for r in receipts:
|
||||
path = os.path.join(folder, r.filename)
|
||||
if not os.path.exists(path):
|
||||
print(f"Brak pliku: {r.filename}")
|
||||
continue
|
||||
|
||||
changed = False
|
||||
|
||||
if not r.filesize:
|
||||
r.filesize = os.path.getsize(path)
|
||||
changed = True
|
||||
print(f"{r.filename} → filesize: {r.filesize} B")
|
||||
|
||||
if not r.uploaded_at:
|
||||
timestamp = os.path.getmtime(path)
|
||||
r.uploaded_at = datetime.fromtimestamp(timestamp)
|
||||
changed = True
|
||||
print(f"{r.filename} → uploaded_at: {r.uploaded_at}")
|
||||
|
||||
if changed:
|
||||
updated += 1
|
||||
|
||||
db.session.commit()
|
||||
print(f"\nZaktualizowano {updated} rekordów.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
update_missing_receipt_fields()
|
@@ -1,87 +0,0 @@
|
||||
import urllib.request
|
||||
import json
|
||||
from app import db, SuggestedProduct, app
|
||||
|
||||
CATEGORIES = {
|
||||
"Przyprawa": [
|
||||
"przyprawa", "pieprz", "sól", "bazylia", "oregano", "papryka", "majeranek", "czosnek",
|
||||
"tymianek", "rozmaryn", "kolendra", "curry", "imbir", "goździki", "chili", "koper",
|
||||
"kminek", "liść laurowy", "ziele angielskie", "kurkuma", "musztarda", "chrzan"
|
||||
],
|
||||
"Mięso": [
|
||||
"kurczak", "piersi z kurczaka", "udka z kurczaka", "wołowina", "mielona wołowina",
|
||||
"wieprzowina", "schab", "łopatka", "szynka", "boczek", "indyk", "filet z indyka",
|
||||
"gulasz", "pasztet", "karkówka", "żeberka", "kiełbasa", "parówki", "salami", "kabanos"
|
||||
],
|
||||
"Ryba i owoce morza": [
|
||||
"łosoś", "dorsz", "mintaj", "makrela", "pstrąg", "karp", "śledź", "tuńczyk",
|
||||
"morszczuk", "sardynka", "szproty", "anchois", "tilapia", "sandacz", "halibut",
|
||||
"sum", "flądra", "ostrobok", "paluszki rybne", "konserwa rybna"
|
||||
],
|
||||
"Nabiał": [
|
||||
"mleko", "jogurt", "ser żółty", "ser biały", "twaróg", "śmietana", "masło",
|
||||
"kefir", "maślanka", "serek wiejski", "serek topiony", "mozzarella", "feta",
|
||||
"parmezan", "gouda", "emmental", "ser pleśniowy", "ser homogenizowany",
|
||||
"serek mascarpone", "ser ricotta"
|
||||
],
|
||||
"Warzywo": [
|
||||
"pomidor", "ogórek", "marchew", "cebula", "sałata", "papryka", "ziemniak",
|
||||
"kapusta", "brokuł", "kalafior", "cukinia", "bakłażan", "szpinak", "rukola",
|
||||
"seler", "por", "burak", "dynia", "rzodkiewka", "fasola"
|
||||
],
|
||||
"Owoc": [
|
||||
"jabłko", "banan", "gruszka", "truskawka", "winogrono", "malina", "borówka",
|
||||
"czereśnia", "wiśnia", "brzoskwinia", "nektaryna", "śliwka", "ananas",
|
||||
"mango", "kiwi", "cytryna", "limonka", "pomarańcza", "mandarynka", "grejpfrut"
|
||||
],
|
||||
"Pieczywo i zboża": [
|
||||
"chleb", "bułka", "bagietka", "kajzerka", "pumpernikiel", "chleb razowy",
|
||||
"chleb żytni", "tost", "grahamka", "croissant", "tortilla", "pizza",
|
||||
"pierogi", "ryż", "makaron", "kasza jaglana", "kasza gryczana", "owsianka",
|
||||
"płatki kukurydziane", "musli"
|
||||
],
|
||||
"Słodycze i przekąski": [
|
||||
"czekolada", "baton", "ciastko", "wafel", "lody", "cukierek", "żelki",
|
||||
"herbatnik", "paluszki", "chipsy", "orzeszki", "popcorn", "krakersy",
|
||||
"ciasto", "muffin", "pączek", "drożdżówka", "babeczka", "piernik", "beza"
|
||||
],
|
||||
"Napoje": [
|
||||
"woda", "sok jabłkowy", "sok pomarańczowy", "sok multiwitamina", "cola",
|
||||
"pepsi", "napój gazowany", "kawa", "herbata", "piwo", "wino czerwone",
|
||||
"wino białe", "tonik", "lemoniada", "napój izotoniczny", "kompot",
|
||||
"napój mleczny", "maślanka pitna", "koktajl owocowy", "nektar"
|
||||
],
|
||||
"Tłuszcze i oleje": [
|
||||
"oliwa", "olej rzepakowy", "olej słonecznikowy", "masło klarowane",
|
||||
"margaryna", "smalec", "masło orzechowe", "tłuszcz kokosowy",
|
||||
"olej lniany", "olej z pestek winogron", "olej sezamowy",
|
||||
"olej ryżowy", "olej z awokado", "olej kukurydziany", "olej arachidowy",
|
||||
"olej palmowy", "olej konopny", "olej sojowy", "olej dyniowy", "olej z orzechów włoskich"
|
||||
],
|
||||
"Dania gotowe": [
|
||||
"pizza", "hamburger", "hot dog", "zupa", "gulasz", "pierogi ruskie",
|
||||
"pierogi z mięsem", "lasagne", "sałatka warzywna", "kanapka",
|
||||
"wrap", "tortilla", "zapiekanka", "sushi", "falafel", "kebab",
|
||||
"pyzy", "kluski śląskie", "kotlet schabowy", "gołąbki"
|
||||
]
|
||||
}
|
||||
|
||||
produkty = []
|
||||
|
||||
for category, names in CATEGORIES.items():
|
||||
for name in names:
|
||||
produkty.append((category, name.lower().strip()))
|
||||
|
||||
print(f"Przygotowano {len(produkty)} produktów do dodania.")
|
||||
|
||||
with app.app_context():
|
||||
dodane = 0
|
||||
for category, name in produkty:
|
||||
full_name = f"{category}: {name}"
|
||||
if not SuggestedProduct.query.filter_by(name=full_name).first():
|
||||
prod = SuggestedProduct(name=full_name)
|
||||
db.session.add(prod)
|
||||
dodane += 1
|
||||
db.session.commit()
|
||||
|
||||
print(f"Dodano {dodane} produktów do bazy.")
|
22
alters.txt
22
alters.txt
@@ -28,6 +28,26 @@ ALTER TABLE shopping_list ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT 1;
|
||||
# ilośc produktów
|
||||
ALTER TABLE item ADD COLUMN quantity INTEGER DEFAULT 1;
|
||||
|
||||
#licznik najczesciej kupowanych reczy
|
||||
# licznik najczesciej kupowanych reczy
|
||||
ALTER TABLE suggested_product ADD COLUMN usage_count INTEGER DEFAULT 0;
|
||||
|
||||
# funkcja niekupione
|
||||
ALTER TABLE item ADD COLUMN not_purchased_reason TEXT;
|
||||
ALTER TABLE item ADD COLUMN not_purchased BOOLEAN DEFAULT 0;
|
||||
|
||||
# funkcja sortowania
|
||||
ALTER TABLE item ADD COLUMN position INTEGER DEFAULT 0;
|
||||
|
||||
# migracja paragonów do nowej tabeli
|
||||
CREATE TABLE receipt (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
list_id INTEGER NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (list_id) REFERENCES shopping_list(id)
|
||||
);
|
||||
|
||||
ALTER TABLE receipt ADD COLUMN filesize INTEGER;
|
||||
|
||||
# unikanie identycznych plikow
|
||||
ALTER TABLE receipt ADD COLUMN file_hash TEXT
|
||||
|
36
config.py
36
config.py
@@ -1,12 +1,32 @@
|
||||
import os
|
||||
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'D8pceNZ8q%YR7^7F&9wAC2')
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', 'sqlite:///shopping.db')
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", "D8pceNZ8q%YR7^7F&9wAC2")
|
||||
|
||||
DB_ENGINE = os.environ.get("DB_ENGINE", "sqlite").lower()
|
||||
if DB_ENGINE == "sqlite":
|
||||
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(basedir, 'instance', 'shopping.db')}"
|
||||
elif DB_ENGINE == "pgsql":
|
||||
SQLALCHEMY_DATABASE_URI = f"postgresql://{os.environ['DB_USER']}:{os.environ['DB_PASSWORD']}@{os.environ['DB_HOST']}:{os.environ.get('DB_PORT', 5432)}/{os.environ['DB_NAME']}"
|
||||
elif DB_ENGINE == "mysql":
|
||||
SQLALCHEMY_DATABASE_URI = f"mysql+pymysql://{os.environ['DB_USER']}:{os.environ['DB_PASSWORD']}@{os.environ['DB_HOST']}:{os.environ.get('DB_PORT', 3306)}/{os.environ['DB_NAME']}"
|
||||
else:
|
||||
raise ValueError("Nieobsługiwany typ bazy danych.")
|
||||
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
SYSTEM_PASSWORD = os.environ.get('SYSTEM_PASSWORD', 'admin')
|
||||
DEFAULT_ADMIN_USERNAME = os.environ.get('DEFAULT_ADMIN_USERNAME', 'admin')
|
||||
DEFAULT_ADMIN_PASSWORD = os.environ.get('DEFAULT_ADMIN_PASSWORD', 'admin123')
|
||||
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', 'uploads')
|
||||
AUTHORIZED_COOKIE_VALUE = os.environ.get('AUTHORIZED_COOKIE_VALUE', 'cookievalue')
|
||||
AUTH_COOKIE_MAX_AGE = int(os.environ.get('AUTH_COOKIE_MAX_AGE', 86400))
|
||||
SYSTEM_PASSWORD = os.environ.get("SYSTEM_PASSWORD", "admin")
|
||||
DEFAULT_ADMIN_USERNAME = os.environ.get("DEFAULT_ADMIN_USERNAME", "admin")
|
||||
DEFAULT_ADMIN_PASSWORD = os.environ.get("DEFAULT_ADMIN_PASSWORD", "admin123")
|
||||
UPLOAD_FOLDER = os.environ.get("UPLOAD_FOLDER", "uploads")
|
||||
AUTHORIZED_COOKIE_VALUE = os.environ.get("AUTHORIZED_COOKIE_VALUE", "cookievalue")
|
||||
try:
|
||||
AUTH_COOKIE_MAX_AGE = int(os.environ.get("AUTH_COOKIE_MAX_AGE", "86400") or "86400")
|
||||
except ValueError:
|
||||
AUTH_COOKIE_MAX_AGE = 86400
|
||||
|
||||
HEALTHCHECK_TOKEN = os.environ.get("HEALTHCHECK_TOKEN", "alamapsaikota1234")
|
||||
try:
|
||||
SESSION_TIMEOUT_MINUTES = int(os.environ.get("SESSION_TIMEOUT_MINUTES", "10080") or "10080")
|
||||
except ValueError:
|
||||
SESSION_TIMEOUT_MINUTES = 10080
|
||||
|
@@ -1,13 +1,28 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Zatrzymuję i usuwam stare kontenery..."
|
||||
docker compose down --rmi all
|
||||
PROFILE=$1
|
||||
|
||||
if [[ -z "$PROFILE" ]]; then
|
||||
echo "Uzycie: $0 {pgsql|mysql|sqlite}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Zatrzymuje kontenery aplikacji i bazy..."
|
||||
if [[ "$PROFILE" == "sqlite" ]]; then
|
||||
docker compose stop
|
||||
else
|
||||
docker compose --profile "$PROFILE" stop
|
||||
fi
|
||||
|
||||
echo "Pobieram najnowszy kod z repozytorium..."
|
||||
git pull
|
||||
|
||||
echo "Buduję obrazy i uruchamiam kontenery..."
|
||||
docker compose up -d --build
|
||||
echo "Buduje i uruchamiam kontenery..."
|
||||
if [[ "$PROFILE" == "sqlite" ]]; then
|
||||
docker compose up -d --build
|
||||
else
|
||||
DB_ENGINE="$PROFILE" docker compose --profile "$PROFILE" up -d --build
|
||||
fi
|
||||
|
||||
echo "Gotowe!"
|
||||
|
@@ -4,15 +4,41 @@ services:
|
||||
container_name: live-lista-zakupow
|
||||
ports:
|
||||
- "${APP_PORT:-8000}:8000"
|
||||
environment:
|
||||
- FLASK_APP=app.py
|
||||
- FLASK_ENV=production
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- SYSTEM_PASSWORD=${SYSTEM_PASSWORD}
|
||||
- DEFAULT_ADMIN_USERNAME=${DEFAULT_ADMIN_USERNAME}
|
||||
- DEFAULT_ADMIN_PASSWORD=${DEFAULT_ADMIN_PASSWORD}
|
||||
- UPLOAD_FOLDER=${UPLOAD_FOLDER}
|
||||
- AUTHORIZED_COOKIE_VALUE=${AUTHORIZED_COOKIE_VALUE}
|
||||
- AUTH_COOKIE_MAX_AGE=${AUTH_COOKIE_MAX_AGE}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; import sys; req = urllib.request.Request('http://localhost:8000/healthcheck', headers={'X-Internal-Check': '${HEALTHCHECK_TOKEN}'}); sys.exit(0) if urllib.request.urlopen(req).read() == b'OK' else sys.exit(1)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./uploads:/app/uploads
|
||||
- ./instance:/app/instance
|
||||
restart: unless-stopped
|
||||
|
||||
pgsql:
|
||||
image: postgres:17
|
||||
container_name: pgsql-db
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- ./db/pgsql:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
profiles: ["pgsql"]
|
||||
|
||||
mysql:
|
||||
image: mysql:8
|
||||
container_name: mysql-db
|
||||
environment:
|
||||
MYSQL_DATABASE: ${DB_NAME}
|
||||
MYSQL_USER: ${DB_USER}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD}
|
||||
MYSQL_ROOT_PASSWORD: 89o38kUX5T4C
|
||||
volumes:
|
||||
- ./db/mysql:/var/lib/mysql
|
||||
restart: unless-stopped
|
||||
profiles: ["mysql"]
|
@@ -6,4 +6,11 @@ Flask-Compress
|
||||
eventlet
|
||||
Werkzeug
|
||||
Pillow
|
||||
psutil
|
||||
psutil
|
||||
pillow-heif
|
||||
|
||||
pytesseract
|
||||
opencv-python-headless
|
||||
psycopg2-binary # pgsql
|
||||
pymysql # mysql
|
||||
cryptography # mysql8
|
||||
|
@@ -3,6 +3,7 @@
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
}
|
||||
|
||||
.clickable-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -38,7 +39,8 @@
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none; /* klikalne przyciski obok paska nie ucierpią */
|
||||
pointer-events: none;
|
||||
/* klikalne przyciski obok paska nie ucierpią */
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -53,7 +55,7 @@
|
||||
|
||||
/* --- Styl przycisku wyboru pliku --- */
|
||||
input[type="file"]::file-selector-button {
|
||||
background-color: #225d36;
|
||||
background-color: #225d36;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.5em 1em;
|
||||
@@ -69,16 +71,19 @@ input[type="file"]::file-selector-button {
|
||||
color: #eaffea !important;
|
||||
border-color: #174428 !important;
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background-color: #7a1f23 !important;
|
||||
color: #ffeaea !important;
|
||||
border-color: #531417 !important;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background-color: #1d3a4d !important;
|
||||
color: #eaf6ff !important;
|
||||
border-color: #152837 !important;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background-color: #665c1e !important;
|
||||
color: #fffbe5 !important;
|
||||
@@ -86,35 +91,50 @@ input[type="file"]::file-selector-button {
|
||||
}
|
||||
|
||||
/* Badge - kolory pasujące do ciemnych alertów */
|
||||
.badge.bg-success, .badge.text-bg-success {
|
||||
.badge.bg-success,
|
||||
.badge.text-bg-success {
|
||||
background-color: #225d36 !important;
|
||||
color: #eaffea !important;
|
||||
}
|
||||
.badge.bg-danger, .badge.text-bg-danger {
|
||||
|
||||
.badge.bg-danger,
|
||||
.badge.text-bg-danger {
|
||||
background-color: #7a1f23 !important;
|
||||
color: #ffeaea !important;
|
||||
}
|
||||
.badge.bg-info, .badge.text-bg-info {
|
||||
|
||||
.badge.bg-info,
|
||||
.badge.text-bg-info {
|
||||
background-color: #1d3a4d !important;
|
||||
color: #eaf6ff !important;
|
||||
}
|
||||
.badge.bg-warning, .badge.text-bg-warning {
|
||||
|
||||
.badge.bg-warning,
|
||||
.badge.text-bg-warning {
|
||||
background-color: #665c1e !important;
|
||||
color: #fffbe5 !important;
|
||||
}
|
||||
.badge.bg-secondary, .badge.text-bg-secondary {
|
||||
|
||||
.badge.bg-secondary,
|
||||
.badge.text-bg-secondary {
|
||||
background-color: #343a40 !important;
|
||||
color: #e2e3e5 !important;
|
||||
}
|
||||
.badge.bg-primary, .badge.text-bg-primary {
|
||||
|
||||
.badge.bg-primary,
|
||||
.badge.text-bg-primary {
|
||||
background-color: #184076 !important;
|
||||
color: #e6f0ff !important;
|
||||
}
|
||||
.badge.bg-light, .badge.text-bg-light {
|
||||
|
||||
.badge.bg-light,
|
||||
.badge.text-bg-light {
|
||||
background-color: #444950 !important;
|
||||
color: #f8f9fa !important;
|
||||
}
|
||||
.badge.bg-dark, .badge.text-bg-dark {
|
||||
|
||||
.badge.bg-dark,
|
||||
.badge.text-bg-dark {
|
||||
background-color: #181a1b !important;
|
||||
color: #f8f9fa !important;
|
||||
}
|
||||
@@ -157,6 +177,7 @@ input[type="checkbox"].large-checkbox:disabled::before {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
input[type="checkbox"].large-checkbox:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -172,25 +193,25 @@ input.form-control {
|
||||
}
|
||||
|
||||
.info-bar-fixed {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
color: #f8f9fa;
|
||||
background-color: #212529;
|
||||
border-radius: 12px 12px 0 0;
|
||||
text-align: center;
|
||||
padding: 10px 8px;
|
||||
padding: 10px 10px;
|
||||
font-size: 0.95rem;
|
||||
z-index: 9999;
|
||||
box-sizing: border-box;
|
||||
margin-top: 2rem;
|
||||
box-shadow: 0 -1px 4px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.info-bar-fixed {
|
||||
position: static;
|
||||
font-size: 0.85rem;
|
||||
padding: 8px 4px;
|
||||
border-radius: 10px 10px 0 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +243,7 @@ input.form-control {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
@@ -231,11 +253,13 @@ input.form-control {
|
||||
#mass-add-list li.active {
|
||||
background: #198754 !important;
|
||||
color: #fff !important;
|
||||
border: 1px solid #000000 !important;
|
||||
border: 1px solid #000000 !important;
|
||||
}
|
||||
|
||||
#mass-add-list li {
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.quantity-input {
|
||||
width: 60px;
|
||||
background: #343a40;
|
||||
@@ -244,6 +268,7 @@ input.form-control {
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
margin-left: 10px;
|
||||
}
|
||||
@@ -255,6 +280,7 @@ input.form-control {
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.list-group-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -264,4 +290,24 @@ input.form-control {
|
||||
#empty-placeholder {
|
||||
font-style: italic;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#items li.hide-purchased {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.list-group-item:first-child,
|
||||
.list-group-item:last-child {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.fade-out {
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
@media (pointer: fine) {
|
||||
.only-mobile {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
@@ -1,31 +1,41 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.querySelectorAll('.clickable-item').forEach(item => {
|
||||
item.addEventListener('click', function(e) {
|
||||
if (!e.target.closest('button') && e.target.tagName.toLowerCase() !== 'input') {
|
||||
const checkbox = this.querySelector('input[type="checkbox"]');
|
||||
const itemsContainer = document.getElementById('items');
|
||||
if (!itemsContainer) return;
|
||||
|
||||
if (checkbox.disabled) {
|
||||
return;
|
||||
}
|
||||
itemsContainer.addEventListener('click', function (e) {
|
||||
const row = e.target.closest('.clickable-item');
|
||||
if (!row || !itemsContainer.contains(row)) return;
|
||||
|
||||
if (checkbox.checked) {
|
||||
socket.emit('uncheck_item', { item_id: parseInt(this.id.replace('item-', ''), 10) });
|
||||
} else {
|
||||
socket.emit('check_item', { item_id: parseInt(this.id.replace('item-', ''), 10) });
|
||||
}
|
||||
// Ignoruj kliknięcia w przyciski i inputy
|
||||
if (e.target.closest('button') || e.target.tagName.toLowerCase() === 'input') {
|
||||
return;
|
||||
}
|
||||
|
||||
checkbox.disabled = true;
|
||||
this.classList.add('opacity-50');
|
||||
const checkbox = row.querySelector('input[type="checkbox"]');
|
||||
if (!checkbox || checkbox.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let existingSpinner = this.querySelector('.spinner-border');
|
||||
if (!existingSpinner) {
|
||||
const spinner = document.createElement('span');
|
||||
spinner.className = 'spinner-border spinner-border-sm ms-2';
|
||||
spinner.setAttribute('role', 'status');
|
||||
spinner.setAttribute('aria-hidden', 'true');
|
||||
checkbox.parentElement.appendChild(spinner);
|
||||
}
|
||||
}
|
||||
});
|
||||
const itemId = parseInt(row.id.replace('item-', ''), 10);
|
||||
if (isNaN(itemId)) return;
|
||||
|
||||
if (checkbox.checked) {
|
||||
socket.emit('uncheck_item', { item_id: itemId });
|
||||
} else {
|
||||
socket.emit('check_item', { item_id: itemId });
|
||||
}
|
||||
|
||||
checkbox.disabled = true;
|
||||
row.classList.add('opacity-50');
|
||||
|
||||
// Dodaj spinner tylko jeśli nie ma
|
||||
let existingSpinner = row.querySelector('.spinner-border');
|
||||
if (!existingSpinner) {
|
||||
const spinner = document.createElement('span');
|
||||
spinner.className = 'spinner-border spinner-border-sm ms-2';
|
||||
spinner.setAttribute('role', 'status');
|
||||
spinner.setAttribute('aria-hidden', 'true');
|
||||
checkbox.parentElement.appendChild(spinner);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
20
static/js/confirm_delete.js
Normal file
20
static/js/confirm_delete.js
Normal file
@@ -0,0 +1,20 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const input = document.getElementById('confirm-delete-input');
|
||||
const button = document.getElementById('confirm-delete-btn');
|
||||
let timer = null;
|
||||
|
||||
input.addEventListener('input', function () {
|
||||
button.disabled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
|
||||
if (input.value.trim().toLowerCase() === 'usuń') {
|
||||
timer = setTimeout(() => {
|
||||
button.disabled = false;
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
button.addEventListener('click', function () {
|
||||
document.getElementById('delete-form').submit();
|
||||
});
|
||||
});
|
@@ -1,4 +1,4 @@
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
let expensesChart = null;
|
||||
const rangeLabel = document.getElementById("chartRangeLabel");
|
||||
|
||||
@@ -8,57 +8,57 @@ document.addEventListener("DOMContentLoaded", function() {
|
||||
url += `&start_date=${startDate}&end_date=${endDate}`;
|
||||
}
|
||||
|
||||
fetch(url, {cache: "no-store"})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const ctx = document.getElementById('expensesChart').getContext('2d');
|
||||
fetch(url, { cache: "no-store" })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const ctx = document.getElementById('expensesChart').getContext('2d');
|
||||
|
||||
if (expensesChart) {
|
||||
expensesChart.destroy();
|
||||
}
|
||||
if (expensesChart) {
|
||||
expensesChart.destroy();
|
||||
}
|
||||
|
||||
expensesChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [{
|
||||
label: 'Suma wydatków [PLN]',
|
||||
data: data.expenses,
|
||||
backgroundColor: '#0d6efd'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
expensesChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [{
|
||||
label: 'Suma wydatków [PLN]',
|
||||
data: data.expenses,
|
||||
backgroundColor: '#0d6efd'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (startDate && endDate) {
|
||||
rangeLabel.textContent = `Widok: własny zakres (${startDate} → ${endDate})`;
|
||||
} else {
|
||||
let labelText = "";
|
||||
if (range === "monthly") labelText = "Widok: miesięczne";
|
||||
else if (range === "quarterly") labelText = "Widok: kwartalne";
|
||||
else if (range === "halfyearly") labelText = "Widok: półroczne";
|
||||
else if (range === "yearly") labelText = "Widok: roczne";
|
||||
rangeLabel.textContent = labelText;
|
||||
}
|
||||
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Błąd pobierania danych:", error);
|
||||
});
|
||||
|
||||
if (startDate && endDate) {
|
||||
rangeLabel.textContent = `Widok: własny zakres (${startDate} → ${endDate})`;
|
||||
} else {
|
||||
let labelText = "";
|
||||
if (range === "monthly") labelText = "Widok: miesięczne";
|
||||
else if (range === "quarterly") labelText = "Widok: kwartalne";
|
||||
else if (range === "halfyearly") labelText = "Widok: półroczne";
|
||||
else if (range === "yearly") labelText = "Widok: roczne";
|
||||
rangeLabel.textContent = labelText;
|
||||
}
|
||||
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Błąd pobierania danych:", error);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('loadExpensesBtn').addEventListener('click', function() {
|
||||
document.getElementById('loadExpensesBtn').addEventListener('click', function () {
|
||||
loadExpenses();
|
||||
});
|
||||
|
||||
document.querySelectorAll('.range-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
btn.addEventListener('click', function () {
|
||||
document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
const range = this.getAttribute('data-range');
|
||||
@@ -66,7 +66,7 @@ document.addEventListener("DOMContentLoaded", function() {
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('customRangeBtn').addEventListener('click', function() {
|
||||
document.getElementById('customRangeBtn').addEventListener('click', function () {
|
||||
const startDate = document.getElementById('startDate').value;
|
||||
const endDate = document.getElementById('endDate').value;
|
||||
if (startDate && endDate) {
|
||||
@@ -78,7 +78,7 @@ document.addEventListener("DOMContentLoaded", function() {
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const startDateInput = document.getElementById("startDate");
|
||||
const endDateInput = document.getElementById("endDate");
|
||||
|
||||
|
@@ -16,6 +16,7 @@ function updateItemState(itemId, isChecked) {
|
||||
if (sp) sp.remove();
|
||||
}
|
||||
updateProgressBar();
|
||||
applyHidePurchased();
|
||||
}
|
||||
|
||||
function updateProgressBar() {
|
||||
@@ -24,12 +25,40 @@ function updateProgressBar() {
|
||||
const purchased = Array.from(items).filter(li => li.classList.contains('bg-success')).length;
|
||||
const percent = total > 0 ? Math.round((purchased / total) * 100) : 0;
|
||||
|
||||
// Pasek postępu
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
if (progressBar) {
|
||||
progressBar.style.width = `${percent}%`;
|
||||
progressBar.setAttribute('aria-valuenow', percent);
|
||||
progressBar.textContent = `${percent}%`;
|
||||
progressBar.textContent = percent > 0 ? `${percent}%` : ''; // opcjonalnie
|
||||
}
|
||||
|
||||
// Label na pasku postępu
|
||||
const progressLabel = document.getElementById('progress-label');
|
||||
if (progressLabel) {
|
||||
progressLabel.textContent = `${percent}%`;
|
||||
if (percent === 0) {
|
||||
progressLabel.style.display = 'inline';
|
||||
} else {
|
||||
progressLabel.style.display = 'none';
|
||||
}
|
||||
// Kolor tekstu labela
|
||||
if (percent < 50) {
|
||||
progressLabel.classList.remove('text-dark');
|
||||
progressLabel.classList.add('text-white');
|
||||
} else {
|
||||
progressLabel.classList.remove('text-white');
|
||||
progressLabel.classList.add('text-dark');
|
||||
}
|
||||
}
|
||||
|
||||
// Nagłówek
|
||||
const purchasedCount = document.getElementById('purchased-count');
|
||||
if (purchasedCount) purchasedCount.textContent = purchased;
|
||||
const totalCount = document.getElementById('total-count');
|
||||
if (totalCount) totalCount.textContent = total;
|
||||
const percentValue = document.getElementById('percent-value');
|
||||
if (percentValue) percentValue.textContent = percent;
|
||||
}
|
||||
|
||||
function addItem(listId) {
|
||||
@@ -91,6 +120,22 @@ function submitExpense(listId) {
|
||||
}
|
||||
|
||||
function copyLink(link) {
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: 'Udostępnij link',
|
||||
text: 'Udostępniam link do listy:',
|
||||
url: link
|
||||
}).then(() => {
|
||||
showToast('Link udostępniony!');
|
||||
}).catch((err) => {
|
||||
tryClipboard(link);
|
||||
});
|
||||
return;
|
||||
}
|
||||
tryClipboard(link);
|
||||
}
|
||||
|
||||
function tryClipboard(link) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
showToast('Link skopiowany do schowka!');
|
||||
@@ -103,33 +148,6 @@ function copyLink(link) {
|
||||
}
|
||||
}
|
||||
|
||||
/* function shareLink(link) {
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: 'Udostępnij moją listę',
|
||||
text: 'Zobacz tę listę!',
|
||||
url: link
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Błąd podczas udostępniania', error);
|
||||
alert('Nie udało się udostępnić linka');
|
||||
});
|
||||
} else {
|
||||
copyLink(link);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(link) {
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
alert('Link skopiowany do schowka!');
|
||||
});
|
||||
}
|
||||
*/
|
||||
|
||||
function openList(link) {
|
||||
window.open(link, '_blank');
|
||||
}
|
||||
|
||||
function fallbackCopyText(text) {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
@@ -156,8 +174,50 @@ function fallbackCopyText(text) {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
function openList(link) {
|
||||
window.open(link, '_blank');
|
||||
}
|
||||
|
||||
function applyHidePurchased(isInit = false) {
|
||||
//console.log("applyHidePurchased: wywołana, isInit =", isInit);
|
||||
const toggle = document.getElementById('hidePurchasedToggle');
|
||||
if (!toggle) return;
|
||||
const hide = toggle.checked;
|
||||
|
||||
const items = document.querySelectorAll('#items li');
|
||||
|
||||
items.forEach(li => {
|
||||
const isPurchased = li.classList.contains('bg-success');
|
||||
|
||||
if (isPurchased) {
|
||||
if (hide) {
|
||||
if (isInit) {
|
||||
// Jeśli inicjalizacja: od razu ukryj
|
||||
li.classList.add('hide-purchased');
|
||||
li.classList.remove('fade-out');
|
||||
} else {
|
||||
// Z animacją
|
||||
li.classList.add('fade-out');
|
||||
setTimeout(() => {
|
||||
li.classList.add('hide-purchased');
|
||||
}, 700);
|
||||
}
|
||||
} else {
|
||||
// Odsłanianie
|
||||
li.classList.remove('hide-purchased');
|
||||
setTimeout(() => {
|
||||
li.classList.remove('fade-out');
|
||||
}, 10);
|
||||
}
|
||||
} else {
|
||||
// Element niekupiony — zawsze pokazany
|
||||
li.classList.remove('hide-purchased', 'fade-out');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleVisibility(listId) {
|
||||
fetch('/toggle_visibility/' + listId, {method: 'POST'})
|
||||
fetch('/toggle_visibility/' + listId, { method: 'POST' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const shareHeader = document.getElementById('share-header');
|
||||
@@ -180,6 +240,14 @@ function toggleVisibility(listId) {
|
||||
});
|
||||
}
|
||||
|
||||
function markNotPurchasedModal(e, id) {
|
||||
e.stopPropagation();
|
||||
const reason = prompt("Podaj powód oznaczenia jako niekupione:");
|
||||
if (reason !== null) {
|
||||
socket.emit('mark_not_purchased', { item_id: id, reason: reason });
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type = 'primary') {
|
||||
const toastContainer = document.getElementById('toast-container');
|
||||
const toast = document.createElement('div');
|
||||
@@ -205,6 +273,7 @@ function isListDifferent(oldItems, newItems) {
|
||||
}
|
||||
|
||||
function updateListSmoothly(newItems) {
|
||||
|
||||
const itemsContainer = document.getElementById('items');
|
||||
const existingItemsMap = new Map();
|
||||
|
||||
@@ -222,58 +291,62 @@ function updateListSmoothly(newItems) {
|
||||
quantityBadge = `<span class="badge bg-secondary">x${item.quantity}</span>`;
|
||||
}
|
||||
|
||||
if (li) {
|
||||
const checkbox = li.querySelector('input[type="checkbox"]');
|
||||
if (checkbox) {
|
||||
checkbox.checked = item.purchased;
|
||||
checkbox.disabled = false;
|
||||
}
|
||||
|
||||
li.classList.remove('bg-success', 'text-white', 'item-not-checked', 'opacity-50');
|
||||
if (item.purchased) {
|
||||
li.classList.add('bg-success', 'text-white');
|
||||
} else {
|
||||
li.classList.add('item-not-checked');
|
||||
}
|
||||
|
||||
const nameSpan = li.querySelector(`#name-${item.id}`);
|
||||
const expectedName = `${item.name} ${quantityBadge}`.trim();
|
||||
if (nameSpan && nameSpan.innerHTML.trim() !== expectedName) {
|
||||
nameSpan.innerHTML = expectedName;
|
||||
}
|
||||
|
||||
let noteEl = li.querySelector('small');
|
||||
if (item.note) {
|
||||
if (!noteEl) {
|
||||
const newNote = document.createElement('small');
|
||||
newNote.className = 'text-danger ms-4';
|
||||
newNote.innerHTML = `[ <b>${item.note}</b> ]`;
|
||||
nameSpan.insertAdjacentElement('afterend', newNote);
|
||||
} else {
|
||||
noteEl.innerHTML = `[ <b>${item.note}</b> ]`;
|
||||
}
|
||||
} else if (noteEl) {
|
||||
noteEl.remove();
|
||||
}
|
||||
|
||||
const sp = li.querySelector('.spinner-border');
|
||||
if (sp) sp.remove();
|
||||
|
||||
} else {
|
||||
if (!li) {
|
||||
li = document.createElement('li');
|
||||
li.className = `list-group-item d-flex justify-content-between align-items-center flex-wrap ${item.purchased ? 'bg-success text-white' : 'item-not-checked'}`;
|
||||
li.id = `item-${item.id}`;
|
||||
|
||||
li.innerHTML = `
|
||||
<div class="d-flex align-items-center gap-3 flex-grow-1">
|
||||
<input class="large-checkbox" type="checkbox" ${item.purchased ? 'checked' : ''}>
|
||||
<span id="name-${item.id}" class="text-white">${item.name} ${quantityBadge}</span>
|
||||
${item.note ? `<small class="text-danger ms-4">[ <b>${item.note}</b> ]</small>` : ''}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-info" onclick="openNoteModal(event, ${item.id})">📝</button>
|
||||
`;
|
||||
}
|
||||
|
||||
// Klasy tła
|
||||
li.className = `list-group-item d-flex justify-content-between align-items-center flex-wrap clickable-item ${item.purchased ? 'bg-success text-white' :
|
||||
item.not_purchased ? 'bg-warning text-dark' : 'item-not-checked'
|
||||
}`;
|
||||
|
||||
// Wewnętrzny HTML
|
||||
li.innerHTML = `
|
||||
<div class="d-flex align-items-center gap-2 flex-grow-1">
|
||||
${isSorting ? `<span class="drag-handle me-2 text-danger" style="cursor: grab;">☰</span>` : ''}
|
||||
${!item.not_purchased ? `
|
||||
<input id="checkbox-${item.id}" class="large-checkbox" type="checkbox"
|
||||
${item.purchased ? 'checked' : ''}>
|
||||
` : `
|
||||
<span class="ms-1 block-icon">🚫</span>
|
||||
`}
|
||||
<span id="name-${item.id}" class="text-white">${item.name} ${quantityBadge}</span>
|
||||
|
||||
${item.note ? `<small class="text-danger ms-4">[ <b>${item.note}</b> ]</small>` : ''}
|
||||
${item.not_purchased_reason ? `<small class="text-dark ms-4">[ <b>Powód: ${item.not_purchased_reason}</b> ]</small>` : ''}
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
${item.not_purchased ? `
|
||||
<button type="button" class="btn btn-outline-light me-auto"
|
||||
onclick="unmarkNotPurchased(${item.id})">
|
||||
✅ Przywróć
|
||||
</button>
|
||||
` : `
|
||||
<button type="button" class="btn btn-outline-light"
|
||||
onclick="markNotPurchasedModal(event, ${item.id})">
|
||||
⚠️
|
||||
</button>
|
||||
${window.IS_SHARE ? `
|
||||
<button type="button" class="btn btn-outline-light"
|
||||
onclick="openNoteModal(event, ${item.id})">
|
||||
📝
|
||||
</button>
|
||||
` : ''}
|
||||
`}
|
||||
${!window.IS_SHARE ? `
|
||||
<button type="button" class="btn btn-outline-light"
|
||||
onclick="editItem(${item.id}, '${item.name.replace(/'/g, "\\'")}', ${item.quantity || 1})">
|
||||
✏️
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-light"
|
||||
onclick="deleteItem(${item.id})">
|
||||
🗑️
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
fragment.appendChild(li);
|
||||
});
|
||||
|
||||
@@ -282,9 +355,11 @@ function updateListSmoothly(newItems) {
|
||||
|
||||
updateProgressBar();
|
||||
toggleEmptyPlaceholder();
|
||||
applyHidePurchased();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const receiptSection = document.getElementById("receiptSection");
|
||||
const toggleBtn = document.querySelector('[data-bs-target="#receiptSection"]');
|
||||
|
||||
@@ -302,3 +377,16 @@ document.addEventListener("DOMContentLoaded", function() {
|
||||
localStorage.setItem("receiptSectionOpen", "false");
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const toggle = document.getElementById('hidePurchasedToggle');
|
||||
if (!toggle) return;
|
||||
|
||||
const savedState = localStorage.getItem('hidePurchasedToggle');
|
||||
toggle.checked = savedState === 'true';
|
||||
applyHidePurchased(true);
|
||||
toggle.addEventListener('change', function () {
|
||||
localStorage.setItem('hidePurchasedToggle', toggle.checked ? 'true' : 'false');
|
||||
applyHidePurchased();
|
||||
});
|
||||
});
|
@@ -7,11 +7,11 @@ function toggleEmptyPlaceholder() {
|
||||
|
||||
// prawdziwe <li> to te z data‑name lub id="item‑…"
|
||||
const hasRealItems = list.querySelector('li[data-name], li[id^="item-"]') !== null;
|
||||
const placeholder = document.getElementById('empty-placeholder');
|
||||
const placeholder = document.getElementById('empty-placeholder');
|
||||
|
||||
if (!hasRealItems && !placeholder) {
|
||||
const li = document.createElement('li');
|
||||
li.id = 'empty-placeholder';
|
||||
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.';
|
||||
list.appendChild(li);
|
||||
@@ -124,51 +124,83 @@ function setupList(listId, username) {
|
||||
summaryEl.innerHTML = `<b>💸 Łącznie wydano:</b> ${data.total.toFixed(2)} PLN`;
|
||||
}
|
||||
|
||||
showToast(`Dodano wydatek: ${data.amount.toFixed(2)} PLN`);
|
||||
showToast(`Dodano wydatek: ${data.amount.toFixed(2)} PLN`, 'info');
|
||||
});
|
||||
|
||||
socket.on('item_added', data => {
|
||||
showToast(`${data.added_by} dodał: ${data.name}`);
|
||||
showToast(`${data.added_by} dodał: ${data.name}`, 'info');
|
||||
const li = document.createElement('li');
|
||||
li.className = 'list-group-item d-flex justify-content-between align-items-center flex-wrap item-not-checked';
|
||||
li.id = `item-${data.id}`;
|
||||
|
||||
|
||||
let quantityBadge = '';
|
||||
if (data.quantity && data.quantity > 1) {
|
||||
quantityBadge = `<span class="badge bg-secondary">x${data.quantity}</span>`;
|
||||
}
|
||||
|
||||
li.innerHTML = `
|
||||
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||
<input class="large-checkbox" type="checkbox">
|
||||
<span id="name-${data.id}" class="text-white">${data.name} ${quantityBadge}</span>
|
||||
</div>
|
||||
<div class="mt-2 mt-md-0">
|
||||
<button class="btn btn-sm btn-outline-warning me-1" onclick="editItem(${data.id}, '${data.name}', ${data.quantity || 1})">✏️</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteItem(${data.id})">🗑️</button>
|
||||
</div>
|
||||
`;
|
||||
const countdownId = `countdown-${data.id}`;
|
||||
const countdownBtn = `
|
||||
<button type="button" class="btn btn-outline-warning" id="${countdownId}" disabled>15s</button>
|
||||
`;
|
||||
|
||||
// #### WERSJA Z NAPISAMI ####
|
||||
// <button class="btn btn-sm btn-outline-warning me-1" onclick="editItem(${data.id}, '${data.name}', ${data.quantity || 1})">✏️ Edytuj</button>
|
||||
// <button class="btn btn-sm btn-outline-danger" onclick="deleteItem(${data.id})">🗑️ Usuń</button>
|
||||
li.innerHTML = `
|
||||
<div class="d-flex align-items-center flex-wrap gap-2 flex-grow-1">
|
||||
<input class="large-checkbox" type="checkbox">
|
||||
<span id="name-${data.id}" class="text-white">
|
||||
${data.name} ${quantityBadge}
|
||||
</span>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
${countdownBtn}
|
||||
<button type="button" class="btn btn-outline-light"
|
||||
onclick="editItem(${data.id}, '${data.name.replace(/'/g, "\\'")}', ${data.quantity || 1})">
|
||||
✏️
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-light"
|
||||
onclick="deleteItem(${data.id})">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('items').appendChild(li);
|
||||
updateProgressBar();
|
||||
toggleEmptyPlaceholder();
|
||||
|
||||
// ⏳ Licznik odliczania
|
||||
let seconds = 15;
|
||||
const countdownEl = document.getElementById(countdownId);
|
||||
const intervalId = setInterval(() => {
|
||||
seconds--;
|
||||
if (countdownEl) {
|
||||
countdownEl.textContent = `${seconds}s`;
|
||||
}
|
||||
if (seconds <= 0) {
|
||||
clearInterval(intervalId);
|
||||
if (countdownEl) countdownEl.remove();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// 🔁 Request listy po 15s
|
||||
setTimeout(() => {
|
||||
if (window.LIST_ID) {
|
||||
socket.emit('request_full_list', { list_id: window.LIST_ID });
|
||||
}
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
|
||||
|
||||
socket.on('item_deleted', data => {
|
||||
const li = document.getElementById(`item-${data.item_id}`);
|
||||
if (li) {
|
||||
li.remove();
|
||||
}
|
||||
showToast('Usunięto produkt');
|
||||
showToast('Usunięto produkt z listy', 'success');
|
||||
updateProgressBar();
|
||||
toggleEmptyPlaceholder();
|
||||
});
|
||||
|
||||
socket.on('progress_updated', function(data) {
|
||||
socket.on('progress_updated', function (data) {
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
if (progressBar) {
|
||||
progressBar.style.width = data.percent + '%';
|
||||
@@ -203,7 +235,7 @@ function setupList(listId, username) {
|
||||
}
|
||||
}
|
||||
}
|
||||
showToast('Notatka zaktualizowana!');
|
||||
showToast('Notatka dodana/zaktualizowana', 'success');
|
||||
});
|
||||
|
||||
socket.on('item_edited', data => {
|
||||
@@ -215,7 +247,7 @@ function setupList(listId, username) {
|
||||
}
|
||||
nameSpan.innerHTML = `${data.new_name}${quantityBadge}`;
|
||||
}
|
||||
showToast(`Zaktualizowano produkt: ${data.new_name} (x${data.new_quantity})`);
|
||||
showToast(`Zaktualizowano produkt: ${data.new_name} (x${data.new_quantity})`, 'success');
|
||||
});
|
||||
|
||||
updateProgressBar();
|
||||
@@ -225,4 +257,8 @@ function setupList(listId, username) {
|
||||
window.LIST_ID = listId;
|
||||
window.usernameForReconnect = username;
|
||||
|
||||
}
|
||||
|
||||
function unmarkNotPurchased(itemId) {
|
||||
socket.emit('unmark_not_purchased', { item_id: itemId });
|
||||
}
|
@@ -2,11 +2,16 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
const modal = document.getElementById('massAddModal');
|
||||
const productList = document.getElementById('mass-add-list');
|
||||
|
||||
// Funkcja normalizacji (usuwa diakrytyki i zamienia na lowercase)
|
||||
function normalize(str) {
|
||||
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
modal.addEventListener('show.bs.modal', async function () {
|
||||
let addedProducts = new Set();
|
||||
document.querySelectorAll('#items li').forEach(li => {
|
||||
if (li.dataset.name) {
|
||||
addedProducts.add(li.dataset.name.toLowerCase());
|
||||
addedProducts.add(normalize(li.dataset.name));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -20,8 +25,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'list-group-item d-flex justify-content-between align-items-center bg-dark text-light';
|
||||
|
||||
if (addedProducts.has(name.toLowerCase())) {
|
||||
// Produkt już dodany — oznacz jako nieaktywny
|
||||
if (addedProducts.has(normalize(name))) {
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.textContent = name;
|
||||
li.appendChild(nameSpan);
|
||||
@@ -32,17 +36,14 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
badge.textContent = 'Dodano';
|
||||
li.appendChild(badge);
|
||||
} else {
|
||||
// Nazwa produktu
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.textContent = name;
|
||||
nameSpan.style.flex = '1 1 auto';
|
||||
li.appendChild(nameSpan);
|
||||
|
||||
// Kontener na minus, pole i plus
|
||||
const qtyWrapper = document.createElement('div');
|
||||
qtyWrapper.className = 'd-flex align-items-center ms-2 quantity-controls';
|
||||
|
||||
// Minus
|
||||
const minusBtn = document.createElement('button');
|
||||
minusBtn.type = 'button';
|
||||
minusBtn.className = 'btn btn-outline-light btn-sm px-2';
|
||||
@@ -51,18 +52,15 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
qty.value = Math.max(1, parseInt(qty.value) - 1);
|
||||
};
|
||||
|
||||
// Pole ilości
|
||||
const qty = document.createElement('input');
|
||||
qty.type = 'number';
|
||||
qty.min = 1;
|
||||
qty.value = 1;
|
||||
qty.className = 'form-control text-center p-1';
|
||||
qty.classList.add('rounded');
|
||||
qty.className = 'form-control text-center p-1 rounded';
|
||||
qty.style.width = '50px';
|
||||
qty.style.margin = '0 2px';
|
||||
qty.title = 'Ilość';
|
||||
|
||||
// Plus
|
||||
const plusBtn = document.createElement('button');
|
||||
plusBtn.type = 'button';
|
||||
plusBtn.className = 'btn btn-outline-light btn-sm px-2';
|
||||
@@ -75,13 +73,13 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
qtyWrapper.appendChild(qty);
|
||||
qtyWrapper.appendChild(plusBtn);
|
||||
|
||||
// Przycisk dodania
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn btn-sm btn-primary ms-4';
|
||||
btn.textContent = '+';
|
||||
btn.onclick = () => {
|
||||
const quantity = parseInt(qty.value) || 1;
|
||||
socket.emit('add_item', { list_id: LIST_ID, name: name, quantity: quantity });
|
||||
socket.emit('request_full_list', { list_id: LIST_ID });
|
||||
};
|
||||
|
||||
li.appendChild(qtyWrapper);
|
||||
@@ -99,25 +97,19 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('#mass-add-list li').forEach(li => {
|
||||
const itemName = li.firstChild.textContent.trim();
|
||||
|
||||
if (itemName === data.name && !li.classList.contains('opacity-50')) {
|
||||
// Usuń wszystkie dzieci
|
||||
if (normalize(itemName) === normalize(data.name) && !li.classList.contains('opacity-50')) {
|
||||
while (li.firstChild) {
|
||||
li.removeChild(li.firstChild);
|
||||
}
|
||||
|
||||
// Ustaw nazwę
|
||||
li.textContent = data.name;
|
||||
|
||||
// Dodaj klasę wyszarzenia
|
||||
li.classList.add('opacity-50');
|
||||
|
||||
// Dodaj badge
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'badge bg-success ms-auto';
|
||||
badge.textContent = 'Dodano';
|
||||
li.appendChild(badge);
|
||||
|
||||
// Zablokuj kliknięcia
|
||||
li.onclick = null;
|
||||
}
|
||||
});
|
||||
|
@@ -1,13 +1,13 @@
|
||||
let currentItemId = null;
|
||||
|
||||
function openNoteModal(event, itemId) {
|
||||
window.openNoteModal = function (event, itemId) {
|
||||
event.stopPropagation();
|
||||
currentItemId = itemId;
|
||||
const noteEl = document.querySelector(`#item-${itemId} small`);
|
||||
document.getElementById('noteText').value = noteEl ? noteEl.innerText : "";
|
||||
const noteEl = document.querySelector(`#item-${itemId} small.text-danger`);
|
||||
document.getElementById('noteText').value = noteEl ? noteEl.innerText.replace(/\[|\]|Powód:/g, "").trim() : "";
|
||||
const modal = new bootstrap.Modal(document.getElementById('noteModal'));
|
||||
modal.show();
|
||||
}
|
||||
};
|
||||
|
||||
function submitNote(e) {
|
||||
e.preventDefault();
|
||||
@@ -20,3 +20,4 @@ function submitNote(e) {
|
||||
modal.hide();
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,4 +1,4 @@
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
// Odśwież eventy
|
||||
document.querySelectorAll('.sync-btn').forEach(btn => {
|
||||
btn.replaceWith(btn.cloneNode(true));
|
||||
@@ -9,7 +9,7 @@ document.addEventListener("DOMContentLoaded", function() {
|
||||
|
||||
// Synchronizacja sugestii
|
||||
document.querySelectorAll('.sync-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function(e) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const itemId = this.getAttribute('data-item-id');
|
||||
@@ -22,28 +22,28 @@ document.addEventListener("DOMContentLoaded", function() {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
showToast(data.message, data.success ? 'success' : 'danger');
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
showToast(data.message, data.success ? 'success' : 'danger');
|
||||
|
||||
if (data.success) {
|
||||
button.innerText = '✅ Zsynchronizowano';
|
||||
button.classList.remove('btn-outline-primary');
|
||||
button.classList.add('btn-success');
|
||||
} else {
|
||||
if (data.success) {
|
||||
button.innerText = '✅ Zsynchronizowano';
|
||||
button.classList.remove('btn-outline-primary');
|
||||
button.classList.add('btn-success');
|
||||
} else {
|
||||
button.disabled = false;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
showToast('Błąd synchronizacji', 'danger');
|
||||
button.disabled = false;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
showToast('Błąd synchronizacji', 'danger');
|
||||
button.disabled = false;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Usuwanie sugestii
|
||||
document.querySelectorAll('.delete-suggestion-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function(e) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const suggestionId = this.getAttribute('data-suggestion-id');
|
||||
@@ -56,21 +56,21 @@ document.addEventListener("DOMContentLoaded", function() {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
showToast(data.message, data.success ? 'success' : 'danger');
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
showToast(data.message, data.success ? 'success' : 'danger');
|
||||
|
||||
if (data.success) {
|
||||
const row = button.closest('tr');
|
||||
if (row) row.remove();
|
||||
} else {
|
||||
if (data.success) {
|
||||
const row = button.closest('tr');
|
||||
if (row) row.remove();
|
||||
} else {
|
||||
button.disabled = false;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
showToast('Błąd usuwania sugestii', 'danger');
|
||||
button.disabled = false;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
showToast('Błąd usuwania sugestii', 'danger');
|
||||
button.disabled = false;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
99
static/js/receipt_analysis.js
Normal file
99
static/js/receipt_analysis.js
Normal file
@@ -0,0 +1,99 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const analyzeBtn = document.getElementById("analyzeBtn");
|
||||
if (analyzeBtn) {
|
||||
analyzeBtn.addEventListener("click", () => analyzeReceipts(LIST_ID));
|
||||
}
|
||||
});
|
||||
|
||||
async function analyzeReceipts(listId) {
|
||||
const resultsDiv = document.getElementById("analysisResults");
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="text-info d-flex align-items-center gap-2">
|
||||
<div class="spinner-border spinner-border-sm text-info" role="status"></div>
|
||||
<span>Trwa analiza paragonów...</span>
|
||||
</div>`;
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const res = await fetch(`/lists/${listId}/analyze`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
const duration = ((performance.now() - start) / 1000).toFixed(2);
|
||||
|
||||
let html = `<div class="card bg-dark text-white border-secondary p-3">`;
|
||||
html += `<p><b>📊 Łącznie wykryto:</b> ${data.total.toFixed(2)} PLN</p>`;
|
||||
html += `<p class="text-secondary"><small>⏱ Czas analizy OCR: ${duration} sek.</small></p>`;
|
||||
|
||||
data.results.forEach((r, i) => {
|
||||
const disabled = r.already_added ? "disabled" : "";
|
||||
const inputStyle = "form-control d-inline-block bg-dark text-white border-light rounded";
|
||||
const inputField = `<input type="number" id="amount-${i}" value="${r.amount}" step="0.01" class="${inputStyle}" style="width: 120px;" ${disabled}>`;
|
||||
|
||||
const button = r.already_added
|
||||
? `<span class="badge bg-primary ms-2">✅ Dodano</span>`
|
||||
: `<button id="add-btn-${i}" onclick="emitExpense(${i})" class="btn btn-sm btn-outline-success ms-2">➕ Dodaj</button>`;
|
||||
|
||||
html += `
|
||||
<div class="mb-2 d-flex align-items-center gap-2 flex-wrap">
|
||||
<span class="text-light flex-grow-1">${r.filename}</span>
|
||||
${inputField}
|
||||
${button}
|
||||
</div>`;
|
||||
});
|
||||
|
||||
|
||||
if (data.results.length > 1) {
|
||||
html += `<button id="addAllBtn" onclick="emitAllExpenses(${data.results.length})" class="btn btn-success mt-3 w-100">➕ Dodaj wszystkie</button>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
resultsDiv.innerHTML = html;
|
||||
window._ocr_results = data.results;
|
||||
|
||||
} catch (err) {
|
||||
resultsDiv.innerHTML = `<div class="text-danger">❌ Wystąpił błąd podczas analizy.</div>`;
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
function emitExpense(i) {
|
||||
const r = window._ocr_results[i];
|
||||
const val = parseFloat(document.getElementById(`amount-${i}`).value);
|
||||
const btn = document.getElementById(`add-btn-${i}`);
|
||||
|
||||
if (!isNaN(val) && val > 0) {
|
||||
socket.emit('add_expense', {
|
||||
list_id: LIST_ID,
|
||||
amount: val,
|
||||
receipt_filename: r.filename
|
||||
});
|
||||
|
||||
document.getElementById(`amount-${i}`).disabled = true;
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.classList.remove('btn-outline-success');
|
||||
btn.classList.add('btn-success');
|
||||
btn.textContent = '✅ Dodano';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function emitAllExpenses(n) {
|
||||
const btnAll = document.getElementById('addAllBtn');
|
||||
if (btnAll) {
|
||||
btnAll.disabled = true;
|
||||
btnAll.innerHTML = `<span class="spinner-border spinner-border-sm me-2" role="status"></span>Dodawanie...`;
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
setTimeout(() => emitExpense(i), i * 150);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (btnAll) {
|
||||
btnAll.innerHTML = '✅ Wszystko dodano';
|
||||
btnAll.classList.remove('btn-success');
|
||||
btnAll.classList.add('btn-outline-success');
|
||||
}
|
||||
}, n * 150 + 300);
|
||||
}
|
63
static/js/receipt_crop.js
Normal file
63
static/js/receipt_crop.js
Normal file
@@ -0,0 +1,63 @@
|
||||
let cropper;
|
||||
let currentReceiptId;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const cropModal = document.getElementById("cropModal");
|
||||
const cropImage = document.getElementById("cropImage");
|
||||
|
||||
cropModal.addEventListener("shown.bs.modal", function (event) {
|
||||
const button = event.relatedTarget;
|
||||
const imgSrc = button.getAttribute("data-img-src");
|
||||
currentReceiptId = button.getAttribute("data-receipt-id");
|
||||
|
||||
const image = document.getElementById("cropImage");
|
||||
image.src = imgSrc;
|
||||
|
||||
if (cropper) {
|
||||
cropper.destroy();
|
||||
cropper = null;
|
||||
}
|
||||
|
||||
image.onload = () => {
|
||||
cropper = new Cropper(image, {
|
||||
viewMode: 1,
|
||||
autoCropArea: 1,
|
||||
responsive: true,
|
||||
background: false,
|
||||
zoomable: true,
|
||||
movable: true,
|
||||
dragMode: 'move',
|
||||
minContainerHeight: 400,
|
||||
minContainerWidth: 400,
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
document.getElementById("saveCrop").addEventListener("click", function () {
|
||||
if (!cropper) return;
|
||||
|
||||
cropper.getCroppedCanvas().toBlob(function (blob) {
|
||||
const formData = new FormData();
|
||||
formData.append("receipt_id", currentReceiptId);
|
||||
formData.append("cropped_image", blob);
|
||||
|
||||
fetch("/admin/crop_receipt", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.success) {
|
||||
showToast("Zapisano przycięty paragon", "success");
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
showToast("Błąd: " + (data.error || "Nieznany"), "danger");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast("Błąd sieci", "danger");
|
||||
console.error(err);
|
||||
});
|
||||
}, "image/webp");
|
||||
});
|
||||
});
|
@@ -1,4 +1,4 @@
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const receiptSection = document.getElementById("receiptSection");
|
||||
const toggleBtn = document.querySelector('[data-bs-target="#receiptSection"]');
|
||||
|
||||
|
@@ -3,29 +3,28 @@ window.receiptToastShown = window.receiptToastShown || false;
|
||||
if (!window.receiptUploaderInitialized) {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const form = document.getElementById("receiptForm");
|
||||
const input = document.getElementById("receiptInput");
|
||||
const gallery = document.getElementById("receiptGallery");
|
||||
const inputCamera = document.getElementById("cameraInput");
|
||||
const inputGallery = document.getElementById("galleryInput");
|
||||
const galleryBtn = document.getElementById("galleryBtn");
|
||||
const galleryBtnText = document.getElementById("galleryBtnText");
|
||||
const cameraBtn = document.getElementById("cameraBtn");
|
||||
const progressContainer = document.getElementById("progressContainer");
|
||||
const progressBar = document.getElementById("progressBar");
|
||||
const fileLabel = document.getElementById("fileLabel");
|
||||
const gallery = document.getElementById("receiptGallery");
|
||||
|
||||
if (!form || !input || !gallery) return;
|
||||
if (!form || !inputCamera || !inputGallery || !gallery) return;
|
||||
|
||||
// Zmiana labela po wyborze pliku
|
||||
if (input && fileLabel) {
|
||||
input.addEventListener("change", function () {
|
||||
if (input.files.length > 0) {
|
||||
fileLabel.textContent = input.files[0].name;
|
||||
} else {
|
||||
fileLabel.textContent = "Wybierz zdjęcie paragonu";
|
||||
}
|
||||
});
|
||||
const isDesktop = window.matchMedia("(pointer: fine)").matches;
|
||||
|
||||
// 🧼 Jedno miejsce, pełna logika desktopowa
|
||||
if (isDesktop) {
|
||||
if (cameraBtn) cameraBtn.remove(); // całkowicie usuń przycisk
|
||||
if (inputCamera) inputCamera.remove(); // oraz input
|
||||
if (galleryBtnText) galleryBtnText.textContent = "➕ Dodaj paragon";
|
||||
}
|
||||
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const file = input.files[0];
|
||||
function handleFileUpload(inputElement) {
|
||||
const file = inputElement.files[0];
|
||||
if (!file) {
|
||||
showToast("Nie wybrano pliku!", "warning");
|
||||
return;
|
||||
@@ -56,31 +55,30 @@ if (!window.receiptUploaderInitialized) {
|
||||
progressContainer.style.display = "none";
|
||||
progressBar.style.width = "0%";
|
||||
progressBar.textContent = "";
|
||||
input.value = "";
|
||||
|
||||
if (fileLabel) {
|
||||
fileLabel.textContent = "Wybierz zdjęcie paragonu";
|
||||
}
|
||||
inputElement.value = "";
|
||||
window.receiptToastShown = false;
|
||||
};
|
||||
|
||||
xhr.onreadystatechange = function () {
|
||||
if (xhr.readyState === XMLHttpRequest.DONE) {
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
const res = JSON.parse(xhr.responseText);
|
||||
if (res.success && res.url) {
|
||||
|
||||
if (xhr.status === 200 && res.success && res.url) {
|
||||
fetch(window.location.href)
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
const newGallery = doc.getElementById("receiptGallery");
|
||||
|
||||
if (newGallery) {
|
||||
gallery.innerHTML = newGallery.innerHTML;
|
||||
|
||||
lightbox.destroy();
|
||||
lightbox = GLightbox({
|
||||
selector: '.glightbox'
|
||||
});
|
||||
if (typeof lightbox !== "undefined") {
|
||||
lightbox.destroy();
|
||||
}
|
||||
lightbox = GLightbox({ selector: ".glightbox" });
|
||||
|
||||
if (!window.receiptToastShown) {
|
||||
showToast("Wgrano paragon", "success");
|
||||
@@ -89,16 +87,21 @@ if (!window.receiptUploaderInitialized) {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
showToast(res.message || "Błąd podczas wgrywania.", "danger");
|
||||
const errorMessage = res.error || res.message || "Błąd podczas wgrywania.";
|
||||
showToast(errorMessage, "danger");
|
||||
}
|
||||
} else {
|
||||
showToast("Błąd serwera. Spróbuj ponownie.", "danger");
|
||||
} catch (err) {
|
||||
showToast("Błąd serwera (nieprawidłowa odpowiedź).", "danger");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
inputCamera?.addEventListener("change", () => handleFileUpload(inputCamera));
|
||||
inputGallery?.addEventListener("change", () => handleFileUpload(inputGallery));
|
||||
});
|
||||
|
||||
window.receiptUploaderInitialized = true;
|
||||
|
@@ -2,83 +2,83 @@ let didReceiveFirstFullList = false;
|
||||
|
||||
// --- Automatyczny reconnect po powrocie do karty/przywróceniu internetu ---
|
||||
function reconnectIfNeeded() {
|
||||
if (!socket.connected) {
|
||||
socket.connect();
|
||||
}
|
||||
if (!socket.connected) {
|
||||
socket.connect();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", function() {
|
||||
if (!document.hidden) {
|
||||
reconnectIfNeeded();
|
||||
}
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (!document.hidden) {
|
||||
reconnectIfNeeded();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("focus", function() {
|
||||
reconnectIfNeeded();
|
||||
window.addEventListener("focus", function () {
|
||||
reconnectIfNeeded();
|
||||
});
|
||||
|
||||
window.addEventListener("online", function() {
|
||||
reconnectIfNeeded();
|
||||
window.addEventListener("online", function () {
|
||||
reconnectIfNeeded();
|
||||
});
|
||||
|
||||
// --- Blokowanie checkboxów na czas reconnect ---
|
||||
function disableCheckboxes(disable) {
|
||||
document.querySelectorAll('#items input[type="checkbox"]').forEach(cb => {
|
||||
cb.disabled = disable;
|
||||
});
|
||||
document.querySelectorAll('#items input[type="checkbox"]').forEach(cb => {
|
||||
cb.disabled = disable;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Toasty przy rozłączeniu i połączeniu ---
|
||||
let firstConnect = true;
|
||||
let wasReconnected = false; // flaga do kontrolowania toasta
|
||||
|
||||
socket.on('connect', function() {
|
||||
if (!firstConnect) {
|
||||
//showToast('Połączono z serwerem!', 'info');
|
||||
disableCheckboxes(true);
|
||||
wasReconnected = true;
|
||||
socket.on('connect', function () {
|
||||
if (!firstConnect) {
|
||||
//showToast('Połączono z serwerem!', 'info');
|
||||
disableCheckboxes(true);
|
||||
wasReconnected = true;
|
||||
|
||||
if (window.LIST_ID && window.usernameForReconnect) {
|
||||
socket.emit('join_list', { room: window.LIST_ID, username: window.usernameForReconnect });
|
||||
}
|
||||
if (window.LIST_ID && window.usernameForReconnect) {
|
||||
socket.emit('join_list', { room: window.LIST_ID, username: window.usernameForReconnect });
|
||||
}
|
||||
firstConnect = false;
|
||||
}
|
||||
firstConnect = false;
|
||||
});
|
||||
|
||||
socket.on('disconnect', function(reason) {
|
||||
showToast('Utracono połączenie z serwerem...', 'warning');
|
||||
disableCheckboxes(true);
|
||||
socket.on('disconnect', function (reason) {
|
||||
//showToast('Utracono połączenie z serwerem...', 'warning');
|
||||
disableCheckboxes(true);
|
||||
});
|
||||
|
||||
socket.off('joined_confirmation');
|
||||
socket.on('joined_confirmation', function(data) {
|
||||
if (wasReconnected) {
|
||||
showToast(`Lista: ${data.list_title} – ponownie dołączono.`, 'info');
|
||||
wasReconnected = false;
|
||||
}
|
||||
if (window.LIST_ID) {
|
||||
socket.emit('request_full_list', { list_id: window.LIST_ID });
|
||||
}
|
||||
socket.on('joined_confirmation', function (data) {
|
||||
if (wasReconnected) {
|
||||
showToast(`Lista: ${data.list_title} – ponownie dołączono.`, 'info');
|
||||
wasReconnected = false;
|
||||
}
|
||||
if (window.LIST_ID) {
|
||||
socket.emit('request_full_list', { list_id: window.LIST_ID });
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
socket.on('user_joined', function(data) {
|
||||
showToast(`${data.username} dołączył do listy`, 'info');
|
||||
socket.on('user_joined', function (data) {
|
||||
showToast(`${data.username} dołączył do listy`, 'info');
|
||||
});
|
||||
|
||||
socket.on('user_left', function(data) {
|
||||
showToast(`${data.username} opuścił listę`, 'warning');
|
||||
socket.on('user_left', function (data) {
|
||||
showToast(`${data.username} opuścił listę`, 'warning');
|
||||
});
|
||||
|
||||
socket.on('user_list', function(data) {
|
||||
if (data.users.length > 0) {
|
||||
const userList = data.users.join(', ');
|
||||
showToast(`Obecni: ${userList}`, 'info');
|
||||
}
|
||||
socket.on('user_list', function (data) {
|
||||
if (data.users.length > 0) {
|
||||
const userList = data.users.join(', ');
|
||||
showToast(`Obecni: ${userList}`, 'info');
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('receipt_added', function (data) {
|
||||
|
||||
|
||||
const gallery = document.getElementById("receiptGallery");
|
||||
if (!gallery) return;
|
||||
|
||||
@@ -103,6 +103,20 @@ socket.on('receipt_added', function (data) {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("items_reordered", data => {
|
||||
if (data.list_id !== window.LIST_ID) return;
|
||||
|
||||
if (window.currentItems) {
|
||||
window.currentItems = data.order.map(id =>
|
||||
window.currentItems.find(item => item.id === id)
|
||||
).filter(Boolean);
|
||||
|
||||
updateListSmoothly(window.currentItems);
|
||||
//showToast('Kolejność produktów zaktualizowana', 'info');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
socket.on('full_list', function (data) {
|
||||
const itemsContainer = document.getElementById('items');
|
||||
|
||||
@@ -112,6 +126,7 @@ socket.on('full_list', function (data) {
|
||||
|
||||
const isDifferent = isListDifferent(oldItems, data.items);
|
||||
|
||||
window.currentItems = data.items;
|
||||
updateListSmoothly(data.items);
|
||||
toggleEmptyPlaceholder();
|
||||
|
||||
@@ -119,4 +134,12 @@ socket.on('full_list', function (data) {
|
||||
showToast('Lista została zaktualizowana', 'info');
|
||||
}
|
||||
didReceiveFirstFullList = true;
|
||||
});
|
||||
|
||||
socket.on('item_marked_not_purchased', data => {
|
||||
socket.emit('request_full_list', { list_id: window.LIST_ID });
|
||||
});
|
||||
|
||||
socket.on('item_unmarked_not_purchased', data => {
|
||||
socket.emit('request_full_list', { list_id: window.LIST_ID });
|
||||
});
|
83
static/js/sort_mode.js
Normal file
83
static/js/sort_mode.js
Normal file
@@ -0,0 +1,83 @@
|
||||
let sortable = null;
|
||||
let isSorting = false;
|
||||
|
||||
function enableSortMode() {
|
||||
if (sortable || isSorting) return;
|
||||
isSorting = true;
|
||||
localStorage.setItem('sortModeEnabled', 'true');
|
||||
|
||||
const itemsContainer = document.getElementById('items');
|
||||
const listId = window.LIST_ID;
|
||||
|
||||
if (!itemsContainer || !listId) return;
|
||||
|
||||
sortable = Sortable.create(itemsContainer, {
|
||||
animation: 150,
|
||||
handle: '.drag-handle',
|
||||
ghostClass: 'drag-ghost',
|
||||
filter: 'input, button',
|
||||
preventOnFilter: false,
|
||||
onEnd: function () {
|
||||
const order = Array.from(itemsContainer.children)
|
||||
.map(li => parseInt(li.id.replace('item-', '')))
|
||||
.filter(id => !isNaN(id));
|
||||
|
||||
fetch('/reorder_items', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ list_id: listId, order })
|
||||
}).then(() => {
|
||||
showToast('Zapisano nową kolejność', 'success');
|
||||
|
||||
if (window.currentItems) {
|
||||
window.currentItems = order.map(id =>
|
||||
window.currentItems.find(item => item.id === id)
|
||||
);
|
||||
updateListSmoothly(window.currentItems);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const btn = document.getElementById('sort-toggle-btn');
|
||||
if (btn) {
|
||||
btn.textContent = '✔️ Zakończ sortowanie';
|
||||
btn.classList.remove('btn-outline-warning');
|
||||
btn.classList.add('btn-outline-success');
|
||||
}
|
||||
|
||||
if (window.currentItems) {
|
||||
updateListSmoothly(window.currentItems);
|
||||
}
|
||||
}
|
||||
|
||||
function disableSortMode() {
|
||||
if (sortable) {
|
||||
sortable.destroy();
|
||||
sortable = null;
|
||||
}
|
||||
isSorting = false;
|
||||
localStorage.removeItem('sortModeEnabled');
|
||||
|
||||
const btn = document.getElementById('sort-toggle-btn');
|
||||
if (btn) {
|
||||
btn.textContent = '✳️ Zmień kolejność';
|
||||
btn.classList.remove('btn-outline-success');
|
||||
btn.classList.add('btn-outline-warning');
|
||||
}
|
||||
|
||||
if (window.currentItems) {
|
||||
updateListSmoothly(window.currentItems);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSortMode() {
|
||||
isSorting ? disableSortMode() : enableSortMode();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const wasSorting = localStorage.getItem('sortModeEnabled') === 'true';
|
||||
if (wasSorting) {
|
||||
enableSortMode();
|
||||
}
|
||||
});
|
@@ -1,4 +1,4 @@
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const toggleBtn = document.getElementById("tempToggle");
|
||||
const hiddenInput = document.getElementById("temporaryHidden");
|
||||
|
||||
@@ -23,7 +23,7 @@ document.addEventListener("DOMContentLoaded", function() {
|
||||
updateToggle(active);
|
||||
|
||||
// Obsługa kliknięcia
|
||||
toggleBtn.addEventListener("click", function() {
|
||||
toggleBtn.addEventListener("click", function () {
|
||||
active = !active;
|
||||
toggleBtn.setAttribute("data-active", active ? "1" : "0");
|
||||
hiddenInput.value = active ? "1" : "0";
|
||||
|
90
static/js/user_expenses.js
Normal file
90
static/js/user_expenses.js
Normal file
@@ -0,0 +1,90 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
let expensesChart = null;
|
||||
const rangeLabel = document.getElementById("chartRangeLabel");
|
||||
|
||||
function loadExpenses(range = "monthly", startDate = null, endDate = null) {
|
||||
let url = '/user/expenses_data?range=' + range;
|
||||
if (startDate && endDate) {
|
||||
url += `&start_date=${startDate}&end_date=${endDate}`;
|
||||
}
|
||||
|
||||
fetch(url, { cache: "no-store" })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const ctx = document.getElementById('expensesChart').getContext('2d');
|
||||
|
||||
if (expensesChart) {
|
||||
expensesChart.destroy();
|
||||
}
|
||||
|
||||
expensesChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [{
|
||||
label: 'Suma wydatków [PLN]',
|
||||
data: data.expenses,
|
||||
backgroundColor: '#0d6efd'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (startDate && endDate) {
|
||||
rangeLabel.textContent = `Widok: własny zakres (${startDate} → ${endDate})`;
|
||||
} else {
|
||||
let labelText = "";
|
||||
if (range === "monthly") labelText = "Widok: miesięczne";
|
||||
else if (range === "quarterly") labelText = "Widok: kwartalne";
|
||||
else if (range === "halfyearly") labelText = "Widok: półroczne";
|
||||
else if (range === "yearly") labelText = "Widok: roczne";
|
||||
rangeLabel.textContent = labelText;
|
||||
}
|
||||
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Błąd pobierania danych:", error);
|
||||
});
|
||||
}
|
||||
|
||||
// Inicjalizacja zakresu dat
|
||||
const startDateInput = document.getElementById("startDate");
|
||||
const endDateInput = document.getElementById("endDate");
|
||||
const today = new Date();
|
||||
const lastWeek = new Date(today);
|
||||
lastWeek.setDate(today.getDate() - 7);
|
||||
const formatDate = (d) => d.toISOString().split('T')[0];
|
||||
startDateInput.value = formatDate(lastWeek);
|
||||
endDateInput.value = formatDate(today);
|
||||
|
||||
// Załaduj początkowy widok
|
||||
loadExpenses();
|
||||
|
||||
// Przycisk własnego zakresu
|
||||
document.getElementById('customRangeBtn').addEventListener('click', function () {
|
||||
const startDate = startDateInput.value;
|
||||
const endDate = endDateInput.value;
|
||||
if (startDate && endDate) {
|
||||
document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
|
||||
loadExpenses('custom', startDate, endDate);
|
||||
} else {
|
||||
alert("Proszę wybrać obie daty!");
|
||||
}
|
||||
});
|
||||
|
||||
// Zakresy predefiniowane
|
||||
document.querySelectorAll('.range-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function () {
|
||||
document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
const range = this.getAttribute('data-range');
|
||||
loadExpenses(range);
|
||||
});
|
||||
});
|
||||
});
|
@@ -1,4 +1,4 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var resetPasswordModal = document.getElementById('resetPasswordModal');
|
||||
resetPasswordModal.addEventListener('show.bs.modal', function (event) {
|
||||
var button = event.relatedTarget;
|
||||
|
9
static/lib/css/cropper.min.css
vendored
Normal file
9
static/lib/css/cropper.min.css
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/*!
|
||||
* Cropper.js v1.6.2
|
||||
* https://fengyuanchen.github.io/cropperjs
|
||||
*
|
||||
* Copyright 2015-present Chen Fengyuan
|
||||
* Released under the MIT license
|
||||
*
|
||||
* Date: 2024-04-21T07:43:02.731Z
|
||||
*/.cropper-container{-webkit-touch-callout:none;direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed}
|
2
static/lib/js/Sortable.min.js
vendored
Normal file
2
static/lib/js/Sortable.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10
static/lib/js/cropper.min.js
vendored
Normal file
10
static/lib/js/cropper.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -10,7 +10,8 @@
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark rounded mb-4">
|
||||
<div class="container-fluid p-0">
|
||||
<a class="navbar-brand" href="#">Funkcje:</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#adminNavbar" aria-controls="adminNavbar" aria-expanded="false" aria-label="Przełącz nawigację">
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#adminNavbar"
|
||||
aria-controls="adminNavbar" aria-expanded="false" aria-label="Przełącz nawigację">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
@@ -20,18 +21,10 @@
|
||||
<a class="nav-link" href="/admin/users">👥 Zarządzanie użytkownikami</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/receipts">📸 Paragony</a>
|
||||
<a class="nav-link" href="/admin/receipts/all">📸 Wszystkie paragony</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/products">🛍️ Produkty</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle text-danger" href="#" id="clearDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
🗑️ Czyszczenie
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item text-danger" href="/admin/delete_all_items">Usuń wszystkie produkty</a></li>
|
||||
</ul>
|
||||
<a class="nav-link" href="/admin/products">🛍️ Produkty i sugestie</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -57,7 +50,7 @@
|
||||
<h5>🔥 Najczęściej kupowane produkty:</h5>
|
||||
<ul class="mb-0">
|
||||
{% for name, count in top_products %}
|
||||
<li>{{ name }} — {{ count }}×</li>
|
||||
<li>{{ name }} — {{ count }}×</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -74,133 +67,140 @@
|
||||
<li><strong>Obecny rok:</strong> {{ '%.2f'|format(year_expense_sum) }} PLN</li>
|
||||
<li><strong>Całkowite:</strong> {{ '%.2f'|format(total_expense_sum) }} PLN</li>
|
||||
</ul>
|
||||
<button type="button" class="btn btn-outline-primary w-100 mt-3" data-bs-toggle="modal" data-bs-target="#expensesChartModal" id="loadExpensesBtn">
|
||||
<button type="button" class="btn btn-outline-primary w-100 mt-3" data-bs-toggle="modal"
|
||||
data-bs-target="#expensesChartModal" id="loadExpensesBtn">
|
||||
📊 Pokaż wykres wydatków
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<h3 class="mt-4">📄 Wszystkie listy zakupowe</h3>
|
||||
<form method="post" action="{{ url_for('delete_selected_lists') }}">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" id="select-all"></th>
|
||||
<th>ID</th>
|
||||
<th>Tytuł</th>
|
||||
<th>Status</th>
|
||||
<th>Utworzono</th>
|
||||
<th>Właściciel</th>
|
||||
<th>Produkty</th>
|
||||
<th>Wypełnienie</th>
|
||||
<th>Komentarze</th>
|
||||
<th>Paragony</th>
|
||||
<th>Wydatki</th>
|
||||
<th>Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in enriched_lists %}
|
||||
{% set l = e.list %}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="list_ids" value="{{ l.id }}"></td>
|
||||
<td>{{ l.id }}</td>
|
||||
<td class="fw-bold">
|
||||
<a href="{{ url_for('view_list', list_id=l.id) }}" class="text-white">{{ l.title }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if l.is_archived %}
|
||||
<span class="badge bg-secondary">Archiwalna</span>
|
||||
{% elif e.expired %}
|
||||
<span class="badge bg-warning text-dark">Wygasła</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">Aktywna</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }}</td>
|
||||
<td>
|
||||
{% if l.owner_id %}
|
||||
{{ l.owner_id }} / {{ l.owner.username if l.owner else 'Brak użytkownika' }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ e.total_count }}</td>
|
||||
<td>{{ e.purchased_count }}/{{ e.total_count }} ({{ e.percent }}%)</td>
|
||||
<td>{{ e.comments_count }}</td>
|
||||
<td>{{ e.receipts_count }}</td>
|
||||
<td>
|
||||
{% if e.total_expense > 0 %}
|
||||
{{ '%.2f'|format(e.total_expense) }} PLN
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="d-flex flex-wrap gap-1">
|
||||
<a href="{{ url_for('edit_list', list_id=l.id) }}" class="btn btn-sm btn-outline-primary">✏️
|
||||
Edytuj</a>
|
||||
<a href="{{ url_for('delete_list', list_id=l.id) }}" class="btn btn-sm btn-outline-danger">🗑️
|
||||
Usuń</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
<h3 class="mt-4">📄 Wszystkie listy zakupowe</h3>
|
||||
<form method="post" action="{{ url_for('delete_selected_lists') }}">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" id="select-all"></th>
|
||||
<th>ID</th>
|
||||
<th>Tytuł</th>
|
||||
<th>Status</th>
|
||||
<th>Utworzono</th>
|
||||
<th>Właściciel</th>
|
||||
<th>Produkty</th>
|
||||
<th>Wypełnienie</th>
|
||||
<th>Komentarze</th>
|
||||
<th>Paragony</th>
|
||||
<th>Wydatki</th>
|
||||
<th>Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in enriched_lists %}
|
||||
{% set l = e.list %}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="list_ids" value="{{ l.id }}"></td>
|
||||
<td>{{ l.id }}</td>
|
||||
<td class="fw-bold">
|
||||
<a href="{{ url_for('view_list', list_id=l.id) }}" class="text-white">{{ l.title }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if l.is_archived %}
|
||||
<span class="badge bg-secondary">Archiwalna</span>
|
||||
{% elif l.is_temporary and l.expires_at and l.expires_at < now %}
|
||||
<span class="badge bg-warning text-dark">Wygasła</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">Aktywna</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }}</td>
|
||||
<td>
|
||||
{% if l.owner_id %}
|
||||
{{ l.owner_id }} / {{ l.owner.username if l.owner else 'Brak użytkownika' }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ e.total_count }}</td>
|
||||
<td>{{ e.purchased_count }}/{{ e.total_count }} ({{ e.percent }}%)</td>
|
||||
<td>{{ e.comments_count }}</td>
|
||||
<td>{{ e.receipts_count }}</td>
|
||||
<td>
|
||||
{% if e.total_expense > 0 %}
|
||||
{{ '%.2f'|format(e.total_expense) }} PLN
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="d-flex flex-wrap gap-1">
|
||||
<a href="{{ url_for('edit_list', list_id=l.id) }}" class="btn btn-sm btn-outline-primary">✏️ Edytuj</a>
|
||||
<a href="{{ url_for('archive_list', list_id=l.id) }}" class="btn btn-sm btn-outline-secondary">📥 Archiwizuj</a>
|
||||
<a href="{{ url_for('delete_list', list_id=l.id) }}" class="btn btn-sm btn-outline-danger">🗑️ Usuń</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
</table>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger mt-2">🗑️ Usuń zaznaczone listy</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger mt-2">🗑️ Usuń zaznaczone listy</button>
|
||||
</form>
|
||||
|
||||
<div class="modal fade" id="expensesChartModal" tabindex="-1" aria-labelledby="expensesChartModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content bg-dark text-white rounded">
|
||||
<div class="modal-header border-0">
|
||||
<div>
|
||||
<h5 class="modal-title m-0" id="expensesChartModalLabel">📊 Wydatki</h5>
|
||||
<small id="chartRangeLabel" class="text-muted">Widok: miesięczne</small>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
|
||||
</div>
|
||||
<div class="modal-body pt-0">
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<button class="btn btn-outline-light btn-sm range-btn active" data-range="monthly">📅 Miesięczne</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="quarterly">📊 Kwartalne</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="halfyearly">🗓️ Półroczne</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="yearly">📆 Roczne</button>
|
||||
<div class="modal fade" id="expensesChartModal" tabindex="-1" aria-labelledby="expensesChartModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content bg-dark text-white rounded">
|
||||
<div class="modal-header border-0">
|
||||
<div>
|
||||
<h5 class="modal-title m-0" id="expensesChartModalLabel">📊 Wydatki</h5>
|
||||
<small id="chartRangeLabel" class="text-muted">Widok: miesięczne</small>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
|
||||
</div>
|
||||
<div class="modal-body pt-0">
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<button class="btn btn-outline-light btn-sm range-btn active" data-range="monthly">📅 Miesięczne</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="quarterly">📊 Kwartalne</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="halfyearly">🗓️ Półroczne</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="yearly">📆 Roczne</button>
|
||||
</div>
|
||||
|
||||
<div class="input-group input-group-sm mb-3 w-100" style="max-width: 570px;">
|
||||
<span class="input-group-text bg-secondary text-white border-secondary">Od</span>
|
||||
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="startDate">
|
||||
<span class="input-group-text bg-secondary text-white border-secondary">Do</span>
|
||||
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="endDate">
|
||||
<button class="btn btn-outline-success" id="customRangeBtn">Pokaż dane z zakresu 📅</button>
|
||||
</div>
|
||||
<div class="input-group input-group-sm mb-3 w-100" style="max-width: 570px;">
|
||||
<span class="input-group-text bg-secondary text-white border-secondary">Od</span>
|
||||
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="startDate">
|
||||
<span class="input-group-text bg-secondary text-white border-secondary">Do</span>
|
||||
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="endDate">
|
||||
<button class="btn btn-outline-success" id="customRangeBtn">Pokaż dane z zakresu 📅</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-dark rounded p-2">
|
||||
<canvas id="expensesChart" height="100"></canvas>
|
||||
<div class="bg-dark rounded p-2">
|
||||
<canvas id="expensesChart" height="100"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='chart.js') }}"></script>
|
||||
<script>
|
||||
document.getElementById('select-all').addEventListener('click', function(){
|
||||
const checkboxes = document.querySelectorAll('input[name="list_ids"]');
|
||||
checkboxes.forEach(cb => cb.checked = this.checked);
|
||||
});
|
||||
</script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='expenses.js') }}"></script>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='chart.js') }}"></script>
|
||||
<script>
|
||||
document.getElementById('select-all').addEventListener('click', function () {
|
||||
const checkboxes = document.querySelectorAll('input[name="list_ids"]');
|
||||
checkboxes.forEach(cb => cb.checked = this.checked);
|
||||
});
|
||||
</script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='expenses.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
<div class="info-bar-fixed">
|
||||
Python: {{ python_version.split()[0] }} | {{ system_info }} | RAM app: {{ app_memory }}
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
<div class="info-bar-fixed">
|
||||
Python: {{ python_version.split()[0] }} | {{ system_info }} | RAM app: {{ app_memory }} |
|
||||
DB: {{ db_info.engine|upper }}{% if db_info.version %} v{{ db_info.version[0] }}{% endif %} |
|
||||
Tabele: {{ table_count }} | Rekordy: {{ record_total }} |
|
||||
Uptime: {{ uptime_minutes }} min
|
||||
</div>
|
||||
{% endblock %}
|
@@ -3,43 +3,253 @@
|
||||
{% block content %}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
|
||||
<h2 class="mb-2">✏️ Edytuj listę #{{ list.id }}</h2>
|
||||
<h2 class="mb-2">🛠️ Edytuj listę #{{ list.id }}</h2>
|
||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót</a>
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
<div class="mb-4">
|
||||
<label for="title" class="form-label">Ustaw nazwę</label>
|
||||
<input type="text" class="form-control bg-dark text-white border-secondary rounded" id="title" name="title" value="{{ list.title }}" required>
|
||||
</div>
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">📄 Podstawowe informacje</h4>
|
||||
<form method="post" class="mt-3">
|
||||
<input type="hidden" name="action" value="save">
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="amount" class="form-label">Ustaw kwotę wydatku (PLN)</label>
|
||||
<input type="number" step="0.01" min="0" class="form-control bg-dark text-white border-secondary rounded" id="amount" name="amount" value="{{ '%.2f'|format(total_expense) }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">Nazwa listy</label>
|
||||
<input type="text" class="form-control bg-dark text-white border-secondary rounded" id="title" name="title"
|
||||
value="{{ list.title }}" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="owner_id" class="form-label">Zmień właściciela</label>
|
||||
<select class="form-select bg-dark text-white border-secondary rounded" id="owner_id" name="owner_id">
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if list.owner_id == user.id %}selected{% endif %}>
|
||||
{{ user.username }}
|
||||
</option>
|
||||
<div class="mb-3">
|
||||
<label for="amount" class="form-label">Całkowity wydatek (PLN)</label>
|
||||
<input type="number" step="0.01" min="0" class="form-control bg-dark text-white border-secondary rounded"
|
||||
id="amount" name="amount" value="{{ '%.2f'|format(total_expense) }}">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="owner_id" class="form-label">Właściciel</label>
|
||||
<select class="form-select bg-dark text-white border-secondary" id="owner_id" name="owner_id">
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if list.owner_id==user.id %}selected{% endif %}>{{ user.username }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch mb-3">
|
||||
<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 mb-4">
|
||||
<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 mb-3">
|
||||
<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</label>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="expires_date" class="form-label">Data wygaśnięcia</label>
|
||||
<input type="date" class="form-control bg-dark text-white border-secondary rounded" id="expires_date"
|
||||
name="expires_date" value="{{ list.expires_at.strftime('%Y-%m-%d') if list.expires_at else '' }}">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="expires_time" class="form-label">Godzina wygaśnięcia</label>
|
||||
<input type="time" class="form-control bg-dark text-white border-secondary rounded" id="expires_time"
|
||||
name="expires_time" value="{{ list.expires_at.strftime('%H:%M') if list.expires_at else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Link do udostępnienia</label>
|
||||
<input type="text" class="form-control bg-dark text-white border-secondary rounded" readonly
|
||||
value="{{ request.url_root }}share/{{ list.share_token }}">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success me-2">💾 Zapisz zmiany</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">🛒 Produkty</h4>
|
||||
<form method="post" class="row g-2 mb-3">
|
||||
<input type="hidden" name="action" value="add_item">
|
||||
<div class="col-md-8">
|
||||
<input type="text" class="form-control bg-dark text-white border-secondary rounded" name="item_name"
|
||||
placeholder="Nazwa produktu" required>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<input type="number" class="form-control bg-dark text-white border-secondary rounded" name="quantity" min="1"
|
||||
value="1">
|
||||
</div>
|
||||
<div class="col-md-3 d-grid">
|
||||
<button type="submit" class="btn btn-outline-success">➕ Dodaj</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-bordered align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Nazwa</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Oznaczenie</th>
|
||||
<th scope="col">Usuń</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ item.name }}</strong>
|
||||
<small class="text-small text-success">(x{{ item.quantity }})</small>
|
||||
|
||||
{% if item.note %}
|
||||
<div class="text-info small mt-1">
|
||||
<strong>Notatka:</strong> {{ item.note }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.not_purchased_reason %}
|
||||
<div class="text-warning small mt-1">
|
||||
<strong>Powód:</strong> {{ item.not_purchased_reason }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('edit_list', list_id=list.id) }}" class="mt-2">
|
||||
<input type="hidden" name="action" value="edit_quantity">
|
||||
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||
<div class="input-group input-group-sm ">
|
||||
<input type="number" name="quantity"
|
||||
class="form-control bg-dark text-white border-secondary rounded-left" min="1"
|
||||
value="{{ item.quantity }}">
|
||||
<button type="submit" class="btn btn-outline-light">💾</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
{% if item.purchased %}
|
||||
<span class="badge bg-success">✔️ Kupiony</span>
|
||||
{% elif item.not_purchased %}
|
||||
<span class="badge bg-warning text-dark">⚠️ Nie kupione</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Nieoznaczony</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="{{ url_for('edit_list', list_id=list.id) }}" class="d-grid gap-1">
|
||||
<input type="hidden" name="action" value="toggle_purchased">
|
||||
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||
{% if not item.not_purchased %}
|
||||
<form method="post" action="{{ url_for('edit_list', list_id=list.id) }}" class="d-grid gap-1">
|
||||
<input type="hidden" name="action" value="toggle_purchased">
|
||||
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||
{% if item.purchased %}
|
||||
<button type="submit" class="btn btn-outline-warning btn-sm">🚫 Odznacz</button>
|
||||
{% else %}
|
||||
<button type="submit" class="btn btn-outline-success btn-sm">✅ Oznacz</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
<form method="post" action="{{ url_for('edit_list', list_id=list.id) }}" class="d-grid gap-1 mt-1">
|
||||
<input type="hidden" name="action" value="mark_not_purchased">
|
||||
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||
<button type="submit" class="btn btn-outline-warning btn-sm">⚠️ Nie kupione</button>
|
||||
</form>
|
||||
|
||||
{% if item.not_purchased %}
|
||||
<form method="post" action="{{ url_for('edit_list', list_id=list.id) }}"
|
||||
class="d-grid gap-1 mt-3 border-top pt-2">
|
||||
<input type="hidden" name="action" value="unmark_not_purchased">
|
||||
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||
<button type="submit" class="btn btn-outline-success btn-sm">✅ Przywróć na liste</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<form method="post" action="{{ url_for('edit_list', list_id=list.id) }}" class="d-inline">
|
||||
<input type="hidden" name="action" value="delete_item">
|
||||
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||
<button type="submit" class="btn btn-danger btn-sm w-100">🗑️ Usuń</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted">Brak produktów.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">🧾 Paragony</h4>
|
||||
|
||||
<div class="mb-3 text-end">
|
||||
<a href="{{ url_for('admin_receipts', id=list.id) }}" class="btn btn-sm btn-outline-light">
|
||||
📂 Otwórz widok pełny dla tej listy
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
{% for r in receipts %}
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<div class="card bg-dark text-white h-100">
|
||||
<a href="{{ url_for('uploaded_file', filename=r.filename) }}" class="glightbox" data-gallery="receipts"
|
||||
data-title="{{ r.filename }}">
|
||||
<img src="{{ url_for('uploaded_file', filename=r.filename) }}" class="card-img-top"
|
||||
style="object-fit: cover; height: 200px;">
|
||||
</a>
|
||||
<div class="card-body text-center">
|
||||
<p class="small text-truncate mb-1">{{ r.filename }}</p>
|
||||
<p class="small mb-1">Wgrano: {{ r.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</p>
|
||||
|
||||
{% if r.filesize and r.filesize >= 1024 * 1024 %}
|
||||
<p class="small mb-1">Rozmiar: {{ (r.filesize / 1024 / 1024) | round(2) }} MB</p>
|
||||
{% elif r.filesize %}
|
||||
<p class="small mb-1">Rozmiar: {{ (r.filesize / 1024) | round(1) }} kB</p>
|
||||
{% else %}
|
||||
<p class="small mb-1 text-muted">Brak danych o rozmiarze</p>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ url_for('rotate_receipt', receipt_id=r.id) }}"
|
||||
class="btn btn-sm btn-outline-warning w-100 mb-2">🔄 Obróć o 90°</a>
|
||||
<a href="{{ url_for('rename_receipt', receipt_id=r.id) }}" class="btn btn-sm btn-outline-info w-100 mb-2">✏️
|
||||
Zmień nazwę</a>
|
||||
{% if not r.file_hash %}
|
||||
<a href="{{ url_for('generate_receipt_hash', receipt_id=r.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary w-100 mb-2">🔐 Generuj hash</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('delete_receipt', receipt_id=r.id) }}"
|
||||
class="btn btn-sm btn-outline-danger w-100 mb-2">🗑️ Usuń</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch mb-4">
|
||||
<input class="form-check-input" type="checkbox" id="archived" name="archived" {% if list.is_archived %}checked{% endif %}>
|
||||
<label class="form-check-label" for="archived">
|
||||
Lista archiwalna
|
||||
</label>
|
||||
{% if not receipts %}
|
||||
<div class="alert alert-info text-center mt-3" role="alert">
|
||||
Brak paragonów.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<button type="submit" class="btn btn-success me-2">💾 Zapisz</button>
|
||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-secondary">Anuluj</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
@@ -8,92 +8,100 @@
|
||||
</div>
|
||||
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h4 class="m-0">📦 Produkty (z synchronizacją sugestii)</h4>
|
||||
<span class="badge bg-secondary">{{ items|length }} produktów</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-dark table-striped align-middle m-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Nazwa</th>
|
||||
<th>Dodana przez</th>
|
||||
<th>Sugestia</th>
|
||||
<th>Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td>{{ item.id }}</td>
|
||||
<td class="fw-bold">{{ item.name }}</td>
|
||||
<td>
|
||||
{% if item.added_by %}
|
||||
<div class="card-body">
|
||||
|
||||
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h4 class="m-0">📦 Produkty (z synchronizacją sugestii)</h4>
|
||||
<span class="badge bg-secondary">{{ items|length }} produktów</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-dark table-striped align-middle m-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Nazwa</th>
|
||||
<th>Dodana przez</th>
|
||||
<th>Sugestia</th>
|
||||
<th>Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td>{{ item.id }}</td>
|
||||
<td class="fw-bold">{{ item.name }}</td>
|
||||
<td>
|
||||
{% if item.added_by %}
|
||||
{{ users_dict.get(item.added_by, 'Nieznany') }}
|
||||
{% else %}
|
||||
{% else %}
|
||||
Gość
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% set suggestion = suggestions_dict.get(item.name.lower()) %}
|
||||
{% if suggestion %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% set suggestion = suggestions_dict.get(item.name.lower()) %}
|
||||
{% if suggestion %}
|
||||
✅ Istnieje (ID: {{ suggestion.id }})
|
||||
<button class="btn btn-sm btn-outline-danger ms-1 delete-suggestion-btn" data-suggestion-id="{{ suggestion.id }}">🗑️ Usuń</button>
|
||||
{% else %}
|
||||
<button class="btn btn-sm btn-outline-primary sync-btn" data-item-id="{{ item.id }}">🔄 Synchronizuj</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="/list/{{ item.list_id }}" class="btn btn-sm btn-outline-light mb-1">📄 Zobacz listę</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if items|length == 0 %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted">Brak produktów do wyświetlenia.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
<button class="btn btn-sm btn-outline-danger ms-1 delete-suggestion-btn"
|
||||
data-suggestion-id="{{ suggestion.id }}">🗑️ Usuń</button>
|
||||
{% else %}
|
||||
<button class="btn btn-sm btn-outline-primary sync-btn" data-item-id="{{ item.id }}">🔄
|
||||
Synchronizuj</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="/list/{{ item.list_id }}" class="btn btn-sm btn-outline-light mb-1">📄 Zobacz listę</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if items|length == 0 %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted">Brak produktów do wyświetlenia.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabela z samymi sugestiami -->
|
||||
<div class="card bg-dark text-white">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h4 class="m-0">💡 Wszystkie sugestie (poza powiązanymi)</h4>
|
||||
<span class="badge bg-secondary">{{ suggestions_dict|length }} sugestii</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% set item_names = items | map(attribute='name') | map('lower') | list %}
|
||||
<table class="table table-dark table-striped align-middle m-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Nazwa</th>
|
||||
<th>Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for suggestion in suggestions_dict.values() %}
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h4 class="m-0">💡 Wszystkie sugestie (poza powiązanymi)</h4>
|
||||
<span class="badge bg-secondary">{{ suggestions_dict|length }} sugestii</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% set item_names = items | map(attribute='name') | map('lower') | list %}
|
||||
<table class="table table-dark table-striped align-middle m-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Nazwa</th>
|
||||
<th>Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for suggestion in suggestions_dict.values() %}
|
||||
{% if suggestion.name.lower() not in item_names %}
|
||||
<tr>
|
||||
<td>{{ suggestion.id }}</td>
|
||||
<td class="fw-bold">{{ suggestion.name }}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-danger delete-suggestion-btn" data-suggestion-id="{{ suggestion.id }}">🗑️ Usuń</button>
|
||||
<button class="btn btn-sm btn-outline-danger delete-suggestion-btn"
|
||||
data-suggestion-id="{{ suggestion.id }}">🗑️ Usuń</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if suggestions_dict|length == 0 %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center text-muted">Brak sugestii do wyświetlenia.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endfor %}
|
||||
{% if suggestions_dict|length == 0 %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center text-muted">Brak sugestii do wyświetlenia.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -101,4 +109,4 @@
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='product_suggestion.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
@@ -4,38 +4,87 @@
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
|
||||
<h2 class="mb-2">📸 Wszystkie paragony</h2>
|
||||
<a href="/admin" class="btn btn-outline-secondary">← Powrót do panelu</a>
|
||||
<div>
|
||||
<a href="{{ url_for('recalculate_filesizes') }}" class="btn btn-sm btn-outline-primary me-2">
|
||||
🔄 Przelicz rozmiary plików
|
||||
</a>
|
||||
<a href="/admin" class="btn btn-outline-secondary">← Powrót do panelu</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
{% for img in image_files %}
|
||||
{% set list_id = img.split('_')[1] if '_' in img else None %}
|
||||
{% set file_path = (upload_folder ~ '/' ~ img) %}
|
||||
{% set file_size = (file_path | filesizeformat) %}
|
||||
{% set upload_time = (file_path | filemtime) %}
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<div class="card bg-dark text-white h-100">
|
||||
<a href="{{ url_for('uploaded_file', filename=img) }}" data-lightbox="receipts" data-title="{{ img }}">
|
||||
<img src="{{ url_for('uploaded_file', filename=img) }}" class="card-img-top" style="object-fit: cover; height: 200px;">
|
||||
</a>
|
||||
<div class="card-body text-center">
|
||||
<p class="small text-truncate mb-1">{{ img }}</p>
|
||||
<p class="small mb-1">Rozmiar: {{ file_size }}</p>
|
||||
<p class="small mb-1">Wgrano: {{ upload_time.strftime('%Y-%m-%d %H:%M') }}</p>
|
||||
{% if list_id %}
|
||||
<a href="{{ url_for('view_list', list_id=list_id|int) }}" class="btn btn-sm btn-outline-light w-100 mb-2">🔗 Lista #{{ list_id }}</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('delete_receipt', filename=img) }}" class="btn btn-sm btn-outline-danger w-100">🗑️ Usuń</a>
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
{% for r in receipts %}
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<div class="card bg-dark text-white h-100">
|
||||
<a href="{{ url_for('uploaded_file', filename=r.filename) }}" class="glightbox" data-gallery="receipts"
|
||||
data-title="{{ r.filename }}">
|
||||
<img src="{{ url_for('uploaded_file', filename=r.filename) }}" class="card-img-top"
|
||||
style="object-fit: cover; height: 200px;">
|
||||
</a>
|
||||
<div class="card-body text-center">
|
||||
<p class="small text-truncate mb-1">{{ r.filename }}</p>
|
||||
<p class="small mb-1">Wgrano: {{ r.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</p>
|
||||
{% if r.filesize and r.filesize >= 1024 * 1024 %}
|
||||
<p class="small mb-1">Rozmiar: {{ (r.filesize / 1024 / 1024) | round(2) }} MB</p>
|
||||
{% elif r.filesize %}
|
||||
<p class="small mb-1">Rozmiar: {{ (r.filesize / 1024) | round(1) }} kB</p>
|
||||
{% else %}
|
||||
<p class="small mb-1 text-muted">Brak danych o rozmiarze</p>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('rotate_receipt', receipt_id=r.id) }}"
|
||||
class="btn btn-sm btn-outline-warning w-100 mb-2">🔄 Obróć o 90°</a>
|
||||
<a href="#" class="btn btn-sm btn-outline-secondary w-100 mb-2" data-bs-toggle="modal"
|
||||
data-bs-target="#cropModal" data-img-src="{{ url_for('uploaded_file', filename=r.filename) }}"
|
||||
data-receipt-id="{{ r.id }}">✂️ Przytnij</a>
|
||||
<a href="{{ url_for('rename_receipt', receipt_id=r.id) }}" class="btn btn-sm btn-outline-info w-100 mb-2">✏️
|
||||
Zmień nazwę</a>
|
||||
{% if not r.file_hash %}
|
||||
<a href="{{ url_for('generate_receipt_hash', receipt_id=r.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary w-100 mb-2">🔐 Generuj hash</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('delete_receipt', receipt_id=r.id) }}"
|
||||
class="btn btn-sm btn-outline-danger w-100 mb-2">🗑️
|
||||
Usuń</a>
|
||||
<a href="{{ url_for('edit_list', list_id=r.list_id) }}" class="btn btn-sm btn-outline-light w-100 mb-2">✏️
|
||||
Edytuj listę #{{ r.list_id }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if not receipts %}
|
||||
<div class="alert alert-info text-center mt-4" role="alert">
|
||||
Nie wgrano żadnych paragonów.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not image_files %}
|
||||
<div class="alert alert-info text-center" role="alert">
|
||||
Nie wgrano paragonów.
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="modal fade" id="cropModal" tabindex="-1" aria-labelledby="cropModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content bg-dark text-white">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">✂️ Przycinanie paragonu</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div style="position: relative; width: 100%; height: 75vh;">
|
||||
<img id="cropImage" style="max-width: 100%; max-height: 100%; display: block; margin: auto;">
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" data-bs-dismiss="modal">Anuluj</button>
|
||||
<button class="btn btn-success" id="saveCrop">Zapisz</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% endblock %}
|
@@ -14,10 +14,12 @@
|
||||
<form method="post" action="{{ url_for('add_user') }}">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<input type="text" name="username" class="form-control bg-dark text-white border-secondary rounded" placeholder="Nazwa użytkownika" required>
|
||||
<input type="text" name="username" class="form-control bg-dark text-white border-secondary rounded"
|
||||
placeholder="Nazwa użytkownika" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="password" name="password" class="form-control bg-dark text-white border-secondary rounded" placeholder="Hasło" required>
|
||||
<input type="password" name="password" class="form-control bg-dark text-white border-secondary rounded"
|
||||
placeholder="Hasło" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button type="submit" class="btn btn-outline-success w-100">Dodaj użytkownika</button>
|
||||
@@ -27,50 +29,50 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table table-dark table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Login</th>
|
||||
<th>Rola</th>
|
||||
<th>Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{{ user.id }}</td>
|
||||
<td class="fw-bold">{{ user.username }}</td>
|
||||
<td>
|
||||
{% if user.is_admin %}
|
||||
<span class="badge bg-primary">Admin</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Użytkownik</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
class="btn btn-sm btn-outline-warning me-1"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#resetPasswordModal"
|
||||
data-user-id="{{ user.id }}"
|
||||
data-username="{{ user.username }}">
|
||||
🔑 Ustaw hasło
|
||||
</button>
|
||||
{% if not user.is_admin %}
|
||||
<a href="/admin/promote_user/{{ user.id }}" class="btn btn-sm btn-outline-info">⬆️ Ustaw admina</a>
|
||||
{% else %}
|
||||
<a href="/admin/demote_user/{{ user.id }}" class="btn btn-sm btn-outline-secondary">⬇️ Usuń admina</a>
|
||||
{% endif %}
|
||||
<a href="/admin/delete_user/{{ user.id }}" class="btn btn-sm btn-outline-danger me-1">🗑️ Usuń</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Login</th>
|
||||
<th>Rola</th>
|
||||
<th>Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{{ user.id }}</td>
|
||||
<td class="fw-bold">{{ user.username }}</td>
|
||||
<td>
|
||||
{% if user.is_admin %}
|
||||
<span class="badge bg-primary">Admin</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Użytkownik</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-warning me-1" data-bs-toggle="modal"
|
||||
data-bs-target="#resetPasswordModal" data-user-id="{{ user.id }}" data-username="{{ user.username }}">
|
||||
🔑 Ustaw hasło
|
||||
</button>
|
||||
{% if not user.is_admin %}
|
||||
<a href="/admin/promote_user/{{ user.id }}" class="btn btn-sm btn-outline-info">⬆️ Ustaw admina</a>
|
||||
{% else %}
|
||||
<a href="/admin/demote_user/{{ user.id }}" class="btn btn-sm btn-outline-secondary">⬇️ Usuń admina</a>
|
||||
{% endif %}
|
||||
<a href="/admin/delete_user/{{ user.id }}" class="btn btn-sm btn-outline-danger me-1">🗑️ Usuń</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Modal resetowania hasła -->
|
||||
<div class="modal fade" id="resetPasswordModal" tabindex="-1" aria-labelledby="resetPasswordModalLabel" aria-hidden="true">
|
||||
<div class="modal fade" id="resetPasswordModal" tabindex="-1" aria-labelledby="resetPasswordModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content bg-dark text-white">
|
||||
<form method="post" id="resetPasswordForm">
|
||||
|
@@ -1,5 +1,6 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pl">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -10,85 +11,101 @@
|
||||
<link href="{{ url_for('static_bp.serve_css_lib', filename='glightbox.min.css') }}" rel="stylesheet">
|
||||
{% endif %}
|
||||
<link href="{{ url_for('static_bp.serve_css_lib', filename='bootstrap.min.css') }}" rel="stylesheet">
|
||||
{% if '/admin/' in request.path %}
|
||||
<link href="{{ url_for('static_bp.serve_css_lib', filename='cropper.min.css') }}" rel="stylesheet">
|
||||
{% endif %}
|
||||
</head>
|
||||
|
||||
<body class="bg-dark text-white">
|
||||
|
||||
<nav class="navbar navbar-dark bg-dark mb-3">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand fw-bold fs-4 text-success" href="/">
|
||||
🛒 <span class="text-warning">Lista</span> Zakupów
|
||||
</a>
|
||||
<nav class="navbar navbar-dark bg-dark mb-3">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand fw-bold fs-4 text-success" href="/">
|
||||
🛒 <span class="text-warning">Lista</span> Zakupów
|
||||
</a>
|
||||
|
||||
{% if has_authorized_cookie and not is_blocked %}
|
||||
{% if has_authorized_cookie and not is_blocked %}
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="d-flex justify-content-center align-items-center text-white small flex-wrap text-center">
|
||||
<span class="me-1">Zalogowany:</span>
|
||||
<span class="badge bg-success">{{ current_user.username }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-center align-items-center text-white small flex-wrap text-center">
|
||||
<span class="me-1">Zalogowany:</span>
|
||||
<span class="badge bg-success">{{ current_user.username }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="d-flex justify-content-center align-items-center text-white small flex-wrap text-center">
|
||||
<span class="me-1">Przeglądasz jako</span>
|
||||
<span class="badge bg-info">gość</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-center align-items-center text-white small flex-wrap text-center">
|
||||
<span class="me-1">Przeglądasz jako</span>
|
||||
<span class="badge bg-info">gość</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if not is_blocked %}
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
{% if request.endpoint and request.endpoint != 'system_auth' %}
|
||||
{% if current_user.is_authenticated and current_user.is_admin %}
|
||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-warning btn-sm">⚙️ Panel admina</a>
|
||||
{% endif %}
|
||||
{% if not is_blocked and request.endpoint and request.endpoint != 'system_auth' %}
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="{{ url_for('logout') }}" class="btn btn-outline-light btn-sm">🚪 Wyloguj</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('login') }}" class="btn btn-outline-light btn-sm">🔑 Zaloguj</a>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-light btn-sm">⚙️</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('user_expenses') }}" class="btn btn-outline-light btn-sm">📊</a>
|
||||
<a href="{{ url_for('logout') }}" class="btn btn-outline-light btn-sm">🚪</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('login') }}" class="btn btn-outline-light btn-sm">🔑 Zaloguj</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
<div class="container px-2">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container px-2">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<div id="toast-container" class="toast-container position-fixed bottom-0 end-0 p-3"></div>
|
||||
|
||||
<div id="toast-container" class="toast-container position-fixed bottom-0 end-0 p-3"></div>
|
||||
<footer class="text-center text-secondary small mt-5 mb-3">
|
||||
<hr class="text-secondary">
|
||||
<p class="mb-0">© 2025 <strong>linuxiarz.pl</strong> · <a href="https://gitea.linuxiarz.pl/gru/lista_zakupowa_live"
|
||||
target="_blank" class="link-success text-decoration-none"> source code</a>
|
||||
</footer>
|
||||
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='bootstrap.bundle.min.js') }}"></script>
|
||||
{% if not is_blocked %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='bootstrap.bundle.min.js') }}"></script>
|
||||
{% if not is_blocked %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
{% 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 %}
|
||||
{% 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>
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='glightbox.min.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='socket.io.min.js') }}"></script>
|
||||
{% if request.endpoint != 'system_auth' %}
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='functions.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='live.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='sockets.js') }}"></script>
|
||||
{% endif %}
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='toasts.js') }}"></script>
|
||||
<script>
|
||||
let lightbox = GLightbox({
|
||||
selector: '.glightbox'
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
</script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
{% if request.endpoint != 'system_auth' %}
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='glightbox.min.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='socket.io.min.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='functions.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='live.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='sockets.js') }}"></script>
|
||||
{% endif %}
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='toasts.js') }}"></script>
|
||||
<script>
|
||||
let lightbox = GLightbox({
|
||||
selector: '.glightbox'
|
||||
});
|
||||
</script>
|
||||
|
||||
{% if '/admin/' in request.path %}
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='cropper.min.js') }}"></script>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
@@ -1,14 +1,129 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Edytuj listę: <strong>{{ list.title }}</strong></h2>
|
||||
|
||||
<h2>Edytuj listę: {{ list.title }}</h2>
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">Ustaw nazwe</label>
|
||||
<input type="text" name="title" id="title" class="form-control" value="{{ list.title }}" required>
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">Nazwa listy</label>
|
||||
<input type="text" name="title" id="title" class="form-control bg-dark text-white border-secondary rounded"
|
||||
value="{{ list.title }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input rounded" type="checkbox" name="is_public" id="is_public" {% if list.is_public
|
||||
%}checked{% endif %}>
|
||||
<label class="form-check-label" for="is_public">Lista publiczna</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input rounded" type="checkbox" name="is_temporary" id="is_temporary" {% if
|
||||
list.is_temporary %}checked{% endif %}>
|
||||
<label class="form-check-label" for="is_temporary">Lista tymczasowa</label>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="expires_date" class="form-label">Data wygaśnięcia</label>
|
||||
<input type="date" class="form-control bg-dark text-white border-secondary rounded" id="expires_date"
|
||||
name="expires_date" value="{{ list.expires_at.strftime('%Y-%m-%d') if list.expires_at else '' }}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="expires_time" class="form-label">Godzina wygaśnięcia</label>
|
||||
<input type="time" class="form-control bg-dark text-white border-secondary rounded" id="expires_time"
|
||||
name="expires_time" value="{{ list.expires_at.strftime('%H:%M') if list.expires_at else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" name="is_archived" id="is_archived" {% if list.is_archived
|
||||
%}checked{% endif %}>
|
||||
<label class="form-check-label" for="is_archived">Zarchiwizowana</label>
|
||||
</div>
|
||||
|
||||
<div class="btn-group mt-4" role="group">
|
||||
<button type="submit" class="btn btn-outline-success">Zapisz</button>
|
||||
<a href="{{ url_for('main_page') }}" class="btn btn-outline-light">Anuluj</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if receipts %}
|
||||
<hr class="my-4">
|
||||
<h5>Paragony przypisane do tej listy</h5>
|
||||
|
||||
<div class="row">
|
||||
{% for r in receipts %}
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<div class="card bg-dark text-white h-100">
|
||||
<a href="{{ url_for('uploaded_file', filename=r.filename) }}" class="glightbox" data-gallery="receipts"
|
||||
data-title="{{ r.filename }}">
|
||||
<img src="{{ url_for('uploaded_file', filename=r.filename) }}" class="card-img-top"
|
||||
style="object-fit: cover; height: 200px;">
|
||||
</a>
|
||||
<div class="card-body text-center">
|
||||
<p class="small text-truncate mb-1">{{ r.filename }}</p>
|
||||
<p class="small mb-1">Wgrano: {{ r.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</p>
|
||||
{% if r.filesize and r.filesize >= 1024 * 1024 %}
|
||||
<p class="small mb-1">Rozmiar: {{ (r.filesize / 1024 / 1024) | round(2) }} MB</p>
|
||||
{% elif r.filesize %}
|
||||
<p class="small mb-1">Rozmiar: {{ (r.filesize / 1024) | round(1) }} kB</p>
|
||||
{% else %}
|
||||
<p class="small mb-1 text-muted">Brak danych o rozmiarze</p>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ url_for('rotate_receipt_user', receipt_id=r.id) }}"
|
||||
class="btn btn-sm btn-outline-warning w-100 mb-2">🔄 Obróć o 90°</a>
|
||||
|
||||
<a href="{{ url_for('delete_receipt_user', receipt_id=r.id) }}" class="btn btn-sm btn-outline-danger w-100"
|
||||
onclick="return confirm('Na pewno usunąć ten paragon?')">🗑️ Usuń</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<hr class="my-3">
|
||||
<!-- Trigger przycisk -->
|
||||
<div class="btn-group mt-4" role="group">
|
||||
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteModal">
|
||||
🗑️ Usuń tę listę
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">Zapisz</button>
|
||||
<a href="{{ url_for('main_page') }}" class="btn btn-secondary">Anuluj</a>
|
||||
</form>
|
||||
</div>
|
||||
<!-- MODAL -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content bg-dark border-danger text-white">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title text-danger" id="deleteModalLabel">Potwierdź usunięcie</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Aby usunąć listę <strong>{{ list.title }}</strong>, wpisz <code>usuń</code> i poczekaj 2 sekundy:</p>
|
||||
<input type="text" id="confirm-delete-input" class="form-control bg-dark text-white border-warning"
|
||||
placeholder="usuń">
|
||||
</div>
|
||||
<div class="modal-footer justify-content-between">
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Anuluj</button>
|
||||
<button id="confirm-delete-btn" class="btn btn-outline-danger" disabled>🗑️ Usuń</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="delete-form" method="post" action="{{ url_for('delete_user_list', list_id=list.id) }}"></form>
|
||||
|
||||
|
||||
<!-- Hidden delete form -->
|
||||
<form id="delete-form" method="post" action="{{ url_for('delete_user_list', list_id=list.id) }}"></form>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='confirm_delete.js') }}"></script>
|
||||
{% endblock %}
|
@@ -13,4 +13,4 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
@@ -3,18 +3,18 @@
|
||||
{% 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 bg-secondary ms-2">(Archiwalna)</span>
|
||||
{% endif %}</h2>
|
||||
<h2 class="mb-2">
|
||||
Lista: <strong>{{ list.title }}</strong>
|
||||
{% if list.is_archived %}
|
||||
<span class="badge bg-secondary ms-2">(Archiwalna)</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
||||
<a href="/" class="btn btn-outline-secondary">← Powrót do list</a>
|
||||
<a href="/" class="btn btn-outline-secondary">← Powrót do list</a>
|
||||
</div>
|
||||
|
||||
<a href="{{ request.url_root }}share/{{ list.share_token }}"
|
||||
class="btn btn-primary btn-sm w-100 mb-3"
|
||||
{% if not list.is_public %}disabled{% endif %}>
|
||||
<a href="{{ request.url_root }}share/{{ list.share_token }}" class="btn btn-primary btn-sm w-100 mb-3" {% if not
|
||||
list.is_public %}disabled{% endif %}>
|
||||
✅ Otwórz tryb zakupowy / odznaczania produktów
|
||||
</a>
|
||||
<div id="share-card" class="card bg-dark text-white mb-4">
|
||||
@@ -22,26 +22,28 @@ Lista: <strong>{{ list.title }}</strong>
|
||||
<div class="mb-2">
|
||||
<strong id="share-header">
|
||||
{% if list.is_public %}
|
||||
🔗 Udostępnij link:
|
||||
🔗 Udostępnij link:
|
||||
{% else %}
|
||||
🙈 Lista jest ukryta przed gośćmi
|
||||
🙈 Lista jest ukryta przed gośćmi
|
||||
{% endif %}
|
||||
</strong>
|
||||
<span id="share-url" class="badge bg-secondary text-wrap" style="font-size: 0.7rem; {% if not list.is_public %}display: none;{% endif %}">
|
||||
<span id="share-url" class="badge bg-secondary text-wrap"
|
||||
style="font-size: 0.7rem; {% if not list.is_public %}display: none;{% endif %}">
|
||||
{{ 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-success btn-sm flex-fill"
|
||||
onclick="copyLink('{{ request.url_root }}share/{{ list.share_token }}')"
|
||||
{% if not list.is_public %}disabled{% endif %}>
|
||||
onclick="copyLink('{{ request.url_root }}share/{{ list.share_token }}')" {% if not list.is_public %}disabled{%
|
||||
endif %}>
|
||||
📋 Skopiuj / Udostępnij
|
||||
</button>
|
||||
<button id="toggleVisibilityBtn" class="btn btn-outline-light btn-sm flex-fill" onclick="toggleVisibility({{ list.id }})">
|
||||
<button id="toggleVisibilityBtn" class="btn btn-outline-light btn-sm flex-fill"
|
||||
onclick="toggleVisibility({{ list.id }})">
|
||||
{% if list.is_public %}
|
||||
🙈 Ukryj listę
|
||||
🙈 Ukryj listę
|
||||
{% else %}
|
||||
👁️ Udostępnij ponownie
|
||||
👁️ Udostępnij ponownie
|
||||
{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
@@ -50,65 +52,101 @@ Lista: <strong>{{ list.title }}</strong>
|
||||
|
||||
<!-- Progress bar (dynamic) -->
|
||||
<h5 id="progress-title" class="mb-2">
|
||||
📊 Postęp listy — {{ purchased_count }}/{{ total_count }} kupionych ({{ percent|round(0) }}%)
|
||||
📊 Postęp listy —
|
||||
<span id="purchased-count">{{ purchased_count }}</span>/
|
||||
<span id="total-count">{{ total_count }}</span> kupionych
|
||||
(<span id="percent-value">{{ percent|round(0) }}</span>%)
|
||||
</h5>
|
||||
|
||||
<div class="progress progress-dark position-relative">
|
||||
{# właściwy pasek postępu #}
|
||||
<div id="progress-bar"
|
||||
class="progress-bar bg-warning text-dark"
|
||||
role="progressbar"
|
||||
style="width: {{ percent }}%;"
|
||||
aria-valuenow="{{ percent }}" aria-valuemin="0" aria-valuemax="100">
|
||||
<div id="progress-bar" class="progress-bar bg-warning text-dark" role="progressbar" style="width: {{ percent }}%;"
|
||||
aria-valuenow="{{ percent }}" aria-valuemin="0" aria-valuemax="100">
|
||||
</div>
|
||||
|
||||
<span class="progress-label small fw-bold
|
||||
<span id="progress-label" class="progress-label small fw-bold
|
||||
{% if percent < 50 %}text-white{% else %}text-dark{% endif %}">
|
||||
{{ percent|round(0) }}%
|
||||
{{ percent|round(0) }}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if total_expense > 0 %}
|
||||
<div id="total-expense2" class="text-success fw-bold mb-3">
|
||||
💸 Łącznie wydano: {{ '%.2f'|format(total_expense) }} PLN
|
||||
</div>
|
||||
<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>
|
||||
<div id="total-expense2" class="text-success fw-bold mb-3">
|
||||
💸 Łącznie wydano: 0.00 PLN
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<ul id="items" class="list-group mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap">
|
||||
<button id="sort-toggle-btn" class="btn btn-sm btn-outline-warning" onclick="toggleSortMode()">
|
||||
✳️ Zmień kolejność
|
||||
</button>
|
||||
<div class="form-check form-switch">
|
||||
<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 d-flex justify-content-between align-items-center flex-wrap {% if item.purchased %}bg-success text-white{% else %}item-not-checked{% endif %}" id="item-{{ item.id }}">
|
||||
<div class="d-flex align-items-center flex-wrap gap-2 flex-grow-1">
|
||||
<input class="large-checkbox" type="checkbox" {% if item.purchased %}checked{% endif %} {% if list.is_archived %}disabled{% endif %}>
|
||||
<span id="name-{{ item.id }}" class="{% if item.purchased %}text-white{% else %}text-white{% endif %}">
|
||||
{{ item.name }}
|
||||
{% if item.quantity and item.quantity > 1 %}
|
||||
<span class="badge bg-secondary">x{{ item.quantity }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if item.note %}
|
||||
<small class="text-danger ms-4">[ <b>{{ item.note }}</b> ]</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mt-2 mt-md-0 d-flex gap-1">
|
||||
<button class="btn btn-sm btn-outline-warning"
|
||||
{% if list.is_archived %}disabled{% else %}onclick="editItem({{ item.id }}, '{{ item.name }}', {{ item.quantity or 1 }})"{% endif %}>
|
||||
✏️
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
{% if list.is_archived %}disabled{% else %}onclick="deleteItem({{ item.id }})"{% endif %}>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li data-name="{{ item.name|lower }}" id="item-{{ item.id }}"
|
||||
class="list-group-item d-flex justify-content-between align-items-center flex-wrap clickable-item
|
||||
{% if item.purchased %}bg-success text-white{% elif item.not_purchased %}bg-warning text-dark{% else %}item-not-checked{% endif %}"
|
||||
data-is-share="{{ 'true' if is_share else 'false' }}">
|
||||
|
||||
<div class="d-flex align-items-center gap-2 flex-grow-1">
|
||||
|
||||
<input id="checkbox-{{ item.id }}" class="large-checkbox" type="checkbox" {% if item.purchased %}checked{% endif
|
||||
%} {% if list.is_archived or item.not_purchased %}disabled{% endif %}>
|
||||
|
||||
<span id="name-{{ item.id }}" class="text-white">
|
||||
{{ item.name }}
|
||||
{% if item.quantity and item.quantity > 1 %}
|
||||
<span class="badge bg-secondary">x{{ item.quantity }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
{% if item.note %}
|
||||
<small class="text-danger ms-4">[ <b>{{ item.note }}</b> ]</small>
|
||||
{% endif %}
|
||||
|
||||
{% if item.not_purchased_reason %}
|
||||
<small class="text-dark ms-4">[ <b>Powód: {{ item.not_purchased_reason }}</b> ]</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
{% if item.not_purchased %}
|
||||
<button type="button" class="btn btn-outline-success" {% if list.is_archived %}disabled{% else
|
||||
%}onclick="unmarkNotPurchased({{ item.id }})" {% endif %}>
|
||||
✅ Przywróć
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else
|
||||
%}onclick="markNotPurchasedModal(event, {{ item.id }})" {% endif %}>
|
||||
⚠️
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if not is_share %}
|
||||
<button type="button" class="btn btn-outline-warning" {% if list.is_archived %}disabled{% else
|
||||
%}onclick="editItem({{ item.id }}, '{{ item.name }}', {{ item.quantity or 1 }})" {% endif %}>
|
||||
✏️
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger" {% if list.is_archived %}disabled{% else
|
||||
%}onclick="deleteItem({{ item.id }})" {% endif %}>
|
||||
🗑️
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
{% else %}
|
||||
<li id="empty-placeholder"
|
||||
class="list-group-item bg-dark text-secondary text-center w-100">
|
||||
Brak produktów w tej liście.
|
||||
</li>
|
||||
<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>
|
||||
|
||||
@@ -121,8 +159,10 @@ Lista: <strong>{{ list.title }}</strong>
|
||||
</div>
|
||||
<div class="col-12 col-md-10">
|
||||
<div class="input-group w-100">
|
||||
<input type="text" id="newItem" name="name" class="form-control bg-dark text-white border-secondary" placeholder="Dodaj produkt i ilość" required>
|
||||
<input type="number" id="newQuantity" name="quantity" class="form-control bg-dark text-white border-secondary" placeholder="Ilość" min="1" value="1" style="max-width: 90px;">
|
||||
<input type="text" id="newItem" name="name" class="form-control bg-dark text-white border-secondary"
|
||||
placeholder="Dodaj produkt i ilość" required>
|
||||
<input type="number" id="newQuantity" name="quantity" class="form-control bg-dark text-white border-secondary"
|
||||
placeholder="Ilość" min="1" value="1" style="max-width: 90px;">
|
||||
<button type="button" class="btn btn-success rounded-end" onclick="addItem({{ list.id }})">➕ Dodaj</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,17 +175,18 @@ Lista: <strong>{{ list.title }}</strong>
|
||||
|
||||
<div class="row g-3 mt-2" id="receiptGallery">
|
||||
{% if receipt_files %}
|
||||
{% for file in receipt_files %}
|
||||
<div class="col-6 col-md-4 col-lg-3 text-center">
|
||||
<a href="{{ url_for('uploaded_file', filename=file) }}" class="glightbox" data-gallery="receipt-gallery">
|
||||
<img src="{{ url_for('uploaded_file', filename=file) }}" class="img-fluid rounded shadow-sm border border-secondary" style="max-height: 200px; object-fit: cover;">
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for file in receipt_files %}
|
||||
<div class="col-6 col-md-4 col-lg-3 text-center">
|
||||
<a href="{{ url_for('uploaded_file', filename=file) }}" class="glightbox" data-gallery="receipt-gallery">
|
||||
<img src="{{ url_for('uploaded_file', filename=file) }}"
|
||||
class="img-fluid rounded shadow-sm border border-secondary" style="max-height: 200px; object-fit: cover;">
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="alert alert-info text-center w-100" role="alert">
|
||||
Brak wgranych paragonów do tej listy.
|
||||
</div>
|
||||
<div class="alert alert-info text-center w-100" role="alert">
|
||||
Brak wgranych paragonów do tej listy.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -169,11 +210,19 @@ Lista: <strong>{{ list.title }}</strong>
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='Sortable.min.js') }}"></script>
|
||||
<script>
|
||||
const isShare = document.getElementById('items').dataset.isShare === 'true';
|
||||
window.IS_SHARE = isShare;
|
||||
window.LIST_ID = {{ list.id }};
|
||||
</script>
|
||||
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='mass_add.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='receipt_upload.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='sort_mode.js') }}"></script>
|
||||
<script>
|
||||
setupList({{ list.id }}, '{{ current_user.username if current_user.is_authenticated else 'Gość' }}');
|
||||
</script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='receipt_upload.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
@@ -4,104 +4,172 @@
|
||||
|
||||
<h2 class="mb-2">
|
||||
🛍️ {{ list.title }}
|
||||
|
||||
{% if list.is_archived %}
|
||||
<span class="badge bg-secondary ms-2">(Archiwalna)</span>
|
||||
<span class="badge bg-secondary ms-2">(Archiwalna)</span>
|
||||
{% endif %}
|
||||
{% if total_expense > 0 %}
|
||||
<span id="total-expense1" class="badge bg-success ms-2">
|
||||
💸 {{ '%.2f'|format(total_expense) }} PLN
|
||||
</span>
|
||||
<span id="total-expense1" class="badge bg-success ms-2">
|
||||
💸 {{ '%.2f'|format(total_expense) }} PLN
|
||||
</span>
|
||||
{% else %}
|
||||
<span id="total-expense" class="badge bg-secondary ms-2" style="display: none;">
|
||||
💸 0.00 PLN
|
||||
</span>
|
||||
<span id="total-expense" class="badge bg-secondary ms-2" style="display: none;">
|
||||
💸 0.00 PLN
|
||||
</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
||||
<ul id="items" class="list-group mb-3">
|
||||
<div class="form-check form-switch mb-3 d-flex justify-content-end">
|
||||
<input class="form-check-input" type="checkbox" id="hidePurchasedToggle">
|
||||
<label class="form-check-label ms-2" for="hidePurchasedToggle">Ukryj zaznaczone</label>
|
||||
</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 d-flex justify-content-between align-items-center flex-wrap clickable-item {% if item.purchased %}bg-success text-white{% else %}item-not-checked{% endif %}" id="item-{{ item.id }}"> <div class="d-flex align-items-center gap-3 flex-grow-1">
|
||||
<input class="large-checkbox" type="checkbox" {% if item.purchased %}checked{% endif %} {% if list.is_archived %}disabled{% endif %}>
|
||||
<span id="name-{{ item.id }}" class="{% if item.purchased %}text-white{% else %}text-white{% endif %}">
|
||||
|
||||
<li data-name="{{ item.name|lower }}" id="item-{{ item.id }}"
|
||||
class="list-group-item d-flex justify-content-between align-items-center flex-wrap clickable-item
|
||||
{% if item.purchased %}bg-success text-white{% elif item.not_purchased %}bg-warning text-dark{% else %}item-not-checked{% endif %}">
|
||||
|
||||
<div class="d-flex align-items-center gap-2 flex-grow-1">
|
||||
|
||||
<input id="checkbox-{{ item.id }}" class="large-checkbox" type="checkbox" {% if item.purchased %}checked{% endif
|
||||
%} {% if list.is_archived or item.not_purchased %}disabled{% endif %}>
|
||||
|
||||
<span id="name-{{ item.id }}" class="text-white">
|
||||
{{ item.name }}
|
||||
{% if item.quantity and item.quantity > 1 %}
|
||||
<span class="badge bg-secondary">x{{ item.quantity }}</span>
|
||||
<span class="badge bg-secondary">x{{ item.quantity }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
{% if item.note %}
|
||||
<small class="text-danger ms-4">[ <b>{{ item.note }}</b> ]</small>
|
||||
<small class="text-danger ms-4">[ <b>{{ item.note }}</b> ]</small>
|
||||
{% endif %}
|
||||
{% if item.not_purchased_reason %}
|
||||
<small class="text-dark ms-4">[ <b>Powód: {{ item.not_purchased_reason }}</b> ]</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-info"
|
||||
{% if list.is_archived %}disabled{% else %}onclick="openNoteModal(event, {{ item.id }})"{% endif %}>
|
||||
📝
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
{% if item.not_purchased %}
|
||||
<button type="button" class="btn btn-outline-success" {% if list.is_archived %}disabled{% else
|
||||
%}onclick="unmarkNotPurchased({{ item.id }})" {% endif %}>
|
||||
✅ Przywróć
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else
|
||||
%}onclick="markNotPurchasedModal(event, {{ item.id }})" {% endif %}>
|
||||
⚠️
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else
|
||||
%}onclick="openNoteModal(event, {{ item.id }})" {% endif %}>
|
||||
📝
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% else %}
|
||||
<li id="empty-placeholder"
|
||||
class="list-group-item bg-dark text-secondary text-center w-100">
|
||||
Brak produktów w tej liście.
|
||||
</li>
|
||||
<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="input-group mb-2">
|
||||
<input id="newItem" class="form-control bg-dark text-white border-secondary" placeholder="Dodaj produkt i ilość">
|
||||
<input id="newQuantity" type="number" class="form-control bg-dark text-white border-secondary" placeholder="Ilość" min="1" value="1" style="max-width: 90px;">
|
||||
<button onclick="addItem({{ list.id }})" class="btn btn-success rounded-end">➕ Dodaj</button>
|
||||
</div>
|
||||
<div class="input-group mb-2">
|
||||
<input id="newItem" class="form-control bg-dark text-white border-secondary" placeholder="Dodaj produkt i ilość" {% if
|
||||
not current_user.is_authenticated %}disabled{% endif %}>
|
||||
<input id="newQuantity" type="number" class="form-control bg-dark text-white border-secondary" placeholder="Ilość"
|
||||
min="1" value="1" style="max-width: 90px;" {% if not current_user.is_authenticated %}disabled{% endif %}>
|
||||
<button onclick="addItem({{ list.id }})" class="btn btn-success rounded-end" {% if not current_user.is_authenticated
|
||||
%}disabled{% endif %}>➕ Dodaj</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not list.is_archived %}
|
||||
<hr>
|
||||
<h5>💰 Dodaj wydatek</h5>
|
||||
<div class="input-group mb-2">
|
||||
<input id="expenseAmount" type="number" step="0.01" min="0" class="form-control bg-dark text-white border-secondary" placeholder="Kwota (PLN)">
|
||||
<button onclick="submitExpense({{ list.id }})" class="btn btn-success rounded-end">💾 Zapisz</button>
|
||||
</div>
|
||||
<hr>
|
||||
<h5>💰 Dodaj wydatek</h5>
|
||||
<div class="input-group mb-2">
|
||||
<input id="expenseAmount" type="number" step="0.01" min="0" class="form-control bg-dark text-white border-secondary"
|
||||
placeholder="Kwota (PLN)">
|
||||
<button onclick="submitExpense({{ list.id }})" class="btn btn-success rounded-end">💾 Zapisz</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<p id="total-expense2"><b>💸 Łącznie wydano:</b> {{ '%.2f'|format(total_expense) }} PLN</p>
|
||||
<p id="total-expense2"><b>💸 Łącznie wydano:</b> {{ '%.2f'|format(total_expense) }} PLN</p>
|
||||
|
||||
<button class="btn btn-outline-light mb-3" type="button" data-bs-toggle="collapse" data-bs-target="#receiptSection" aria-expanded="false" aria-controls="receiptSection">
|
||||
<button class="btn btn-outline-light mb-3" type="button" data-bs-toggle="collapse" data-bs-target="#receiptSection"
|
||||
aria-expanded="false" aria-controls="receiptSection">
|
||||
📄 Pokaż sekcję paragonów
|
||||
</button>
|
||||
<div class="collapse" id="receiptSection">
|
||||
{% set receipt_pattern = 'list_' ~ list.id %}
|
||||
{% set receipt_pattern = 'list_' ~ list.id %}
|
||||
|
||||
<h5 class="mt-4">📸 Paragony dodane do tej listy</h5>
|
||||
|
||||
<div class="row g-3 mt-2" id="receiptGallery">
|
||||
{% if receipt_files %}
|
||||
<hr>
|
||||
<div class="mt-3 p-3 border border-secondary rounded bg-dark text-white" id="receiptAnalysisBlock">
|
||||
<h5>🧠 Analiza paragonów (OCR)</h5>
|
||||
<p class="text-small">System spróbuje automatycznie rozpoznać kwoty z dodanych paragonów.</p>
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<button id="analyzeBtn" class="btn btn-outline-info mb-3">
|
||||
🔍 Zleć analizę OCR
|
||||
</button>
|
||||
{% else %}
|
||||
<div class="alert alert-warning">🔒 Tylko zalogowani użytkownicy mogą zlecać analizę OCR paragonów.</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="analysisResults" class="mt-2"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<h5 class="mt-4">📸 Paragony dodane do tej listy</h5>
|
||||
|
||||
<div class="row g-3 mt-2" id="receiptGallery">
|
||||
{% if receipt_files %}
|
||||
{% for file in receipt_files %}
|
||||
<div class="col-6 col-md-4 col-lg-3 text-center">
|
||||
<a href="{{ url_for('uploaded_file', filename=file) }}" class="glightbox" data-gallery="receipt-gallery">
|
||||
<img src="{{ url_for('uploaded_file', filename=file) }}" class="img-fluid rounded shadow-sm border border-secondary" style="max-height: 200px; object-fit: cover;">
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-lg-3 text-center">
|
||||
<a href="{{ url_for('uploaded_file', filename=file) }}" class="glightbox" data-gallery="receipt-gallery">
|
||||
<img src="{{ url_for('uploaded_file', filename=file) }}"
|
||||
class="img-fluid rounded shadow-sm border border-secondary" style="max-height: 200px; object-fit: cover;">
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% else %}
|
||||
<div class="alert alert-info text-center w-100" role="alert">
|
||||
Brak wgranych paragonów do tej listy.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if not list.is_archived %}
|
||||
{% if not list.is_archived and current_user.is_authenticated %}
|
||||
<hr>
|
||||
<h5>📤 Dodaj zdjęcie paragonu</h5>
|
||||
<form id="receiptForm" action="{{ url_for('upload_receipt', list_id=list.id) }}" method="post" enctype="multipart/form-data" class="text-center">
|
||||
<label for="receiptInput" class="btn btn-outline-light w-100 py-3 mb-2 d-flex align-items-center justify-content-center gap-2">
|
||||
<i class="bi bi-upload"></i> 📸 <span id="fileLabel">Wybierz zdjęcie paragonu</span>
|
||||
<form id="receiptForm" action="{{ url_for('upload_receipt', list_id=list.id) }}" method="post"
|
||||
enctype="multipart/form-data" class="text-center">
|
||||
|
||||
<!-- Zrób zdjęcie (tylko mobile) -->
|
||||
<label for="cameraInput" id="cameraBtn"
|
||||
class="btn btn-outline-light w-100 py-2 mb-2 d-flex align-items-center justify-content-center gap-2">
|
||||
<i class="bi bi-camera"></i> 📸 Zrób zdjęcie
|
||||
</label>
|
||||
<input type="file" name="receipt" accept="image/*" capture="environment" class="d-none" id="receiptInput">
|
||||
<button type="submit" class="btn btn-success w-100 mb-2">➕ Wgraj paragon</button>
|
||||
<input type="file" name="receipt" accept="image/*" capture="environment" class="d-none" id="cameraInput">
|
||||
|
||||
<!-- Z galerii / Dodaj paragon -->
|
||||
<label for="galleryInput" id="galleryBtn"
|
||||
class="btn btn-outline-light w-100 py-2 mb-2 d-flex align-items-center justify-content-center gap-2">
|
||||
<i class="bi bi-image"></i> <span id="galleryBtnText">🖼️ Z galerii</span>
|
||||
</label>
|
||||
<input type="file" name="receipt" accept="image/*" class="d-none" id="galleryInput">
|
||||
|
||||
<div id="progressContainer" class="progress" style="height: 20px; display: none;">
|
||||
<div id="progressBar" class="progress-bar bg-success fw-bold" role="progressbar" style="width: 0%;">0%</div>
|
||||
</div>
|
||||
|
||||
<div id="receiptGallery" class="mt-3"></div>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -115,7 +183,8 @@
|
||||
</div>
|
||||
<form id="noteForm" onsubmit="submitNote(event)">
|
||||
<div class="modal-body">
|
||||
<textarea id="noteText" class="form-control" rows="4" placeholder="Np. 'Nie było, zamieniłem na inny'"></textarea>
|
||||
<textarea id="noteText" class="form-control" rows="4"
|
||||
placeholder="Np. 'Nie było, zamieniłem na inny'"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuluj</button>
|
||||
@@ -127,14 +196,23 @@
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const isShare = document.getElementById('items').dataset.isShare === 'true';
|
||||
window.IS_SHARE = isShare;
|
||||
window.LIST_ID = {{ list.id }};
|
||||
if (typeof isSorting === 'undefined') {
|
||||
var isSorting = false;
|
||||
}
|
||||
</script>
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='Sortable.min.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='notes.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='clickable_row.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='receipt_section.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='receipt_upload.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='receipt_analysis.js') }}"></script>
|
||||
<script>
|
||||
setupList({{ list.id }}, '{{ current_user.username if current_user.is_authenticated else 'Gość' }}');
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
@@ -9,10 +9,12 @@
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<input type="text" name="username" placeholder="Login" class="form-control bg-dark text-white border-secondary rounded" required>
|
||||
<input type="text" name="username" placeholder="Login"
|
||||
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>
|
||||
<input type="password" name="password" placeholder="Hasło"
|
||||
class="form-control bg-dark text-white border-secondary rounded" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success w-100">🔑 Zaloguj</button>
|
||||
</form>
|
||||
|
@@ -3,9 +3,9 @@
|
||||
{% block content %}
|
||||
|
||||
{% if not current_user.is_authenticated %}
|
||||
<div class="alert alert-info text-center" role="alert">
|
||||
Jesteś w trybie gościa. Możesz tylko przeglądać listy udostępnione publicznie.
|
||||
</div>
|
||||
<div class="alert alert-info text-center" role="alert">
|
||||
Jesteś w trybie gościa. Możesz tylko przeglądać listy udostępnione publicznie.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
@@ -17,15 +17,10 @@
|
||||
<div class="card-body">
|
||||
<form action="/create" method="post">
|
||||
<div class="input-group mb-3">
|
||||
<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 rounded-end"
|
||||
id="tempToggle"
|
||||
data-active="0"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="top"
|
||||
title="Po zaznaczeniu lista będzie ważna tylko 7 dni">
|
||||
<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 rounded-end" 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">
|
||||
@@ -37,89 +32,89 @@
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<h3 class="mt-4 d-flex justify-content-between align-items-center flex-wrap">
|
||||
Twoje listy
|
||||
<button type="button" class="btn btn-sm btn-outline-light ms-2" data-bs-toggle="modal" data-bs-target="#archivedModal">
|
||||
📁 Zarchiwizowane
|
||||
</button>
|
||||
</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="d-flex justify-content-between align-items-center flex-wrap w-100">
|
||||
<span class="fw-bold">{{ l.title }} (Autor: Ty)</span>
|
||||
<h3 class="mt-4 d-flex justify-content-between align-items-center flex-wrap">
|
||||
Twoje listy
|
||||
<button type="button" class="btn btn-sm btn-outline-light ms-2" data-bs-toggle="modal"
|
||||
data-bs-target="#archivedModal">
|
||||
📁 Zarchiwizowane
|
||||
</button>
|
||||
</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="d-flex justify-content-between align-items-center flex-wrap w-100">
|
||||
<span class="fw-bold">{{ l.title }} (Autor: Ty)</span>
|
||||
|
||||
<div class="d-flex flex-wrap mt-2 mt-md-0">
|
||||
<a href="/list/{{ l.id }}" class="btn btn-sm btn-outline-light me-1 mb-1">📄 Otwórz</a>
|
||||
<a href="/copy/{{ l.id }}" class="btn btn-sm btn-outline-light me-1 mb-1">📋 Kopiuj</a>
|
||||
<a href="/edit_my_list/{{ l.id }}" class="btn btn-sm btn-outline-light me-1 mb-1">✏️ Edytuj</a>
|
||||
<a href="/toggle_archive_list/{{ l.id }}?archive=true" class="btn btn-sm btn-outline-light me-1 mb-1">🗄️ Archiwizuj</a>
|
||||
{% if l.is_public %}
|
||||
<a href="/toggle_visibility/{{ l.id }}" class="btn btn-sm btn-outline-light me-1 mb-1">🙈 Ukryj</a>
|
||||
{% else %}
|
||||
<a href="/toggle_visibility/{{ l.id }}" class="btn btn-sm btn-outline-light me-1 mb-1">👁️ Odkryj</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress progress-dark progress-thin mt-2 position-relative">
|
||||
<div class="progress-bar bg-warning text-dark"
|
||||
role="progressbar"
|
||||
style="width: {{ percent }}%;"
|
||||
aria-valuenow="{{ percent }}" aria-valuemin="0" aria-valuemax="100">
|
||||
</div>
|
||||
<span class="progress-label small fw-bold
|
||||
<div class="btn-group mt-2 mt-md-0" role="group">
|
||||
<a href="/list/{{ l.id }}" class="btn btn-sm btn-outline-light">📄 Otwórz</a>
|
||||
<a href="/copy/{{ l.id }}" class="btn btn-sm btn-outline-light">📋 Kopiuj</a>
|
||||
<a href="/edit_my_list/{{ l.id }}" class="btn btn-sm btn-outline-light">✏️ Edytuj</a>
|
||||
<a href="/toggle_archive_list/{{ l.id }}?archive=true" class="btn btn-sm btn-outline-light">🗄️ Archiwizuj</a>
|
||||
{% if l.is_public %}
|
||||
<a href="/toggle_visibility/{{ l.id }}" class="btn btn-sm btn-outline-light">🙈 Ukryj</a>
|
||||
{% else %}
|
||||
<a href="/toggle_visibility/{{ l.id }}" class="btn btn-sm btn-outline-light">👁️ Odkryj</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress progress-dark progress-thin mt-2 position-relative">
|
||||
<div class="progress-bar bg-warning text-dark" role="progressbar" style="width: {{ percent }}%;"
|
||||
aria-valuenow="{{ percent }}" aria-valuemin="0" aria-valuemax="100">
|
||||
</div>
|
||||
<span class="progress-label small fw-bold
|
||||
{% if percent < 50 %}text-white{% else %}text-dark{% endif %}">
|
||||
Produkty: {{ purchased_count }}/{{ total_count }} ({{ percent|round(0) }}%)
|
||||
{% if l.total_expense > 0 %}
|
||||
— 💸 {{ '%.2f'|format(l.total_expense) }} PLN
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p><span class="badge rounded-pill bg-secondary opacity-75">Nie masz jeszcze żadnych list. Utwórz pierwszą, korzystając z formularza powyżej!</span></p>
|
||||
{% endif %}
|
||||
Produkty: {{ purchased_count }}/{{ total_count }} ({{ percent|round(0) }}%)
|
||||
{% if l.total_expense > 0 %}
|
||||
— 💸 {{ '%.2f'|format(l.total_expense) }} PLN
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p><span class="badge rounded-pill bg-secondary opacity-75">Nie masz jeszcze żadnych list. Utwórz pierwszą, korzystając
|
||||
z formularza powyżej</span></p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<h3 class="mt-4">Publiczne listy innych użytkowników</h3>
|
||||
{% if public_lists %}
|
||||
<ul class="list-group">
|
||||
{% for l in public_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="d-flex justify-content-between align-items-center flex-wrap w-100">
|
||||
<span class="fw-bold">{{ l.title }} (Autor: {{ l.owner.username }})</span>
|
||||
<a href="/guest-list/{{ l.id }}" class="btn btn-sm btn-outline-light">📄 Otwórz</a>
|
||||
</div>
|
||||
<div class="progress progress-dark progress-thin mt-2 position-relative">
|
||||
<div class="progress-bar bg-warning text-dark"
|
||||
role="progressbar"
|
||||
style="width: {{ percent }}%;"
|
||||
aria-valuenow="{{ percent }}" aria-valuemin="0" aria-valuemax="100">
|
||||
</div>
|
||||
<span class="progress-label small fw-bold
|
||||
<ul class="list-group">
|
||||
{% for l in public_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="d-flex justify-content-between align-items-center flex-wrap w-100">
|
||||
<span class="fw-bold">{{ l.title }} (Autor: {{ l.owner.username }})</span>
|
||||
<a href="/guest-list/{{ l.id }}" class="btn btn-sm btn-outline-light">📄 Otwórz</a>
|
||||
</div>
|
||||
<div class="progress progress-dark progress-thin mt-2 position-relative">
|
||||
<div class="progress-bar bg-warning text-dark" role="progressbar" style="width: {{ percent }}%;"
|
||||
aria-valuenow="{{ percent }}" aria-valuemin="0" aria-valuemax="100">
|
||||
</div>
|
||||
<span class="progress-label small fw-bold
|
||||
{% if percent < 50 %}text-white{% else %}text-dark{% endif %}">
|
||||
Produkty: {{ purchased_count }}/{{ total_count }} ({{ percent|round(0) }}%)
|
||||
{% if l.total_expense > 0 %}
|
||||
— 💸 {{ '%.2f'|format(l.total_expense) }} PLN
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
Produkty: {{ purchased_count }}/{{ total_count }} ({{ percent|round(0) }}%)
|
||||
{% if l.total_expense > 0 %}
|
||||
— 💸 {{ '%.2f'|format(l.total_expense) }} PLN
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p><span class="badge rounded-pill bg-secondary opacity-75">Brak dostępnych list publicznych do wyświetlenia</span></p>
|
||||
<p><span class="badge rounded-pill bg-secondary opacity-75">Brak dostępnych list publicznych do wyświetlenia</span></p>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<div class="modal fade" id="archivedModal" tabindex="-1" aria-labelledby="archivedModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content bg-dark text-white">
|
||||
@@ -129,18 +124,19 @@
|
||||
</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>
|
||||
<a href="/toggle_archive_list/{{ l.id }}?archive=false" class="btn btn-sm btn-outline-success">♻️ Przywróć</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<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>
|
||||
<a href="/toggle_archive_list/{{ l.id }}?archive=false" class="btn btn-sm btn-outline-success">♻️
|
||||
Przywróć</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="alert alert-info text-center" role="alert">
|
||||
Nie masz żadnych zarchiwizowanych list.
|
||||
</div>
|
||||
<div class="alert alert-info text-center" role="alert">
|
||||
Nie masz żadnych zarchiwizowanych list.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -154,4 +150,4 @@
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='toggle_button.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
@@ -10,7 +10,8 @@
|
||||
<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>
|
||||
<input type="password" name="password" placeholder="Hasło"
|
||||
class="form-control bg-dark text-white border-secondary rounded" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success w-100">🔓 Wejdź</button>
|
||||
</form>
|
||||
@@ -19,9 +20,9 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelector('input[name="password"]').focus();
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelector('input[name="password"]').focus();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
89
templates/user_expenses.html
Normal file
89
templates/user_expenses.html
Normal file
@@ -0,0 +1,89 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Wydatki z Twoich list{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
|
||||
<h2 class="mb-2">Statystyki wydatków</h2>
|
||||
<a href="{{ url_for('main_page') }}" class="btn btn-outline-secondary">← Powrót</a>
|
||||
</div>
|
||||
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<ul class="nav nav-tabs mb-3" id="expenseTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="table-tab" data-bs-toggle="tab" data-bs-target="#tableTab" type="button"
|
||||
role="tab">
|
||||
📄 Tabela
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="chart-tab" data-bs-toggle="tab" data-bs-target="#chartTab" type="button"
|
||||
role="tab">
|
||||
📊 Wykres
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="expenseTabsContent">
|
||||
<!-- Tabela -->
|
||||
<div class="tab-pane fade show active" id="tableTab" role="tabpanel">
|
||||
<div class="card bg-dark text-white mb-4">
|
||||
<div class="card-body">
|
||||
{% if expense_table %}
|
||||
<div class="row g-4">
|
||||
{% for row in expense_table %}
|
||||
<div class="col-12 col-sm-6 col-lg-4">
|
||||
<div class="card bg-dark text-white border border-secondary h-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-truncate" title="{{ row.title }}">{{ row.title }}</h5>
|
||||
<p class="mb-1">💸 <strong>{{ '%.2f'|format(row.amount) }} PLN</strong></p>
|
||||
<p class="mb-0">📅 {{ row.added_at.strftime('%Y-%m-%d') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info text-center mb-0">Brak wydatków do wyświetlenia.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wykres -->
|
||||
<div class="tab-pane fade" id="chartTab" role="tabpanel">
|
||||
<div class="card bg-dark text-white mb-4">
|
||||
<div class="card-body">
|
||||
<p id="chartRangeLabel" class="fw-bold mb-3">Widok: miesięczne</p>
|
||||
<canvas id="expensesChart" height="120"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mb-4">
|
||||
<button class="btn btn-outline-light btn-sm range-btn active" data-range="monthly">📅 Miesięczne</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="quarterly">📆 Kwartalne</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="halfyearly">🗓️ Półroczne</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="yearly">📈 Roczne</button>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<input type="date" id="startDate" class="form-control bg-dark text-white border-secondary rounded">
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<input type="date" id="endDate" class="form-control bg-dark text-white border-secondary rounded">
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<button class="btn btn-outline-light w-100" id="customRangeBtn">📊 Zakres własny</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='chart.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='user_expenses.js') }}"></script>
|
||||
{% endblock %}
|
Reference in New Issue
Block a user