Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b94fb1cd97 | |||
| ce20f569a3 | |||
| ec7f32994c | |||
| 8358bf257f | |||
| 193495d6a2 | |||
| 5afcaa5e19 | |||
| 3aa44b64b8 | |||
| 86d8f19313 | |||
| e74798f252 | |||
| 31c3d700a8 | |||
| a3c114e4b5 | |||
| 170a2a53ab | |||
| a8ac08d5b0 | |||
| 05c4c73635 | |||
| 76dd00831e | |||
| 205a56ff24 | |||
| 60ec21f34e | |||
| 57f6d2f8e7 | |||
| 53575c1876 | |||
| 0f372f5056 | |||
| c078de0076 |
+1
-1
@@ -32,4 +32,4 @@ RUN if [ "$NUITKA_BUILD" = "1" ]; then \
|
||||
WORKDIR /app/Web
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "60", "--graceful-timeout", "20", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||
|
||||
+2
-1
@@ -6,9 +6,10 @@ The latest version will allways be supported the rest are old version that are n
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 0.2.17 | ✅ |
|
||||
| 3.2.x | :white_check_mark: |
|
||||
| 3.1.x | :x: |
|
||||
| 3.0.x | :white_check_mark: |
|
||||
| 3.0.x | :x: |
|
||||
| 2.6.x | :x: |
|
||||
| 2.4.x | :x: |
|
||||
| 1.8.x | :x: |
|
||||
|
||||
+714
-89
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -25,7 +25,6 @@ Sammlungsstruktur:
|
||||
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
|
||||
For commercial licensing inquiries: https://github.com/AIIrondev
|
||||
'''
|
||||
from pymongo import MongoClient
|
||||
from bson.objectid import ObjectId
|
||||
import datetime
|
||||
import pytz
|
||||
@@ -34,6 +33,7 @@ import os
|
||||
import json
|
||||
import shutil
|
||||
import settings as cfg
|
||||
from settings import MongoClient
|
||||
|
||||
# Add this helper function after imports
|
||||
def ensure_timezone_aware(dt):
|
||||
|
||||
+1
-1
@@ -26,10 +26,10 @@ Collection Structure:
|
||||
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
|
||||
For commercial licensing inquiries: https://github.com/AIIrondev
|
||||
'''
|
||||
from pymongo import MongoClient
|
||||
from bson.objectid import ObjectId
|
||||
import datetime
|
||||
import settings as cfg
|
||||
from settings import MongoClient
|
||||
|
||||
|
||||
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'media')
|
||||
|
||||
+109
-2
@@ -12,6 +12,9 @@ defaults for the web application and helper modules.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import atexit
|
||||
from threading import Lock
|
||||
from pymongo import MongoClient as _PyMongoClient
|
||||
|
||||
# Base directory of this Web package
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -98,10 +101,27 @@ def _get(conf, path, default):
|
||||
return default
|
||||
return cur
|
||||
|
||||
|
||||
def _get_bool_env(name, default):
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in ('1', 'true', 'yes', 'on')
|
||||
|
||||
|
||||
def _get_int_env(name, default):
|
||||
value = os.getenv(name)
|
||||
if value is None or not value.strip():
|
||||
return int(default)
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return int(default)
|
||||
|
||||
# Expose settings
|
||||
APP_VERSION = _get(_conf, ['ver'], DEFAULTS['version'])
|
||||
DEBUG = _get(_conf, ['dbg'], DEFAULTS['debug'])
|
||||
SECRET_KEY = str(_get(_conf, ['key'], DEFAULTS['secret_key']))
|
||||
DEBUG = _get_bool_env('INVENTAR_DEBUG', _get(_conf, ['dbg'], DEFAULTS['debug']))
|
||||
SECRET_KEY = str(os.getenv('INVENTAR_SECRET_KEY', _get(_conf, ['key'], DEFAULTS['secret_key'])))
|
||||
HOST = _get(_conf, ['host'], DEFAULTS['host'])
|
||||
PORT = _get(_conf, ['port'], DEFAULTS['port'])
|
||||
|
||||
@@ -114,6 +134,13 @@ MONGODB_DB = _get(_conf, ['mongodb', 'db'], DEFAULTS['mongodb']['db'])
|
||||
MONGODB_HOST = os.getenv('INVENTAR_MONGODB_HOST', MONGODB_HOST)
|
||||
MONGODB_PORT = int(os.getenv('INVENTAR_MONGODB_PORT', str(MONGODB_PORT)))
|
||||
MONGODB_DB = os.getenv('INVENTAR_MONGODB_DB', MONGODB_DB)
|
||||
MONGODB_MAX_POOL_SIZE = _get_int_env('INVENTAR_MONGODB_MAX_POOL_SIZE', 20)
|
||||
MONGODB_MIN_POOL_SIZE = _get_int_env('INVENTAR_MONGODB_MIN_POOL_SIZE', 0)
|
||||
MONGODB_MAX_IDLE_TIME_MS = _get_int_env('INVENTAR_MONGODB_MAX_IDLE_TIME_MS', 300000)
|
||||
MONGODB_CONNECT_TIMEOUT_MS = _get_int_env('INVENTAR_MONGODB_CONNECT_TIMEOUT_MS', 5000)
|
||||
MONGODB_SERVER_SELECTION_TIMEOUT_MS = _get_int_env('INVENTAR_MONGODB_SERVER_SELECTION_TIMEOUT_MS', 5000)
|
||||
MONGODB_SOCKET_TIMEOUT_MS = _get_int_env('INVENTAR_MONGODB_SOCKET_TIMEOUT_MS', 30000)
|
||||
MONGODB_MAX_CONNECTING = _get_int_env('INVENTAR_MONGODB_MAX_CONNECTING', 2)
|
||||
|
||||
# Scheduler
|
||||
SCHEDULER_INTERVAL_MIN = _get(_conf, ['scheduler', 'interval_minutes'], DEFAULTS['scheduler']['interval_minutes'])
|
||||
@@ -173,3 +200,83 @@ if not os.path.isabs(BACKUP_FOLDER):
|
||||
BACKUP_FOLDER = os.path.join(PROJECT_ROOT, BACKUP_FOLDER)
|
||||
if not os.path.isabs(LOGS_FOLDER):
|
||||
LOGS_FOLDER = os.path.join(PROJECT_ROOT, LOGS_FOLDER)
|
||||
|
||||
|
||||
_MONGO_CLIENT_CACHE = {}
|
||||
_MONGO_CLIENT_LOCK = Lock()
|
||||
|
||||
|
||||
class _MongoClientProxy:
|
||||
def __init__(self, client):
|
||||
self._client = client
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._client, name)
|
||||
|
||||
def __getitem__(self, name):
|
||||
return self._client[name]
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
def _close_cached_mongo_clients():
|
||||
with _MONGO_CLIENT_LOCK:
|
||||
clients = list(_MONGO_CLIENT_CACHE.values())
|
||||
_MONGO_CLIENT_CACHE.clear()
|
||||
|
||||
for proxy in clients:
|
||||
try:
|
||||
proxy._client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
atexit.register(_close_cached_mongo_clients)
|
||||
|
||||
|
||||
def MongoClient(*args, **kwargs):
|
||||
"""Return a process-local MongoDB client configured from this settings module."""
|
||||
explicit_host = 'host' in kwargs
|
||||
explicit_port = 'port' in kwargs
|
||||
host = args[0] if len(args) >= 1 else kwargs.pop('host', MONGODB_HOST)
|
||||
port = args[1] if len(args) >= 2 else kwargs.pop('port', MONGODB_PORT)
|
||||
client_kwargs = {
|
||||
'maxPoolSize': MONGODB_MAX_POOL_SIZE,
|
||||
'minPoolSize': MONGODB_MIN_POOL_SIZE,
|
||||
'maxIdleTimeMS': MONGODB_MAX_IDLE_TIME_MS,
|
||||
'connectTimeoutMS': MONGODB_CONNECT_TIMEOUT_MS,
|
||||
'serverSelectionTimeoutMS': MONGODB_SERVER_SELECTION_TIMEOUT_MS,
|
||||
'socketTimeoutMS': MONGODB_SOCKET_TIMEOUT_MS,
|
||||
'maxConnecting': MONGODB_MAX_CONNECTING,
|
||||
'retryWrites': True,
|
||||
'retryReads': True,
|
||||
}
|
||||
client_kwargs.update(kwargs)
|
||||
|
||||
if len(args) >= 2 and not explicit_host and not explicit_port:
|
||||
mongo_args = args
|
||||
else:
|
||||
mongo_args = (host, port)
|
||||
|
||||
cache_key = (
|
||||
mongo_args,
|
||||
tuple(sorted((key, repr(value)) for key, value in client_kwargs.items())),
|
||||
)
|
||||
|
||||
with _MONGO_CLIENT_LOCK:
|
||||
cached_client = _MONGO_CLIENT_CACHE.get(cache_key)
|
||||
if cached_client is not None:
|
||||
return cached_client
|
||||
|
||||
client = _PyMongoClient(*mongo_args, **client_kwargs)
|
||||
|
||||
cached_client = _MongoClientProxy(client)
|
||||
_MONGO_CLIENT_CACHE[cache_key] = cached_client
|
||||
return cached_client
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Defekte Items verwalten{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="max-width: 1250px; margin: 18px auto 32px;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap; margin-bottom:16px;">
|
||||
<div>
|
||||
<h1 style="margin:0;">Defekte Items</h1>
|
||||
<p style="margin:6px 0 0; color:#64748b;">Eigenes Verwaltungsfenster fuer gemeldete Defekte mit schneller Reparatur-Funktion.</p>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
||||
<a class="btn btn-outline-secondary" href="{{ url_for('admin_borrowings') }}">Ausleihen</a>
|
||||
{% if library_module_enabled %}
|
||||
<a class="btn btn-outline-secondary" href="{{ url_for('library_loans_admin') }}">Bibliothek</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:14px; margin-bottom:12px;">
|
||||
<input id="damage-search" type="text" placeholder="Suche nach Name, Code, Typ, Benutzer oder Schaden..." style="width:100%; padding:10px 12px; border:1px solid #d1d5db; border-radius:10px;">
|
||||
</div>
|
||||
|
||||
<div style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:0; overflow:hidden;">
|
||||
<table id="damage-table" style="width:100%; border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr style="background:#f8fafc;">
|
||||
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Item</th>
|
||||
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Status</th>
|
||||
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Letzte Meldung</th>
|
||||
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Ausleihe</th>
|
||||
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in damaged_items %}
|
||||
<tr class="damage-row" data-search="{{ (item.name ~ ' ' ~ item.code ~ ' ' ~ item.item_type ~ ' ' ~ item.borrow_user ~ ' ' ~ item.latest_damage_description)|lower }}">
|
||||
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
|
||||
<div style="font-weight:700;">{{ item.name }}</div>
|
||||
<div style="font-size:0.86rem; color:#64748b;">{{ item.code or '-' }} | {{ item.item_type or '-' }}</div>
|
||||
{% if item.author %}<div style="font-size:0.82rem; color:#64748b;">{{ item.author }}</div>{% endif %}
|
||||
{% if item.isbn %}<div style="font-size:0.82rem; color:#64748b;">ISBN: {{ item.isbn }}</div>{% endif %}
|
||||
</td>
|
||||
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
|
||||
<div style="display:inline-block; padding:3px 9px; border-radius:999px; background:#fee2e2; color:#991b1b; font-size:0.75rem; font-weight:700;">Defekt</div>
|
||||
<div style="font-size:0.84rem; color:#475569; margin-top:6px;">Meldungen: {{ item.damage_count }}</div>
|
||||
<div style="font-size:0.84rem; color:#475569;">Condition: {{ item.condition or '-' }}</div>
|
||||
<div style="font-size:0.84rem; color:#475569;">Verfuegbar: {{ 'Ja' if item.available else 'Nein' }}</div>
|
||||
</td>
|
||||
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
|
||||
<div style="font-size:0.9rem; color:#1f2937;">{{ item.latest_damage_description or '-' }}</div>
|
||||
<div style="font-size:0.82rem; color:#64748b; margin-top:4px;">
|
||||
Gemeldet von {{ item.latest_damage_by or '-' }}
|
||||
{% if item.latest_damage_at %} am {{ item.latest_damage_at.strftime('%d.%m.%Y %H:%M') }}{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
|
||||
{% if item.active_borrow %}
|
||||
<div style="display:inline-block; padding:3px 9px; border-radius:999px; background:#dbeafe; color:#1d4ed8; font-size:0.75rem; font-weight:700;">Aktiv/Geplant</div>
|
||||
<div style="font-size:0.84rem; color:#475569; margin-top:6px;">User: {{ item.active_borrow.User or item.borrow_user or '-' }}</div>
|
||||
{% if item.active_borrow.End %}
|
||||
<div style="font-size:0.84rem; color:#475569;">Ende: {{ item.active_borrow.End.strftime('%d.%m.%Y %H:%M') }}</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div style="display:inline-block; padding:3px 9px; border-radius:999px; background:#dcfce7; color:#166534; font-size:0.75rem; font-weight:700;">Keine offene Ausleihe</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
|
||||
<button class="btn btn-success btn-sm" onclick="repairDamage('{{ item.id }}', this)">Als repariert markieren</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if not damaged_items %}
|
||||
<div style="padding:24px; color:#64748b; text-align:center;">Keine defekten Items vorhanden.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const searchInput = document.getElementById('damage-search');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', function() {
|
||||
const q = (searchInput.value || '').toLowerCase();
|
||||
document.querySelectorAll('.damage-row').forEach(function(row) {
|
||||
const text = row.getAttribute('data-search') || '';
|
||||
row.style.display = text.includes(q) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
function repairDamage(itemId, button) {
|
||||
if (!itemId) return;
|
||||
if (!confirm('Alle offenen Defektmeldungen fuer dieses Item als repariert markieren?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
button.disabled = true;
|
||||
fetch('/mark_damage_repaired/' + itemId, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || 'Reparatur fehlgeschlagen');
|
||||
}
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function(err) {
|
||||
button.disabled = false;
|
||||
alert(err.message || 'Fehler beim Markieren als repariert.');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
+470
-14
@@ -7,7 +7,7 @@
|
||||
For commercial licensing inquiries: https://github.com/AIIrondev
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-module="{{ CURRENT_MODULE }}">
|
||||
<html lang="de" data-module="{{ CURRENT_MODULE }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
|
||||
@@ -63,6 +63,9 @@
|
||||
.navbar {
|
||||
box-shadow: 0 4px 14px rgba(2, 6, 23, 0.24);
|
||||
background-color: var(--module-primary-color) !important;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1900;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
@@ -82,6 +85,65 @@
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link.nav-active {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.function-search-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 10px;
|
||||
width: min(420px, 42vw);
|
||||
}
|
||||
|
||||
.function-search-form {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.function-search-input {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #ffffff;
|
||||
padding: 8px 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.function-search-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.function-search-input:focus {
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 0 0 2px rgba(147, 197, 253, 0.35);
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.function-search-btn {
|
||||
min-height: 38px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #ffffff;
|
||||
padding: 7px 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.function-search-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.26);
|
||||
}
|
||||
|
||||
.quick-link-pill {
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link:hover,
|
||||
.navbar-nav .nav-link:focus {
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
@@ -255,6 +317,27 @@
|
||||
.nav-item.dropdown .dropdown-toggle {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.navbar-toggler {
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.function-search-wrap {
|
||||
width: 100%;
|
||||
margin: 8px 0 10px;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-item {
|
||||
margin-right: 0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link {
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Better touch targets for mobile */
|
||||
.dropdown-toggle::after {
|
||||
@@ -363,6 +446,7 @@
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% set current_path = request.path %}
|
||||
<!-- Module Selector Bar -->
|
||||
{% if 'username' in session %}
|
||||
<div class="module-selector-bar" id="moduleBar">
|
||||
@@ -396,16 +480,24 @@
|
||||
<div class="collapse navbar-collapse" id="inventoryNavContent">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="inventoryNavList">
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<a class="nav-link" href="{{ url_for('home') }}">Artikel</a>
|
||||
<a class="nav-link {% if current_path == url_for('home') %}nav-active{% endif %}" href="{{ url_for('home') }}">Artikel</a>
|
||||
</li>
|
||||
{% if 'username' in session %}
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a>
|
||||
</li>
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link nav-priority-link" href="{{ url_for('upload_admin') }}">➕ Hochladen</a>
|
||||
<a class="nav-link nav-priority-link {% if current_path == url_for('upload_admin') %}nav-active{% endif %}" href="{{ url_for('upload_admin') }}">➕ Hochladen</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item dropdown ms-lg-auto">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="invMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
⋯ Mehr
|
||||
<a class="nav-link dropdown-toggle" href="#" id="invMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">
|
||||
Mehr Optionen
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown">
|
||||
{% if 'username' in session %}
|
||||
@@ -418,6 +510,7 @@
|
||||
<li><a class="dropdown-item" href="{{ url_for('manage_filters') }}">Filter verwalten</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('manage_locations') }}">Orte verwalten</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin_borrowings') }}">Ausleihen</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin_audit_dashboard') }}">Audit Dashboard</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('logs') }}">Logs</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
@@ -434,12 +527,28 @@
|
||||
</ul>
|
||||
<div class="d-flex">
|
||||
{% if 'username' in session %}
|
||||
<div class="function-search-wrap">
|
||||
<form class="function-search-form" data-function-search="true">
|
||||
<input
|
||||
class="function-search-input"
|
||||
type="search"
|
||||
name="function_search"
|
||||
placeholder="Funktion suchen..."
|
||||
list="function-search-options"
|
||||
autocomplete="off"
|
||||
>
|
||||
<button class="function-search-btn" type="submit">Los</button>
|
||||
</form>
|
||||
</div>
|
||||
<span class="navbar-text text-light me-3">{{ session['username'] }}</span>
|
||||
<div class="dropdown me-2">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" id="invUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<div class="dropdown me-2 user-menu-wrap">
|
||||
<button class="btn btn-secondary dropdown-toggle user-menu-btn" type="button" id="invUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false" data-notification-button="true">
|
||||
👤
|
||||
<span class="user-notification-dot {% if unread_notification_count and unread_notification_count > 0 %}visible{% endif %}" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invUserMenuDropdown">
|
||||
<li><a class="dropdown-item" href="{{ url_for('notifications_view') }}">Benachrichtigungen</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
|
||||
@@ -462,16 +571,24 @@
|
||||
<div class="collapse navbar-collapse" id="libraryNavContent">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="libraryNavList">
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<a class="nav-link" href="{{ url_for('library_view') }}">Medien</a>
|
||||
<a class="nav-link {% if current_path == url_for('library_view') %}nav-active{% endif %}" href="{{ url_for('library_view') }}">Medien</a>
|
||||
</li>
|
||||
{% if 'username' in session %}
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a>
|
||||
</li>
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link nav-priority-link" href="{{ url_for('library_admin') }}">📖 Hochladen</a>
|
||||
<a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}">📖 Hochladen</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item dropdown ms-lg-auto">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="libMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
⋯ Mehr
|
||||
<a class="nav-link dropdown-toggle" href="#" id="libMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">
|
||||
Mehr Optionen
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown">
|
||||
{% if 'username' in session %}
|
||||
@@ -482,8 +599,9 @@
|
||||
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
|
||||
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li>
|
||||
{% if student_cards_module_enabled %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Schülerausweise</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li>
|
||||
{% endif %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><h6 class="dropdown-header">System</h6></li>
|
||||
@@ -499,12 +617,28 @@
|
||||
</ul>
|
||||
<div class="d-flex">
|
||||
{% if 'username' in session %}
|
||||
<div class="function-search-wrap">
|
||||
<form class="function-search-form" data-function-search="true">
|
||||
<input
|
||||
class="function-search-input"
|
||||
type="search"
|
||||
name="function_search"
|
||||
placeholder="Funktion suchen..."
|
||||
list="function-search-options"
|
||||
autocomplete="off"
|
||||
>
|
||||
<button class="function-search-btn" type="submit">Los</button>
|
||||
</form>
|
||||
</div>
|
||||
<span class="navbar-text text-light me-3">{{ session['username'] }}</span>
|
||||
<div class="dropdown me-2">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" id="libUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<div class="dropdown me-2 user-menu-wrap">
|
||||
<button class="btn btn-secondary dropdown-toggle user-menu-btn" type="button" id="libUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false" data-notification-button="true">
|
||||
👤
|
||||
<span class="user-notification-dot {% if unread_notification_count and unread_notification_count > 0 %}visible{% endif %}" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libUserMenuDropdown">
|
||||
<li><a class="dropdown-item" href="{{ url_for('notifications_view') }}">Benachrichtigungen</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
|
||||
@@ -597,6 +731,73 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.notif-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
padding: 0 6px;
|
||||
margin-left: 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.user-menu-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-menu-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-notification-dot {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 10px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #dc2626;
|
||||
border: 2px solid #ffffff;
|
||||
box-shadow: 0 0 0 1px rgba(220, 38, 38, 0.35);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-notification-dot.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.notification-toast {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 18px;
|
||||
z-index: 2300;
|
||||
max-width: 360px;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
box-shadow: 0 18px 35px rgba(2, 6, 23, 0.4);
|
||||
padding: 12px 14px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.35;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.notification-toast strong {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.notification-toast.show {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
<div id="cookie-banner" role="dialog" aria-live="polite" aria-label="Cookie-Hinweis">
|
||||
<div class="cb-inner">
|
||||
@@ -624,6 +825,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="notification-toast" class="notification-toast" role="status" aria-live="polite">
|
||||
<strong id="notification-toast-title">Neue Benachrichtigung</strong>
|
||||
<span id="notification-toast-message"></span>
|
||||
</div>
|
||||
|
||||
<datalist id="function-search-options">
|
||||
<option value="Artikel"></option>
|
||||
<option value="Meine Ausleihen"></option>
|
||||
<option value="Benachrichtigungen"></option>
|
||||
<option value="Tutorial"></option>
|
||||
<option value="Impressum"></option>
|
||||
<option value="Lizenz"></option>
|
||||
{% if library_module_enabled %}
|
||||
<option value="Bibliothek"></option>
|
||||
<option value="Meine Medien"></option>
|
||||
{% endif %}
|
||||
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
|
||||
<option value="Hochladen"></option>
|
||||
<option value="Ausleihen Verwaltung"></option>
|
||||
<option value="Defekte Items"></option>
|
||||
<option value="Filter verwalten"></option>
|
||||
<option value="Orte verwalten"></option>
|
||||
<option value="Audit Dashboard"></option>
|
||||
<option value="Logs"></option>
|
||||
<option value="Benutzer verwalten"></option>
|
||||
<option value="Neuer Benutzer"></option>
|
||||
{% if student_cards_module_enabled %}
|
||||
<option value="Bibliotheksausweis"></option>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</datalist>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
function getCookie(name){
|
||||
@@ -657,8 +890,231 @@
|
||||
const username = {{ (session['username'] if 'username' in session else '')|tojson }};
|
||||
const isTutorialPage = window.location.pathname === {{ url_for('tutorial_page')|tojson }};
|
||||
const isLoginPage = window.location.pathname === {{ url_for('login')|tojson }};
|
||||
const notificationsPagePath = {{ url_for('notifications_view')|tojson }};
|
||||
const onboardingKey = username ? ('inventarsystem_tutorial_prompt_v1_' + username) : null;
|
||||
const onboardingOverlay = document.getElementById('onboarding-overlay');
|
||||
const notificationButtons = Array.from(document.querySelectorAll('[data-notification-button="true"]'));
|
||||
const notificationToast = document.getElementById('notification-toast');
|
||||
const notificationToastTitle = document.getElementById('notification-toast-title');
|
||||
const notificationToastMessage = document.getElementById('notification-toast-message');
|
||||
let lastUnreadCount = Number({{ unread_notification_count|default(0)|int }});
|
||||
const loginHintKey = username ? ('inventarsystem_notification_login_hint_v1_' + username) : null;
|
||||
|
||||
const functionSearchEntries = [
|
||||
{ label: 'Artikel', keywords: ['artikel', 'inventar', 'home'], url: {{ url_for('home')|tojson }} },
|
||||
{ label: 'Meine Ausleihen', keywords: ['meine ausleihen', 'ausleihen', 'borrowed'], url: {{ url_for('my_borrowed_items')|tojson }} },
|
||||
{ label: 'Benachrichtigungen', keywords: ['benachrichtigungen', 'nachrichten', 'notifications'], url: {{ url_for('notifications_view')|tojson }} },
|
||||
{ label: 'Tutorial', keywords: ['tutorial', 'hilfe', 'anleitung'], url: {{ url_for('tutorial_page')|tojson }} },
|
||||
{ label: 'Impressum', keywords: ['impressum'], url: {{ url_for('impressum')|tojson }} },
|
||||
{ label: 'Lizenz', keywords: ['lizenz', 'license'], url: {{ url_for('license')|tojson }} },
|
||||
{% if library_module_enabled %}
|
||||
{ label: 'Bibliothek', keywords: ['bibliothek', 'medien'], url: {{ url_for('library_view')|tojson }} },
|
||||
{% endif %}
|
||||
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
|
||||
{ label: 'Hochladen', keywords: ['hochladen', 'upload'], url: {{ url_for('upload_admin')|tojson }} },
|
||||
{ label: 'Ausleihen Verwaltung', keywords: ['ausleihen verwaltung', 'admin borrowings'], url: {{ url_for('admin_borrowings')|tojson }} },
|
||||
{ label: 'Defekte Items', keywords: ['defekte items', 'defekt', 'schaden'], url: {{ url_for('admin_damaged_items')|tojson }} },
|
||||
{ label: 'Filter verwalten', keywords: ['filter verwalten', 'filter'], url: {{ url_for('manage_filters')|tojson }} },
|
||||
{ label: 'Orte verwalten', keywords: ['orte verwalten', 'orte', 'location'], url: {{ url_for('manage_locations')|tojson }} },
|
||||
{ label: 'Audit Dashboard', keywords: ['audit', 'audit dashboard'], url: {{ url_for('admin_audit_dashboard')|tojson }} },
|
||||
{ label: 'Logs', keywords: ['logs', 'protokoll'], url: {{ url_for('logs')|tojson }} },
|
||||
{ label: 'Benutzer verwalten', keywords: ['benutzer verwalten', 'user'], url: {{ url_for('user_del')|tojson }} },
|
||||
{ label: 'Neuer Benutzer', keywords: ['neuer benutzer', 'register'], url: {{ url_for('register')|tojson }} },
|
||||
{% if library_module_enabled %}
|
||||
{ label: 'Bibliotheks-Ausleihen', keywords: ['bibliotheks ausleihen', 'library loans'], url: {{ url_for('library_loans_admin')|tojson }} },
|
||||
{% endif %}
|
||||
{% if student_cards_module_enabled %}
|
||||
{ label: 'Bibliotheksausweis', keywords: ['bibliotheksausweis', 'student card'], url: {{ url_for('student_cards_admin')|tojson }} },
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
];
|
||||
|
||||
function normalizeSearchText(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[ä]/g, 'ae')
|
||||
.replace(/[ö]/g, 'oe')
|
||||
.replace(/[ü]/g, 'ue')
|
||||
.replace(/[ß]/g, 'ss')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function findFunctionRoute(rawValue) {
|
||||
const input = normalizeSearchText(rawValue);
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let exact = null;
|
||||
for (const entry of functionSearchEntries) {
|
||||
const candidates = [entry.label].concat(entry.keywords || []);
|
||||
for (const candidate of candidates) {
|
||||
if (normalizeSearchText(candidate) === input) {
|
||||
exact = entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (exact) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (exact) {
|
||||
return exact.url;
|
||||
}
|
||||
|
||||
for (const entry of functionSearchEntries) {
|
||||
const candidates = [entry.label].concat(entry.keywords || []);
|
||||
for (const candidate of candidates) {
|
||||
if (normalizeSearchText(candidate).includes(input) || input.includes(normalizeSearchText(candidate))) {
|
||||
return entry.url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function bindFunctionSearchForms() {
|
||||
const forms = document.querySelectorAll('form[data-function-search="true"]');
|
||||
forms.forEach(function(form) {
|
||||
form.addEventListener('submit', function(event) {
|
||||
event.preventDefault();
|
||||
const input = form.querySelector('input[name="function_search"]');
|
||||
const targetUrl = findFunctionRoute(input ? input.value : '');
|
||||
if (targetUrl) {
|
||||
window.location.href = targetUrl;
|
||||
return;
|
||||
}
|
||||
showInAppNotification('Keine Funktion gefunden', 'Bitte Suche verfeinern, z.B. "Defekte Items" oder "Benachrichtigungen".');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
bindFunctionSearchForms();
|
||||
|
||||
function maybeShowLoginNotificationHint() {
|
||||
if (!username || !loginHintKey) {
|
||||
return;
|
||||
}
|
||||
if (window.location.pathname === notificationsPagePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alreadyShown = sessionStorage.getItem(loginHintKey);
|
||||
if (alreadyShown) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastUnreadCount > 0) {
|
||||
const hintTitle = 'Neue Benachrichtigungen';
|
||||
const hintMessage =
|
||||
lastUnreadCount === 1
|
||||
? 'Sie haben 1 neue Benachrichtigung. Im Nutzer-Menue finden Sie den Eintrag Benachrichtigungen.'
|
||||
: 'Sie haben ' + lastUnreadCount + ' neue Benachrichtigungen. Im Nutzer-Menue finden Sie den Eintrag Benachrichtigungen.';
|
||||
showInAppNotification(hintTitle, hintMessage);
|
||||
}
|
||||
|
||||
sessionStorage.setItem(loginHintKey, 'shown');
|
||||
}
|
||||
|
||||
maybeShowLoginNotificationHint();
|
||||
|
||||
function updateNotificationDots(unreadCount) {
|
||||
const hasUnread = Number(unreadCount) > 0;
|
||||
notificationButtons.forEach(function(btn) {
|
||||
const dot = btn.querySelector('.user-notification-dot');
|
||||
if (!dot) {
|
||||
return;
|
||||
}
|
||||
if (hasUnread) {
|
||||
dot.classList.add('visible');
|
||||
} else {
|
||||
dot.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showInAppNotification(title, message) {
|
||||
if (!notificationToast || !notificationToastTitle || !notificationToastMessage) {
|
||||
return;
|
||||
}
|
||||
notificationToastTitle.textContent = title || 'Neue Benachrichtigung';
|
||||
notificationToastMessage.textContent = message || '';
|
||||
notificationToast.classList.add('show');
|
||||
window.setTimeout(function() {
|
||||
notificationToast.classList.remove('show');
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function showBrowserNotification(title, message) {
|
||||
if (!('Notification' in window)) {
|
||||
showInAppNotification(title, message);
|
||||
return;
|
||||
}
|
||||
if (Notification.permission === 'granted') {
|
||||
new Notification(title || 'Neue Benachrichtigung', {
|
||||
body: message || '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (Notification.permission === 'default') {
|
||||
Notification.requestPermission().then(function(permission) {
|
||||
if (permission === 'granted') {
|
||||
new Notification(title || 'Neue Benachrichtigung', {
|
||||
body: message || '',
|
||||
});
|
||||
} else {
|
||||
showInAppNotification(title, message);
|
||||
}
|
||||
}).catch(function() {
|
||||
showInAppNotification(title, message);
|
||||
});
|
||||
return;
|
||||
}
|
||||
showInAppNotification(title, message);
|
||||
}
|
||||
|
||||
function pollNotificationStatus() {
|
||||
if (!username) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/notifications/unread_status', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(function(response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('status request failed');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (!data || !data.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unreadCount = Number(data.unread_count || 0);
|
||||
updateNotificationDots(unreadCount);
|
||||
|
||||
if (unreadCount > lastUnreadCount && window.location.pathname !== notificationsPagePath) {
|
||||
const latest = data.latest_unread || {};
|
||||
showBrowserNotification(latest.title || 'Neue Nachricht', latest.message || 'Es gibt neue Benachrichtigungen.');
|
||||
}
|
||||
|
||||
lastUnreadCount = unreadCount;
|
||||
})
|
||||
.catch(function() {
|
||||
// Silent fail; polling retries automatically.
|
||||
});
|
||||
}
|
||||
|
||||
updateNotificationDots(lastUnreadCount);
|
||||
if (username) {
|
||||
window.setInterval(pollNotificationStatus, 30000);
|
||||
}
|
||||
|
||||
function showOnboarding(){
|
||||
if (onboardingOverlay) {
|
||||
|
||||
@@ -14,20 +14,21 @@
|
||||
<div class="container my-4">
|
||||
<div class="impressum-content bg-white p-4 shadow rounded">
|
||||
<h1 class="mb-4 text-center">Impressum</h1>
|
||||
<p class="text-center mb-5">(Angaben gemäß § 5 TMG)</p>
|
||||
<p class="text-center mb-5">(Angaben gemäß § 5 DDG)</p>
|
||||
|
||||
<div class="impressum-section mb-4">
|
||||
<h3 class="mb-3">Kontaktinformationen</h3>
|
||||
<p><strong>Name:</strong> . ..</p>
|
||||
<p><strong>Adresse:</strong> Musterstraße 123<br>12345 Musterstadt<br>Deutschland</p>
|
||||
<p><strong>E-Mail:</strong> <a href="mailto:kontakt@example.com">kontakt@example.com</a></p>
|
||||
<p><strong>Telefon:</strong> +49 123 456789</p>
|
||||
<address class="mb-0">
|
||||
<p><strong>Name:</strong> Invario UG</p>
|
||||
<p><strong>Adresse:</strong> Musterstraße 123<br>12345 Musterstadt<br>Deutschland</p>
|
||||
</address>
|
||||
<p><strong>E-Mail:</strong> <a href="mailto:info@invario.eu">info@invario.eu</a></p>
|
||||
</div>
|
||||
|
||||
<div class="impressum-section mb-4">
|
||||
<h3 class="mb-3">Verantwortlich für den Inhalt</h3>
|
||||
<p>(nach § 55 Abs. 2 RStV)</p>
|
||||
<p>...<br>
|
||||
<p>(nach § 18 Abs. 2 MStV)</p>
|
||||
<p>Invario UG<br>
|
||||
Musterstraße 123<br>
|
||||
12345 Musterstadt<br>
|
||||
Deutschland</p>
|
||||
@@ -37,7 +38,7 @@
|
||||
<h3 class="mb-3">Haftungsausschluss</h3>
|
||||
|
||||
<h4 class="mb-2">Haftung für Inhalte</h4>
|
||||
<p>Die Inhalte unserer Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte können wir jedoch keine Gewähr übernehmen. Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.</p>
|
||||
<p>Die Inhalte unserer Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte können wir jedoch keine Gewähr übernehmen. Als Diensteanbieter sind wir gemäß § 7 Abs. 1 DDG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 DDG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.</p>
|
||||
|
||||
<h4 class="mb-2">Haftung für Links</h4>
|
||||
<p>Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung nicht erkennbar.</p>
|
||||
|
||||
@@ -290,6 +290,20 @@
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.library-load-row {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.library-load-hint {
|
||||
font-size: 0.9em;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.modal {
|
||||
display: none;
|
||||
@@ -501,6 +515,10 @@
|
||||
<!-- Items will be loaded here -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="loadMoreRow" class="library-load-row" style="display:none;">
|
||||
<span id="loadInfo" class="library-load-hint"></span>
|
||||
<button id="loadMoreBtn" class="button" type="button">Mehr laden</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
@@ -523,6 +541,24 @@
|
||||
// State
|
||||
let libraryItems = [];
|
||||
let filteredItems = [];
|
||||
let visibleItems = [];
|
||||
let currentSearchTerm = '';
|
||||
let activeFilters = {
|
||||
author: '',
|
||||
isbn: '',
|
||||
type: '',
|
||||
status: ''
|
||||
};
|
||||
let pagingState = {
|
||||
offset: 0,
|
||||
total: 0,
|
||||
hasMore: true,
|
||||
loading: false
|
||||
};
|
||||
const API_PAGE_SIZE = 120;
|
||||
const INITIAL_RENDER_COUNT = 120;
|
||||
const RENDER_BATCH_COUNT = 120;
|
||||
let renderedCount = INITIAL_RENDER_COUNT;
|
||||
let filterPanelOpen = false;
|
||||
let scannerInstance = null;
|
||||
let scannerRunning = false;
|
||||
@@ -531,32 +567,124 @@
|
||||
let lastScanAt = 0;
|
||||
const canEditLibraryItems = (document.getElementById('libraryTableContainer')?.dataset.canEdit === '1');
|
||||
|
||||
// Load items
|
||||
async function fetchLibraryPage(offset, limit) {
|
||||
const response = await fetch(`/api/library_items?offset=${offset}&limit=${limit}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load library items');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Load items progressively: first page immediately, rest in background
|
||||
async function loadLibraryItems() {
|
||||
if (pagingState.loading) return;
|
||||
pagingState.loading = true;
|
||||
try {
|
||||
const response = await fetch('/api/library_items');
|
||||
if (!response.ok) throw new Error('Failed to load library items');
|
||||
libraryItems = await response.json();
|
||||
displayItems(libraryItems);
|
||||
const firstPage = await fetchLibraryPage(0, API_PAGE_SIZE);
|
||||
libraryItems = firstPage.items || [];
|
||||
pagingState.offset = (firstPage.offset || 0) + (firstPage.count || libraryItems.length);
|
||||
pagingState.total = firstPage.total || libraryItems.length;
|
||||
pagingState.hasMore = Boolean(firstPage.has_more);
|
||||
|
||||
applyFiltersAndSearch(true);
|
||||
|
||||
if (pagingState.hasMore) {
|
||||
loadRemainingLibraryItemsInBackground();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading library items:', error);
|
||||
document.getElementById('itemsTableBody').innerHTML = '<tr><td colspan="6" style="text-align:center; color:#999;">Fehler beim Laden der Bibliothekselemente.</td></tr>';
|
||||
} finally {
|
||||
pagingState.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Display items in table
|
||||
function displayItems(items) {
|
||||
async function loadRemainingLibraryItemsInBackground() {
|
||||
while (pagingState.hasMore) {
|
||||
try {
|
||||
const page = await fetchLibraryPage(pagingState.offset, API_PAGE_SIZE);
|
||||
const nextItems = page.items || [];
|
||||
if (nextItems.length === 0) {
|
||||
pagingState.hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
libraryItems = libraryItems.concat(nextItems);
|
||||
pagingState.offset = (page.offset || pagingState.offset) + (page.count || nextItems.length);
|
||||
pagingState.total = page.total || pagingState.total;
|
||||
pagingState.hasMore = Boolean(page.has_more);
|
||||
|
||||
// Keep the current view reactive while avoiding a full rerender burst.
|
||||
applyFiltersAndSearch(false);
|
||||
|
||||
if ('requestIdleCallback' in window) {
|
||||
await new Promise(resolve => requestIdleCallback(() => resolve(), { timeout: 250 }));
|
||||
} else {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading additional library items:', error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function filterItems(items) {
|
||||
const author = activeFilters.author;
|
||||
const isbn = activeFilters.isbn;
|
||||
const type = activeFilters.type;
|
||||
const status = activeFilters.status;
|
||||
|
||||
return items.filter(item => {
|
||||
const authorMatch = !author || (item.Autor || item.Author || '').toLowerCase().includes(author);
|
||||
const isbnMatch = !isbn || (item.ISBN || item.Code_4 || item.Code4 || '').toLowerCase().includes(isbn);
|
||||
const typeMatch = !type || (item.ItemType || 'book') === type;
|
||||
const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available');
|
||||
const statusMatch = !status || statusKey === status;
|
||||
return authorMatch && isbnMatch && typeMatch && statusMatch;
|
||||
});
|
||||
}
|
||||
|
||||
function applySearch(items) {
|
||||
if (!currentSearchTerm) return items;
|
||||
return items.filter(item =>
|
||||
(item.Name || '').toLowerCase().includes(currentSearchTerm) ||
|
||||
(item.Autor || item.Author || '').toLowerCase().includes(currentSearchTerm) ||
|
||||
(item.ISBN || item.Code_4 || item.Code4 || '').toLowerCase().includes(currentSearchTerm)
|
||||
);
|
||||
}
|
||||
|
||||
function applyFiltersAndSearch(resetRenderCount) {
|
||||
const filteredByPanel = filterItems(libraryItems);
|
||||
filteredItems = applySearch(filteredByPanel);
|
||||
if (resetRenderCount) {
|
||||
renderedCount = Math.min(INITIAL_RENDER_COUNT, filteredItems.length);
|
||||
} else {
|
||||
renderedCount = Math.min(Math.max(renderedCount, INITIAL_RENDER_COUNT), filteredItems.length || INITIAL_RENDER_COUNT);
|
||||
}
|
||||
visibleItems = filteredItems;
|
||||
renderItems();
|
||||
}
|
||||
|
||||
// Display current view with incremental rendering
|
||||
function renderItems() {
|
||||
const tbody = document.getElementById('itemsTableBody');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const loadMoreRow = document.getElementById('loadMoreRow');
|
||||
const loadInfo = document.getElementById('loadInfo');
|
||||
const loadMoreBtn = document.getElementById('loadMoreBtn');
|
||||
|
||||
if (items.length === 0) {
|
||||
if (visibleItems.length === 0) {
|
||||
tbody.innerHTML = '';
|
||||
emptyState.style.display = 'block';
|
||||
loadMoreRow.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
tbody.innerHTML = items.map(item => `
|
||||
const rowsToRender = visibleItems.slice(0, renderedCount);
|
||||
|
||||
tbody.innerHTML = rowsToRender.map(item => `
|
||||
${(() => {
|
||||
const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available');
|
||||
const statusClass = statusKey === 'damaged' ? 'status-damaged' : (statusKey === 'borrowed' ? 'status-borrowed' : 'status-available');
|
||||
@@ -567,7 +695,7 @@
|
||||
<tr>
|
||||
<td class="table-title">${escapeHtml(item.Name || 'Untitled')}</td>
|
||||
<td>${escapeHtml(item.Autor || item.Author || '-')}</td>
|
||||
<td>${escapeHtml(item.ISBN || item.Code4 || '-')}</td>
|
||||
<td>${escapeHtml(item.ISBN || item.Code_4 || item.Code4 || '-')}</td>
|
||||
<td>${getItemTypeLabel(item.ItemType || 'book')}</td>
|
||||
<td>
|
||||
<span class="table-status ${statusClass}">
|
||||
@@ -585,6 +713,18 @@
|
||||
`;
|
||||
})()}
|
||||
`).join('');
|
||||
|
||||
const canLoadMoreRows = renderedCount < visibleItems.length;
|
||||
if (canLoadMoreRows) {
|
||||
loadMoreRow.style.display = 'flex';
|
||||
loadMoreBtn.style.display = 'inline-block';
|
||||
loadInfo.textContent = `${renderedCount} von ${visibleItems.length} angezeigt`;
|
||||
} else {
|
||||
const backgroundHint = pagingState.hasMore ? `Lade weitere Medien im Hintergrund (${libraryItems.length}/${pagingState.total || '?'})...` : `${visibleItems.length} Treffer`;
|
||||
loadMoreRow.style.display = 'flex';
|
||||
loadMoreBtn.style.display = 'none';
|
||||
loadInfo.textContent = backgroundHint;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter toggle
|
||||
@@ -598,21 +738,11 @@
|
||||
|
||||
// Apply filters
|
||||
document.getElementById('applyFilterBtn').addEventListener('click', () => {
|
||||
const author = document.getElementById('filterAuthor').value.toLowerCase();
|
||||
const isbn = document.getElementById('filterISBN').value.toLowerCase();
|
||||
const type = document.getElementById('filterType').value;
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
|
||||
filteredItems = libraryItems.filter(item => {
|
||||
const authorMatch = !author || (item.Autor || item.Author || '').toLowerCase().includes(author);
|
||||
const isbnMatch = !isbn || (item.ISBN || item.Code4 || '').toLowerCase().includes(isbn);
|
||||
const typeMatch = !type || (item.ItemType || 'book') === type;
|
||||
const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available');
|
||||
const statusMatch = !status || statusKey === status;
|
||||
return authorMatch && isbnMatch && typeMatch && statusMatch;
|
||||
});
|
||||
|
||||
displayItems(filteredItems);
|
||||
activeFilters.author = document.getElementById('filterAuthor').value.toLowerCase();
|
||||
activeFilters.isbn = document.getElementById('filterISBN').value.toLowerCase();
|
||||
activeFilters.type = document.getElementById('filterType').value;
|
||||
activeFilters.status = document.getElementById('filterStatus').value;
|
||||
applyFiltersAndSearch(true);
|
||||
});
|
||||
|
||||
// Clear filters
|
||||
@@ -621,19 +751,19 @@
|
||||
document.getElementById('filterISBN').value = '';
|
||||
document.getElementById('filterType').value = '';
|
||||
document.getElementById('filterStatus').value = '';
|
||||
displayItems(libraryItems);
|
||||
activeFilters = { author: '', isbn: '', type: '', status: '' };
|
||||
applyFiltersAndSearch(true);
|
||||
});
|
||||
|
||||
// Search input
|
||||
document.getElementById('librarySearch').addEventListener('input', (e) => {
|
||||
const searchTerm = e.target.value.toLowerCase();
|
||||
const toSearch = filteredItems.length > 0 ? filteredItems : libraryItems;
|
||||
const results = toSearch.filter(item =>
|
||||
(item.Name || '').toLowerCase().includes(searchTerm) ||
|
||||
(item.Autor || item.Author || '').toLowerCase().includes(searchTerm) ||
|
||||
(item.ISBN || item.Code4 || '').toLowerCase().includes(searchTerm)
|
||||
);
|
||||
displayItems(results);
|
||||
currentSearchTerm = (e.target.value || '').toLowerCase();
|
||||
applyFiltersAndSearch(true);
|
||||
});
|
||||
|
||||
document.getElementById('loadMoreBtn').addEventListener('click', () => {
|
||||
renderedCount = Math.min(renderedCount + RENDER_BATCH_COUNT, visibleItems.length);
|
||||
renderItems();
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
|
||||
@@ -321,11 +321,6 @@
|
||||
<li><strong>Einwilligung:</strong> Falls private Daten der Nutzer erfasst werden, muss eine explizite
|
||||
Einwilligung vorliegen (Art. 6 Abs. 1 lit. a DSGVO).</li>
|
||||
</ol>
|
||||
<div class="license-exception-notice" style="background-color:#f8d7da; border-color:#f5c2c7; border-left-color:#dc3545;">
|
||||
<h3 style="color:#842029;">⚠️ Hinweis</h3>
|
||||
<p>Dieses Dokument stellt <strong>keine Rechtsberatung</strong> dar. Bitte konsultieren Sie im Zweifelsfall
|
||||
einen Fachanwalt für Datenschutzrecht.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /#pane-legal -->
|
||||
|
||||
|
||||
+219
-9
@@ -724,8 +724,75 @@
|
||||
// Add this line to define allItems globally
|
||||
let allItems = [];
|
||||
|
||||
function loadItems() {
|
||||
fetch("{{ url_for('get_items') }}")
|
||||
const MAIN_ITEMS_PAGE_SIZE = 120;
|
||||
let mainItemsNextOffset = 0;
|
||||
let mainItemsHasMore = false;
|
||||
let mainItemsLoadingMore = false;
|
||||
let mainItemsLightMode = true; // Track if we're in light mode to gradually request full data
|
||||
let mainItemsObserver = null;
|
||||
let mainItemsSentinel = null;
|
||||
let mainItemsLoadingIndicator = null;
|
||||
let mainItemsScrollPrefetchBound = false;
|
||||
|
||||
function ensureMainItemsLoadingIndicator() {
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
if (!itemsContainer) return null;
|
||||
|
||||
if (!mainItemsLoadingIndicator) {
|
||||
mainItemsLoadingIndicator = document.createElement('div');
|
||||
mainItemsLoadingIndicator.id = 'main-items-loading-indicator';
|
||||
mainItemsLoadingIndicator.className = 'items-loading-indicator';
|
||||
mainItemsLoadingIndicator.innerHTML = `
|
||||
<div class="loading-track"><div class="loading-fill"></div></div>
|
||||
<div class="loading-label">Weitere Objekte werden vorbereitet...</div>
|
||||
`;
|
||||
}
|
||||
|
||||
itemsContainer.appendChild(mainItemsLoadingIndicator);
|
||||
return itemsContainer;
|
||||
}
|
||||
|
||||
function updateMainItemsLoadingIndicator() {
|
||||
const itemsContainer = ensureMainItemsLoadingIndicator();
|
||||
if (!itemsContainer || !mainItemsLoadingIndicator) return;
|
||||
|
||||
const shouldShow = mainItemsHasMore || mainItemsLoadingMore;
|
||||
mainItemsLoadingIndicator.style.display = shouldShow ? 'flex' : 'none';
|
||||
mainItemsLoadingIndicator.classList.toggle('is-loading', mainItemsLoadingMore);
|
||||
|
||||
const label = mainItemsLoadingIndicator.querySelector('.loading-label');
|
||||
if (label) {
|
||||
label.textContent = mainItemsLoadingMore
|
||||
? 'Weitere Objekte werden geladen...'
|
||||
: 'Weitere Objekte werden vorbereitet...';
|
||||
}
|
||||
}
|
||||
|
||||
function maybePrefetchMainItems() {
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
if (!itemsContainer || !mainItemsHasMore || mainItemsLoadingMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
const distanceToEnd = itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth);
|
||||
const prefetchThreshold = Math.max(260, itemsContainer.clientWidth * 1.4);
|
||||
if (distanceToEnd > prefetchThreshold) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainItemsLoadingMore = true;
|
||||
updateMainItemsLoadingIndicator();
|
||||
loadItems(mainItemsNextOffset, true).finally(() => {
|
||||
mainItemsLoadingMore = false;
|
||||
updateMainItemsLoadingIndicator();
|
||||
});
|
||||
}
|
||||
|
||||
function loadItems(offset = 0, append = false) {
|
||||
// Für Pages nach der ersten: Explizit vollständige Daten laden (light_mode=false)
|
||||
// Erste Page: light_mode wird automatisch enablet
|
||||
const lightModeParam = offset > 0 ? '&light_mode=false' : '';
|
||||
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ITEMS_PAGE_SIZE}${lightModeParam}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
@@ -735,27 +802,39 @@
|
||||
const filter3Values = new Set();
|
||||
|
||||
// Store all items data globally for filter rebuilding
|
||||
allItems = data.items || [];
|
||||
const pageItems = data.items || [];
|
||||
allItems = append ? allItems.concat(pageItems) : pageItems;
|
||||
|
||||
// Clear the container first
|
||||
itemsContainer.innerHTML = '';
|
||||
if (!append) {
|
||||
itemsContainer.innerHTML = '';
|
||||
}
|
||||
|
||||
if (!data.items || data.items.length === 0) {
|
||||
if (!append && (!pageItems || pageItems.length === 0)) {
|
||||
itemsContainer.innerHTML = '<div class="no-items-message">Keine Objekte gefunden</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort items by name in ascending order (A-Z)
|
||||
data.items.sort((a, b) => {
|
||||
pageItems.sort((a, b) => {
|
||||
const nameA = a.Name ? a.Name.toLowerCase() : '';
|
||||
const nameB = b.Name ? b.Name.toLowerCase() : '';
|
||||
return nameA.localeCompare(nameB); // Ascending order
|
||||
});
|
||||
|
||||
allItems.forEach(itemForFilters => {
|
||||
const f1 = Array.isArray(itemForFilters.Filter) ? itemForFilters.Filter : (itemForFilters.Filter ? [itemForFilters.Filter] : []);
|
||||
const f2 = Array.isArray(itemForFilters.Filter2) ? itemForFilters.Filter2 : (itemForFilters.Filter2 ? [itemForFilters.Filter2] : []);
|
||||
const f3 = Array.isArray(itemForFilters.Filter3) ? itemForFilters.Filter3 : (itemForFilters.Filter3 ? [itemForFilters.Filter3] : []);
|
||||
f1.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter1Values.add(value); });
|
||||
f2.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter2Values.add(value); });
|
||||
f3.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter3Values.add(value); });
|
||||
});
|
||||
|
||||
const favoriteIds = new Set(data.favorites || []);
|
||||
window.currentFavorites = favoriteIds;
|
||||
try { sessionStorage.setItem('favoritesCache', JSON.stringify(Array.from(favoriteIds))); } catch(e){}
|
||||
data.items.forEach(item => {
|
||||
pageItems.forEach(item => {
|
||||
try {
|
||||
const card = document.createElement('div');
|
||||
card.classList.add('item-card');
|
||||
@@ -995,8 +1074,10 @@
|
||||
populateFilterOptions('filter2-options', [...filter2Values].sort(), 2);
|
||||
populateFilterOptions('filter3-options', [...filter3Values].sort(), 3);
|
||||
|
||||
// Start display from leftmost position (first card, which is now Z)
|
||||
itemsContainer.scrollLeft = 0;
|
||||
// Start display from leftmost position on full reload only.
|
||||
if (!append) {
|
||||
itemsContainer.scrollLeft = 0;
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
applyFilters();
|
||||
@@ -1011,15 +1092,93 @@
|
||||
if (activeFilters.filter1.length > 0 || activeFilters.filter2.length > 0) {
|
||||
rebuildFilter3Options();
|
||||
}
|
||||
|
||||
mainItemsNextOffset = (data.offset || offset) + (data.count || pageItems.length);
|
||||
mainItemsHasMore = Boolean(data.has_more);
|
||||
updateMainItemsLoadingIndicator();
|
||||
setupMainItemsLazyLoading();
|
||||
maybePrefetchMainItems();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching items:', error);
|
||||
mainItemsLoadingMore = false;
|
||||
mainItemsHasMore = false;
|
||||
updateMainItemsLoadingIndicator();
|
||||
if (append) {
|
||||
return;
|
||||
}
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
itemsContainer.innerHTML =
|
||||
'<div class="error-message">Fehler beim Laden der Objekte. Bitte versuchen Sie es später erneut.</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function ensureMainItemsSentinel() {
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
if (!itemsContainer) return null;
|
||||
|
||||
ensureMainItemsLoadingIndicator();
|
||||
|
||||
if (!mainItemsSentinel) {
|
||||
mainItemsSentinel = document.createElement('div');
|
||||
mainItemsSentinel.id = 'main-items-sentinel';
|
||||
mainItemsSentinel.style.flex = '0 0 1px';
|
||||
mainItemsSentinel.style.width = '1px';
|
||||
mainItemsSentinel.style.minWidth = '1px';
|
||||
mainItemsSentinel.style.height = '1px';
|
||||
mainItemsSentinel.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
if (!mainItemsSentinel.parentNode) {
|
||||
itemsContainer.appendChild(mainItemsSentinel);
|
||||
} else {
|
||||
itemsContainer.appendChild(mainItemsSentinel);
|
||||
}
|
||||
|
||||
return itemsContainer;
|
||||
}
|
||||
|
||||
function setupMainItemsLazyLoading() {
|
||||
const itemsContainer = ensureMainItemsSentinel();
|
||||
if (!itemsContainer) return;
|
||||
|
||||
if (mainItemsObserver) {
|
||||
mainItemsObserver.disconnect();
|
||||
mainItemsObserver = null;
|
||||
}
|
||||
|
||||
if (!mainItemsScrollPrefetchBound) {
|
||||
itemsContainer.addEventListener('scroll', maybePrefetchMainItems, { passive: true });
|
||||
mainItemsScrollPrefetchBound = true;
|
||||
}
|
||||
|
||||
if (!mainItemsHasMore) return;
|
||||
|
||||
mainItemsObserver = new IntersectionObserver(entries => {
|
||||
if (!entries.some(entry => entry.isIntersecting)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainItemsLoadingMore || !mainItemsHasMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainItemsLoadingMore = true;
|
||||
updateMainItemsLoadingIndicator();
|
||||
loadItems(mainItemsNextOffset, true).finally(() => {
|
||||
mainItemsLoadingMore = false;
|
||||
updateMainItemsLoadingIndicator();
|
||||
});
|
||||
}, {
|
||||
root: itemsContainer,
|
||||
threshold: 0.15,
|
||||
rootMargin: '0px 900px 0px 0px'
|
||||
});
|
||||
|
||||
mainItemsObserver.observe(mainItemsSentinel);
|
||||
updateMainItemsLoadingIndicator();
|
||||
}
|
||||
|
||||
function searchByCode() {
|
||||
const searchInput = document.getElementById('code-search');
|
||||
const rawSearchTerm = searchInput.value;
|
||||
@@ -2737,6 +2896,57 @@
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.items-loading-indicator {
|
||||
display: none;
|
||||
flex: 0 0 220px;
|
||||
min-width: 220px;
|
||||
max-width: 220px;
|
||||
height: 180px;
|
||||
align-self: center;
|
||||
margin-right: 8px;
|
||||
border: 1px solid #dbe3ee;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
scroll-snap-align: center;
|
||||
}
|
||||
|
||||
.items-loading-indicator .loading-label {
|
||||
font-size: 0.84rem;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
max-width: 170px;
|
||||
}
|
||||
|
||||
.items-loading-indicator .loading-track {
|
||||
width: 160px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.items-loading-indicator .loading-fill {
|
||||
width: 45%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #60a5fa, #2563eb);
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.items-loading-indicator.is-loading .loading-fill {
|
||||
animation: items-loader-slide 1.05s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes items-loader-slide {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(260%); }
|
||||
}
|
||||
|
||||
.item-card {
|
||||
background-color: #fff;
|
||||
border: 1px solid #ddd;
|
||||
|
||||
+231
-14
@@ -323,6 +323,57 @@
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.items-loading-indicator {
|
||||
display: none;
|
||||
flex: 0 0 220px;
|
||||
min-width: 220px;
|
||||
max-width: 220px;
|
||||
height: 180px;
|
||||
align-self: center;
|
||||
margin-right: 8px;
|
||||
border: 1px solid #dbe3ee;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
scroll-snap-align: center;
|
||||
}
|
||||
|
||||
.items-loading-indicator .loading-label {
|
||||
font-size: 0.84rem;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
max-width: 170px;
|
||||
}
|
||||
|
||||
.items-loading-indicator .loading-track {
|
||||
width: 160px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.items-loading-indicator .loading-fill {
|
||||
width: 45%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #60a5fa, #2563eb);
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.items-loading-indicator.is-loading .loading-fill {
|
||||
animation: items-loader-slide 1.05s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes items-loader-slide {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(260%); }
|
||||
}
|
||||
|
||||
.item-card {
|
||||
background-color: #fff;
|
||||
border: 1px solid #ddd;
|
||||
@@ -364,13 +415,17 @@
|
||||
}
|
||||
|
||||
.bulk-delete-row {
|
||||
display: flex;
|
||||
display: none;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bulk-delete-mode-active .bulk-delete-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.bulk-delete-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -2966,9 +3021,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
if (parsedFilters.filter3) activeFilters.filter3 = parsedFilters.filter3;
|
||||
|
||||
// Update UI to show selected filters
|
||||
updateSelectedFilters(1);
|
||||
updateSelectedFilters(2);
|
||||
updateSelectedFilters(3);
|
||||
updateSelectedFiltersDisplay(1);
|
||||
updateSelectedFiltersDisplay(2);
|
||||
updateSelectedFiltersDisplay(3);
|
||||
|
||||
// We'll rebuild Filter 3 options after items are loaded
|
||||
} catch (e) {
|
||||
@@ -3005,6 +3060,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
bulkDeleteDrawerOpen = Boolean(open);
|
||||
const drawer = document.getElementById('bulk-delete-drawer');
|
||||
const toggle = document.getElementById('bulk-delete-drawer-toggle');
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
if (drawer) {
|
||||
drawer.classList.toggle('open', bulkDeleteDrawerOpen);
|
||||
drawer.setAttribute('aria-hidden', bulkDeleteDrawerOpen ? 'false' : 'true');
|
||||
@@ -3014,6 +3070,10 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
toggle.innerHTML = bulkDeleteDrawerOpen ? '<span class="drawer-icon">✕</span>' : '<span class="drawer-icon">⚙</span>';
|
||||
toggle.title = bulkDeleteDrawerOpen ? 'Massenlöschung schließen' : 'Massenlöschung öffnen';
|
||||
}
|
||||
// Toggle checkbox visibility: show checkboxes only when bulk delete mode is active
|
||||
if (itemsContainer) {
|
||||
itemsContainer.classList.toggle('bulk-delete-mode-active', bulkDeleteDrawerOpen);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBulkDeleteDrawer() {
|
||||
@@ -3255,8 +3315,76 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
});
|
||||
|
||||
// Function to load items from server
|
||||
function loadItems() {
|
||||
fetch("{{ url_for('get_items') }}")
|
||||
const MAIN_ADMIN_ITEMS_PAGE_SIZE = 120;
|
||||
let mainAdminItemsNextOffset = 0;
|
||||
let mainAdminItemsHasMore = false;
|
||||
let mainAdminItemsLoadingMore = false;
|
||||
let mainAdminLightMode = true; // Track if we're in light mode to gradually request full data
|
||||
let mainAdminItemsObserver = null;
|
||||
let mainAdminItemsSentinel = null;
|
||||
let mainAdminItemsLoadingIndicator = null;
|
||||
let mainAdminItemsScrollPrefetchBound = false;
|
||||
let totalItemsInSystem = 0;
|
||||
|
||||
function ensureMainAdminItemsLoadingIndicator() {
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
if (!itemsContainer) return null;
|
||||
|
||||
if (!mainAdminItemsLoadingIndicator) {
|
||||
mainAdminItemsLoadingIndicator = document.createElement('div');
|
||||
mainAdminItemsLoadingIndicator.id = 'main-admin-items-loading-indicator';
|
||||
mainAdminItemsLoadingIndicator.className = 'items-loading-indicator';
|
||||
mainAdminItemsLoadingIndicator.innerHTML = `
|
||||
<div class="loading-track"><div class="loading-fill"></div></div>
|
||||
<div class="loading-label">Weitere Objekte werden vorbereitet...</div>
|
||||
`;
|
||||
}
|
||||
|
||||
itemsContainer.appendChild(mainAdminItemsLoadingIndicator);
|
||||
return itemsContainer;
|
||||
}
|
||||
|
||||
function updateMainAdminItemsLoadingIndicator() {
|
||||
const itemsContainer = ensureMainAdminItemsLoadingIndicator();
|
||||
if (!itemsContainer || !mainAdminItemsLoadingIndicator) return;
|
||||
|
||||
const shouldShow = mainAdminItemsHasMore || mainAdminItemsLoadingMore;
|
||||
mainAdminItemsLoadingIndicator.style.display = shouldShow ? 'flex' : 'none';
|
||||
mainAdminItemsLoadingIndicator.classList.toggle('is-loading', mainAdminItemsLoadingMore);
|
||||
|
||||
const label = mainAdminItemsLoadingIndicator.querySelector('.loading-label');
|
||||
if (label) {
|
||||
label.textContent = mainAdminItemsLoadingMore
|
||||
? 'Weitere Objekte werden geladen...'
|
||||
: 'Weitere Objekte werden vorbereitet...';
|
||||
}
|
||||
}
|
||||
|
||||
function maybePrefetchMainAdminItems() {
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
if (!itemsContainer || !mainAdminItemsHasMore || mainAdminItemsLoadingMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
const distanceToEnd = itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth);
|
||||
const prefetchThreshold = Math.max(260, itemsContainer.clientWidth * 1.4);
|
||||
if (distanceToEnd > prefetchThreshold) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainAdminItemsLoadingMore = true;
|
||||
updateMainAdminItemsLoadingIndicator();
|
||||
loadItems(mainAdminItemsNextOffset, true).finally(() => {
|
||||
mainAdminItemsLoadingMore = false;
|
||||
updateMainAdminItemsLoadingIndicator();
|
||||
});
|
||||
}
|
||||
|
||||
function loadItems(offset = 0, append = false) {
|
||||
// Für Pages nach der ersten: Explizit vollständige Daten laden (light_mode=false)
|
||||
// Erste Page: light_mode wird automatisch enablet
|
||||
const lightModeParam = offset > 0 ? '&light_mode=false' : '';
|
||||
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ADMIN_ITEMS_PAGE_SIZE}${lightModeParam}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
@@ -3266,9 +3394,13 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
const filter1Values = new Set();
|
||||
const filter2Values = new Set();
|
||||
const filter3Values = new Set();
|
||||
allItems = data.items || [];
|
||||
itemsContainer.innerHTML = '';
|
||||
if (!data.items || data.items.length === 0) {
|
||||
const pageItems = data.items || [];
|
||||
allItems = append ? allItems.concat(pageItems) : pageItems;
|
||||
totalItemsInSystem = Number(data.total || totalItemsInSystem || pageItems.length || 0);
|
||||
if (!append) {
|
||||
itemsContainer.innerHTML = '';
|
||||
}
|
||||
if (!append && (!pageItems || pageItems.length === 0)) {
|
||||
itemsContainer.innerHTML = `
|
||||
<div class="no-items-message">
|
||||
<div>Keine Objekte gefunden</div>
|
||||
@@ -3281,15 +3413,25 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
return;
|
||||
}
|
||||
if (itemsIndicator) {
|
||||
itemsIndicator.textContent = `Objekte im System: ${data.items.length}`;
|
||||
itemsIndicator.textContent = `Objekte im System: ${totalItemsInSystem}${data.has_more ? ' (lädt...)' : ''}`;
|
||||
}
|
||||
data.items.sort((a, b) => {
|
||||
pageItems.sort((a, b) => {
|
||||
const nameA = a.Name ? a.Name.toLowerCase() : '';
|
||||
const nameB = a.Name ? a.Name.toLowerCase() : '';
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
allItems.forEach(itemForFilters => {
|
||||
const f1 = Array.isArray(itemForFilters.Filter) ? itemForFilters.Filter : (itemForFilters.Filter ? [itemForFilters.Filter] : []);
|
||||
const f2 = Array.isArray(itemForFilters.Filter2) ? itemForFilters.Filter2 : (itemForFilters.Filter2 ? [itemForFilters.Filter2] : []);
|
||||
const f3 = Array.isArray(itemForFilters.Filter3) ? itemForFilters.Filter3 : (itemForFilters.Filter3 ? [itemForFilters.Filter3] : []);
|
||||
f1.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter1Values.add(value); });
|
||||
f2.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter2Values.add(value); });
|
||||
f3.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter3Values.add(value); });
|
||||
});
|
||||
|
||||
const favoriteIds = new Set(data.favorites || []);
|
||||
data.items.forEach(item => {
|
||||
pageItems.forEach(item => {
|
||||
try {
|
||||
const card = document.createElement('div');
|
||||
card.classList.add('item-card');
|
||||
@@ -3521,7 +3663,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
populateFilterOptions('filter2-options', [...filter2Values].sort(), 2);
|
||||
populateFilterOptions('filter3-options', [...filter3Values].sort(), 3);
|
||||
|
||||
itemsContainer.scrollLeft = 0;
|
||||
if (!append) {
|
||||
itemsContainer.scrollLeft = 0;
|
||||
}
|
||||
applyFilters();
|
||||
updateBulkDeleteSummary();
|
||||
|
||||
@@ -3536,9 +3680,21 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
rebuildFilter3Options();
|
||||
}
|
||||
/* favorites render count removed */
|
||||
|
||||
mainAdminItemsNextOffset = (data.offset || offset) + (data.count || pageItems.length);
|
||||
mainAdminItemsHasMore = Boolean(data.has_more);
|
||||
updateMainAdminItemsLoadingIndicator();
|
||||
setupMainAdminItemsLazyLoading();
|
||||
maybePrefetchMainAdminItems();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching items:', error);
|
||||
mainAdminItemsLoadingMore = false;
|
||||
mainAdminItemsHasMore = false;
|
||||
updateMainAdminItemsLoadingIndicator();
|
||||
if (append) {
|
||||
return;
|
||||
}
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
const itemsIndicator = document.getElementById('items-indicator');
|
||||
itemsContainer.innerHTML =
|
||||
@@ -3549,6 +3705,67 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
});
|
||||
}
|
||||
|
||||
function ensureMainAdminItemsSentinel() {
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
if (!itemsContainer) return null;
|
||||
|
||||
ensureMainAdminItemsLoadingIndicator();
|
||||
|
||||
if (!mainAdminItemsSentinel) {
|
||||
mainAdminItemsSentinel = document.createElement('div');
|
||||
mainAdminItemsSentinel.id = 'main-admin-items-sentinel';
|
||||
mainAdminItemsSentinel.style.flex = '0 0 1px';
|
||||
mainAdminItemsSentinel.style.width = '1px';
|
||||
mainAdminItemsSentinel.style.minWidth = '1px';
|
||||
mainAdminItemsSentinel.style.height = '1px';
|
||||
mainAdminItemsSentinel.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
itemsContainer.appendChild(mainAdminItemsSentinel);
|
||||
return itemsContainer;
|
||||
}
|
||||
|
||||
function setupMainAdminItemsLazyLoading() {
|
||||
const itemsContainer = ensureMainAdminItemsSentinel();
|
||||
if (!itemsContainer) return;
|
||||
|
||||
if (mainAdminItemsObserver) {
|
||||
mainAdminItemsObserver.disconnect();
|
||||
mainAdminItemsObserver = null;
|
||||
}
|
||||
|
||||
if (!mainAdminItemsScrollPrefetchBound) {
|
||||
itemsContainer.addEventListener('scroll', maybePrefetchMainAdminItems, { passive: true });
|
||||
mainAdminItemsScrollPrefetchBound = true;
|
||||
}
|
||||
|
||||
if (!mainAdminItemsHasMore) return;
|
||||
|
||||
mainAdminItemsObserver = new IntersectionObserver(entries => {
|
||||
if (!entries.some(entry => entry.isIntersecting)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainAdminItemsLoadingMore || !mainAdminItemsHasMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainAdminItemsLoadingMore = true;
|
||||
updateMainAdminItemsLoadingIndicator();
|
||||
loadItems(mainAdminItemsNextOffset, true).finally(() => {
|
||||
mainAdminItemsLoadingMore = false;
|
||||
updateMainAdminItemsLoadingIndicator();
|
||||
});
|
||||
}, {
|
||||
root: itemsContainer,
|
||||
threshold: 0.15,
|
||||
rootMargin: '0px 900px 0px 0px'
|
||||
});
|
||||
|
||||
mainAdminItemsObserver.observe(mainAdminItemsSentinel);
|
||||
updateMainAdminItemsLoadingIndicator();
|
||||
}
|
||||
|
||||
function searchByCode() {
|
||||
const searchInput = document.getElementById('code-search');
|
||||
const rawSearchTerm = searchInput.value;
|
||||
@@ -3643,7 +3860,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
});
|
||||
|
||||
if (itemsIndicator) {
|
||||
itemsIndicator.textContent = `Objekte im System: ${allItems.length} | Angezeigt: ${visibleCount}`;
|
||||
itemsIndicator.textContent = `Objekte im System: ${totalItemsInSystem || allItems.length} | Angezeigt: ${visibleCount}`;
|
||||
}
|
||||
|
||||
if (isCodeSearch && visibleCount > 0 && (codeMatchCount > 0 || exactMatchCount > 0)) {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Benachrichtigungen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="max-width: 1080px; margin: 18px auto 32px;">
|
||||
<div style="display:flex; justify-content:space-between; gap:12px; align-items:center; margin-bottom:16px; flex-wrap:wrap;">
|
||||
<div>
|
||||
<h1 style="margin:0;">Benachrichtigungen</h1>
|
||||
<p style="margin:6px 0 0; color:#64748b;">Rueckgabe-Erinnerungen und wichtige Hinweise fuer Benutzer und Verwaltung.</p>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('mark_all_notifications_read') }}">
|
||||
<button class="btn btn-outline-secondary" type="submit">Alle als gelesen markieren</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns:1fr; gap:14px;">
|
||||
<section style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:16px;">
|
||||
<h2 style="margin:0 0 12px; font-size:1.15rem;">Meine Benachrichtigungen</h2>
|
||||
{% if user_notifications %}
|
||||
<div style="display:grid; gap:10px;">
|
||||
{% for n in user_notifications %}
|
||||
<article style="border:1px solid #e5e7eb; border-left:5px solid {% if n.severity == 'danger' %}#dc2626{% elif n.severity == 'warning' %}#d97706{% else %}#2563eb{% endif %}; border-radius:10px; padding:12px 12px 10px; background:{% if n.is_read %}#f8fafc{% else %}#ffffff{% endif %};">
|
||||
<div style="display:flex; justify-content:space-between; gap:10px; align-items:flex-start; flex-wrap:wrap;">
|
||||
<div>
|
||||
<div style="font-weight:800; color:#0f172a;">{{ n.title }}</div>
|
||||
<div style="font-size:0.9rem; color:#475569; margin-top:2px;">{{ n.message }}</div>
|
||||
{% if n.type == 'damage_reported' %}
|
||||
<div style="margin-top:8px;">
|
||||
<a class="btn btn-sm btn-outline-danger" href="{{ url_for('admin_damaged_items') }}">Zu Defekte-Items Verwaltung</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div style="font-size:0.78rem; color:#64748b; margin-top:6px;">
|
||||
{% if n.created_at %}{{ n.created_at.strftime('%d.%m.%Y %H:%M') }}{% else %}-{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if not n.is_read %}
|
||||
<form method="post" action="{{ url_for('mark_notification_read', notification_id=n.id) }}">
|
||||
<button class="btn btn-sm btn-primary" type="submit">Als gelesen</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span style="font-size:0.8rem; color:#16a34a; font-weight:700;">Gelesen</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color:#64748b; margin:0;">Keine Benachrichtigungen vorhanden.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if is_admin_user %}
|
||||
<section style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:16px;">
|
||||
<h2 style="margin:0 0 12px; font-size:1.15rem;">Admin-Benachrichtigungen</h2>
|
||||
{% if admin_notifications %}
|
||||
<div style="display:grid; gap:10px;">
|
||||
{% for n in admin_notifications %}
|
||||
<article style="border:1px solid #e5e7eb; border-left:5px solid {% if n.severity == 'danger' %}#dc2626{% elif n.severity == 'warning' %}#d97706{% else %}#2563eb{% endif %}; border-radius:10px; padding:12px 12px 10px; background:{% if n.is_read %}#f8fafc{% else %}#ffffff{% endif %};">
|
||||
<div style="display:flex; justify-content:space-between; gap:10px; align-items:flex-start; flex-wrap:wrap;">
|
||||
<div>
|
||||
<div style="font-weight:800; color:#0f172a;">{{ n.title }}</div>
|
||||
<div style="font-size:0.9rem; color:#475569; margin-top:2px;">{{ n.message }}</div>
|
||||
<div style="font-size:0.78rem; color:#64748b; margin-top:6px;">
|
||||
{% if n.created_at %}{{ n.created_at.strftime('%d.%m.%Y %H:%M') }}{% else %}-{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if not n.is_read %}
|
||||
<form method="post" action="{{ url_for('mark_notification_read', notification_id=n.id) }}">
|
||||
<button class="btn btn-sm btn-primary" type="submit">Als gelesen</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span style="font-size:0.8rem; color:#16a34a; font-weight:700;">Gelesen</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color:#64748b; margin:0;">Keine Admin-Benachrichtigungen vorhanden.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -124,12 +124,12 @@
|
||||
<div class="container">
|
||||
<div class="icon">📇</div>
|
||||
<h1>Schülerausweis-Download</h1>
|
||||
<p>Generieren Sie eine PDF mit Barcodes aller Schülerausweise zum direkten Drucken</p>
|
||||
<p>Generieren Sie eine PDF mit Barcodes aller Bibliotheksausweise zum direkten Drucken</p>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>📌 Was wird heruntergeladen?</strong>
|
||||
<p>Eine druckfertige PDF mit:</p>
|
||||
<p>✓ Alle Schülerausweise</p>
|
||||
<p>✓ Alle Bibliotheksausweise</p>
|
||||
<p>✓ Scanbare CODE128 Barcodes</p>
|
||||
<p>✓ Optimiert für A4-Druck (2 Karten pro Seite)</p>
|
||||
<p>✓ Professionelle Qualität</p>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Schülerausweise - Inventarsystem{% endblock %}
|
||||
{% block title %}Bibliotheksausweise - Inventarsystem{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
@@ -229,7 +229,7 @@
|
||||
<div class="container">
|
||||
<div class="student-card-header">
|
||||
<div>
|
||||
<h1>📚 Schülerausweise (Bibliotek)</h1>
|
||||
<h1>📚 Bibliotheksausweise (Bibliotek)</h1>
|
||||
</div>
|
||||
<div class="export-buttons">
|
||||
<a href="{{ url_for('student_card_barcode_download') }}" class="btn-print" style="background: #28a745;">📥 Alle Ausweise (PDF)</a>
|
||||
@@ -342,7 +342,7 @@
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>Keine Schülerausweise registriert.</p>
|
||||
<p>Keine Bibliotheksausweise registriert.</p>
|
||||
<p>Fügen Sie ein neues Ausweis oben hinzu.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
+1
-1
@@ -10,10 +10,10 @@ Provides methods for creating, validating, and retrieving user information.
|
||||
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
|
||||
For commercial licensing inquiries: https://github.com/AIIrondev
|
||||
'''
|
||||
from pymongo import MongoClient
|
||||
import hashlib
|
||||
from bson.objectid import ObjectId
|
||||
import settings as cfg
|
||||
from settings import MongoClient
|
||||
|
||||
|
||||
def normalize_student_card_id(card_id):
|
||||
|
||||
+3
-1
@@ -30,6 +30,8 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: inventarsystem-app
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
depends_on:
|
||||
mongodb:
|
||||
condition: service_healthy
|
||||
@@ -43,7 +45,7 @@ services:
|
||||
- "8000"
|
||||
volumes:
|
||||
- ./config.json:/app/config.json:ro
|
||||
- ./Web:/app/Web
|
||||
- ./Web:/app/Web:ro
|
||||
- app_uploads:/app/Web/uploads
|
||||
- app_thumbnails:/app/Web/thumbnails
|
||||
- app_previews:/app/Web/previews
|
||||
|
||||
Reference in New Issue
Block a user