wersja 0.0.4 #7
135
.env.example
135
.env.example
@@ -1,29 +1,60 @@
|
||||
# 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
|
||||
HEALTHCHECK_TOKEN=alamapsaikota123
|
||||
# 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
|
||||
|
||||
# sesja zalogowanego usera (domyślnie 7 dni)
|
||||
SESSION_TIMEOUT_MINUTES=10080
|
||||
# SESSION_COOKIE_SECURE:
|
||||
# Określa, czy ciasteczko sesyjne (Flask session) ma mieć ustawiony atrybut "Secure".
|
||||
# Wymusza, by przeglądarka przesyłała je tylko przez HTTPS.
|
||||
# W środowisku deweloperskim (HTTP) ustaw na 0, by uniknąć błędu "secure cookie over insecure connection".
|
||||
# Zalecane: 1 w produkcji (HTTPS), 0 w dev.
|
||||
SESSION_COOKIE_SECURE=0
|
||||
|
||||
|
||||
# HEALTHCHECK_TOKEN:
|
||||
# Token wykorzystywany do sprawdzania stanu aplikacji (np. w Docker Compose)
|
||||
# Domyślnie: alamapsaikota123
|
||||
HEALTHCHECK_TOKEN=alamapsaikota123
|
||||
|
||||
# Rodzaj bazy: sqlite, pgsql, mysql
|
||||
# Mozliwe wartosci: sqlite / pgsql / mysql
|
||||
@@ -37,17 +68,93 @@ DB_ENGINE=sqlite
|
||||
# Ustaw DB_ENGINE=pgsql
|
||||
# Domyslny port PostgreSQL to 5432
|
||||
# Wymaga dzialajacego serwera PostgreSQL (np. kontener `postgres`)
|
||||
# Przyklad URI: postgresql://user:pass@db:5432/myapp
|
||||
|
||||
# --- Konfiguracja dla mysql ---
|
||||
# Ustaw DB_ENGINE=mysql
|
||||
# Domyslny port MySQL to 3306
|
||||
# Wymaga kontenera z MySQL i uzytkownika z dostepem do bazy
|
||||
# Przyklad URI: mysql+pymysql://user:pass@db:3306/myapp
|
||||
|
||||
# Wspolne zmienne (dla pgsql, mysql)
|
||||
DB_HOST=db
|
||||
# DB_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"
|
||||
|
||||
|
16
config.py
16
config.py
@@ -30,3 +30,19 @@ class Config:
|
||||
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")
|
@@ -27,10 +27,7 @@ services:
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- ./db/pgsql:/var/lib/postgresql/data
|
||||
#ports:
|
||||
# - ":5432:5432"
|
||||
restart: unless-stopped
|
||||
hostname: db
|
||||
profiles: ["pgsql"]
|
||||
|
||||
mysql:
|
||||
@@ -43,8 +40,5 @@ services:
|
||||
MYSQL_ROOT_PASSWORD: 89o38kUX5T4C
|
||||
volumes:
|
||||
- ./db/mysql:/var/lib/mysql
|
||||
#ports:
|
||||
# - "3306:3306"
|
||||
restart: unless-stopped
|
||||
hostname: db
|
||||
profiles: ["mysql"]
|
||||
profiles: ["mysql"]
|
@@ -14,3 +14,4 @@ opencv-python-headless
|
||||
psycopg2-binary # pgsql
|
||||
pymysql # mysql
|
||||
cryptography # mysql8
|
||||
flask-talisman # nagłówki
|
@@ -205,7 +205,6 @@ input.form-control {
|
||||
box-shadow: 0 -1px 4px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.info-bar-fixed {
|
||||
position: static;
|
||||
|
@@ -4,22 +4,22 @@ 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");
|
||||
|
||||
const image = document.getElementById("cropImage");
|
||||
image.src = imgSrc;
|
||||
cropImage.src = imgSrc;
|
||||
|
||||
if (cropper) {
|
||||
cropper.destroy();
|
||||
cropper = null;
|
||||
}
|
||||
|
||||
image.onload = () => {
|
||||
cropper = new Cropper(image, {
|
||||
cropImage.onload = () => {
|
||||
cropper = new Cropper(cropImage, {
|
||||
viewMode: 1,
|
||||
autoCropArea: 1,
|
||||
responsive: true,
|
||||
@@ -36,7 +36,51 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
document.getElementById("saveCrop").addEventListener("click", function () {
|
||||
if (!cropper) return;
|
||||
|
||||
cropper.getCroppedCanvas().toBlob(function (blob) {
|
||||
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);
|
||||
@@ -47,6 +91,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
spinner.classList.add("d-none");
|
||||
if (data.success) {
|
||||
showToast("Zapisano przycięty paragon", "success");
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
@@ -55,9 +100,10 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
spinner.classList.add("d-none");
|
||||
showToast("Błąd sieci", "danger");
|
||||
console.error(err);
|
||||
});
|
||||
}, "image/webp");
|
||||
}, "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);
|
||||
});
|
||||
});
|
||||
})();
|
@@ -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);
|
||||
});
|
||||
|
||||
|
@@ -127,69 +127,59 @@ function setupList(listId, username) {
|
||||
showToast(`Dodano wydatek: ${data.amount.toFixed(2)} PLN`, 'info');
|
||||
});
|
||||
|
||||
|
||||
socket.on('item_added', data => {
|
||||
showToast(`${data.added_by} dodał: ${data.name}`, 'info');
|
||||
const li = document.createElement('li');
|
||||
li.className = 'list-group-item d-flex justify-content-between align-items-center flex-wrap item-not-checked';
|
||||
li.id = `item-${data.id}`;
|
||||
|
||||
let quantityBadge = '';
|
||||
if (data.quantity && data.quantity > 1) {
|
||||
quantityBadge = `<span class="badge bg-secondary">x${data.quantity}</span>`;
|
||||
}
|
||||
|
||||
const countdownId = `countdown-${data.id}`;
|
||||
const countdownBtn = `
|
||||
<button type="button" class="btn btn-outline-warning" id="${countdownId}" disabled>15s</button>
|
||||
`;
|
||||
|
||||
li.innerHTML = `
|
||||
<div class="d-flex align-items-center flex-wrap gap-2 flex-grow-1">
|
||||
<input class="large-checkbox" type="checkbox">
|
||||
<span id="name-${data.id}" class="text-white">
|
||||
${data.name} ${quantityBadge}
|
||||
</span>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
${countdownBtn}
|
||||
<button type="button" class="btn btn-outline-light"
|
||||
onclick="editItem(${data.id}, '${data.name.replace(/'/g, "\\'")}', ${data.quantity || 1})">
|
||||
✏️
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-light"
|
||||
onclick="deleteItem(${data.id})">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
const item = {
|
||||
...data,
|
||||
purchased: false,
|
||||
not_purchased: false,
|
||||
not_purchased_reason: '',
|
||||
note: ''
|
||||
};
|
||||
|
||||
const li = renderItem(item, false, true); // ← tryb 15s
|
||||
document.getElementById('items').appendChild(li);
|
||||
toggleEmptyPlaceholder();
|
||||
updateProgressBar();
|
||||
|
||||
// ⏳ Licznik odliczania
|
||||
let seconds = 15;
|
||||
const countdownEl = document.getElementById(countdownId);
|
||||
const intervalId = setInterval(() => {
|
||||
seconds--;
|
||||
if (countdownEl) {
|
||||
countdownEl.textContent = `${seconds}s`;
|
||||
}
|
||||
if (seconds <= 0) {
|
||||
clearInterval(intervalId);
|
||||
if (countdownEl) countdownEl.remove();
|
||||
}
|
||||
}, 1000);
|
||||
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';
|
||||
|
||||
// 🔁 Request listy po 15s
|
||||
setTimeout(() => {
|
||||
if (window.LIST_ID) {
|
||||
socket.emit('request_full_list', { list_id: window.LIST_ID });
|
||||
}
|
||||
}, 15000);
|
||||
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 => {
|
||||
const li = document.getElementById(`item-${data.item_id}`);
|
||||
if (li) {
|
||||
@@ -215,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 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})`, 'success');
|
||||
});
|
||||
|
||||
updateProgressBar();
|
||||
toggleEmptyPlaceholder();
|
||||
socket.on('item_edited', data => {
|
||||
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;
|
||||
|
||||
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;
|
||||
|
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();
|
||||
});
|
@@ -16,7 +16,6 @@ if (!window.receiptUploaderInitialized) {
|
||||
|
||||
const isDesktop = window.matchMedia("(pointer: fine)").matches;
|
||||
|
||||
// 🧼 Jedno miejsce, pełna logika desktopowa
|
||||
if (isDesktop) {
|
||||
if (cameraBtn) cameraBtn.remove(); // całkowicie usuń przycisk
|
||||
if (inputCamera) inputCamera.remove(); // oraz input
|
||||
@@ -80,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;
|
||||
@@ -96,7 +101,6 @@ if (!window.receiptUploaderInitialized) {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
|
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();
|
||||
});
|
||||
});
|
@@ -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);
|
||||
});
|
||||
});
|
||||
})();
|
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:"▴"}
|
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,6 +195,7 @@
|
||||
});
|
||||
</script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='expenses.js') }}"></script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
<div class="info-bar-fixed">
|
||||
|
@@ -65,6 +65,21 @@
|
||||
</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">
|
||||
<label class="form-label">Link do udostępnienia</label>
|
||||
<input type="text" class="form-control bg-dark text-white border-secondary rounded" readonly
|
||||
|
@@ -5,8 +5,8 @@
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
|
||||
<h2 class="mb-2">📸 Wszystkie paragony</h2>
|
||||
<div>
|
||||
<a href="{{ url_for('recalculate_filesizes') }}" class="btn btn-sm btn-outline-primary me-2">
|
||||
🔄 Przelicz rozmiary plików
|
||||
<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>
|
||||
@@ -36,16 +36,18 @@
|
||||
<a href="{{ url_for('rotate_receipt', receipt_id=r.id) }}"
|
||||
class="btn btn-sm btn-outline-warning w-100 mb-2">🔄 Obróć o 90°</a>
|
||||
<a href="#" class="btn btn-sm btn-outline-secondary w-100 mb-2" data-bs-toggle="modal"
|
||||
data-bs-target="#cropModal" data-img-src="{{ url_for('uploaded_file', filename=r.filename) }}"
|
||||
data-receipt-id="{{ r.id }}">✂️ Przytnij</a>
|
||||
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">🗑️
|
||||
<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>
|
||||
@@ -64,7 +66,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="cropModal" tabindex="-1" aria-labelledby="cropModalLabel" aria-hidden="true">
|
||||
{% 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">
|
||||
@@ -72,19 +100,26 @@
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div style="position: relative; width: 100%; height: 75vh;">
|
||||
<img id="cropImage" style="max-width: 100%; max-height: 100%; display: block; margin: auto;">
|
||||
<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="saveCrop">Zapisz</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,6 +9,7 @@
|
||||
{% 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 %}
|
||||
@@ -51,7 +52,6 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -87,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>
|
||||
@@ -98,7 +99,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{% if '/admin/' in request.path %}
|
||||
{% 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 %}
|
||||
|
||||
|
@@ -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 %}>
|
||||
@@ -76,6 +91,11 @@
|
||||
<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>
|
||||
@@ -116,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>
|
||||
|
@@ -43,31 +43,47 @@
|
||||
{% 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">
|
||||
@@ -98,16 +114,15 @@
|
||||
{% 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 %}
|
||||
|
||||
{% if receipt_files %}
|
||||
<hr>
|
||||
<div class="mt-3 p-3 border border-secondary rounded bg-dark text-white" id="receiptAnalysisBlock">
|
||||
<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>
|
||||
|
||||
@@ -118,14 +133,10 @@
|
||||
{% else %}
|
||||
<div class="alert alert-warning">🔒 Tylko zalogowani użytkownicy mogą zlecać analizę OCR paragonów.</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="analysisResults" class="mt-2"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<h5 class="mt-4">📸 Paragony dodane do tej listy</h5>
|
||||
|
||||
<div class="row g-3 mt-2" id="receiptGallery">
|
||||
{% if receipt_files %}
|
||||
{% for file in receipt_files %}
|
||||
@@ -163,14 +174,15 @@
|
||||
</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 -->
|
||||
|
@@ -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 %}
|
@@ -6,16 +6,22 @@
|
||||
<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