Compare commits
95 Commits
d8233cb6e5
...
v0.0.4
Author | SHA1 | Date | |
---|---|---|---|
67d4fd0024 | |||
e1d1ec67c3 | |||
a81737b2ce | |||
![]() |
40a3d60da0 | ||
![]() |
9a844fc539 | ||
![]() |
396a56e773 | ||
![]() |
c6b089472a | ||
![]() |
1de3171183 | ||
![]() |
18e2d376c2 | ||
![]() |
159b52099e | ||
![]() |
643757e45e | ||
![]() |
9e3068a722 | ||
![]() |
b9b91ff82b | ||
![]() |
a5025b94ff | ||
![]() |
5c6e2f6540 | ||
![]() |
f913aeac60 | ||
![]() |
359b5fb61b | ||
![]() |
5519f7eef5 | ||
![]() |
4b76df795b | ||
![]() |
81985f7f84 | ||
![]() |
50d67d5b1a | ||
![]() |
e5e498a5a9 | ||
![]() |
4cea094465 | ||
![]() |
b7b6453b42 | ||
![]() |
7e69610981 | ||
![]() |
bc6f64e546 | ||
![]() |
e5ef1309e7 | ||
![]() |
6b2469778f | ||
![]() |
07d06ded60 | ||
![]() |
a2c333014e | ||
![]() |
04c187d3d3 | ||
![]() |
8db5cd82ac | ||
![]() |
f2811148f1 | ||
![]() |
c8a5db6715 | ||
![]() |
e806976453 | ||
![]() |
d8d786aed8 | ||
![]() |
b17a12b9fd | ||
![]() |
1a98b7165d | ||
![]() |
0357a63dcf | ||
![]() |
ddbd224e06 | ||
![]() |
a417889810 | ||
![]() |
d42d973ffd | ||
![]() |
7dc49fe160 | ||
![]() |
5e782ba170 | ||
![]() |
be986fc8f5 | ||
![]() |
cd06fc3ca4 | ||
![]() |
e4322f2bc6 | ||
![]() |
bb667a2cbd | ||
![]() |
0d5b170cac | ||
![]() |
34205f0e65 | ||
![]() |
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 |
154
.env.example
154
.env.example
@@ -1,26 +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
|
||||
|
||||
AUTHORIZED_COOKIE_VALUE=twoj_wlasny_hash
|
||||
# SESSION_TIMEOUT_MINUTES:
|
||||
# Czas bezczynności użytkownika (w minutach), po którym sesja wygasa
|
||||
# Domyślnie: 10080 (7 dni)
|
||||
SESSION_TIMEOUT_MINUTES=10080
|
||||
|
||||
# czas zycia cookie
|
||||
# AUTH_COOKIE_MAX_AGE:
|
||||
# Czas życia ciasteczka autoryzacyjnego (w sekundach)
|
||||
# Domyślnie: 86400 (1 dzień)
|
||||
AUTH_COOKIE_MAX_AGE=86400
|
||||
|
||||
# dla compose
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# sesja zalogowanego usera (domyślnie 7 dni)
|
||||
SESSION_TIMEOUT_MINUTES=10080
|
||||
# Rodzaj bazy: sqlite, pgsql, mysql
|
||||
# Mozliwe wartosci: sqlite / pgsql / mysql
|
||||
DB_ENGINE=sqlite
|
||||
|
||||
# --- Konfiguracja dla sqlite ---
|
||||
# Plik bazy bedzie utworzony automatycznie w katalogu ./instance
|
||||
# Pozostale zmienne sa ignorowane przy DB_ENGINE=sqlite
|
||||
|
||||
# --- Konfiguracja dla pgsql ---
|
||||
# Ustaw DB_ENGINE=pgsql
|
||||
# Domyslny port PostgreSQL to 5432
|
||||
# Wymaga dzialajacego serwera PostgreSQL (np. kontener `postgres`)
|
||||
|
||||
# --- 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.")
|
14
alters.txt
14
alters.txt
@@ -37,3 +37,17 @@ ALTER TABLE item ADD COLUMN not_purchased BOOLEAN DEFAULT 0;
|
||||
|
||||
# funkcja sortowania
|
||||
ALTER TABLE item ADD COLUMN position INTEGER DEFAULT 0;
|
||||
|
||||
# migracja paragonów do nowej tabeli
|
||||
CREATE TABLE receipt (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
list_id INTEGER NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (list_id) REFERENCES shopping_list(id)
|
||||
);
|
||||
|
||||
ALTER TABLE receipt ADD COLUMN filesize INTEGER;
|
||||
|
||||
# unikanie identycznych plikow
|
||||
ALTER TABLE receipt ADD COLUMN file_hash TEXT
|
||||
|
54
config.py
54
config.py
@@ -1,14 +1,48 @@
|
||||
import os
|
||||
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'D8pceNZ8q%YR7^7F&9wAC2')
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', 'sqlite:///shopping.db')
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", "D8pceNZ8q%YR7^7F&9wAC2")
|
||||
|
||||
DB_ENGINE = os.environ.get("DB_ENGINE", "sqlite").lower()
|
||||
if DB_ENGINE == "sqlite":
|
||||
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(basedir, 'instance', 'shopping.db')}"
|
||||
elif DB_ENGINE == "pgsql":
|
||||
SQLALCHEMY_DATABASE_URI = f"postgresql://{os.environ['DB_USER']}:{os.environ['DB_PASSWORD']}@{os.environ['DB_HOST']}:{os.environ.get('DB_PORT', 5432)}/{os.environ['DB_NAME']}"
|
||||
elif DB_ENGINE == "mysql":
|
||||
SQLALCHEMY_DATABASE_URI = f"mysql+pymysql://{os.environ['DB_USER']}:{os.environ['DB_PASSWORD']}@{os.environ['DB_HOST']}:{os.environ.get('DB_PORT', 3306)}/{os.environ['DB_NAME']}"
|
||||
else:
|
||||
raise ValueError("Nieobsługiwany typ bazy danych.")
|
||||
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
SYSTEM_PASSWORD = os.environ.get('SYSTEM_PASSWORD', 'admin')
|
||||
DEFAULT_ADMIN_USERNAME = os.environ.get('DEFAULT_ADMIN_USERNAME', 'admin')
|
||||
DEFAULT_ADMIN_PASSWORD = os.environ.get('DEFAULT_ADMIN_PASSWORD', 'admin123')
|
||||
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', 'uploads')
|
||||
AUTHORIZED_COOKIE_VALUE = os.environ.get('AUTHORIZED_COOKIE_VALUE', 'cookievalue')
|
||||
AUTH_COOKIE_MAX_AGE = int(os.environ.get('AUTH_COOKIE_MAX_AGE', 86400))
|
||||
HEALTHCHECK_TOKEN = os.environ.get('HEALTHCHECK_TOKEN', 'alamapsaikota1234')
|
||||
SESSION_TIMEOUT_MINUTES = int(os.environ.get('SESSION_TIMEOUT_MINUTES', 10080))
|
||||
SYSTEM_PASSWORD = os.environ.get("SYSTEM_PASSWORD", "admin")
|
||||
DEFAULT_ADMIN_USERNAME = os.environ.get("DEFAULT_ADMIN_USERNAME", "admin")
|
||||
DEFAULT_ADMIN_PASSWORD = os.environ.get("DEFAULT_ADMIN_PASSWORD", "admin123")
|
||||
UPLOAD_FOLDER = os.environ.get("UPLOAD_FOLDER", "uploads")
|
||||
AUTHORIZED_COOKIE_VALUE = os.environ.get("AUTHORIZED_COOKIE_VALUE", "cookievalue")
|
||||
try:
|
||||
AUTH_COOKIE_MAX_AGE = int(os.environ.get("AUTH_COOKIE_MAX_AGE", "86400") or "86400")
|
||||
except ValueError:
|
||||
AUTH_COOKIE_MAX_AGE = 86400
|
||||
|
||||
HEALTHCHECK_TOKEN = os.environ.get("HEALTHCHECK_TOKEN", "alamapsaikota1234")
|
||||
try:
|
||||
SESSION_TIMEOUT_MINUTES = int(os.environ.get("SESSION_TIMEOUT_MINUTES", "10080") or "10080")
|
||||
except ValueError:
|
||||
SESSION_TIMEOUT_MINUTES = 10080
|
||||
SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "0") == "1"
|
||||
|
||||
ENABLE_HSTS = os.environ.get("ENABLE_HSTS", "0") == "1"
|
||||
ENABLE_XFO = os.environ.get("ENABLE_XFO", "0") == "1"
|
||||
ENABLE_XCTO = os.environ.get("ENABLE_XCTO", "0") == "1"
|
||||
ENABLE_CSP = os.environ.get("ENABLE_CSP", "0") == "1"
|
||||
ENABLE_PP = os.environ.get("ENABLE_PP", "0") == "1"
|
||||
REFERRER_POLICY = os.environ.get("REFERRER_POLICY") or None
|
||||
|
||||
DEBUG_MODE = os.environ.get("DEBUG_MODE", "1") == "1"
|
||||
DISABLE_ROBOTS = os.environ.get("DISABLE_ROBOTS", "0") == "1"
|
||||
JS_CACHE_CONTROL = os.environ.get("JS_CACHE_CONTROL", "no-cache, no-store, must-revalidate")
|
||||
CSS_CACHE_CONTROL = os.environ.get("CSS_CACHE_CONTROL", "public, max-age=3600")
|
||||
LIB_JS_CACHE_CONTROL = os.environ.get("LIB_JS_CACHE_CONTROL", "public, max-age=604800")
|
||||
LIB_CSS_CACHE_CONTROL = os.environ.get("LIB_CSS_CACHE_CONTROL", "public, max-age=604800")
|
||||
UPLOADS_CACHE_CONTROL = os.environ.get("UPLOADS_CACHE_CONTROL", "public, max-age=2592000, immutable")
|
@@ -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!"
|
||||
|
@@ -10,17 +10,35 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
environment:
|
||||
- FLASK_APP=app.py
|
||||
- FLASK_ENV=production
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- SYSTEM_PASSWORD=${SYSTEM_PASSWORD}
|
||||
- DEFAULT_ADMIN_USERNAME=${DEFAULT_ADMIN_USERNAME}
|
||||
- DEFAULT_ADMIN_PASSWORD=${DEFAULT_ADMIN_PASSWORD}
|
||||
- UPLOAD_FOLDER=${UPLOAD_FOLDER}
|
||||
- AUTHORIZED_COOKIE_VALUE=${AUTHORIZED_COOKIE_VALUE}
|
||||
- AUTH_COOKIE_MAX_AGE=${AUTH_COOKIE_MAX_AGE}
|
||||
- HEALTHCHECK_TOKEN=${HEALTHCHECK_TOKEN}
|
||||
- SESSION_TIMEOUT_MINUTES=${SESSION_TIMEOUT_MINUTES}
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./uploads:/app/uploads
|
||||
- ./instance:/app/instance
|
||||
restart: unless-stopped
|
||||
|
||||
pgsql:
|
||||
image: postgres:17
|
||||
container_name: pgsql-db
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- ./db/pgsql:/var/lib/postgresql/data
|
||||
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,12 @@ Flask-Compress
|
||||
eventlet
|
||||
Werkzeug
|
||||
Pillow
|
||||
psutil
|
||||
psutil
|
||||
pillow-heif
|
||||
|
||||
pytesseract
|
||||
opencv-python-headless
|
||||
psycopg2-binary # pgsql
|
||||
pymysql # mysql
|
||||
cryptography # mysql8
|
||||
flask-talisman # nagłówki
|
@@ -193,18 +193,16 @@ input.form-control {
|
||||
}
|
||||
|
||||
.info-bar-fixed {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
color: #f8f9fa;
|
||||
background-color: #212529;
|
||||
border-radius: 12px 12px 0 0;
|
||||
text-align: center;
|
||||
padding: 10px 8px;
|
||||
padding: 10px 10px;
|
||||
font-size: 0.95rem;
|
||||
z-index: 9999;
|
||||
box-sizing: border-box;
|
||||
margin-top: 2rem;
|
||||
box-shadow: 0 -1px 4px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -305,4 +303,10 @@ input.form-control {
|
||||
.fade-out {
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
@media (pointer: fine) {
|
||||
.only-mobile {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
109
static/js/_unused_receipt_crop.js
Normal file
109
static/js/_unused_receipt_crop.js
Normal file
@@ -0,0 +1,109 @@
|
||||
let cropper;
|
||||
let currentReceiptId;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const cropModal = document.getElementById("cropModal");
|
||||
const cropImage = document.getElementById("cropImage");
|
||||
const spinner = document.getElementById("cropLoading");
|
||||
|
||||
cropModal.addEventListener("shown.bs.modal", function (event) {
|
||||
const button = event.relatedTarget;
|
||||
const imgSrc = button.getAttribute("data-img-src");
|
||||
currentReceiptId = button.getAttribute("data-receipt-id");
|
||||
|
||||
cropImage.src = imgSrc;
|
||||
|
||||
if (cropper) {
|
||||
cropper.destroy();
|
||||
cropper = null;
|
||||
}
|
||||
|
||||
cropImage.onload = () => {
|
||||
cropper = new Cropper(cropImage, {
|
||||
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;
|
||||
|
||||
spinner.classList.remove("d-none");
|
||||
|
||||
const cropData = cropper.getData();
|
||||
const imageData = cropper.getImageData();
|
||||
|
||||
const scaleX = imageData.naturalWidth / imageData.width;
|
||||
const scaleY = imageData.naturalHeight / imageData.height;
|
||||
|
||||
const width = cropData.width * scaleX;
|
||||
const height = cropData.height * scaleY;
|
||||
|
||||
if (width < 1 || height < 1) {
|
||||
spinner.classList.add("d-none");
|
||||
showToast("Obszar przycięcia jest zbyt mały lub pusty", "danger");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ogranicz do 2000x2000 w proporcji
|
||||
const maxDim = 2000;
|
||||
const scale = Math.min(1, maxDim / Math.max(width, height));
|
||||
|
||||
const finalWidth = Math.round(width * scale);
|
||||
const finalHeight = Math.round(height * scale);
|
||||
|
||||
const croppedCanvas = cropper.getCroppedCanvas({
|
||||
width: finalWidth,
|
||||
height: finalHeight,
|
||||
imageSmoothingEnabled: true,
|
||||
imageSmoothingQuality: 'high',
|
||||
});
|
||||
|
||||
|
||||
if (!croppedCanvas) {
|
||||
spinner.classList.add("d-none");
|
||||
showToast("Nie można uzyskać obrazu przycięcia", "danger");
|
||||
return;
|
||||
}
|
||||
|
||||
croppedCanvas.toBlob(function (blob) {
|
||||
if (!blob) {
|
||||
spinner.classList.add("d-none");
|
||||
showToast("Nie udało się zapisać obrazu", "danger");
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("receipt_id", currentReceiptId);
|
||||
formData.append("cropped_image", blob);
|
||||
|
||||
fetch("/admin/crop_receipt", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
spinner.classList.add("d-none");
|
||||
if (data.success) {
|
||||
showToast("Zapisano przycięty paragon", "success");
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
showToast("Błąd: " + (data.error || "Nieznany"), "danger");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
spinner.classList.add("d-none");
|
||||
showToast("Błąd sieci", "danger");
|
||||
console.error(err);
|
||||
});
|
||||
}, "image/webp", 1.0);
|
||||
});
|
||||
});
|
39
static/js/admin_receipt_crop.js
Normal file
39
static/js/admin_receipt_crop.js
Normal file
@@ -0,0 +1,39 @@
|
||||
(function () {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const cropModal = document.getElementById("adminCropModal");
|
||||
const cropImage = document.getElementById("adminCropImage");
|
||||
const spinner = document.getElementById("adminCropLoading");
|
||||
const saveButton = document.getElementById("adminSaveCrop");
|
||||
|
||||
if (!cropModal || !cropImage || !spinner || !saveButton) return;
|
||||
|
||||
let cropper;
|
||||
let currentReceiptId;
|
||||
const currentEndpoint = "/admin/crop_receipt";
|
||||
|
||||
cropModal.addEventListener("shown.bs.modal", function (event) {
|
||||
const button = event.relatedTarget;
|
||||
const imgSrc = button.getAttribute("data-img-src");
|
||||
currentReceiptId = button.getAttribute("data-receipt-id");
|
||||
cropImage.src = imgSrc;
|
||||
|
||||
document.querySelectorAll('.cropper-container').forEach(e => e.remove());
|
||||
|
||||
if (cropper) cropper.destroy();
|
||||
cropImage.onload = () => {
|
||||
cropper = cropUtils.initCropper(cropImage);
|
||||
};
|
||||
});
|
||||
|
||||
cropModal.addEventListener("hidden.bs.modal", function () {
|
||||
cropUtils.cleanUpCropper(cropImage, cropper);
|
||||
cropper = null;
|
||||
});
|
||||
|
||||
saveButton.addEventListener("click", function () {
|
||||
if (!cropper) return;
|
||||
spinner.classList.remove("d-none");
|
||||
cropUtils.handleCrop(currentEndpoint, currentReceiptId, cropper, spinner);
|
||||
});
|
||||
});
|
||||
})();
|
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@@ -123,7 +123,7 @@ function copyLink(link) {
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: 'Udostępnij link',
|
||||
text: 'Link do listy::',
|
||||
text: 'Udostępniam link do listy:',
|
||||
url: link
|
||||
}).then(() => {
|
||||
showToast('Link udostępniony!');
|
||||
@@ -272,8 +272,101 @@ function isListDifferent(oldItems, newItems) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function updateListSmoothly(newItems) {
|
||||
function renderItem(item, isShare = window.IS_SHARE, showEditOnly = false) {
|
||||
const li = document.createElement('li');
|
||||
li.id = `item-${item.id}`;
|
||||
li.dataset.name = item.name.toLowerCase();
|
||||
li.className = `list-group-item d-flex justify-content-between align-items-center flex-wrap clickable-item ${item.purchased ? 'bg-success text-white'
|
||||
: item.not_purchased ? 'bg-warning text-dark'
|
||||
: 'item-not-checked'
|
||||
}`;
|
||||
|
||||
const isOwner = window.IS_OWNER === true || window.IS_OWNER === 'true';
|
||||
const allowEdit = !isShare || showEditOnly || isOwner;
|
||||
|
||||
let quantityBadge = '';
|
||||
if (item.quantity && item.quantity > 1) {
|
||||
quantityBadge = `<span class="badge bg-secondary">x${item.quantity}</span>`;
|
||||
}
|
||||
|
||||
let checkboxOrIcon = item.not_purchased
|
||||
? `<span class="ms-1 block-icon">🚫</span>`
|
||||
: `<input id="checkbox-${item.id}" class="large-checkbox" type="checkbox" ${item.purchased ? 'checked' : ''}>`;
|
||||
|
||||
let noteHTML = item.note
|
||||
? `<small class="text-danger ms-4">[ <b>${item.note}</b> ]</small>` : '';
|
||||
|
||||
let reasonHTML = item.not_purchased_reason
|
||||
? `<small class="text-dark ms-4">[ <b>Powód: ${item.not_purchased_reason}</b> ]</small>` : '';
|
||||
|
||||
let dragHandle = window.isSorting ? `<span class="drag-handle me-2 text-danger" style="cursor: grab;">☰</span>` : '';
|
||||
|
||||
let left = `
|
||||
<div class="d-flex align-items-center gap-2 flex-grow-1">
|
||||
${dragHandle}
|
||||
${checkboxOrIcon}
|
||||
<span id="name-${item.id}" class="text-white">${item.name} ${quantityBadge}</span>
|
||||
${noteHTML}
|
||||
${reasonHTML}
|
||||
</div>`;
|
||||
|
||||
let rightButtons = '';
|
||||
|
||||
// ✏️ i 🗑️ — tylko jeśli nie jesteśmy w trybie /share lub jesteśmy w 15s (tymczasowo) lub jesteśmy właścicielem
|
||||
if (allowEdit) {
|
||||
rightButtons += `
|
||||
<button type="button" class="btn btn-outline-light"
|
||||
onclick="editItem(${item.id}, '${item.name.replace(/'/g, "\\'")}', ${item.quantity || 1})">
|
||||
✏️
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-light"
|
||||
onclick="deleteItem(${item.id})">
|
||||
🗑️
|
||||
</button>`;
|
||||
}
|
||||
|
||||
// ✅ Jeśli element jest oznaczony jako niekupiony — pokaż "Przywróć"
|
||||
if (item.not_purchased) {
|
||||
rightButtons += `
|
||||
<button type="button" class="btn btn-outline-light me-auto"
|
||||
onclick="unmarkNotPurchased(${item.id})">
|
||||
✅ Przywróć
|
||||
</button>`;
|
||||
}
|
||||
|
||||
// ⚠️ tylko jeśli NIE jest oznaczony jako niekupiony i nie jesteśmy w 15s
|
||||
if (!item.not_purchased && (isOwner || (isShare && !showEditOnly))) {
|
||||
|
||||
rightButtons += `
|
||||
<button type="button" class="btn btn-outline-light"
|
||||
onclick="markNotPurchasedModal(event, ${item.id})">
|
||||
⚠️
|
||||
</button>`;
|
||||
}
|
||||
|
||||
// 📝 tylko jeśli jesteśmy w /share i nie jesteśmy w 15s
|
||||
if (isShare && !showEditOnly && !isOwner) {
|
||||
|
||||
rightButtons += `
|
||||
<button type="button" class="btn btn-outline-light"
|
||||
onclick="openNoteModal(event, ${item.id})">
|
||||
📝
|
||||
</button>`;
|
||||
}
|
||||
|
||||
li.innerHTML = `${left}<div class="btn-group btn-group-sm" role="group">${rightButtons}</div>`;
|
||||
|
||||
if (item.added_by && item.owner_id && item.added_by_id && item.added_by_id !== item.owner_id) {
|
||||
const infoEl = document.createElement('small');
|
||||
infoEl.className = 'text-info ms-4';
|
||||
infoEl.innerHTML = `[Dodał/a: <b>${item.added_by}</b>]`;
|
||||
li.querySelector('.d-flex.align-items-center')?.appendChild(infoEl);
|
||||
}
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
function updateListSmoothly(newItems) {
|
||||
const itemsContainer = document.getElementById('items');
|
||||
const existingItemsMap = new Map();
|
||||
|
||||
@@ -285,68 +378,7 @@ function updateListSmoothly(newItems) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
newItems.forEach(item => {
|
||||
let li = existingItemsMap.get(item.id);
|
||||
let quantityBadge = '';
|
||||
if (item.quantity && item.quantity > 1) {
|
||||
quantityBadge = `<span class="badge bg-secondary">x${item.quantity}</span>`;
|
||||
}
|
||||
|
||||
if (!li) {
|
||||
li = document.createElement('li');
|
||||
li.id = `item-${item.id}`;
|
||||
}
|
||||
|
||||
// Klasy tła
|
||||
li.className = `list-group-item d-flex justify-content-between align-items-center flex-wrap clickable-item ${item.purchased ? 'bg-success text-white' :
|
||||
item.not_purchased ? 'bg-warning text-dark' : 'item-not-checked'
|
||||
}`;
|
||||
|
||||
// Wewnętrzny HTML
|
||||
li.innerHTML = `
|
||||
<div class="d-flex align-items-center gap-2 flex-grow-1">
|
||||
${isSorting ? `<span class="drag-handle me-2 text-danger" style="cursor: grab;">☰</span>` : ''}
|
||||
${!item.not_purchased ? `
|
||||
<input id="checkbox-${item.id}" class="large-checkbox" type="checkbox"
|
||||
${item.purchased ? 'checked' : ''}>
|
||||
` : `
|
||||
<span class="ms-1 block-icon">🚫</span>
|
||||
`}
|
||||
<span id="name-${item.id}" class="text-white">${item.name} ${quantityBadge}</span>
|
||||
|
||||
${item.note ? `<small class="text-danger ms-4">[ <b>${item.note}</b> ]</small>` : ''}
|
||||
${item.not_purchased_reason ? `<small class="text-dark ms-4">[ <b>Powód: ${item.not_purchased_reason}</b> ]</small>` : ''}
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
${item.not_purchased ? `
|
||||
<button type="button" class="btn btn-outline-light me-auto"
|
||||
onclick="unmarkNotPurchased(${item.id})">
|
||||
✅ Przywróć
|
||||
</button>
|
||||
` : `
|
||||
<button type="button" class="btn btn-outline-light"
|
||||
onclick="markNotPurchasedModal(event, ${item.id})">
|
||||
⚠️
|
||||
</button>
|
||||
${window.IS_SHARE ? `
|
||||
<button type="button" class="btn btn-outline-light"
|
||||
onclick="openNoteModal(event, ${item.id})">
|
||||
📝
|
||||
</button>
|
||||
` : ''}
|
||||
`}
|
||||
${!window.IS_SHARE ? `
|
||||
<button type="button" class="btn btn-outline-light"
|
||||
onclick="editItem(${item.id}, '${item.name.replace(/'/g, "\\'")}', ${item.quantity || 1})">
|
||||
✏️
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-light"
|
||||
onclick="deleteItem(${item.id})">
|
||||
🗑️
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const li = renderItem(item);
|
||||
fragment.appendChild(li);
|
||||
});
|
||||
|
||||
|
@@ -124,50 +124,60 @@ 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}`);
|
||||
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}`;
|
||||
showToast(`${data.added_by} dodał: ${data.name}`, 'info');
|
||||
|
||||
let quantityBadge = '';
|
||||
if (data.quantity && data.quantity > 1) {
|
||||
quantityBadge = `<span class="badge bg-secondary">x${data.quantity}</span>`;
|
||||
}
|
||||
const item = {
|
||||
...data,
|
||||
purchased: false,
|
||||
not_purchased: false,
|
||||
not_purchased_reason: '',
|
||||
note: ''
|
||||
};
|
||||
|
||||
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">
|
||||
<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>
|
||||
`;
|
||||
|
||||
// góra listy
|
||||
//document.getElementById('items').prepend(li);
|
||||
|
||||
// dół listy
|
||||
const li = renderItem(item, false, true); // ← tryb 15s
|
||||
document.getElementById('items').appendChild(li);
|
||||
toggleEmptyPlaceholder();
|
||||
updateProgressBar();
|
||||
|
||||
setTimeout(() => {
|
||||
if (window.LIST_ID) {
|
||||
socket.emit('request_full_list', { list_id: window.LIST_ID });
|
||||
}
|
||||
}, 15000);
|
||||
if (window.IS_SHARE) {
|
||||
const countdownId = `countdown-${data.id}`;
|
||||
const countdownBtn = document.createElement('button');
|
||||
countdownBtn.type = 'button';
|
||||
countdownBtn.className = 'btn btn-outline-warning';
|
||||
countdownBtn.id = countdownId;
|
||||
countdownBtn.disabled = true;
|
||||
countdownBtn.textContent = '15s';
|
||||
|
||||
li.querySelector('.btn-group')?.prepend(countdownBtn);
|
||||
|
||||
let seconds = 15;
|
||||
const intervalId = setInterval(() => {
|
||||
const el = document.getElementById(countdownId);
|
||||
if (el) {
|
||||
seconds--;
|
||||
el.textContent = `${seconds}s`;
|
||||
if (seconds <= 0) {
|
||||
el.remove();
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
} else {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
setTimeout(() => {
|
||||
const existing = document.getElementById(`item-${data.id}`);
|
||||
if (existing) {
|
||||
const updated = renderItem(item, true);
|
||||
existing.replaceWith(updated);
|
||||
}
|
||||
}, 15000);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('item_deleted', data => {
|
||||
@@ -175,7 +185,7 @@ function setupList(listId, username) {
|
||||
if (li) {
|
||||
li.remove();
|
||||
}
|
||||
showToast('Usunięto produkt');
|
||||
showToast('Usunięto produkt z listy', 'success');
|
||||
updateProgressBar();
|
||||
toggleEmptyPlaceholder();
|
||||
});
|
||||
@@ -195,43 +205,39 @@ function setupList(listId, username) {
|
||||
});
|
||||
|
||||
socket.on('note_updated', data => {
|
||||
const itemEl = document.getElementById(`item-${data.item_id}`);
|
||||
if (itemEl) {
|
||||
let noteEl = itemEl.querySelector('small');
|
||||
if (noteEl) {
|
||||
//noteEl.innerHTML = `[ Notatka: <b>${data.note}</b> ]`;
|
||||
noteEl.innerHTML = `[ <b>${data.note}</b> ]`;
|
||||
} else {
|
||||
const newNote = document.createElement('small');
|
||||
newNote.className = 'text-danger ms-4';
|
||||
//newNote.innerHTML = `[ Notatka: <b>${data.note}</b> ]`;
|
||||
newNote.innerHTML = `[ <b>${data.note}</b> ]`;
|
||||
const idx = window.currentItems.findIndex(i => i.id === data.item_id);
|
||||
if (idx !== -1) {
|
||||
window.currentItems[idx].note = data.note;
|
||||
|
||||
const flexColumn = itemEl.querySelector('.d-flex.flex-column');
|
||||
if (flexColumn) {
|
||||
flexColumn.appendChild(newNote);
|
||||
} else {
|
||||
itemEl.appendChild(newNote);
|
||||
}
|
||||
const newItem = renderItem(window.currentItems[idx], true);
|
||||
const oldItem = document.getElementById(`item-${data.item_id}`);
|
||||
if (oldItem && newItem) {
|
||||
oldItem.replaceWith(newItem);
|
||||
}
|
||||
}
|
||||
showToast('Notatka zaktualizowana!');
|
||||
|
||||
showToast('Notatka dodana/zaktualizowana', 'success');
|
||||
});
|
||||
|
||||
|
||||
socket.on('item_edited', data => {
|
||||
const nameSpan = document.getElementById(`name-${data.item_id}`);
|
||||
if (nameSpan) {
|
||||
let quantityBadge = '';
|
||||
if (data.new_quantity && data.new_quantity > 1) {
|
||||
quantityBadge = ` <span class="badge bg-secondary">x${data.new_quantity}</span>`;
|
||||
}
|
||||
nameSpan.innerHTML = `${data.new_name}${quantityBadge}`;
|
||||
}
|
||||
showToast(`Zaktualizowano produkt: ${data.new_name} (x${data.new_quantity})`);
|
||||
});
|
||||
const idx = window.currentItems.findIndex(i => i.id === data.item_id);
|
||||
if (idx !== -1) {
|
||||
window.currentItems[idx].name = data.new_name;
|
||||
window.currentItems[idx].quantity = data.new_quantity;
|
||||
|
||||
updateProgressBar();
|
||||
toggleEmptyPlaceholder();
|
||||
const newItem = renderItem(window.currentItems[idx], true);
|
||||
const oldItem = document.getElementById(`item-${data.item_id}`);
|
||||
if (oldItem && newItem) {
|
||||
oldItem.replaceWith(newItem);
|
||||
}
|
||||
}
|
||||
|
||||
showToast(`Zaktualizowano produkt: ${data.new_name} (x${data.new_quantity})`, 'success');
|
||||
|
||||
updateProgressBar();
|
||||
toggleEmptyPlaceholder();
|
||||
});
|
||||
|
||||
// --- WAŻNE: zapisz dane do reconnect ---
|
||||
window.LIST_ID = listId;
|
||||
|
@@ -79,6 +79,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
btn.onclick = () => {
|
||||
const quantity = parseInt(qty.value) || 1;
|
||||
socket.emit('add_item', { list_id: LIST_ID, name: name, quantity: quantity });
|
||||
socket.emit('request_full_list', { list_id: LIST_ID });
|
||||
};
|
||||
|
||||
li.appendChild(qtyWrapper);
|
||||
|
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);
|
||||
}
|
96
static/js/receipt_crop_logic.js
Normal file
96
static/js/receipt_crop_logic.js
Normal file
@@ -0,0 +1,96 @@
|
||||
(function () {
|
||||
function initCropper(imgEl) {
|
||||
return new Cropper(imgEl, {
|
||||
viewMode: 1,
|
||||
autoCropArea: 1,
|
||||
responsive: true,
|
||||
background: false,
|
||||
zoomable: true,
|
||||
movable: true,
|
||||
dragMode: 'move',
|
||||
minContainerHeight: 400,
|
||||
minContainerWidth: 400,
|
||||
});
|
||||
}
|
||||
|
||||
function cleanUpCropper(imgEl, cropperInstance) {
|
||||
if (cropperInstance) {
|
||||
cropperInstance.destroy();
|
||||
}
|
||||
if (imgEl) imgEl.src = "";
|
||||
}
|
||||
|
||||
function handleCrop(endpoint, receiptId, cropper, spinner) {
|
||||
const cropData = cropper.getData();
|
||||
const imageData = cropper.getImageData();
|
||||
|
||||
const scaleX = imageData.naturalWidth / imageData.width;
|
||||
const scaleY = imageData.naturalHeight / imageData.height;
|
||||
|
||||
const width = cropData.width * scaleX;
|
||||
const height = cropData.height * scaleY;
|
||||
|
||||
if (width < 1 || height < 1) {
|
||||
spinner.classList.add("d-none");
|
||||
showToast("Obszar przycięcia jest zbyt mały lub pusty", "danger");
|
||||
return;
|
||||
}
|
||||
|
||||
const maxDim = 2000;
|
||||
const scale = Math.min(1, maxDim / Math.max(width, height));
|
||||
|
||||
const finalWidth = Math.round(width * scale);
|
||||
const finalHeight = Math.round(height * scale);
|
||||
|
||||
const croppedCanvas = cropper.getCroppedCanvas({
|
||||
width: finalWidth,
|
||||
height: finalHeight,
|
||||
imageSmoothingEnabled: true,
|
||||
imageSmoothingQuality: 'high',
|
||||
});
|
||||
|
||||
if (!croppedCanvas) {
|
||||
spinner.classList.add("d-none");
|
||||
showToast("Nie można uzyskać obrazu przycięcia", "danger");
|
||||
return;
|
||||
}
|
||||
|
||||
croppedCanvas.toBlob(function (blob) {
|
||||
if (!blob) {
|
||||
spinner.classList.add("d-none");
|
||||
showToast("Nie udało się zapisać obrazu", "danger");
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("receipt_id", receiptId);
|
||||
formData.append("cropped_image", blob);
|
||||
|
||||
fetch(endpoint, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
spinner.classList.add("d-none");
|
||||
if (data.success) {
|
||||
showToast("Zapisano przycięty paragon", "success");
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
showToast("Błąd: " + (data.error || "Nieznany"), "danger");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
spinner.classList.add("d-none");
|
||||
showToast("Błąd sieci", "danger");
|
||||
console.error(err);
|
||||
});
|
||||
}, "image/webp", 1.0);
|
||||
}
|
||||
|
||||
window.cropUtils = {
|
||||
initCropper,
|
||||
cleanUpCropper,
|
||||
handleCrop,
|
||||
};
|
||||
})();
|
@@ -16,3 +16,24 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
localStorage.setItem("receiptSectionOpen", "false");
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const btn = document.getElementById("toggleReceiptBtn");
|
||||
const target = document.querySelector(btn.getAttribute("data-bs-target"));
|
||||
|
||||
function updateUI() {
|
||||
const isShown = target.classList.contains("show");
|
||||
btn.innerHTML = isShown
|
||||
? "📄 Ukryj sekcję paragonów"
|
||||
: "📄 Pokaż sekcję paragonów";
|
||||
|
||||
btn.classList.toggle("active", isShown);
|
||||
btn.classList.toggle("btn-outline-light", !isShown);
|
||||
btn.classList.toggle("btn-secondary", isShown);
|
||||
}
|
||||
|
||||
target.addEventListener("shown.bs.collapse", updateUI);
|
||||
target.addEventListener("hidden.bs.collapse", updateUI);
|
||||
|
||||
updateUI();
|
||||
});
|
@@ -5,12 +5,23 @@ if (!window.receiptUploaderInitialized) {
|
||||
const form = document.getElementById("receiptForm");
|
||||
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 gallery = document.getElementById("receiptGallery");
|
||||
|
||||
if (!form || !inputCamera || !inputGallery || !gallery) return;
|
||||
|
||||
const isDesktop = window.matchMedia("(pointer: fine)").matches;
|
||||
|
||||
if (isDesktop) {
|
||||
if (cameraBtn) cameraBtn.remove(); // całkowicie usuń przycisk
|
||||
if (inputCamera) inputCamera.remove(); // oraz input
|
||||
if (galleryBtnText) galleryBtnText.textContent = "➕ Dodaj paragon";
|
||||
}
|
||||
|
||||
function handleFileUpload(inputElement) {
|
||||
const file = inputElement.files[0];
|
||||
if (!file) {
|
||||
@@ -44,19 +55,22 @@ if (!window.receiptUploaderInitialized) {
|
||||
progressBar.style.width = "0%";
|
||||
progressBar.textContent = "";
|
||||
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;
|
||||
|
||||
@@ -65,6 +79,12 @@ if (!window.receiptUploaderInitialized) {
|
||||
}
|
||||
lightbox = GLightbox({ selector: ".glightbox" });
|
||||
|
||||
// Pokaż sekcję OCR jeśli była ukryta
|
||||
const analysisBlock = document.getElementById("receiptAnalysisBlock");
|
||||
if (analysisBlock) {
|
||||
analysisBlock.classList.remove("d-none");
|
||||
}
|
||||
|
||||
if (!window.receiptToastShown) {
|
||||
showToast("Wgrano paragon", "success");
|
||||
window.receiptToastShown = true;
|
||||
@@ -72,10 +92,11 @@ 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");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -83,22 +104,9 @@ if (!window.receiptUploaderInitialized) {
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
inputCamera.addEventListener("change", () => handleFileUpload(inputCamera));
|
||||
inputGallery.addEventListener("change", () => handleFileUpload(inputGallery));
|
||||
inputCamera?.addEventListener("change", () => handleFileUpload(inputCamera));
|
||||
inputGallery?.addEventListener("change", () => handleFileUpload(inputGallery));
|
||||
});
|
||||
|
||||
window.receiptUploaderInitialized = true;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const galleryBtn = document.getElementById("galleryBtn");
|
||||
const galleryInput = document.getElementById("galleryInput");
|
||||
|
||||
const isProbablyMobile =
|
||||
window.matchMedia("(pointer: coarse)").matches || /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent);
|
||||
|
||||
if (!isProbablyMobile && galleryBtn && galleryInput) {
|
||||
galleryBtn.style.display = "none";
|
||||
galleryInput.disabled = true;
|
||||
}
|
||||
});
|
||||
|
14
static/js/select_month.js
Normal file
14
static/js/select_month.js
Normal file
@@ -0,0 +1,14 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const select = document.getElementById("monthSelect");
|
||||
if (!select) return;
|
||||
select.addEventListener("change", () => {
|
||||
const month = select.value;
|
||||
const url = new URL(window.location.href);
|
||||
if (month) {
|
||||
url.searchParams.set("month", month);
|
||||
} else {
|
||||
url.searchParams.delete("month");
|
||||
}
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
});
|
@@ -46,7 +46,7 @@ socket.on('connect', function () {
|
||||
});
|
||||
|
||||
socket.on('disconnect', function (reason) {
|
||||
showToast('Utracono połączenie z serwerem...', 'warning');
|
||||
//showToast('Utracono połączenie z serwerem...', 'warning');
|
||||
disableCheckboxes(true);
|
||||
});
|
||||
|
||||
|
@@ -2,53 +2,54 @@ let sortable = null;
|
||||
let isSorting = false;
|
||||
|
||||
function enableSortMode() {
|
||||
if (sortable || isSorting) return;
|
||||
if (isSorting) return;
|
||||
isSorting = true;
|
||||
window.isSorting = true;
|
||||
localStorage.setItem('sortModeEnabled', 'true');
|
||||
|
||||
const itemsContainer = document.getElementById('items');
|
||||
const listId = window.LIST_ID;
|
||||
|
||||
if (!itemsContainer || !listId) return;
|
||||
|
||||
sortable = Sortable.create(itemsContainer, {
|
||||
animation: 150,
|
||||
handle: '.drag-handle',
|
||||
ghostClass: 'drag-ghost',
|
||||
filter: 'input, button',
|
||||
preventOnFilter: false,
|
||||
onEnd: function () {
|
||||
const order = Array.from(itemsContainer.children)
|
||||
.map(li => parseInt(li.id.replace('item-', '')))
|
||||
.filter(id => !isNaN(id));
|
||||
|
||||
fetch('/reorder_items', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ list_id: listId, order })
|
||||
}).then(() => {
|
||||
showToast('Zapisano nową kolejność', 'success');
|
||||
|
||||
if (window.currentItems) {
|
||||
window.currentItems = order.map(id =>
|
||||
window.currentItems.find(item => item.id === id)
|
||||
);
|
||||
updateListSmoothly(window.currentItems);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const btn = document.getElementById('sort-toggle-btn');
|
||||
if (btn) {
|
||||
btn.textContent = '✔️ Zakończ sortowanie';
|
||||
btn.classList.remove('btn-outline-warning');
|
||||
btn.classList.add('btn-outline-success');
|
||||
}
|
||||
|
||||
// Odśwież widok listy z uchwytami (☰)
|
||||
if (window.currentItems) {
|
||||
updateListSmoothly(window.currentItems);
|
||||
}
|
||||
|
||||
// Poczekaj na DOM po odświeżeniu listy
|
||||
setTimeout(() => {
|
||||
if (sortable) sortable.destroy();
|
||||
|
||||
sortable = Sortable.create(itemsContainer, {
|
||||
animation: 150,
|
||||
handle: '.drag-handle',
|
||||
ghostClass: 'drag-ghost',
|
||||
filter: 'input, button',
|
||||
preventOnFilter: false,
|
||||
onEnd: () => {
|
||||
const order = Array.from(itemsContainer.children)
|
||||
.map(li => parseInt(li.id.replace('item-', '')))
|
||||
.filter(id => !isNaN(id));
|
||||
|
||||
fetch('/reorder_items', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ list_id: listId, order })
|
||||
}).then(() => {
|
||||
showToast('Zapisano nową kolejność', 'success');
|
||||
|
||||
if (window.currentItems) {
|
||||
window.currentItems = order.map(id =>
|
||||
window.currentItems.find(item => item.id === id)
|
||||
);
|
||||
updateListSmoothly(window.currentItems);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
updateSortButtonUI(true);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function disableSortMode() {
|
||||
@@ -56,28 +57,40 @@ function disableSortMode() {
|
||||
sortable.destroy();
|
||||
sortable = null;
|
||||
}
|
||||
|
||||
isSorting = false;
|
||||
localStorage.removeItem('sortModeEnabled');
|
||||
|
||||
const btn = document.getElementById('sort-toggle-btn');
|
||||
if (btn) {
|
||||
btn.textContent = '✳️ Zmień kolejność';
|
||||
btn.classList.remove('btn-outline-success');
|
||||
btn.classList.add('btn-outline-warning');
|
||||
}
|
||||
|
||||
window.isSorting = false;
|
||||
if (window.currentItems) {
|
||||
updateListSmoothly(window.currentItems);
|
||||
}
|
||||
|
||||
updateSortButtonUI(false);
|
||||
|
||||
}
|
||||
|
||||
function toggleSortMode() {
|
||||
isSorting ? disableSortMode() : enableSortMode();
|
||||
}
|
||||
|
||||
function updateSortButtonUI(active) {
|
||||
const btn = document.getElementById('sort-toggle-btn');
|
||||
if (!btn) return;
|
||||
|
||||
if (active) {
|
||||
btn.textContent = '✔️ Zakończ sortowanie';
|
||||
btn.classList.remove('btn-outline-warning');
|
||||
btn.classList.add('btn-outline-success');
|
||||
} else {
|
||||
btn.textContent = '✳️ Zmień kolejność';
|
||||
btn.classList.remove('btn-outline-success');
|
||||
btn.classList.add('btn-outline-warning');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const wasSorting = localStorage.getItem('sortModeEnabled') === 'true';
|
||||
if (wasSorting) {
|
||||
enableSortMode();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
160
static/js/user_expense_lists.js
Normal file
160
static/js/user_expense_lists.js
Normal file
@@ -0,0 +1,160 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const checkboxes = document.querySelectorAll('.list-checkbox');
|
||||
const totalEl = document.getElementById('listsTotal');
|
||||
const filterButtons = document.querySelectorAll('.range-btn');
|
||||
const rows = document.querySelectorAll('#listsTableBody tr');
|
||||
|
||||
const onlyWith = document.getElementById('onlyWithExpenses');
|
||||
const customStart = document.getElementById('customStart');
|
||||
const customEnd = document.getElementById('customEnd');
|
||||
|
||||
// Przywróć zapisane daty
|
||||
if (localStorage.getItem('customStart')) {
|
||||
customStart.value = localStorage.getItem('customStart');
|
||||
}
|
||||
if (localStorage.getItem('customEnd')) {
|
||||
customEnd.value = localStorage.getItem('customEnd');
|
||||
}
|
||||
|
||||
function updateTotal() {
|
||||
let total = 0;
|
||||
checkboxes.forEach(cb => {
|
||||
const row = cb.closest('tr');
|
||||
if (cb.checked && row.style.display !== 'none') {
|
||||
total += parseFloat(cb.dataset.amount);
|
||||
}
|
||||
});
|
||||
|
||||
totalEl.textContent = total.toFixed(2) + ' PLN';
|
||||
totalEl.parentElement.classList.add('animate__animated', 'animate__fadeIn');
|
||||
setTimeout(() => {
|
||||
totalEl.parentElement.classList.remove('animate__animated', 'animate__fadeIn');
|
||||
}, 400);
|
||||
}
|
||||
|
||||
checkboxes.forEach(cb => cb.addEventListener('change', updateTotal));
|
||||
|
||||
filterButtons.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
filterButtons.forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
const range = btn.dataset.range;
|
||||
|
||||
// Czyść lokalne daty przy kliknięciu zakresu
|
||||
localStorage.removeItem('customStart');
|
||||
localStorage.removeItem('customEnd');
|
||||
|
||||
const now = new Date();
|
||||
const todayStr = now.toISOString().slice(0, 10);
|
||||
const year = now.getFullYear();
|
||||
const month = now.toISOString().slice(0, 7);
|
||||
const week = `${year}-${String(getISOWeek(now)).padStart(2, '0')}`;
|
||||
|
||||
rows.forEach(row => {
|
||||
const rDate = row.dataset.date;
|
||||
const rMonth = row.dataset.month;
|
||||
const rWeek = row.dataset.week;
|
||||
const rYear = row.dataset.year;
|
||||
|
||||
let show = true;
|
||||
if (range === 'day') show = rDate === todayStr;
|
||||
if (range === 'month') show = rMonth === month;
|
||||
if (range === 'week') show = rWeek === week;
|
||||
if (range === 'year') show = rYear === String(year);
|
||||
|
||||
row.style.display = show ? '' : 'none';
|
||||
});
|
||||
|
||||
applyExpenseFilter();
|
||||
updateTotal();
|
||||
});
|
||||
});
|
||||
|
||||
function getISOWeek(date) {
|
||||
const target = new Date(date.valueOf());
|
||||
const dayNr = (date.getDay() + 6) % 7;
|
||||
target.setDate(target.getDate() - dayNr + 3);
|
||||
const firstThursday = new Date(target.getFullYear(), 0, 4);
|
||||
const dayDiff = (target - firstThursday) / 86400000;
|
||||
return 1 + Math.floor(dayDiff / 7);
|
||||
}
|
||||
|
||||
document.getElementById('applyCustomRange').addEventListener('click', () => {
|
||||
const start = customStart.value;
|
||||
const end = customEnd.value;
|
||||
|
||||
// Zapamiętaj daty
|
||||
localStorage.setItem('customStart', start);
|
||||
localStorage.setItem('customEnd', end);
|
||||
|
||||
filterButtons.forEach(b => b.classList.remove('active'));
|
||||
|
||||
rows.forEach(row => {
|
||||
const date = row.dataset.date;
|
||||
const show = (!start || date >= start) && (!end || date <= end);
|
||||
row.style.display = show ? '' : 'none';
|
||||
});
|
||||
|
||||
applyExpenseFilter();
|
||||
updateTotal();
|
||||
});
|
||||
|
||||
if (onlyWith) {
|
||||
onlyWith.addEventListener('change', () => {
|
||||
applyExpenseFilter();
|
||||
updateTotal();
|
||||
});
|
||||
}
|
||||
|
||||
function applyExpenseFilter() {
|
||||
if (!onlyWith || !onlyWith.checked) return;
|
||||
rows.forEach(row => {
|
||||
const amt = parseFloat(row.querySelector('.list-checkbox').dataset.amount || 0);
|
||||
if (amt <= 0) row.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Domyślnie kliknij „Miesiąc”
|
||||
const defaultBtn = document.querySelector('.range-btn[data-range="month"]');
|
||||
if (defaultBtn && !customStart.value && !customEnd.value) {
|
||||
defaultBtn.click();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const toggleBtn = document.getElementById("toggleAllCheckboxes");
|
||||
let allChecked = false;
|
||||
|
||||
toggleBtn?.addEventListener("click", () => {
|
||||
const checkboxes = document.querySelectorAll(".list-checkbox");
|
||||
allChecked = !allChecked;
|
||||
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = allChecked;
|
||||
});
|
||||
|
||||
toggleBtn.textContent = allChecked ? "🚫 Odznacz wszystkie" : "✅ Zaznacz wszystkie";
|
||||
const updateTotalEvent = new Event('change');
|
||||
checkboxes.forEach(cb => cb.dispatchEvent(updateTotalEvent));
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("applyCustomRange")?.addEventListener("click", () => {
|
||||
const start = document.getElementById("customStart")?.value;
|
||||
const end = document.getElementById("customEnd")?.value;
|
||||
if (start && end) {
|
||||
const url = `/user_expenses?start_date=${start}&end_date=${end}`;
|
||||
window.location.href = url;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("showAllLists").addEventListener("change", function () {
|
||||
const checked = this.checked;
|
||||
const url = new URL(window.location.href);
|
||||
if (checked) {
|
||||
url.searchParams.set("show_all", "true");
|
||||
} else {
|
||||
url.searchParams.delete("show_all");
|
||||
}
|
||||
window.location.href = url.toString();
|
||||
});
|
@@ -3,7 +3,11 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
const rangeLabel = document.getElementById("chartRangeLabel");
|
||||
|
||||
function loadExpenses(range = "monthly", startDate = null, endDate = null) {
|
||||
let url = '/user/expenses_data?range=' + range;
|
||||
let url = '/user_expenses_data?range=' + range;
|
||||
const showAllCheckbox = document.getElementById("showAllLists");
|
||||
if (showAllCheckbox && showAllCheckbox.checked) {
|
||||
url += '&show_all=true';
|
||||
}
|
||||
if (startDate && endDate) {
|
||||
url += `&start_date=${startDate}&end_date=${endDate}`;
|
||||
}
|
||||
|
39
static/js/user_receipt_crop.js
Normal file
39
static/js/user_receipt_crop.js
Normal file
@@ -0,0 +1,39 @@
|
||||
(function () {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const cropModal = document.getElementById("userCropModal");
|
||||
const cropImage = document.getElementById("userCropImage");
|
||||
const spinner = document.getElementById("userCropLoading");
|
||||
const saveButton = document.getElementById("userSaveCrop");
|
||||
|
||||
if (!cropModal || !cropImage || !spinner || !saveButton) return;
|
||||
|
||||
let cropper;
|
||||
let currentReceiptId;
|
||||
const currentEndpoint = "/user_crop_receipt";
|
||||
|
||||
cropModal.addEventListener("shown.bs.modal", function (event) {
|
||||
const button = event.relatedTarget;
|
||||
const imgSrc = button.getAttribute("data-img-src");
|
||||
currentReceiptId = button.getAttribute("data-receipt-id");
|
||||
cropImage.src = imgSrc;
|
||||
|
||||
document.querySelectorAll('.cropper-container').forEach(e => e.remove());
|
||||
|
||||
if (cropper) cropper.destroy();
|
||||
cropImage.onload = () => {
|
||||
cropper = cropUtils.initCropper(cropImage);
|
||||
};
|
||||
});
|
||||
|
||||
cropModal.addEventListener("hidden.bs.modal", function () {
|
||||
cropUtils.cleanUpCropper(cropImage, cropper);
|
||||
cropper = null;
|
||||
});
|
||||
|
||||
saveButton.addEventListener("click", function () {
|
||||
if (!cropper) return;
|
||||
spinner.classList.remove("d-none");
|
||||
cropUtils.handleCrop(currentEndpoint, currentReceiptId, cropper, spinner);
|
||||
});
|
||||
});
|
||||
})();
|
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}
|
1
static/lib/css/sort_table.min.css
vendored
Normal file
1
static/lib/css/sort_table.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.sortable thead th:not(.no-sort){cursor:pointer}.sortable thead th:not(.no-sort)::after,.sortable thead th:not(.no-sort)::before{transition:color .1s ease-in-out;font-size:1.2em;color:rgba(0,0,0,0)}.sortable thead th:not(.no-sort)::after{margin-left:3px;content:"▸"}.sortable thead th:not(.no-sort):hover::after{color:inherit}.sortable thead th:not(.no-sort)[aria-sort=descending]::after{color:inherit;content:"▾"}.sortable thead th:not(.no-sort)[aria-sort=ascending]::after{color:inherit;content:"▴"}.sortable thead th:not(.no-sort).indicator-left::after{content:""}.sortable thead th:not(.no-sort).indicator-left::before{margin-right:3px;content:"▸"}.sortable thead th:not(.no-sort).indicator-left:hover::before{color:inherit}.sortable thead th:not(.no-sort).indicator-left[aria-sort=descending]::before{color:inherit;content:"▾"}.sortable thead th:not(.no-sort).indicator-left[aria-sort=ascending]::before{color:inherit;content:"▴"}
|
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
4
static/lib/js/sort_table.min.js
vendored
Normal file
4
static/lib/js/sort_table.min.js
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
document.addEventListener("click",function(d){try{var A=d.shiftKey||d.altKey,f=function k(a,l){return a.nodeName===l?a:k(a.parentNode,l)}(d.target,"TH"),v=f.parentNode,w=v.parentNode,g=w.parentNode;if("THEAD"===w.nodeName&&g.classList.contains("sortable")&&!f.classList.contains("no-sort")){var h=v.cells;for(d=0;d<h.length;d++)h[d]!==f&&h[d].removeAttribute("aria-sort");h="descending";("descending"===f.getAttribute("aria-sort")||g.classList.contains("asc")&&"ascending"!==f.getAttribute("aria-sort"))&&
|
||||
(h="ascending");f.setAttribute("aria-sort",h);g.dataset.timer&&clearTimeout(+g.dataset.timer);g.dataset.timer=setTimeout(function(){(function(a,l){function k(b){if(b){if(l&&b.dataset.sortAlt)return b.dataset.sortAlt;if(b.dataset.sort)return b.dataset.sort;if(b.textContent)return b.textContent}return""}a.dispatchEvent(new Event("sort-start",{bubbles:!0}));for(var p=a.tHead.querySelector("th[aria-sort]"),q=a.tHead.children[0],B="ascending"===p.getAttribute("aria-sort"),C=a.classList.contains("n-last"),
|
||||
y=function(b,m,c){var e=k(m.cells[c]),n=k(b.cells[c]);if(C){if(""===e&&""!==n)return-1;if(""===n&&""!==e)return 1}var x=+e-+n;e=isNaN(x)?e.localeCompare(n):x;return 0===e&&q.cells[c]&&q.cells[c].hasAttribute("data-sort-tbr")?y(b,m,+q.cells[c].dataset.sortTbr):B?-e:e},r=0;r<a.tBodies.length;r++){var t=a.tBodies[r],z=[].slice.call(t.rows,0);z.sort(function(b,m){var c;return y(b,m,+(null!==(c=p.dataset.sortCol)&&void 0!==c?c:p.cellIndex))});var u=t.cloneNode();u.append.apply(u,z);a.replaceChild(u,t)}a.dispatchEvent(new Event("sort-end",
|
||||
{bubbles:!0}))})(g,A)},1).toString()}}catch{}});
|
@@ -80,7 +80,7 @@
|
||||
<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">
|
||||
<table class="table table-dark table-striped align-middle sortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" id="select-all"></th>
|
||||
@@ -195,9 +195,13 @@
|
||||
});
|
||||
</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 }}
|
||||
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 %}
|
@@ -65,6 +65,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Aktualna data utworzenia listy</label>
|
||||
<p class="form-control-plaintext text-white">
|
||||
{{ list.created_at.strftime('%Y-%m-%d') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="created_month" class="form-label">Przenieś listę do miesiąca</label>
|
||||
<input type="month" id="created_month" name="created_month"
|
||||
class="form-control bg-dark text-white border-secondary rounded">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mb-4">
|
||||
@@ -111,7 +124,30 @@
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ item.name }}</strong>
|
||||
<small class="text-muted">(x{{ item.quantity }})</small>
|
||||
<small class="text-small text-success">(x{{ item.quantity }})</small>
|
||||
|
||||
{% if item.note %}
|
||||
<div class="text-info small mt-1">
|
||||
<strong>Notatka:</strong> {{ item.note }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.not_purchased_reason %}
|
||||
<div class="text-warning small mt-1">
|
||||
<strong>Powód:</strong> {{ item.not_purchased_reason }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('edit_list', list_id=list.id) }}" class="mt-2">
|
||||
<input type="hidden" name="action" value="edit_quantity">
|
||||
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||
<div class="input-group input-group-sm ">
|
||||
<input type="number" name="quantity"
|
||||
class="form-control bg-dark text-white border-secondary rounded-left" min="1"
|
||||
value="{{ item.quantity }}">
|
||||
<button type="submit" class="btn btn-outline-light">💾</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
{% if item.purchased %}
|
||||
@@ -152,12 +188,6 @@
|
||||
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||
<button type="submit" class="btn btn-outline-success btn-sm">✅ Przywróć na liste</button>
|
||||
</form>
|
||||
|
||||
{% if item.not_purchased_reason %}
|
||||
<div class="mt-2 text-warning small border-top pt-2">
|
||||
<strong>Powód:</strong> {{ item.not_purchased_reason }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
@@ -192,23 +222,36 @@
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
{% for img in receipts %}
|
||||
{% set file_path = upload_folder ~ '/' ~ img %}
|
||||
{% set file_size = (file_path | filesizeformat) %}
|
||||
{% set upload_time = (file_path | filemtime) %}
|
||||
{% for r in receipts %}
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<div class="card bg-dark text-white h-100">
|
||||
<a href="{{ url_for('uploaded_file', filename=img) }}" data-lightbox="receipts" data-title="{{ img }}"
|
||||
class="glightbox">
|
||||
<img src="{{ url_for('uploaded_file', filename=img) }}" class="card-img-top"
|
||||
<a href="{{ url_for('uploaded_file', filename=r.filename) }}" class="glightbox" data-gallery="receipts"
|
||||
data-title="{{ r.filename }}">
|
||||
<img src="{{ url_for('uploaded_file', filename=r.filename) }}" class="card-img-top"
|
||||
style="object-fit: cover; height: 200px;">
|
||||
</a>
|
||||
<div class="card-body text-center">
|
||||
<p class="small text-truncate mb-1">{{ img }}</p>
|
||||
<p class="small mb-1">Rozmiar: {{ file_size }}</p>
|
||||
<p class="small mb-1">Wgrano: {{ upload_time.strftime('%Y-%m-%d %H:%M') }}</p>
|
||||
<a href="{{ url_for('delete_receipt', filename=img) }}?next={{ url_for('edit_list', list_id=list.id) }}"
|
||||
class="btn btn-sm btn-outline-danger w-100">🗑️ Usuń</a>
|
||||
<p class="small text-truncate mb-1">{{ r.filename }}</p>
|
||||
<p class="small mb-1">Wgrano: {{ r.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</p>
|
||||
|
||||
{% if r.filesize and r.filesize >= 1024 * 1024 %}
|
||||
<p class="small mb-1">Rozmiar: {{ (r.filesize / 1024 / 1024) | round(2) }} MB</p>
|
||||
{% elif r.filesize %}
|
||||
<p class="small mb-1">Rozmiar: {{ (r.filesize / 1024) | round(1) }} kB</p>
|
||||
{% else %}
|
||||
<p class="small mb-1 text-muted">Brak danych o rozmiarze</p>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ url_for('rotate_receipt', receipt_id=r.id) }}"
|
||||
class="btn btn-sm btn-outline-warning w-100 mb-2">🔄 Obróć o 90°</a>
|
||||
<a href="{{ url_for('rename_receipt', receipt_id=r.id) }}" class="btn btn-sm btn-outline-info w-100 mb-2">✏️
|
||||
Zmień nazwę</a>
|
||||
{% if not r.file_hash %}
|
||||
<a href="{{ url_for('generate_receipt_hash', receipt_id=r.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary w-100 mb-2">🔐 Generuj hash</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('delete_receipt', receipt_id=r.id) }}"
|
||||
class="btn btn-sm btn-outline-danger w-100 mb-2">🗑️ Usuń</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -4,46 +4,122 @@
|
||||
|
||||
<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_all') }}" class="btn 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="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<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) %}
|
||||
{% for r in receipts %}
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<div class="card bg-dark text-white h-100">
|
||||
<a href="{{ url_for('uploaded_file', filename=img) }}" class="glightbox" data-gallery="receipts"
|
||||
data-title="{{ img }}">
|
||||
<img src="{{ url_for('uploaded_file', filename=img) }}" class="card-img-top"
|
||||
<a href="{{ url_for('uploaded_file', filename=r.filename) }}" class="glightbox" data-gallery="receipts"
|
||||
data-title="{{ r.filename }}">
|
||||
<img src="{{ url_for('uploaded_file', filename=r.filename) }}" class="card-img-top"
|
||||
style="object-fit: cover; height: 200px;">
|
||||
</a>
|
||||
<div class="card-body text-center">
|
||||
<p class="small text-truncate mb-1">{{ img }}</p>
|
||||
<p class="small mb-1">Rozmiar: {{ file_size }}</p>
|
||||
<p class="small mb-1">Wgrano: {{ upload_time.strftime('%Y-%m-%d %H:%M') }}</p>
|
||||
{% if list_id %}
|
||||
<a href="{{ url_for('edit_list', list_id=list_id|int) }}" class="btn btn-sm btn-outline-light w-100 mb-2">✏️
|
||||
Edytuj listę #{{ list_id }}</a>
|
||||
<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('delete_receipt', filename=img) }}?next={{ request.path }}"
|
||||
class="btn btn-sm btn-outline-danger w-100">🗑️ Usuń</a>
|
||||
<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="#adminCropModal" data-img-src="{{ url_for('uploaded_file', filename=r.filename) }}"
|
||||
data-receipt-id="{{ r.id }}" data-crop-endpoint="{{ url_for('crop_receipt_admin') }}">
|
||||
✂️ 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"
|
||||
onclick="return confirm('Na pewno usunąć plik {{ r.filename }}?');">🗑️
|
||||
Usuń</a>
|
||||
<a href="{{ url_for('edit_list', list_id=r.list_id) }}" class="btn btn-sm btn-outline-light w-100 mb-2">✏️
|
||||
Edytuj listę #{{ r.list_id }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{% if not image_files %}
|
||||
{% if not receipts %}
|
||||
<div class="alert alert-info text-center mt-4" role="alert">
|
||||
Nie wgrano żadnych paragonów.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if orphan_files and request.path.endswith('/all') %}
|
||||
<hr class="my-4">
|
||||
<h4 class="mt-3 mb-2 text-warning">Znalezione nieprzypisane pliki ({{ orphan_files_count }})</h4>
|
||||
<div class="row g-3">
|
||||
{% for f in orphan_files %}
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<div class="card bg-dark border-warning text-warning h-100">
|
||||
<a href="{{ url_for('uploaded_file', filename=f) }}" class="glightbox" data-gallery="receipts"
|
||||
data-title="{{ f }}">
|
||||
<img src="{{ url_for('uploaded_file', filename=f) }}" class="card-img-top"
|
||||
style="object-fit: cover; height: 200px;">
|
||||
</a>
|
||||
<div class="card-body text-center">
|
||||
<p class="small mb-1 fw-bold">{{ f }}</p>
|
||||
<div class="alert alert-warning small py-1 mb-2">Brak powiązania z listą!</div>
|
||||
<a href="{{ url_for('delete_receipt', filename=f) }}" class="btn btn-sm btn-outline-danger w-100 mb-2"
|
||||
onclick="return confirm('Na pewno usunąć WYŁĄCZNIE plik {{ f }} z dysku?');">
|
||||
🗑 Usuń plik z serwera
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="modal fade" id="adminCropModal" tabindex="-1" aria-labelledby="userCropModalLabel" 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="adminCropImage" 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="adminSaveCrop">Zapisz</button>
|
||||
<div id="adminCropLoading" class="position-absolute top-50 start-50 translate-middle text-center d-none">
|
||||
<div class="spinner-border text-light" role="status"></div>
|
||||
<div class="mt-2 text-light">⏳ Pracuję...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='admin_receipt_crop.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop_logic.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% endblock %}
|
@@ -9,8 +9,12 @@
|
||||
{% if not is_blocked %}
|
||||
<link href="{{ url_for('static_bp.serve_css', filename='style.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static_bp.serve_css_lib', filename='glightbox.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static_bp.serve_css_lib', filename='sort_table.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">
|
||||
@@ -48,7 +52,6 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -58,6 +61,12 @@
|
||||
|
||||
<div id="toast-container" class="toast-container position-fixed bottom-0 end-0 p-3"></div>
|
||||
|
||||
<footer class="text-center text-secondary small mt-5 mb-3">
|
||||
<hr class="text-secondary">
|
||||
<p class="mb-0">© 2025 <strong>linuxiarz.pl</strong> · <a href="https://gitea.linuxiarz.pl/gru/lista_zakupowa_live"
|
||||
target="_blank" class="link-success text-decoration-none"> source code</a>
|
||||
</footer>
|
||||
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='bootstrap.bundle.min.js') }}"></script>
|
||||
{% if not is_blocked %}
|
||||
<script>
|
||||
@@ -78,6 +87,7 @@
|
||||
{% 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_lib', filename='sort_table.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>
|
||||
@@ -88,6 +98,11 @@
|
||||
selector: '.glightbox'
|
||||
});
|
||||
</script>
|
||||
|
||||
{% if '/admin/receipts' in request.path or '/edit_my_list' in request.path %}
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='cropper.min.js') }}"></script>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
|
@@ -37,6 +37,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Aktualna data utworzenia:</label>
|
||||
<p class="form-control-plaintext text-white">
|
||||
{{ list.created_at.strftime('%Y-%m-%d') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="move_to_month" class="form-label">Przenieś listę do miesiąca</label>
|
||||
<input type="month" id="move_to_month" name="move_to_month"
|
||||
class="form-control bg-dark text-white border-secondary rounded">
|
||||
</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 %}>
|
||||
@@ -47,9 +62,49 @@
|
||||
<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="#" class="btn btn-sm btn-outline-secondary w-100 mb-2 disabled" data-bs-toggle="modal"
|
||||
data-bs-target="#userCropModal" data-img-src="{{ url_for('uploaded_file', filename=r.filename) }}"
|
||||
data-receipt-id="{{ r.id }}" data-crop-endpoint="{{ url_for('crop_receipt_user') }}">
|
||||
✂️ Przytnij
|
||||
</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">
|
||||
@@ -81,14 +136,35 @@
|
||||
</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>
|
||||
|
||||
<div class="modal fade" id="userCropModal" tabindex="-1" aria-labelledby="userCropModalLabel" 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="userCropImage" 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="userSaveCrop">Zapisz</button>
|
||||
<div id="userCropLoading" class="position-absolute top-50 start-50 translate-middle text-center d-none">
|
||||
<div class="spinner-border text-light" role="status"></div>
|
||||
<div class="mt-2 text-light">⏳ Pracuję...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='confirm_delete.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='user_receipt_crop.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop_logic.js') }}"></script>
|
||||
{% endblock %}
|
@@ -55,7 +55,7 @@
|
||||
📊 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>%)
|
||||
(<span id="percent-value">{{ percent|int }}</span>%)
|
||||
</h5>
|
||||
|
||||
<div class="progress progress-dark position-relative">
|
||||
@@ -108,36 +108,49 @@
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
{% if item.note %}
|
||||
<small class="text-danger ms-4">[ <b>{{ item.note }}</b> ]</small>
|
||||
{% endif %}
|
||||
<div class="info-line ms-4 small d-flex flex-wrap gap-2" id="info-{{ item.id }}">
|
||||
{% set info_parts = [] %}
|
||||
{% if item.note %}
|
||||
{% set _ = info_parts.append('<span class="text-danger">[ <b>' ~ item.note ~ '</b> ]</span>') %}
|
||||
{% endif %}
|
||||
{% if item.not_purchased_reason %}
|
||||
{% set _ = info_parts.append('<span class="text-dark">[ <b>Powód: ' ~ item.not_purchased_reason ~ '</b>
|
||||
]</span>') %}
|
||||
{% endif %}
|
||||
{% if item.added_by_display %}
|
||||
{% set _ = info_parts.append('<span class="text-info">[ Dodał/a: <b>' ~ item.added_by_display ~ '</b> ]</span>')
|
||||
%}
|
||||
{% endif %}
|
||||
|
||||
{% if item.not_purchased_reason %}
|
||||
<small class="text-dark ms-4">[ <b>Powód: {{ item.not_purchased_reason }}</b> ]</small>
|
||||
{% endif %}
|
||||
{% if info_parts %}
|
||||
<div class="info-line ms-4 small d-flex flex-wrap gap-2" id="info-{{ item.id }}">
|
||||
{{ info_parts | join(' ') | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
{% if item.not_purchased %}
|
||||
<button type="button" class="btn btn-outline-success" {% if list.is_archived %}disabled{% else
|
||||
%}onclick="unmarkNotPurchased({{ item.id }})" {% endif %}>
|
||||
✅ Przywróć
|
||||
{% if not is_share %}
|
||||
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else %}
|
||||
onclick="editItem({{ item.id }}, '{{ item.name }}', {{ item.quantity or 1 }})" {% endif %}>
|
||||
✏️
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else
|
||||
%}onclick="markNotPurchasedModal(event, {{ item.id }})" {% endif %}>
|
||||
⚠️
|
||||
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else %}
|
||||
onclick="deleteItem({{ item.id }})" {% endif %}>
|
||||
🗑️
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if not is_share %}
|
||||
<button type="button" class="btn btn-outline-warning" {% if list.is_archived %}disabled{% else
|
||||
%}onclick="editItem({{ item.id }}, '{{ item.name }}', {{ item.quantity or 1 }})" {% endif %}>
|
||||
✏️
|
||||
{% if item.not_purchased %}
|
||||
<button type="button" class="btn btn-outline-light me-auto" {% if list.is_archived %}disabled{% else %}
|
||||
onclick="unmarkNotPurchased({{ item.id }})" {% endif %}>
|
||||
✅ Przywróć
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger" {% if list.is_archived %}disabled{% else
|
||||
%}onclick="deleteItem({{ item.id }})" {% endif %}>
|
||||
🗑️
|
||||
{% elif not item.not_purchased %}
|
||||
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else %}
|
||||
onclick="markNotPurchasedModal(event, {{ item.id }})" {% endif %}>
|
||||
⚠️
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -215,6 +228,8 @@
|
||||
const isShare = document.getElementById('items').dataset.isShare === 'true';
|
||||
window.IS_SHARE = isShare;
|
||||
window.LIST_ID = {{ list.id }};
|
||||
window.IS_OWNER = {{ 'true' if is_owner else 'false' }};
|
||||
|
||||
</script>
|
||||
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='mass_add.js') }}"></script>
|
||||
|
@@ -5,7 +5,6 @@
|
||||
<h2 class="mb-2">
|
||||
🛍️ {{ list.title }}
|
||||
|
||||
|
||||
{% if list.is_archived %}
|
||||
<span class="badge bg-secondary ms-2">(Archiwalna)</span>
|
||||
{% endif %}
|
||||
@@ -44,34 +43,48 @@
|
||||
{% 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 class="info-line ms-4 small d-flex flex-wrap gap-2" id="info-{{ item.id }}">
|
||||
{% set info_parts = [] %}
|
||||
{% if item.note %}
|
||||
{% set _ = info_parts.append('<span class="text-danger">[ <b>' ~ item.note ~ '</b> ]</span>') %}
|
||||
{% endif %}
|
||||
{% if item.not_purchased_reason %}
|
||||
{% set _ = info_parts.append('<span class="text-dark">[ <b>Powód: ' ~ item.not_purchased_reason ~ '</b>
|
||||
]</span>') %}
|
||||
{% endif %}
|
||||
{% if item.added_by_display %}
|
||||
{% set _ = info_parts.append('<span class="text-info">[ Dodał/a: <b>' ~ item.added_by_display ~ '</b> ]</span>')
|
||||
%}
|
||||
{% endif %}
|
||||
|
||||
{% if info_parts %}
|
||||
<div class="info-line ms-4 small d-flex flex-wrap gap-2" id="info-{{ item.id }}">
|
||||
{{ info_parts | join(' ') | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</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 %}>
|
||||
<button type="button" class="btn btn-outline-light me-auto" {% if list.is_archived %}disabled{% else %}
|
||||
onclick="unmarkNotPurchased({{ item.id }})" {% endif %}>
|
||||
✅ Przywróć
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else
|
||||
%}onclick="markNotPurchasedModal(event, {{ item.id }})" {% endif %}>
|
||||
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else %}
|
||||
onclick="markNotPurchasedModal(event, {{ item.id }})" {% endif %}>
|
||||
⚠️
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else
|
||||
%}onclick="openNoteModal(event, {{ item.id }})" {% endif %}>
|
||||
{% endif %}
|
||||
|
||||
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else %}
|
||||
onclick="openNoteModal(event, {{ item.id }})" {% endif %}>
|
||||
📝
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</li>
|
||||
|
||||
|
||||
{% else %}
|
||||
<li id="empty-placeholder" class="list-group-item bg-dark text-secondary text-center w-100">
|
||||
Brak produktów w tej liście.
|
||||
@@ -81,10 +94,12 @@
|
||||
|
||||
{% if not list.is_archived %}
|
||||
<div class="input-group mb-2">
|
||||
<input id="newItem" class="form-control bg-dark text-white border-secondary" placeholder="Dodaj produkt i ilość">
|
||||
<input id="newItem" class="form-control bg-dark text-white border-secondary" placeholder="Dodaj produkt i ilość" {% if
|
||||
not current_user.is_authenticated %}disabled{% endif %}>
|
||||
<input id="newQuantity" type="number" class="form-control bg-dark text-white border-secondary" placeholder="Ilość"
|
||||
min="1" value="1" style="max-width: 90px;">
|
||||
<button onclick="addItem({{ list.id }})" class="btn btn-success rounded-end">➕ Dodaj</button>
|
||||
min="1" value="1" style="max-width: 90px;" {% if not current_user.is_authenticated %}disabled{% endif %}>
|
||||
<button onclick="addItem({{ list.id }})" class="btn btn-success rounded-end" {% if not current_user.is_authenticated
|
||||
%}disabled{% endif %}>➕ Dodaj</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -99,15 +114,29 @@
|
||||
{% endif %}
|
||||
<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 id="toggleReceiptBtn" class="btn btn-outline-light mb-3 w-100 w-md-auto d-block mx-auto" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#receiptSection" aria-expanded="false" aria-controls="receiptSection">
|
||||
📄 Pokaż sekcję paragonów
|
||||
</button>
|
||||
<div class="collapse" id="receiptSection">
|
||||
<div class="collapse px-2 px-md-4" id="receiptSection">
|
||||
{% set receipt_pattern = 'list_' ~ list.id %}
|
||||
|
||||
<h5 class="mt-4">📸 Paragony dodane do tej listy</h5>
|
||||
<div class="mt-3 p-3 border border-secondary rounded bg-dark text-white {% if not receipt_files %}d-none{% endif %}"
|
||||
id="receiptAnalysisBlock">
|
||||
<h5>🧠 Analiza paragonów (OCR)</h5>
|
||||
<p class="text-small">System spróbuje automatycznie rozpoznać kwoty z dodanych paragonów.</p>
|
||||
|
||||
{% 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>
|
||||
|
||||
<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 %}
|
||||
@@ -125,35 +154,35 @@
|
||||
{% 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">
|
||||
|
||||
<!-- Zrób zdjęcie -->
|
||||
<!-- 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="cameraInput">
|
||||
|
||||
<!-- Z galerii -->
|
||||
<!-- 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> 🖼️ Z galerii
|
||||
<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 id="progressContainer" class="progress progress-dark rounded-3 overflow-hidden shadow-sm"
|
||||
style="height: 20px; display: none;">
|
||||
<div id="progressBar" class="progress-bar bg-success fw-bold text-white text-center" role="progressbar"
|
||||
style="width: 0%;">0%</div>
|
||||
</div>
|
||||
|
||||
<div id="receiptGallery" class="mt-3"></div>
|
||||
</form>
|
||||
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Modal notatki -->
|
||||
@@ -192,7 +221,7 @@
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='clickable_row.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='receipt_section.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='receipt_upload.js') }}"></script>
|
||||
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='receipt_analysis.js') }}"></script>
|
||||
<script>
|
||||
setupList({{ list.id }}, '{{ current_user.username if current_user.is_authenticated else 'Gość' }}');
|
||||
</script>
|
||||
|
@@ -31,6 +31,33 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% set month_names = ["styczeń", "luty", "marzec", "kwiecień", "maj", "czerwiec", "lipiec", "sierpień", "wrzesień",
|
||||
"październik", "listopad", "grudzień"] %}
|
||||
{% set selected_month = request.args.get('month') or now.strftime('%Y-%m') %}
|
||||
|
||||
<!-- Pulpit: zwykły <select> -->
|
||||
<div class="d-none d-md-flex justify-content-end align-items-center flex-wrap gap-2 mb-3">
|
||||
<label for="monthSelect" class="text-white small mb-0">📅 Wybierz miesiąc:</label>
|
||||
<select id="monthSelect" class="form-select form-select-sm bg-dark text-white border-secondary"
|
||||
style="min-width: 180px;">
|
||||
{% for offset in range(0, 6) %}
|
||||
{% set d = (now - timedelta(days=offset * 30)) %}
|
||||
{% set val = d.strftime('%Y-%m') %}
|
||||
<option value="{{ val }}" {% if selected_month==val %}selected{% endif %}>
|
||||
{{ month_names[d.month - 1] }} {{ d.year }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
<option value="">Wyświetl wszystko</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Telefon: przycisk otwierający modal -->
|
||||
<div class="d-md-none mb-3">
|
||||
<button class="btn btn-outline-light w-100" data-bs-toggle="modal" data-bs-target="#monthPickerModal">
|
||||
📅 Wybierz miesiąc
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<h3 class="mt-4 d-flex justify-content-between align-items-center flex-wrap">
|
||||
Twoje listy
|
||||
@@ -78,8 +105,7 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p><span class="badge rounded-pill bg-secondary opacity-75">Nie masz jeszcze żadnych list. Utwórz pierwszą, korzystając
|
||||
z formularza powyżej!</span></p>
|
||||
<p><span class="badge rounded-pill bg-secondary opacity-75">Nie utworzono żadnej listy</span></p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
@@ -114,7 +140,6 @@
|
||||
<p><span class="badge rounded-pill bg-secondary opacity-75">Brak dostępnych list publicznych do wyświetlenia</span></p>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<div class="modal fade" id="archivedModal" tabindex="-1" aria-labelledby="archivedModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content bg-dark text-white">
|
||||
@@ -146,8 +171,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="monthPickerModal" tabindex="-1" aria-labelledby="monthPickerModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content bg-dark text-white">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">📅 Wybierz miesiąc</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="d-grid gap-2">
|
||||
{% for offset in range(0, 6) %}
|
||||
{% set d = (now - timedelta(days=offset * 30)) %}
|
||||
{% set val = d.strftime('%Y-%m') %}
|
||||
<a href="{{ url_for('main_page', month=val) }}" class="btn btn-outline-light">
|
||||
{{ month_names[d.month - 1] }} {{ d.year }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
<a href="{{ url_for('main_page') }}" class="btn btn-outline-secondary">📋 Wyświetl wszystkie</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='toggle_button.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='select_month.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% endblock %}
|
@@ -1,21 +1,27 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}📊 Twoje wydatki{% endblock %}
|
||||
{% block title %}Wydatki z Twoich list{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
|
||||
<h2 class="mb-2">Statystyki wydatków</h2>
|
||||
<a href="{{ url_for('main_page') }}" class="btn btn-outline-secondary">← Powrót</a>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="showAllLists" {% if show_all %}checked{% endif %}>
|
||||
<label class="form-check-label ms-2 text-white" for="showAllLists">Pokaż wszystkie publiczne listy
|
||||
innych</label>
|
||||
</div>
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<ul class="nav nav-tabs mb-3" id="expenseTabs" role="tablist">
|
||||
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="table-tab" data-bs-toggle="tab" data-bs-target="#tableTab" type="button"
|
||||
<button class="nav-link active" id="lists-tab" data-bs-toggle="tab" data-bs-target="#listsTab" type="button"
|
||||
role="tab">
|
||||
📄 Tabela
|
||||
📚 Listy
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="chart-tab" data-bs-toggle="tab" data-bs-target="#chartTab" type="button"
|
||||
role="tab">
|
||||
@@ -25,32 +31,80 @@
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="expenseTabsContent">
|
||||
<!-- Tabela -->
|
||||
<div class="tab-pane fade show active" id="tableTab" role="tabpanel">
|
||||
|
||||
<!-- LISTY -->
|
||||
<div class="tab-pane fade show active" id="listsTab" role="tabpanel">
|
||||
<div class="card bg-dark text-white mb-4">
|
||||
<div class="card-body">
|
||||
{% if expense_table %}
|
||||
<div class="row g-4">
|
||||
{% for row in expense_table %}
|
||||
<div class="col-12 col-sm-6 col-lg-4">
|
||||
<div class="card bg-dark text-white border border-secondary h-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-truncate" title="{{ row.title }}">{{ row.title }}</h5>
|
||||
<p class="mb-1">💸 <strong>{{ '%.2f'|format(row.amount) }} PLN</strong></p>
|
||||
<p class="mb-0">📅 {{ row.added_at.strftime('%Y-%m-%d') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="day">🗓️ Dzień</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="week">📆 Tydzień</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn active" data-range="month">📅 Miesiąc</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="year">📈 Rok</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="all">🌐 Wszystko</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info text-center mb-0">Brak wydatków do wyświetlenia.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="onlyWithExpenses">
|
||||
<label class="form-check-label ms-2 text-white" for="onlyWithExpenses">Pokaż tylko listy z
|
||||
wydatkami</label>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="input-group input-group-sm mb-3 w-100" style="max-width: 570px;">
|
||||
<span class="input-group-text bg-secondary text-white border-secondary">Od</span>
|
||||
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="customStart">
|
||||
<span class="input-group-text bg-secondary text-white border-secondary">Do</span>
|
||||
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="customEnd">
|
||||
<button class="btn btn-outline-success" id="applyCustomRange">📊 Zastosuj zakres</button>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end mb-2">
|
||||
<button id="toggleAllCheckboxes" class="btn btn-outline-light btn-sm">
|
||||
✅ Zaznacz wszystkie
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-striped align-middle sortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Nazwa listy</th>
|
||||
<th>Data</th>
|
||||
<th>Wydatki (PLN)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="listsTableBody">
|
||||
{% for list in lists_data %}
|
||||
<tr data-date="{{ list.created_at.strftime('%Y-%m-%d') }}"
|
||||
data-week="{{ list.created_at.isocalendar()[0] }}-{{ '%02d' % list.created_at.isocalendar()[1] }}"
|
||||
data-month="{{ list.created_at.strftime('%Y-%m') }}" data-year="{{ list.created_at.year }}">
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input list-checkbox"
|
||||
data-amount="{{ '%.2f'|format(list.total_expense) }}">
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{ list.title }}</strong>
|
||||
<br><small class="text-small">👤 {{ list.owner_username or '?' }}</small>
|
||||
|
||||
</td>
|
||||
<td>{{ list.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td>{{ '%.2f'|format(list.total_expense) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h5 class="text-success mt-3">💰 Suma zaznaczonych: <span id="listsTotal">0.00 PLN</span></h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wykres -->
|
||||
|
||||
<!-- WYKRES -->
|
||||
<div class="tab-pane fade" id="chartTab" role="tabpanel">
|
||||
<div class="card bg-dark text-white mb-4">
|
||||
<div class="card-body">
|
||||
@@ -59,31 +113,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mb-4">
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<button class="btn btn-outline-light btn-sm range-btn active" data-range="monthly">📅 Miesięczne</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="quarterly">📆 Kwartalne</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="quarterly">📊 Kwartalne</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="halfyearly">🗓️ Półroczne</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="yearly">📈 Roczne</button>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<input type="date" id="startDate" class="form-control bg-dark text-white border-secondary rounded">
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<input type="date" id="endDate" class="form-control bg-dark text-white border-secondary rounded">
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<button class="btn btn-outline-light w-100" id="customRangeBtn">📊 Zakres własny</button>
|
||||
</div>
|
||||
<!-- Picker daty w formie input-group -->
|
||||
<div class="input-group input-group-sm mb-4 w-100" style="max-width: 570px;">
|
||||
<span class="input-group-text bg-secondary text-white border-secondary">Od</span>
|
||||
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="startDate">
|
||||
<span class="input-group-text bg-secondary text-white border-secondary">Do</span>
|
||||
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="endDate">
|
||||
<button class="btn btn-outline-success" id="customRangeBtn">📊 Pokaż dane z zakresu</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='chart.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='user_expenses.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='user_expense_lists.js') }}"></script>
|
||||
{% endblock %}
|
Reference in New Issue
Block a user