Compare commits
9 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
a902205960 | ||
![]() |
355b73775f | ||
![]() |
81744b5c5e | ||
![]() |
735fc69562 | ||
![]() |
17a5fd2086 | ||
![]() |
9986716e9e | ||
![]() |
759c78ce87 | ||
![]() |
365791cd35 | ||
![]() |
08b680f030 |
@@ -1,66 +0,0 @@
|
||||
# =========================
|
||||
# Stage 1 – Build
|
||||
# =========================
|
||||
FROM python:3.13-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Instalacja bibliotek do kompilacji + zależności runtime
|
||||
RUN apk add --no-cache \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-data-pol \
|
||||
poppler-utils \
|
||||
libjpeg-turbo \
|
||||
zlib \
|
||||
libpng \
|
||||
libwebp \
|
||||
libffi \
|
||||
libmagic \
|
||||
&& apk add --no-cache --virtual .build-deps \
|
||||
build-base \
|
||||
jpeg-dev \
|
||||
zlib-dev \
|
||||
libpng-dev \
|
||||
libwebp-dev \
|
||||
libffi-dev
|
||||
|
||||
# Kopiujemy plik wymagań
|
||||
COPY requirements.txt .
|
||||
|
||||
# Instalujemy zależności Pythona do folderu tymczasowego
|
||||
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
||||
|
||||
|
||||
# =========================
|
||||
# Stage 2 – Final image
|
||||
# =========================
|
||||
FROM python:3.13-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Instalacja tylko bibliotek runtime (bez dev)
|
||||
RUN apk add --no-cache \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-data-pol \
|
||||
poppler-utils \
|
||||
libjpeg-turbo \
|
||||
zlib \
|
||||
libpng \
|
||||
libwebp \
|
||||
libffi \
|
||||
libmagic
|
||||
|
||||
# Kopiujemy zbudowane biblioteki z buildera
|
||||
COPY --from=builder /install /usr/local
|
||||
|
||||
# Kopiujemy kod aplikacji
|
||||
COPY . .
|
||||
|
||||
# Ustawiamy entrypoint
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Otwieramy port aplikacji
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
75
app.py
75
app.py
@@ -885,12 +885,13 @@ def get_admin_expense_summary():
|
||||
}
|
||||
|
||||
|
||||
def category_to_color(name):
|
||||
hash_val = int(hashlib.md5(name.encode("utf-8")).hexdigest(), 16)
|
||||
hue = (hash_val % 360) / 360.0
|
||||
saturation = 0.60 + ((hash_val >> 8) % 17) / 100.0
|
||||
lightness = 0.28 + ((hash_val >> 16) % 11) / 100.0
|
||||
import hashlib, colorsys
|
||||
|
||||
def category_to_color(name: str) -> str:
|
||||
hash_val = int(hashlib.md5(name.encode("utf-8")).hexdigest(), 16)
|
||||
hue = ((hash_val % 360) + ((hash_val >> 24) % 20)) % 360 / 360.0
|
||||
saturation = 0.55 + ((hash_val >> 8) % 40) / 100.0
|
||||
lightness = 0.35 + ((hash_val >> 16) % 30) / 100.0
|
||||
r, g, b = colorsys.hls_to_rgb(hue, lightness, saturation)
|
||||
return f"#{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}"
|
||||
|
||||
@@ -2110,10 +2111,6 @@ def create_list():
|
||||
|
||||
@app.route("/list/<int:list_id>")
|
||||
@login_required
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Widok listy właściciela – dopięcie permitted_users do kontekstu
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@login_required
|
||||
def view_list(list_id):
|
||||
shopping_list = db.session.get(ShoppingList, list_id)
|
||||
if not shopping_list:
|
||||
@@ -2126,30 +2123,45 @@ def view_list(list_id):
|
||||
flash("W celu modyfikacji listy, przejdź do panelu administracyjnego.", "info")
|
||||
return redirect(url_for("shared_list", token=shopping_list.share_token))
|
||||
|
||||
# Twoja obecna logika ładująca szczegóły listy:
|
||||
shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id)
|
||||
total_count = len(items)
|
||||
purchased_count = len([i for i in items if i.purchased])
|
||||
percent = (purchased_count / total_count * 100) if total_count > 0 else 0
|
||||
|
||||
# Uzupełnienie "added_by_display" — jak u Ciebie:
|
||||
for item in items:
|
||||
if item.added_by != shopping_list.owner_id:
|
||||
item.added_by_display = (item.added_by_user.username if item.added_by_user else "?")
|
||||
else:
|
||||
item.added_by_display = None
|
||||
|
||||
# Badges kategorii (jak u Ciebie)
|
||||
shopping_list.category_badges = [
|
||||
{"name": c.name, "color": category_to_color(c.name)}
|
||||
for c in shopping_list.categories
|
||||
]
|
||||
|
||||
# Dane do modala kategorii
|
||||
# Wszystkie kategorie (do selecta)
|
||||
categories = Category.query.order_by(Category.name.asc()).all()
|
||||
selected_categories_ids = {c.id for c in shopping_list.categories}
|
||||
|
||||
# ⬅️ NOWE: użytkownicy z uprawnieniami do tej listy (dla modala w list.html)
|
||||
# Najczęściej używane kategorie właściciela (top N)
|
||||
popular_categories = (
|
||||
db.session.query(Category)
|
||||
.join(
|
||||
shopping_list_category,
|
||||
shopping_list_category.c.category_id == Category.id,
|
||||
)
|
||||
.join(
|
||||
ShoppingList,
|
||||
ShoppingList.id == shopping_list_category.c.shopping_list_id,
|
||||
)
|
||||
.filter(ShoppingList.owner_id == current_user.id)
|
||||
.group_by(Category.id)
|
||||
.order_by(func.count(ShoppingList.id).desc(), func.lower(Category.name).asc())
|
||||
.limit(6)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Użytkownicy z uprawnieniami do listy
|
||||
permitted_users = (
|
||||
db.session.query(User)
|
||||
.join(ListPermission, ListPermission.user_id == User.id)
|
||||
@@ -2172,7 +2184,8 @@ def view_list(list_id):
|
||||
is_owner=is_owner,
|
||||
categories=categories,
|
||||
selected_categories=selected_categories_ids,
|
||||
permitted_users=permitted_users, # ⬅️ ważne dla tokenów w modalu
|
||||
permitted_users=permitted_users,
|
||||
popular_categories=popular_categories,
|
||||
)
|
||||
|
||||
|
||||
@@ -3720,10 +3733,10 @@ def recalculate_filesizes_all():
|
||||
return redirect(url_for("admin_receipts", id="all"))
|
||||
|
||||
|
||||
@app.route("/admin/mass_edit_categories", methods=["GET", "POST"])
|
||||
@app.route("/admin/edit_categories", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_mass_edit_categories():
|
||||
def admin_edit_categories():
|
||||
page, per_page = get_page_args(default_per_page=50, max_per_page=200)
|
||||
|
||||
lists_query = ShoppingList.query.options(
|
||||
@@ -3752,13 +3765,13 @@ def admin_mass_edit_categories():
|
||||
db.session.commit()
|
||||
flash("Zaktualizowano kategorie dla wybranych list", "success")
|
||||
return redirect(
|
||||
url_for("admin_mass_edit_categories", page=page, per_page=per_page)
|
||||
url_for("admin_edit_categories", page=page, per_page=per_page)
|
||||
)
|
||||
|
||||
query_string = urlencode({k: v for k, v in request.args.items() if k != "page"})
|
||||
|
||||
return render_template(
|
||||
"admin/mass_edit_categories.html",
|
||||
"admin/edit_categories.html",
|
||||
lists=lists,
|
||||
categories=categories,
|
||||
page=page,
|
||||
@@ -3768,6 +3781,30 @@ def admin_mass_edit_categories():
|
||||
query_string=query_string,
|
||||
)
|
||||
|
||||
@app.route("/admin/edit_categories/<int:list_id>/save", methods=["POST"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_edit_categories_save(list_id):
|
||||
l = db.session.get(ShoppingList, list_id)
|
||||
if not l:
|
||||
return jsonify(ok=False, error="not_found"), 404
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
ids = data.get("category_ids", [])
|
||||
|
||||
try:
|
||||
ids = [int(x) for x in ids]
|
||||
except (TypeError, ValueError):
|
||||
return jsonify(ok=False, error="bad_ids"), 400
|
||||
|
||||
l.categories.clear()
|
||||
if ids:
|
||||
cats = Category.query.filter(Category.id.in_(ids)).all()
|
||||
l.categories.extend(cats)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify(ok=True, count=len(l.categories)), 200
|
||||
|
||||
|
||||
@app.route("/admin/list_items/<int:list_id>")
|
||||
@login_required
|
||||
|
@@ -77,7 +77,7 @@ class Config:
|
||||
DEFAULT_CATEGORIES = [
|
||||
c.strip() for c in os.environ.get(
|
||||
"DEFAULT_CATEGORIES",
|
||||
"Spożywcze,Budowlane,Zabawki,Chemia,Inne,Elektronika,Odzież i obuwie,"
|
||||
"Spożywcze,Budowlane,Zabawki,Chemia,Inne,Elektronika,Odzież i obuwie,Jedzenie poza domem,"
|
||||
"Artykuły biurowe,Kosmetyki i higiena,Motoryzacja,Ogród i rośliny,"
|
||||
"Zwierzęta,Sprzęt sportowy,Książki i prasa,Narzędzia i majsterkowanie,"
|
||||
"RTV / AGD,Apteka i suplementy,Artykuły dekoracyjne,Gry i hobby,Usługi,Pieczywo,Różne,Chiny,Dom,Leki,Odzież,Samochód"
|
||||
|
@@ -75,6 +75,27 @@ sub vcl_recv {
|
||||
set req.http.X-Forwarded-Proto = "https";
|
||||
}
|
||||
|
||||
if (req.url == "/healthcheck" || req.http.X-Internal-Check) {
|
||||
set req.http.X-Pass-Reason = "internal";
|
||||
return (pass);
|
||||
}
|
||||
|
||||
if (req.method != "GET" && req.method != "HEAD") {
|
||||
set req.http.X-Pass-Reason = "method";
|
||||
return (pass);
|
||||
}
|
||||
|
||||
if (req.http.Authorization) {
|
||||
set req.http.X-Pass-Reason = "auth";
|
||||
return (pass);
|
||||
}
|
||||
|
||||
# jeśli chcesz PASS przy cookie:
|
||||
# if (req.http.Cookie) {
|
||||
# set req.http.X-Pass-Reason = "cookie";
|
||||
# return (pass);
|
||||
# }
|
||||
|
||||
return (hash);
|
||||
}
|
||||
|
||||
@@ -107,6 +128,7 @@ sub vcl_backend_response {
|
||||
if (beresp.http.Cache-Control ~ "(?i)no-store|private") {
|
||||
set beresp.uncacheable = true;
|
||||
set beresp.ttl = 0s;
|
||||
set beresp.http.X-Pass-Reason = "no-store";
|
||||
return (deliver);
|
||||
}
|
||||
|
||||
@@ -114,6 +136,7 @@ sub vcl_backend_response {
|
||||
if (beresp.status >= 300 && beresp.status < 400) {
|
||||
set beresp.uncacheable = true;
|
||||
set beresp.ttl = 0s;
|
||||
set beresp.http.X-Pass-Reason = "redirect";
|
||||
return (deliver);
|
||||
}
|
||||
|
||||
@@ -210,7 +233,14 @@ sub vcl_backend_error {
|
||||
# ===== DELIVER =====
|
||||
sub vcl_deliver {
|
||||
if (obj.uncacheable) {
|
||||
set resp.http.X-Cache = "PASS";
|
||||
if (req.http.X-Pass-Reason) {
|
||||
set resp.http.X-Cache = "PASS:" + req.http.X-Pass-Reason;
|
||||
} else if (resp.http.X-Pass-Reason) { # z backendu
|
||||
set resp.http.X-Cache = "PASS:" + resp.http.X-Pass-Reason;
|
||||
} else {
|
||||
set resp.http.X-Cache = "PASS";
|
||||
}
|
||||
unset resp.http.X-Pass-Reason;
|
||||
unset resp.http.Age;
|
||||
} else if (obj.hits > 0) {
|
||||
set resp.http.X-Cache = "HIT";
|
||||
|
@@ -2,12 +2,16 @@ services:
|
||||
app:
|
||||
build: .
|
||||
container_name: lista-zakupow-app
|
||||
#ports:
|
||||
# - "${APP_PORT:-8000}:8000"
|
||||
expose:
|
||||
- "${APP_PORT:-8000}"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; import sys; req = urllib.request.Request('http://localhost:${APP_PORT:-8000}/healthcheck', headers={'X-Internal-Check': '${HEALTHCHECK_TOKEN}'}); sys.exit(0) if urllib.request.urlopen(req).read() == b'OK' else sys.exit(1)"]
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"python",
|
||||
"-c",
|
||||
"import urllib.request; import sys; req = urllib.request.Request('http://localhost:${APP_PORT:-8000}/healthcheck', headers={'X-Internal-Check': '${HEALTHCHECK_TOKEN}'}); sys.exit(0) if urllib.request.urlopen(req).read() == b'OK' else sys.exit(1)",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -34,13 +38,6 @@ services:
|
||||
- ./deploy/varnish/default.vcl:/etc/varnish/default.vcl:ro
|
||||
environment:
|
||||
- VARNISH_SIZE=256m
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "curl -fsS -H 'X-Internal-Check=${HEALTHCHECK_TOKEN}' http://localhost/healthcheck | grep -q OK" ]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
env_file:
|
||||
- .env
|
||||
networks:
|
||||
- lista-zakupow_network
|
||||
restart: unless-stopped
|
||||
|
43
static/js/categories_autosave.js
Normal file
43
static/js/categories_autosave.js
Normal file
@@ -0,0 +1,43 @@
|
||||
(function () {
|
||||
const $$ = (sel, ctx = document) => Array.from(ctx.querySelectorAll(sel));
|
||||
const $ = (sel, ctx = document) => ctx.querySelector(sel);
|
||||
|
||||
const saveCategories = async (listId, ids, names, listTitle) => {
|
||||
try {
|
||||
const res = await fetch(`/admin/edit_categories/${listId}/save`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ category_ids: ids })
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.ok) throw new Error(data.error || 'save_failed');
|
||||
|
||||
const cats = names.length ? names.join(', ') : 'brak';
|
||||
showToast(`Zapisano kategorie [${cats}] dla listy <b>${listTitle}</b>`, 'success');
|
||||
} catch (err) {
|
||||
console.error('Autosave error:', err);
|
||||
showToast(`Błąd zapisu kategorii dla listy <b>${listTitle}</b>`, 'danger');
|
||||
}
|
||||
};
|
||||
|
||||
const timers = new Map();
|
||||
const debounce = (key, fn, delay = 300) => {
|
||||
clearTimeout(timers.get(key));
|
||||
timers.set(key, setTimeout(fn, delay));
|
||||
};
|
||||
|
||||
$$('.form-select[name^="categories_"]').forEach(select => {
|
||||
const listId = select.getAttribute('data-list-id') || select.name.replace('categories_', '');
|
||||
const listTitle = select.closest('tr')?.querySelector('td a')?.textContent.trim() || `#${listId}`;
|
||||
|
||||
select.addEventListener('change', () => {
|
||||
const selectedOptions = Array.from(select.options).filter(o => o.selected);
|
||||
const ids = selectedOptions.map(o => o.value); // <-- ID
|
||||
const names = selectedOptions.map(o => o.textContent.trim());
|
||||
debounce(listId, () => saveCategories(listId, ids, names, listTitle));
|
||||
});
|
||||
});
|
||||
|
||||
const fallback = $('#fallback-save-btn');
|
||||
if (fallback) fallback.classList.add('d-none');
|
||||
})();
|
18
static/js/category_modal.js
Normal file
18
static/js/category_modal.js
Normal file
@@ -0,0 +1,18 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('#categoriesModal .category-suggestion').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const select = document.getElementById('category_id');
|
||||
if (!select) return;
|
||||
|
||||
select.value = btn.dataset.catId || '';
|
||||
const form = btn.closest('form');
|
||||
if (form) {
|
||||
if (typeof form.requestSubmit === 'function') {
|
||||
form.requestSubmit();
|
||||
} else {
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
@@ -1,27 +1,45 @@
|
||||
(function () {
|
||||
const configs = (window.CROP_CONFIGS && Array.isArray(window.CROP_CONFIGS))
|
||||
? window.CROP_CONFIGS
|
||||
: (window.CROP_CONFIG ? [window.CROP_CONFIG] : []);
|
||||
|
||||
if (!configs.length) return;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const cropModal = document.getElementById("adminCropModal");
|
||||
const cropImage = document.getElementById("adminCropImage");
|
||||
const spinner = document.getElementById("adminCropLoading");
|
||||
const saveButton = document.getElementById("adminSaveCrop");
|
||||
configs.forEach((cfg) => initCropperSet(cfg));
|
||||
});
|
||||
|
||||
function initCropperSet(cfg) {
|
||||
const {
|
||||
modalId,
|
||||
imageId,
|
||||
spinnerId,
|
||||
saveBtnId,
|
||||
endpoint
|
||||
} = cfg || {};
|
||||
|
||||
const cropModal = document.getElementById(modalId);
|
||||
const cropImage = document.getElementById(imageId);
|
||||
const spinner = document.getElementById(spinnerId);
|
||||
const saveButton = document.getElementById(saveBtnId);
|
||||
|
||||
if (!cropModal || !cropImage || !spinner || !saveButton) return;
|
||||
|
||||
let cropper;
|
||||
let currentReceiptId;
|
||||
const currentEndpoint = "/admin/crop_receipt";
|
||||
const currentEndpoint = endpoint;
|
||||
|
||||
cropModal.addEventListener("shown.bs.modal", function (event) {
|
||||
const button = event.relatedTarget;
|
||||
const baseSrc = button.getAttribute("data-img-src") || "";
|
||||
const ver = button.getAttribute("data-version") || Date.now();
|
||||
const baseSrc = button?.getAttribute("data-img-src") || "";
|
||||
const ver = button?.getAttribute("data-version") || Date.now();
|
||||
const sep = baseSrc.includes("?") ? "&" : "?";
|
||||
cropImage.src = baseSrc + sep + "cb=" + ver;
|
||||
|
||||
currentReceiptId = button.getAttribute("data-receipt-id");
|
||||
currentReceiptId = button?.getAttribute("data-receipt-id");
|
||||
|
||||
document.querySelectorAll('.cropper-container').forEach(e => e.remove());
|
||||
if (cropper) cropper.destroy();
|
||||
if (cropper && cropper.destroy) cropper.destroy();
|
||||
cropImage.onload = () => { cropper = cropUtils.initCropper(cropImage); };
|
||||
});
|
||||
|
||||
@@ -35,5 +53,5 @@
|
||||
spinner.classList.remove("d-none");
|
||||
cropUtils.handleCrop(currentEndpoint, currentReceiptId, cropper, spinner);
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
@@ -1,39 +0,0 @@
|
||||
(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 baseSrc = button.getAttribute("data-img-src") || "";
|
||||
const ver = button.getAttribute("data-version") || Date.now();
|
||||
const sep = baseSrc.includes("?") ? "&" : "?";
|
||||
cropImage.src = baseSrc + sep + "cb=" + ver;
|
||||
|
||||
currentReceiptId = button.getAttribute("data-receipt-id");
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
})();
|
@@ -13,7 +13,7 @@
|
||||
<a href="{{ url_for('list_users') }}" class="btn btn-outline-light btn-sm">👥 Użytkownicy</a>
|
||||
<a href="{{ url_for('admin_receipts') }}" class="btn btn-outline-light btn-sm">📸 Paragony</a>
|
||||
<a href="{{ url_for('list_products') }}" class="btn btn-outline-light btn-sm">🛍️ Produkty</a>
|
||||
<a href="{{ url_for('admin_mass_edit_categories') }}" class="btn btn-outline-light btn-sm">🗂 Kategorie</a>
|
||||
<a href="{{ url_for('admin_edit_categories') }}" class="btn btn-outline-light btn-sm">🗂 Kategorie</a>
|
||||
<a href="{{ url_for('admin_lists_access') }}" class="btn btn-outline-light btn-sm">🔐 Uprawnienia</a>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -12,25 +12,24 @@
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning border-warning text-dark" role="alert">
|
||||
⚠️ <strong>Uwaga!</strong> Przypisanie więcej niż jednej kategorii do listy może zaburzyć
|
||||
poprawne zliczanie wydatków, ponieważ wydatki tej listy będą jednocześnie
|
||||
klasyfikowane do kilku kategorii.
|
||||
⚠️ <strong>Uwaga!</strong> Przypisanie więcej niż jednej kategorii do listy może zaburzyć poprawne zliczanie
|
||||
wydatków.
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<form method="post" id="mass-edit-form">
|
||||
<div class="card bg-dark text-white mb-4">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark align-middle sortable">
|
||||
<thead>
|
||||
<table class="table table-dark align-middle sortable mb-0">
|
||||
<thead class="position-sticky top-0 bg-dark">
|
||||
<tr>
|
||||
<th scope="col">ID</th>
|
||||
<th scope="col">Nazwa listy</th>
|
||||
<th scope="col">Właściciel</th>
|
||||
<th scope="col">Data utworzenia</th>
|
||||
<th scope="col">Data</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Podgląd produktów</th>
|
||||
<th scope="col">Kategorie</th>
|
||||
<th scope="col">Podgląd</th>
|
||||
<th scope="col" style="min-width: 260px;">Kategorie</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -44,22 +43,17 @@
|
||||
<td>
|
||||
{% if l.owner %}
|
||||
👤 {{ l.owner.username }} ({{ l.owner.id }})
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
{% else %}-{% endif %}
|
||||
</td>
|
||||
<td>{{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }}</td>
|
||||
<td>
|
||||
{% if l.is_archived %}<span
|
||||
class="badge rounded-pill bg-secondary">Archiwalna</span>{%
|
||||
endif %}
|
||||
class="badge rounded-pill bg-secondary me-1">Archiwalna</span>{% endif %}
|
||||
{% if l.is_temporary %}<span
|
||||
class="badge rounded-pill bg-warning text-dark">Tymczasowa</span>{%
|
||||
class="badge rounded-pill bg-warning text-dark me-1">Tymczasowa</span>{%
|
||||
endif %}
|
||||
{% if l.is_public %}<span
|
||||
class="badge rounded-pill bg-success">Publiczna</span>{% else
|
||||
%}
|
||||
<span class="badge rounded-pill bg-dark">Prywatna</span>{% endif %}
|
||||
{% if l.is_public %}<span class="badge rounded-pill bg-success">Publiczna</span>
|
||||
{% else %}<span class="badge rounded-pill bg-dark">Prywatna</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-sm btn-outline-light preview-btn"
|
||||
@@ -67,24 +61,25 @@
|
||||
🔍 Zobacz
|
||||
</button>
|
||||
</td>
|
||||
<td style="min-width: 220px;">
|
||||
<select name="categories_{{ l.id }}" multiple
|
||||
class="form-select tom-dark bg-dark text-white border-secondary rounded">
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat.id }}" {% if cat in l.categories %}selected{% endif
|
||||
%}>
|
||||
{{ cat.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<select name="categories_{{ l.id }}" multiple
|
||||
class="form-select tom-dark bg-dark text-white border-secondary rounded"
|
||||
data-list-id="{{ l.id }}"
|
||||
aria-label="Wybierz kategorie dla listy {{ l.id }}">
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat.id }}" {% if cat in l.categories %}selected{%
|
||||
endif %}>{{ cat.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
{% if lists|length == 0 %}
|
||||
<tr>
|
||||
<td colspan="12" class="text-center py-4">
|
||||
Brak list zakupowych do wyświetlenia
|
||||
</td>
|
||||
<td colspan="12" class="text-center py-4">Brak list zakupowych do wyświetlenia</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
@@ -92,9 +87,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="btn btn-sm btn-outline-light">💾 Zapisz zmiany</button>
|
||||
</div>
|
||||
|
||||
{# Fallback – ukryty przez JS #}
|
||||
<button type="submit" class="btn btn-sm btn-outline-light" id="fallback-save-btn">💾 Zapisz zmiany</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,8 +115,7 @@
|
||||
</li>
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
<li class="page-item {% if p == page %}active{% endif %}">
|
||||
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ p }}">{{
|
||||
p }}</a>
|
||||
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ p }}">{{ p }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="page-item {% if page >= total_pages %}disabled{% endif %}">
|
||||
@@ -132,7 +126,6 @@
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Modal podglądu produktów -->
|
||||
<div class="modal fade" id="productPreviewModal" tabindex="-1" aria-labelledby="previewModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
@@ -150,7 +143,9 @@
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='preview_list_modal.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='categories_select_admin.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='categories_autosave.js') }}?v={{ APP_VERSION }}"></script>
|
||||
{% endblock %}
|
@@ -55,7 +55,7 @@
|
||||
<input type="hidden" name="action" value="save_changes">
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark align-middle" id="listsTable">
|
||||
<table class="table table-dark align-middle sortable" id="listsTable">
|
||||
<thead class="align-middle">
|
||||
<tr>
|
||||
<th scope="col" style="width:36px;"></th>
|
||||
@@ -77,7 +77,7 @@
|
||||
<input type="hidden" name="visible_ids" value="{{ l.id }}">
|
||||
</td>
|
||||
|
||||
<td class="text-nowrap">#{{ l.id }}</td>
|
||||
<td class="text-nowrap">{{ l.id }}</td>
|
||||
|
||||
<td class="fw-bold align-middle">
|
||||
<a href="{{ url_for('view_list', list_id=l.id) }}" class="text-white text-decoration-none">{{ l.title
|
||||
|
@@ -213,7 +213,16 @@
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='admin_receipt_crop.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script>
|
||||
window.CROP_CONFIG = {
|
||||
modalId: "adminCropModal",
|
||||
imageId: "adminCropImage",
|
||||
spinnerId: "adminCropLoading",
|
||||
saveBtnId: "adminSaveCrop",
|
||||
endpoint: "/admin/crop_receipt"
|
||||
};
|
||||
</script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop_logic.js') }}?v={{ APP_VERSION }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
|
@@ -31,7 +31,7 @@
|
||||
{% endif %}
|
||||
|
||||
{# --- Tom Select CSS tylko dla wybranych podstron --- #}
|
||||
{% set substrings_tomselect = ['/edit_my_list', '/admin/edit_list', '/admin/mass_edit_categories'] %}
|
||||
{% set substrings_tomselect = ['/edit_my_list', '/admin/edit_list', '/admin/edit_categories'] %}
|
||||
{% if substrings_tomselect | select("in", request.path) | list | length > 0 %}
|
||||
<link href="{{ url_for('static_bp.serve_css_lib', filename='tom-select.bootstrap5.min.css') }}?v={{ APP_VERSION }}"
|
||||
rel="stylesheet">
|
||||
@@ -126,7 +126,7 @@
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='cropper.min.js') }}?v={{ APP_VERSION }}"></script>
|
||||
{% endif %}
|
||||
|
||||
{% set substrings = ['/edit_my_list', '/admin/edit_list', '/admin/mass_edit_categories'] %}
|
||||
{% set substrings = ['/edit_my_list', '/admin/edit_list', '/admin/edit_categories'] %}
|
||||
{% if substrings | select("in", request.path) | list | length > 0 %}
|
||||
<script
|
||||
src="{{ url_for('static_bp.serve_js_lib', filename='tom-select.complete.min.js') }}?v={{ APP_VERSION }}"></script>
|
||||
|
@@ -244,8 +244,17 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
window.CROP_CONFIG = {
|
||||
modalId: "userCropModal",
|
||||
imageId: "userCropImage",
|
||||
spinnerId: "userCropLoading",
|
||||
saveBtnId: "userSaveCrop",
|
||||
endpoint: "/user_crop_receipt"
|
||||
};
|
||||
</script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='confirm_delete.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='user_receipt_crop.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop_logic.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='select.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='access_users.js') }}?v={{ APP_VERSION }}"></script>
|
||||
|
@@ -204,12 +204,29 @@
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content bg-dark text-white">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="grantAccessModalLabel">Ustaw kategorię</h5>
|
||||
<h5 class="modal-title" id="categoriesModalLabel">Ustaw kategorię</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('list_settings', list_id=list.id) }}">
|
||||
<div class="modal-body">
|
||||
|
||||
{% if popular_categories %}
|
||||
<div class="mb-3">
|
||||
<div class="small text-secondary mb-1">Najczęściej używane:</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for cat in popular_categories %}
|
||||
<button type="button" class="btn btn-sm btn-outline-light category-suggestion" data-cat-id="{{ cat.id }}">
|
||||
{{ cat.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary category-suggestion" data-cat-id="">
|
||||
– brak –
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="category_id" class="form-label">🏷️ Kategoria listy</label>
|
||||
<select id="category_id" name="category_id"
|
||||
@@ -234,11 +251,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- MODAL: NADAWANIE DOSTĘPU -->
|
||||
<div class="modal fade" id="grantAccessModal" tabindex="-1" aria-labelledby="grantAccessModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
@@ -320,6 +337,7 @@
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='receipt_upload.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='sort_mode.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='access_users.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='category_modal.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script>
|
||||
setupList({{ list.id }}, '{{ current_user.username if current_user.is_authenticated else 'Gość' }}');
|
||||
</script>
|
||||
|
Reference in New Issue
Block a user