Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45b69e5ddb | |||
| 4971bc859b | |||
| b7f55b0de0 | |||
| 9c24e79bba | |||
| 79c325329c | |||
| 8f81ffb4c5 | |||
| 1d7692ea01 | |||
| 038390b8cd | |||
| f2c1dc2ba5 | |||
| 3eeae76e6c | |||
| 0228e6cb1d | |||
| 4c59cca1a4 | |||
| 8412ae76ee | |||
| ec4483c415 | |||
| 52656c715e | |||
| f6755aad42 | |||
| cbbcc09fc2 | |||
| aa8912e8b7 | |||
| 68488598af | |||
| 6e8bb8236e | |||
| bd92d60d08 | |||
| 4991587d99 | |||
| 2b8bf0b7d5 | |||
| 9ae316cdd9 | |||
| 12f62e5146 | |||
| 9ddaaaae1e | |||
| 3a1518571b | |||
| ebb266e099 | |||
| df677908d0 | |||
| bc61e87ab0 | |||
| 2d37ded5be | |||
| 31de22d460 | |||
| 96f918828c | |||
| eb912c3a73 | |||
| 3044b36e9f | |||
| e464628ab7 | |||
| a5a6f89f18 | |||
| 6835ee87a7 | |||
| 3a9d45327a | |||
| bfbc8b6796 | |||
| 2f6ab9c3fb | |||
| 7a4c3b82c0 | |||
| 1d6c722cd3 | |||
| 3e48aa23cc | |||
| e2f8da213a | |||
| 91fc5cae21 | |||
| b47fcdf804 | |||
| 92bea9a87e | |||
| 84ac9e812a |
+2
-2
@@ -1,4 +1,4 @@
|
||||
NUITKA_BUILD=0
|
||||
INVENTAR_HTTP_PORT=80
|
||||
INVENTAR_HTTPS_PORT=443
|
||||
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/inventarsystem:latest
|
||||
INVENTAR_HTTPS_PORT=8000
|
||||
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:latest
|
||||
|
||||
@@ -229,8 +229,16 @@ jobs:
|
||||
cp config.json release-bundle/config.json
|
||||
cp update.sh release-bundle/update.sh
|
||||
cp init-admin.sh release-bundle/init-admin.sh
|
||||
|
||||
# Multitenant scripts & docs
|
||||
cp docker-compose-multitenant.yml release-bundle/docker-compose-multitenant.yml
|
||||
cp docker/nginx/multitenant.conf release-bundle/docker/nginx/multitenant.conf
|
||||
cp manage-tenant.sh release-bundle/manage-tenant.sh
|
||||
cp run-tenant-cmd.sh release-bundle/run-tenant-cmd.sh
|
||||
cp MULTITENANT_DEPLOYMENT.md release-bundle/MULTITENANT_DEPLOYMENT.md
|
||||
cp MULTITENANT_PYTHON_API.md release-bundle/MULTITENANT_PYTHON_API.md
|
||||
|
||||
chmod +x release-bundle/start.sh release-bundle/stop.sh release-bundle/restart.sh release-bundle/backup.sh release-bundle/update.sh release-bundle/init-admin.sh
|
||||
chmod +x release-bundle/start.sh release-bundle/stop.sh release-bundle/restart.sh release-bundle/backup.sh release-bundle/update.sh release-bundle/init-admin.sh release-bundle/manage-tenant.sh release-bundle/run-tenant-cmd.sh
|
||||
tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle .
|
||||
|
||||
- name: Create or update GitHub Release
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
# Fehlersuche: Geplante Ausleihen werden nicht automatisch aktiviert
|
||||
|
||||
## 🔍 Mögliche Ursachen
|
||||
|
||||
### 1. **Termin wurde für falsches Datum erstellt**
|
||||
Die meisten Probleme entstehen, weil der Termin für ein **falsches Datum** (z.B. morgen statt heute) erstellt wurde.
|
||||
|
||||
### 2. **Scheduler läuft nicht**
|
||||
Der automatische Scheduler kann deaktiviert sein oder nicht starten.
|
||||
|
||||
### 3. **Zeitzonen-Problem**
|
||||
Die Startzeit kann in einer anderen Zeitzone gespeichert sein als erwartet.
|
||||
|
||||
---
|
||||
|
||||
## ✅ SOFORT-FIX: Manuell aktivieren
|
||||
|
||||
Falls du eine geplante Ausleihe sofort aktivieren möchtest, nutze diesen Befehl auf dem Server:
|
||||
|
||||
```bash
|
||||
docker exec inventarsystem-mongodb mongosh --eval "
|
||||
db.ausleihungen.updateOne(
|
||||
{_id: ObjectId('BOOKING_ID_HIER')},
|
||||
{\$set: {Status: 'active', LastUpdated: new Date()}}
|
||||
)" Inventarsystem
|
||||
```
|
||||
|
||||
Ersetze `BOOKING_ID_HIER` mit der ID deiner Ausleihe.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 DEBUG: Alle geplanten Ausleihen anschauen
|
||||
|
||||
Führe auf deinem Server aus:
|
||||
|
||||
```bash
|
||||
docker exec inventarsystem-mongodb mongosh --eval "
|
||||
db.ausleihungen.find({Status: 'planned'}).pretty()
|
||||
" Inventarsystem
|
||||
```
|
||||
|
||||
Das zeigt dir:
|
||||
- **Start**: Das gespeicherte Startdatum/Uhrzeit
|
||||
- **Periode**: Die Schulstunde (falls verwendet)
|
||||
- **Erstellungs-Zeit vs. aktuelle Zeit**: Vergleich
|
||||
|
||||
---
|
||||
|
||||
## 🎯 PROBLEM 1: Falsches Datum beim Erstellen
|
||||
|
||||
**Symptom**: Start-Zeit ist in der Zukunft (z.B. morgen statt heute)
|
||||
|
||||
**Lösung**:
|
||||
1. In der Kalender-Ansicht das **richtige Datum** auswählen
|
||||
2. Die **aktuelle Uhrzeit** für den Beginn nutzen (nicht eine zukünftige Zeit)
|
||||
3. Für Schulstunden: Stelle sicher die **heutige Schulstunde** zu wählen
|
||||
|
||||
---
|
||||
|
||||
## 🎯 PROBLEM 2: Scheduler läuft nicht
|
||||
|
||||
**Symptom**: Keine Logs über Termin-Aktivierungen
|
||||
|
||||
**Überprüfung**:
|
||||
|
||||
```bash
|
||||
# 1. Container-Logs anschauen
|
||||
docker logs inventarsystem-app | grep -i "scheduler\|appointment\|status update"
|
||||
|
||||
# 2. Scheduler ist aktiviert?
|
||||
docker exec inventarsystem-app python3 -c "
|
||||
import sys
|
||||
sys.path.insert(0, '/app/Web')
|
||||
import settings as cfg
|
||||
print(f'Scheduler aktiviert: {cfg.SCHEDULER_ENABLED}')
|
||||
print(f'Scheduler Intervall: {cfg.SCHEDULER_INTERVAL_MIN} Minuten')
|
||||
"
|
||||
```
|
||||
|
||||
**Lösung wenn deaktiviert**:
|
||||
- In `config.json` ändern:
|
||||
```json
|
||||
"scheduler": {
|
||||
"enabled": true,
|
||||
"interval_minutes": 1
|
||||
}
|
||||
```
|
||||
- Container neu starten: `docker-compose restart app`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 PROBLEM 3: Startzeit als nur-Zeit (kein Datum)
|
||||
|
||||
**Symptom**: Start-Zeit zeigt nur "13:30" ohne Datum
|
||||
|
||||
**Überprüfung**: In der Datenbank nach `{Start: {$type: "string"}}` suchen statt datetime
|
||||
|
||||
**Lösung**: Die Ausleihe muss mit einem vollständigen Datum+Uhrzeit erstellt werden.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 AUTOMATISCHE AKTIVIERUNG TESTEN
|
||||
|
||||
Nach dem Fix: Erstelle eine Ausleihe für:
|
||||
- **Heute** (das aktuelle Datum)
|
||||
- **Schulstunde 7** (13:30-14:15)
|
||||
- Startzeitpunkt sollte **jetzt oder in 1 Minute** sein
|
||||
|
||||
Warte 2 Minuten. Der Status sollte von `planned` zu `active` wechseln.
|
||||
|
||||
---
|
||||
|
||||
## 📊 SCHEDULER STATUS ÜBERPRÜFEN
|
||||
|
||||
```bash
|
||||
# Zeige die letzten 50 Log-Zeilen
|
||||
docker logs --tail 50 inventarsystem-app | tail -20
|
||||
|
||||
# Suche nach "Appointment status update"
|
||||
docker logs inventarsystem-app 2>/dev/null | grep -i "Appointment status" | tail -5
|
||||
```
|
||||
|
||||
Erwartete Log-Ausgabe:
|
||||
```
|
||||
Appointment status update finished: X changed (Y active, Z completed)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 DETAILLIERTE DEBUG-INFOS
|
||||
|
||||
Wenn obiges nicht funktioniert, führe dies aus:
|
||||
|
||||
```bash
|
||||
docker exec inventarsystem-app python3 -c "
|
||||
from pymongo import MongoClient
|
||||
import datetime
|
||||
|
||||
client = MongoClient('mongodb', 27017)
|
||||
db = client['Inventarsystem']
|
||||
aus = db['ausleihungen']
|
||||
|
||||
print('=== GEPLANTE AUSLEIHEN ===')
|
||||
for b in aus.find({'Status': 'planned'}).limit(3):
|
||||
print(f\"Start: {b.get('Start')} | Typ: {type(b.get('Start')).__name__}\")
|
||||
print(f\"Periode: {b.get('Period')} | User: {b.get('User')}\")
|
||||
print(f\"Jetzt > Start? {datetime.datetime.now() > b.get('Start', datetime.datetime.max)}\")
|
||||
print()
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 EMPFOHLENE EINSTELLUNG FÜR SCHULEN
|
||||
|
||||
In `config.json` verwende diese Werte:
|
||||
|
||||
```json
|
||||
{
|
||||
"scheduler": {
|
||||
"enabled": true,
|
||||
"interval_minutes": 1,
|
||||
"backup_interval_hours": 24
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Dies checkt **jede Minute** ob Termine aktiviert werden sollen - perfekt für Schulstunden.
|
||||
|
||||
---
|
||||
|
||||
## Weitere Hilfe?
|
||||
|
||||
Falls das nicht funktioniert:
|
||||
1. Schreib mir die **exakte Uhrzeit** wann du die Ausleihe erstellt hast
|
||||
2. Und wann sie hätte aktiviert werden sollen
|
||||
3. Zeige mir ein Beispiel aus der DB mit `docker exec inventarsystem-mongodb mongosh...`
|
||||
@@ -452,3 +452,44 @@ Für Support: Siehe Legal/LICENSE
|
||||
---
|
||||
|
||||
**Version**: 1.0 | **Letzte Aktualisierung**: 2026-04-17 | **Kompatibilität**: Python 3.11+, MongoDB 7.0+, Redis 7+
|
||||
|
||||
## Tenant Management Operationen (manage-tenant.sh)
|
||||
|
||||
Um einzelne Tenants im Multi-Tenant-Umfeld im laufenden Betrieb und ohne globale Downtime zu verwalten, kann das neue CLI-Skript `manage-tenant.sh` verwendet werden.
|
||||
|
||||
### 1. Neuen Tenant hinzufügen
|
||||
Initialisiert die MongoDB-Datenbankstruktur isoliert für einen neuen Tenant und legt initiale Admin-Zugangsdaten an.
|
||||
```bash
|
||||
./manage-tenant.sh add <tenant_id>
|
||||
```
|
||||
|
||||
### 2. Bestimmten Tenant neu starten (Soft-Restart)
|
||||
Erzwingt sofortigen Logout und einen Cache/Session-Reset für die Nutzer *eines spezifischen* Tenants, ohne andere laufende Instanzen zu beeinträchtigen. Ideal bei Konfigurationsänderungen oder feststeckenden Sessions.
|
||||
```bash
|
||||
./manage-tenant.sh restart-tenant <tenant_id>
|
||||
```
|
||||
|
||||
### 3. Tenant sicher entfernen
|
||||
Löscht die dedizierte MongoDB-Datenbank des gewählten Tenants vollständig (erfordert Bestätigung).
|
||||
```bash
|
||||
./manage-tenant.sh remove <tenant_id>
|
||||
```
|
||||
|
||||
### 4. Globale Operationen
|
||||
```bash
|
||||
# Zeigt alle aktiven isolierten Tenant-Datenbanken an
|
||||
./manage-tenant.sh list
|
||||
|
||||
# Führt einen Zero-Downtime Rolling-Restart aller Application-Container durch
|
||||
./manage-tenant.sh restart-all
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Aktuelle UI- & Funktionsoptimierungen (Release April 2026)
|
||||
|
||||
Neben der Docker-Auslagerung wurden spezifische Caching-, Parsing-, und DOM-Tricks integriert, die das Setup weiter entschlacken:
|
||||
|
||||
* **DOM Array Slicing für Bilder:** Bei großen Beständen (hunderte Artikel) rendert der Client im Listen/Kachel-Modus künftig nur noch das primäre Bild (`slice(0, 1)`), was den DOM-Memory-Footprint drastisch reduziert und das Einfrieren von Browsern verhindert.
|
||||
* **Auto-Ingestion von Excel-Filtern:** Der Excel-Importer prüft nun dynamisch neue `categories/filter`, die noch nicht in der Datenbank existieren, und speichert sie direkt in die MongoDB `filter_presets`-Kollektion (Zero-Config für Administratoren).
|
||||
* **Responsive UI Synchronisierung:** Die Standardansicht (`main.html`) der Smartphones wurde CSS-technisch exakt an das skalierungsfähigere Profil der Admin-Mobile-Ansicht (`main_admin.html`) angeglichen.
|
||||
|
||||
@@ -322,6 +322,32 @@ INVENTAR_WORKER_CONNECTIONS=100
|
||||
|
||||
---
|
||||
|
||||
## Schul-Konfiguration pro Tenant
|
||||
|
||||
Die Datei `config.json` unterstützt jetzt einen `tenants`-Block. Damit kann jede Schule eigene Modul-Schalter bekommen, ohne dass das ganze System global umgestellt werden muss.
|
||||
|
||||
```json
|
||||
{
|
||||
"tenants": {
|
||||
"schule1": {
|
||||
"modules": {
|
||||
"library": { "enabled": true },
|
||||
"student_cards": { "enabled": false }
|
||||
}
|
||||
},
|
||||
"schule2": {
|
||||
"modules": {
|
||||
"library": { "enabled": false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Wenn ein Request über Subdomain oder `X-Tenant-ID` aufgelöst wird, liest die App diese Werte automatisch aus und blendet die Bibliothek bzw. andere Module nur für diesen Tenant ein oder aus.
|
||||
|
||||
---
|
||||
|
||||
## Support & Debugging
|
||||
|
||||
**Fragen?**
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Multi-Tenant Python Management API
|
||||
|
||||
This document explains how the multi-tenant architecture isolates data within Python, what the return values are, and how developers can build internal administrative scripts using native Python instead of the Docker CLI.
|
||||
|
||||
## 1. Architectural Concept
|
||||
|
||||
In the system, each "tenant" is essentially a dedicated MongoDB database identified by a dynamically generated string based on a subdomain or header (`inventar_<tenant_id>`).
|
||||
App containers share a connection pool using `pymongo.MongoClient`, and requests are routed to specific databases dynamically based on the current Flask `g.tenant_context`.
|
||||
|
||||
All MongoDB administrative tasks (creating tenants, restarting apps, fetching lists) are done via standard MongoDB Python drivers because the core multi-tenancy happens at the **database level**.
|
||||
|
||||
## 2. Managing Tenants via Python
|
||||
|
||||
If you want to perform multi-tenant administrative operations without traversing through `manage-tenant.sh`, you can execute native Python scripts connecting to the system's `MongoClient`.
|
||||
|
||||
### Basic Connection Boilerplate
|
||||
Whenever automating an administrative task in Python, you simply need to connect to MongoDB using the properties defined in `settings.py`.
|
||||
|
||||
```python
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Append Web folder so we can access configuration
|
||||
sys.path.insert(0, '/app/Web')
|
||||
import settings
|
||||
from pymongo import MongoClient
|
||||
|
||||
# Establish connection pooling
|
||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||
```
|
||||
|
||||
### A. Adding a New Tenant (Database Initialization)
|
||||
A new tenant database isn’t provisioned until the first actual data insert happens. We trigger this manually by creating an `admin` user for them.
|
||||
|
||||
**Operation:**
|
||||
```python
|
||||
def create_tenant(tenant_id, admin_password="hashed_password_here"):
|
||||
db_name = f"{settings.MONGODB_DB}_{tenant_id}"
|
||||
db = client[db_name]
|
||||
|
||||
# MongoDB creates the DB automatically on first insert
|
||||
result = db.users.insert_one({
|
||||
'username': 'admin',
|
||||
'password': admin_password,
|
||||
'role': 'admin'
|
||||
})
|
||||
return result.inserted_id # Returns the BSON ObjectId of the new user
|
||||
```
|
||||
|
||||
### B. List Active Tenants
|
||||
To find out how many isolated tenants have active databases, you query the raw `MongoClient` for all databases and search for your configured MongoDB prefix (default: `inventar_`).
|
||||
|
||||
**Operation:**
|
||||
```python
|
||||
def list_tenants():
|
||||
prefix = f"{settings.MONGODB_DB}_"
|
||||
|
||||
# Returns a Python list of string database names
|
||||
all_dbs = client.list_database_names()
|
||||
|
||||
# Filter and strip the prefix to return just the tenant_ids
|
||||
active_tenants = [d.replace(prefix, "") for d in all_dbs if d.startswith(prefix)]
|
||||
|
||||
return active_tenants # e.g., ['schule1', 'schule2', 'test']
|
||||
```
|
||||
|
||||
### C. Soft-Restarting a Tenant (Invalidating Sessions)
|
||||
"Restarting" a single tenant means signing out all of their users and forcing an application refresh. Because Session data is coupled to the tenant database, dropping their `sessions` collection achieves an instant sign-out.
|
||||
|
||||
**Operation:**
|
||||
```python
|
||||
def restart_tenant(tenant_id):
|
||||
db_name = f"{settings.MONGODB_DB}_{tenant_id}"
|
||||
db = client[db_name]
|
||||
|
||||
# Drops the collection. All active user cookies immediately become invalid.
|
||||
result = db.sessions.drop()
|
||||
|
||||
return result # Returns None. Raises PyMongoError if connection fails.
|
||||
```
|
||||
|
||||
### D. Removing a Tenant Completely (Wipe Data)
|
||||
If a tenant is removed from the service or their lease expires, you can permanently obliterate their data container footprint.
|
||||
|
||||
**Operation:**
|
||||
```python
|
||||
def remove_tenant(tenant_id):
|
||||
db_name = f"{settings.MONGODB_DB}_{tenant_id}"
|
||||
|
||||
# Erases the isolated database. Can't be undone.
|
||||
client.drop_database(db_name)
|
||||
|
||||
return True # Returns True. Raises PyMongoError if connection fails.
|
||||
```
|
||||
|
||||
## 3. Resolving Context Inside Flask (app.py)
|
||||
|
||||
If you are building custom application endpoints inside `Web/app.py`, you shouldn't use the direct MongoDB `client` manually. Instead, you rely on the built-in Flask context manager (`Web/tenant.py`) to give you the correct isolated scope.
|
||||
|
||||
### The `get_tenant_db()` function
|
||||
Every route must use `get_tenant_db(client)` to ensure users can only ever access their own school/domain's database.
|
||||
|
||||
```python
|
||||
from pymongo import MongoClient
|
||||
import settings
|
||||
from tenant import get_tenant_db
|
||||
|
||||
# Example Route
|
||||
@app.route('/api/items')
|
||||
def get_items():
|
||||
# 1. Establish/reuse pooling connection
|
||||
client = MongoClient(settings.MONGODB_HOST, settings.MONGODB_PORT)
|
||||
|
||||
# 2. Get the dynamically routed DB for THIS user
|
||||
# (Based on Nginx Subdomain or X-Tenant-Id header)
|
||||
db = get_tenant_db(client)
|
||||
|
||||
# 3. Runs query solely on `inventar_schule1.items`
|
||||
items = list(db.items.find())
|
||||
|
||||
return items # List of BSON Dictionaries
|
||||
```
|
||||
|
||||
**What it returns internally:**
|
||||
The `get_tenant_db` function queries `g.tenant_context` inside Flask, calculates the database name from the subdomain, and returns a live `pymongo.database.Database` object.
|
||||
|
||||
This ensures that scaling is extremely cheap on resources because 1 Application Container connects to 100 separate Tenant Databases using just 1 shared `MongoClient` pool.
|
||||
|
||||
+57
-20
@@ -339,6 +339,28 @@ def _enforce_user_permissions():
|
||||
return None
|
||||
|
||||
|
||||
@app.before_request
|
||||
def _enforce_active_session_user():
|
||||
endpoint = request.endpoint or ''
|
||||
if endpoint == 'static' or endpoint.startswith('static'):
|
||||
return None
|
||||
|
||||
username = session.get('username')
|
||||
if not username:
|
||||
return None
|
||||
|
||||
user = us.get_user(username)
|
||||
if user:
|
||||
return None
|
||||
|
||||
session.clear()
|
||||
if request.path.startswith('/api/') or request.is_json:
|
||||
return jsonify({'ok': False, 'message': 'Sitzung ungültig. Bitte erneut anmelden.'}), 401
|
||||
|
||||
flash('Ihre Sitzung ist nicht mehr gültig. Bitte erneut anmelden.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
|
||||
def _get_asset_version():
|
||||
"""Return a cache-busting asset version tied to deployment state."""
|
||||
env_version = os.getenv('INVENTAR_ASSET_VERSION', '').strip()
|
||||
@@ -2063,6 +2085,11 @@ def _upload_excel_items(scope='inventory'):
|
||||
validation_warnings = []
|
||||
reserved_codes = set()
|
||||
known_locations = set(it.get_predefined_locations())
|
||||
known_filters = {
|
||||
1: set(it.get_predefined_filter_values(1)),
|
||||
2: set(it.get_predefined_filter_values(2)),
|
||||
3: set(it.get_predefined_filter_values(3))
|
||||
}
|
||||
|
||||
def is_code_available(candidate_code):
|
||||
return candidate_code not in reserved_codes and it.is_code_unique(candidate_code)
|
||||
@@ -2114,8 +2141,19 @@ def _upload_excel_items(scope='inventory'):
|
||||
validation_warnings.append((row_number, f'Ort "{ort}" wird neu angelegt'))
|
||||
|
||||
filter1 = expand_filter_selection(_excel_list(val('filter1')), 1)
|
||||
for f in filter1:
|
||||
if f and f not in known_filters[1]:
|
||||
validation_warnings.append((row_number, f'Kategorie 1 "{f}" wird neu angelegt'))
|
||||
|
||||
filter2 = expand_filter_selection(_excel_list(val('filter2')), 2)
|
||||
for f in filter2:
|
||||
if f and f not in known_filters[2]:
|
||||
validation_warnings.append((row_number, f'Kategorie 2 "{f}" wird neu angelegt'))
|
||||
|
||||
filter3 = _excel_list(val('filter3'))
|
||||
for f in filter3:
|
||||
if f and f not in known_filters[3]:
|
||||
validation_warnings.append((row_number, f'Kategorie 3 "{f}" wird neu angelegt'))
|
||||
|
||||
anschaffungsjahr = _excel_int(val('anschaffungsjahr'))
|
||||
raw_year = val('anschaffungsjahr')
|
||||
@@ -2217,6 +2255,21 @@ def _upload_excel_items(scope='inventory'):
|
||||
it.add_predefined_location(row['ort'])
|
||||
known_locations.add(row['ort'])
|
||||
|
||||
for f in row.get('filter1', []):
|
||||
if f and f not in known_filters[1]:
|
||||
it.add_predefined_filter_value(1, f)
|
||||
known_filters[1].add(f)
|
||||
|
||||
for f in row.get('filter2', []):
|
||||
if f and f not in known_filters[2]:
|
||||
it.add_predefined_filter_value(2, f)
|
||||
known_filters[2].add(f)
|
||||
|
||||
for f in row.get('filter3', []):
|
||||
if f and f not in known_filters[3]:
|
||||
it.add_predefined_filter_value(3, f)
|
||||
known_filters[3].add(f)
|
||||
|
||||
series_group_id = str(uuid.uuid4()) if row['count'] > 1 else None
|
||||
row_created_ids = []
|
||||
|
||||
@@ -7060,9 +7113,7 @@ def register():
|
||||
|
||||
permission_preset = (request.form.get('permission_preset') or 'standard_user').strip()
|
||||
use_custom_permissions = request.form.get('use_custom_permissions') == 'on'
|
||||
is_student = bool(request.form.get('is_student')) if cfg.STUDENT_CARDS_MODULE_ENABLED else False
|
||||
student_card_id = us.normalize_student_card_id(request.form.get('student_card_id')) if cfg.STUDENT_CARDS_MODULE_ENABLED else ''
|
||||
max_borrow_days_raw = request.form.get('max_borrow_days') if cfg.STUDENT_CARDS_MODULE_ENABLED else None
|
||||
|
||||
if not username or not password or not name or not last_name:
|
||||
flash('Bitte füllen Sie alle Felder aus', 'error')
|
||||
return redirect(url_for('register'))
|
||||
@@ -7081,28 +7132,14 @@ def register():
|
||||
for endpoint_name, _ in PERMISSION_PAGE_OPTIONS:
|
||||
page_permissions[endpoint_name] = request.form.get(f'page_{endpoint_name}') == 'on'
|
||||
|
||||
max_borrow_days = None
|
||||
if is_student:
|
||||
if not student_card_id:
|
||||
flash('Für Schüler muss eine Ausweis-ID angegeben werden.', 'error')
|
||||
return redirect(url_for('register'))
|
||||
if us.student_card_exists(student_card_id):
|
||||
flash('Die Ausweis-ID ist bereits vergeben.', 'error')
|
||||
return redirect(url_for('register'))
|
||||
try:
|
||||
max_borrow_days = int(max_borrow_days_raw) if max_borrow_days_raw else cfg.STUDENT_DEFAULT_BORROW_DAYS
|
||||
except (TypeError, ValueError):
|
||||
max_borrow_days = cfg.STUDENT_DEFAULT_BORROW_DAYS
|
||||
max_borrow_days = max(1, min(max_borrow_days, cfg.STUDENT_MAX_BORROW_DAYS))
|
||||
|
||||
us.add_user(
|
||||
username,
|
||||
password,
|
||||
name,
|
||||
last_name,
|
||||
is_student=is_student,
|
||||
student_card_id=student_card_id if is_student else None,
|
||||
max_borrow_days=max_borrow_days,
|
||||
is_student=False,
|
||||
student_card_id=None,
|
||||
max_borrow_days=None,
|
||||
permission_preset=permission_preset,
|
||||
action_permissions=action_permissions,
|
||||
page_permissions=page_permissions,
|
||||
|
||||
+4
-4
@@ -71,6 +71,10 @@ def get_current_status(ausleihung, log_changes=False, user=None):
|
||||
# Bei stornierten Ausleihungen bleibt der Status immer storniert
|
||||
if original_status == 'cancelled':
|
||||
return 'cancelled'
|
||||
|
||||
# Wenn die Ausleihung bereits abgeschlossen ist, bleibt sie es
|
||||
if original_status == 'completed':
|
||||
return 'completed'
|
||||
|
||||
current_time = datetime.datetime.now()
|
||||
start_time = ausleihung.get('Start')
|
||||
@@ -79,10 +83,6 @@ def get_current_status(ausleihung, log_changes=False, user=None):
|
||||
# Wenn kein Startdatum vorhanden ist, Status auf 'planned' setzen
|
||||
if not start_time:
|
||||
new_status = 'planned'
|
||||
# Wenn die Ausleihung als 'completed' markiert wurde und ein Enddatum hat,
|
||||
# bleibt sie bei 'completed'
|
||||
elif original_status == 'completed' and end_time:
|
||||
new_status = 'completed'
|
||||
# Wenn die aktuelle Zeit vor dem Startdatum liegt, ist die Ausleihung geplant
|
||||
elif current_time < start_time:
|
||||
# DEBUG: Log info wenn Booking noch lange in der Zukunft ist
|
||||
|
||||
@@ -324,7 +324,8 @@ def _send_web_push_notification(subscription, payload):
|
||||
data=json.dumps(payload),
|
||||
vapid_private_key=VAPID_PRIVATE_KEY,
|
||||
vapid_claims={'sub': VAPID_SUBJECT},
|
||||
timeout=10
|
||||
timeout=10,
|
||||
ttl=3600 # Notification expires after 1 hour if device is offline
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -15,3 +15,4 @@ python-barcode
|
||||
openpyxl
|
||||
cryptography
|
||||
pywebpush
|
||||
py-vapid==1.9.0
|
||||
|
||||
+30
-2
@@ -157,8 +157,36 @@ SSL_KEY = _get(_conf, ['ssl', 'key'], DEFAULTS['ssl']['key'])
|
||||
SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods'])
|
||||
|
||||
# Optional feature modules
|
||||
LIBRARY_MODULE_ENABLED = bool(_get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled']))
|
||||
STUDENT_CARDS_MODULE_ENABLED = bool(_get(_conf, ['modules', 'student_cards', 'enabled'], DEFAULTS['modules']['student_cards']['enabled']))
|
||||
TENANT_CONFIGS = _get(_conf, ['tenants'], {})
|
||||
|
||||
|
||||
class _TenantAwareBool:
|
||||
def __init__(self, module_name, default):
|
||||
self.module_name = module_name
|
||||
self.default = bool(default)
|
||||
|
||||
def resolve(self):
|
||||
try:
|
||||
from tenant import is_tenant_module_enabled
|
||||
return bool(is_tenant_module_enabled(self.module_name, default=self.default))
|
||||
except Exception:
|
||||
return self.default
|
||||
|
||||
def __bool__(self):
|
||||
return self.resolve()
|
||||
|
||||
def __int__(self):
|
||||
return int(self.resolve())
|
||||
|
||||
def __str__(self):
|
||||
return 'True' if self.resolve() else 'False'
|
||||
|
||||
def __repr__(self):
|
||||
return f"_TenantAwareBool(module_name={self.module_name!r}, value={self.resolve()!r})"
|
||||
|
||||
|
||||
LIBRARY_MODULE_ENABLED = _TenantAwareBool('library', _get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled']))
|
||||
STUDENT_CARDS_MODULE_ENABLED = _TenantAwareBool('student_cards', _get(_conf, ['modules', 'student_cards', 'enabled'], DEFAULTS['modules']['student_cards']['enabled']))
|
||||
STUDENT_DEFAULT_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'default_borrow_days'], DEFAULTS['modules']['student_cards']['default_borrow_days']))
|
||||
STUDENT_MAX_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'max_borrow_days'], DEFAULTS['modules']['student_cards']['max_borrow_days']))
|
||||
|
||||
|
||||
+119
-29
@@ -21,6 +21,47 @@
|
||||
--ui-radius: 12px;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--ui-bg: #0f172a;
|
||||
--ui-bg-accent: #1e293b;
|
||||
--ui-surface: #1e293b;
|
||||
--ui-surface-soft: #25314a;
|
||||
--ui-border: #334155;
|
||||
--ui-text: #f1f5f9;
|
||||
--ui-text-muted: #94a3b8;
|
||||
--ui-title: #ffffff;
|
||||
--ui-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
--ui-shadow-md: 0 10px 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Safe Area Support for iPad notches, Dynamic Island, and rounded corners */
|
||||
html {
|
||||
/* Ensure viewport-fit is respected */
|
||||
viewport-fit: cover;
|
||||
}
|
||||
|
||||
body {
|
||||
/* Respect safe areas on mobile devices */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Future-proof: Support for various viewport and safe-area features */
|
||||
@supports (padding: max(0px)) {
|
||||
body {
|
||||
/* Ensure body padding never conflicts with safe areas */
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Make inputs visible in dark mode */
|
||||
:root[data-theme="dark"] input,
|
||||
:root[data-theme="dark"] select,
|
||||
:root[data-theme="dark"] textarea {
|
||||
background-color: var(--ui-surface-soft) !important;
|
||||
color: var(--ui-text) !important;
|
||||
border-color: var(--ui-border) !important;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Manrope", "Segoe UI", "Noto Sans", sans-serif;
|
||||
color: var(--ui-text);
|
||||
@@ -92,7 +133,7 @@ span {
|
||||
.card-header,
|
||||
.modal-header,
|
||||
thead th {
|
||||
background: linear-gradient(180deg, #f7fafd, #edf3f8) !important;
|
||||
background: var(--ui-surface-soft) !important;
|
||||
border-bottom: 1px solid var(--ui-border) !important;
|
||||
color: var(--ui-title) !important;
|
||||
}
|
||||
@@ -107,10 +148,10 @@ input[type="email"],
|
||||
input[type="date"],
|
||||
select {
|
||||
border-radius: 10px !important;
|
||||
border: 1px solid #cad5e0 !important;
|
||||
border: 1px solid var(--ui-border) !important;
|
||||
min-height: 40px;
|
||||
color: var(--ui-text) !important;
|
||||
background-color: #fff !important;
|
||||
background-color: var(--ui-surface) !important;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
@@ -131,11 +172,13 @@ select:focus {
|
||||
.table th,
|
||||
.table td {
|
||||
vertical-align: middle;
|
||||
border-color: #dee6ee !important;
|
||||
border-color: var(--ui-border) !important;
|
||||
background-color: var(--ui-surface) !important;
|
||||
color: var(--ui-text) !important;
|
||||
}
|
||||
|
||||
.table-striped > tbody > tr:nth-of-type(odd) {
|
||||
background-color: #f8fbff;
|
||||
background-color: var(--ui-surface-soft) !important;
|
||||
}
|
||||
|
||||
.alert {
|
||||
@@ -144,13 +187,20 @@ select:focus {
|
||||
}
|
||||
|
||||
.navbar {
|
||||
border-bottom: 1px solid #273341;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
box-shadow: none;
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(10px);
|
||||
/* Safe area insets for iPad notches and Dynamic Island */
|
||||
padding-left: env(safe-area-inset-left, 0);
|
||||
padding-right: env(safe-area-inset-right, 0);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
/* Prevent navbar brand from extending into unsafe areas */
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@@ -164,21 +214,48 @@ select:focus {
|
||||
}
|
||||
}
|
||||
|
||||
/* Edit and generate buttons with black text for better visibility */
|
||||
.edit-button {
|
||||
color: black !important; /* Override any existing color */
|
||||
/* Mobile and tablet safe area handling for navbar */
|
||||
@media (max-width: 992px) {
|
||||
.navbar {
|
||||
/* Ensure navbar spans full width but respects safe areas */
|
||||
width: 100vw;
|
||||
margin-left: calc(-50vw + 50%);
|
||||
/* Safe area padding is already handled by navbar class */
|
||||
}
|
||||
|
||||
.navbar .container-fluid {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
/* Ensure padding accounts for safe areas on small screens */
|
||||
padding-left: max(env(safe-area-inset-left, 0), 12px);
|
||||
padding-right: max(env(safe-area-inset-right, 0), 12px);
|
||||
}
|
||||
|
||||
.navbar-collapse {
|
||||
/* Ensure collapsed navbar respects safe areas */
|
||||
padding-left: env(safe-area-inset-left, 0);
|
||||
padding-right: env(safe-area-inset-right, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.duplicate-button {
|
||||
color: black !important; /* Override any existing color */
|
||||
/* iPad-specific landscape and portrait handling */
|
||||
@media (max-height: 600px) and (orientation: landscape) {
|
||||
.navbar {
|
||||
padding-top: max(env(safe-area-inset-top, 0), 4px);
|
||||
}
|
||||
|
||||
.navbar .container-fluid {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.generate-qr-button {
|
||||
color: black !important; /* Override any existing color */
|
||||
/* Edit and generate buttons with theme-aware text for better visibility */
|
||||
.edit-button, .duplicate-button, .generate-qr-button {
|
||||
color: var(--ui-text) !important; /* Override any existing color */
|
||||
}
|
||||
|
||||
.ausleihen {
|
||||
color: black !important; /* Override any existing color */
|
||||
/* Color handled securely below */
|
||||
}
|
||||
|
||||
/* Modal image display fix */
|
||||
@@ -245,12 +322,12 @@ select:focus {
|
||||
.view-toggle-btn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border: 1px solid #c7ced6;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
background: var(--ui-surface);
|
||||
font-size: 1.15rem;
|
||||
cursor: pointer;
|
||||
color: #2f3e4e;
|
||||
color: var(--ui-text);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -259,8 +336,8 @@ select:focus {
|
||||
|
||||
.view-toggle-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: #8fa2b7;
|
||||
background: #f8fbff;
|
||||
border-color: var(--ui-border);
|
||||
background: var(--ui-surface-soft);
|
||||
box-shadow: 0 3px 8px rgba(33, 37, 41, 0.12);
|
||||
}
|
||||
|
||||
@@ -269,9 +346,9 @@ select:focus {
|
||||
}
|
||||
|
||||
.view-toggle-btn[aria-pressed='true'] {
|
||||
background: #e9f2ff;
|
||||
border-color: #7ea1c8;
|
||||
color: #1f4f7a;
|
||||
background: var(--module-primary-color);
|
||||
border-color: var(--module-primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.table-view-header {
|
||||
@@ -288,7 +365,7 @@ body.table-view .table-view-header {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: #3f4e5d;
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
@@ -307,9 +384,9 @@ body.table-view .item-card {
|
||||
max-width: none;
|
||||
margin: 0 0 10px 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e1e6ed;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
background: var(--ui-surface);
|
||||
box-shadow: none;
|
||||
scroll-snap-align: none;
|
||||
}
|
||||
@@ -335,7 +412,7 @@ body.table-view .item-card .card-content .item-name-cell,
|
||||
body.table-view .item-card .card-content .item-col-name {
|
||||
font-size: 0.98rem;
|
||||
font-weight: 700;
|
||||
color: #1f2d3d;
|
||||
color: var(--ui-title);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -352,7 +429,7 @@ body.table-view .item-card .card-content .item-col-filter3,
|
||||
body.table-view .item-card .card-content .item-code-cell,
|
||||
body.table-view .item-card .card-content .item-col-code,
|
||||
body.table-view .item-card .card-content .item-col-count {
|
||||
color: #445465;
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -369,7 +446,7 @@ body.table-view .item-card .damage-badge {
|
||||
body.table-view .item-card .actions {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed #d9e0e8;
|
||||
border-top: 1px dashed var(--ui-border);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@@ -805,4 +882,17 @@ body.table-view .item-card .actions {
|
||||
padding: 7px 12px !important;
|
||||
font-size: 0.84rem !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* iOS and Mobile optimization fixes */
|
||||
html, body {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.scrollable-container, .items-container {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
@supports (padding-bottom: env(safe-area-inset-bottom)) {
|
||||
.fixed-bottom, .modal-footer {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 545 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
+9
-120
@@ -1,134 +1,23 @@
|
||||
{
|
||||
"name": "Inventarsystem",
|
||||
"short_name": "Inventar",
|
||||
"description": "Verwaltungssystem für Schulinventare mit Ausleihmanagement",
|
||||
"name": "Ausleihsystem",
|
||||
"short_name": "Ausleihe",
|
||||
"description": "System zur Verwaltung von Ausleihen",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#007bff",
|
||||
"dir": "ltr",
|
||||
"lang": "de-DE",
|
||||
"prefer_related_applications": false,
|
||||
"related_applications": [],
|
||||
"theme_color": "#1f2937",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/img/logo-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"src": "/static/img/icon-192.png",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/img/logo-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/img/logo-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/img/logo-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/img/logo-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/img/logo-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/img/logo-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"src": "/static/img/icon-512.png",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/img/logo-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/img/logo-192x192-maskable.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/img/logo-512x512-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "/static/img/screenshot-540x720.png",
|
||||
"sizes": "540x720",
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow"
|
||||
},
|
||||
{
|
||||
"src": "/static/img/screenshot-1280x720.png",
|
||||
"sizes": "1280x720",
|
||||
"type": "image/png",
|
||||
"form_factor": "wide"
|
||||
}
|
||||
],
|
||||
"categories": ["education", "productivity"],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Meine Ausleihungen",
|
||||
"short_name": "Ausleihungen",
|
||||
"description": "Zeige meine aktuellen Ausleihungen",
|
||||
"url": "/my_borrowed_items",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/img/shortcut-96x96.png",
|
||||
"sizes": "96x96"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Kalender",
|
||||
"short_name": "Kalender",
|
||||
"description": "Kalender für Ausleihplanungen",
|
||||
"url": "/terminplan",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/img/shortcut-96x96.png",
|
||||
"sizes": "96x96"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/share",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"title": "title",
|
||||
"text": "text",
|
||||
"url": "url",
|
||||
"files": [
|
||||
{
|
||||
"name": "media",
|
||||
"accept": ["image/*", "video/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
const CACHE_NAME = 'ausleihe-cache-v1';
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// Einfacher Fallback aufs Netzwerk (Offline-Seite könnte hier ergänzt werden)
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() => {
|
||||
return caches.match(event.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; margin:16px 0 20px;">
|
||||
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background:#fff;">
|
||||
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);">
|
||||
<div style="font-size:0.85rem; color:#64748b;">Chain Status</div>
|
||||
{% if verify_result.ok %}
|
||||
<div style="font-size:1.35rem; font-weight:700; color:#166534;">OK</div>
|
||||
@@ -19,15 +19,15 @@
|
||||
<div style="font-size:1.35rem; font-weight:700; color:#991b1b;">FEHLER</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background:#fff;">
|
||||
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);">
|
||||
<div style="font-size:0.85rem; color:#64748b;">Einträge</div>
|
||||
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.count }}</div>
|
||||
</div>
|
||||
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background:#fff;">
|
||||
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);">
|
||||
<div style="font-size:0.85rem; color:#64748b;">Letzter Index</div>
|
||||
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.last_chain_index }}</div>
|
||||
</div>
|
||||
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background:#fff;">
|
||||
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);">
|
||||
<div style="font-size:0.85rem; color:#64748b;">Mismatch Count</div>
|
||||
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.mismatches|length }}</div>
|
||||
</div>
|
||||
@@ -61,7 +61,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="border:1px solid #e2e8f0; border-radius:10px; background:#fff; padding:12px;">
|
||||
<div style="border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface); padding:12px;">
|
||||
<h3 style="margin-top:0;">Letzte Audit-Ereignisse</h3>
|
||||
<div style="max-height:560px; overflow:auto;">
|
||||
<table class="table" style="width:100%; border-collapse:collapse;">
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
</div>
|
||||
|
||||
<div id="invoice-modal" style="display:none; position:fixed; inset:0; background:rgba(15,23,42,0.72); z-index:9999; padding:20px; overflow:auto;">
|
||||
<div style="max-width:760px; margin:40px auto; background:#fff; border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);">
|
||||
<div style="max-width:760px; margin:40px auto; background: var(--ui-surface); border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:18px;">
|
||||
<div>
|
||||
<h2 style="margin:0;">Rechnung erstellen</h2>
|
||||
@@ -174,15 +174,15 @@
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:16px; margin-bottom:16px;">
|
||||
<div>
|
||||
<label for="invoice-item" style="display:block; font-weight:700; margin-bottom:6px;">Element</label>
|
||||
<input id="invoice-item" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
|
||||
<input id="invoice-item" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background: var(--ui-surface-soft);">
|
||||
</div>
|
||||
<div>
|
||||
<label for="invoice-borrower" style="display:block; font-weight:700; margin-bottom:6px;">Schüler</label>
|
||||
<input id="invoice-borrower" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
|
||||
<input id="invoice-borrower" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background: var(--ui-surface-soft);">
|
||||
</div>
|
||||
<div>
|
||||
<label for="invoice-code" style="display:block; font-weight:700; margin-bottom:6px;">Element-ID / Code</label>
|
||||
<input id="invoice-code" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
|
||||
<input id="invoice-code" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background: var(--ui-surface-soft);">
|
||||
</div>
|
||||
<div>
|
||||
<label for="invoice-amount" style="display:block; font-weight:700; margin-bottom:6px;">Preis</label>
|
||||
|
||||
@@ -17,14 +17,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:14px; margin-bottom:12px;">
|
||||
<div style="background: var(--ui-surface); 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;">
|
||||
<div style="background: var(--ui-surface); 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;">
|
||||
<tr style="background: var(--ui-surface-soft);">
|
||||
<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>
|
||||
|
||||
+341
-97
@@ -10,10 +10,24 @@
|
||||
<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">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes, viewport-fit=cover">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="theme-color" content="#2c3e50" id="meta-theme-color">
|
||||
<script>
|
||||
// Init theme early to prevent FOUC (Flash of Unstyled Content)
|
||||
(function() {
|
||||
var theme = localStorage.getItem('inventarsystem-theme');
|
||||
if (theme === 'dark' || (!theme && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
document.getElementById('meta-theme-color').setAttribute('content', '#1a252f');
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
document.getElementById('meta-theme-color').setAttribute('content', '#2c3e50');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>{% block title %}Inventarsystem{% endblock %}</title>
|
||||
{% block head %}
|
||||
@@ -102,6 +116,9 @@
|
||||
--module-accent-color: #3498db;
|
||||
--module-accent-light: rgba(52, 152, 219, 0.1);
|
||||
--module-brand-icon: "📦";
|
||||
--page-bg-gradient: linear-gradient(180deg, #f2f5f8, #e7edf4);
|
||||
--page-bg-overlay1: rgba(86, 145, 200, 0.08);
|
||||
--page-bg-overlay2: rgba(52, 98, 150, 0.09);
|
||||
}
|
||||
|
||||
html[data-module="library"] {
|
||||
@@ -112,17 +129,34 @@
|
||||
--module-brand-icon: "📚";
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--module-primary-color: #1a252f;
|
||||
--module-primary-light: #2c3e50;
|
||||
--module-accent-color: #2980b9;
|
||||
--module-brand-icon: "📦";
|
||||
--page-bg-gradient: linear-gradient(180deg, #0f172a, #1e293b);
|
||||
--page-bg-overlay1: rgba(56, 189, 248, 0.05);
|
||||
--page-bg-overlay2: rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
|
||||
html[data-theme="dark"][data-module="library"] {
|
||||
--module-primary-color: #4a354d;
|
||||
--module-primary-light: #614666;
|
||||
--module-accent-color: #8e44ad;
|
||||
}
|
||||
|
||||
/* Global styles */
|
||||
body {
|
||||
font-family: "Manrope", "Segoe UI", "Noto Sans", sans-serif;
|
||||
background:
|
||||
radial-gradient(1200px 500px at 10% -10%, rgba(86, 145, 200, 0.08), transparent 55%),
|
||||
radial-gradient(900px 420px at 95% 0%, rgba(52, 98, 150, 0.09), transparent 52%),
|
||||
linear-gradient(180deg, #f2f5f8, #e7edf4);
|
||||
radial-gradient(1200px 500px at 10% -10%, var(--page-bg-overlay1), transparent 55%),
|
||||
radial-gradient(900px 420px at 95% 0%, var(--page-bg-overlay2), transparent 52%),
|
||||
var(--page-bg-gradient);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding-bottom: 30px;
|
||||
color: #1f2d3d;
|
||||
padding-bottom: auto; /* Fallback for dynamic calculation */
|
||||
padding-bottom: env(safe-area-inset-bottom, 30px);
|
||||
color: var(--ui-text, #1f2d3d);
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -135,29 +169,87 @@
|
||||
background-color: var(--module-primary-color) !important;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
/* iOS Safe Area insets for notches and rounded corners */
|
||||
padding-top: env(safe-area-inset-top, 0);
|
||||
padding-left: env(safe-area-inset-left, 0);
|
||||
padding-right: env(safe-area-inset-right, 0);
|
||||
z-index: 1900;
|
||||
}
|
||||
|
||||
.navbar .container-fluid {
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
/* Ensure container respects safe areas on all sides */
|
||||
padding-left: max(env(safe-area-inset-left, 0), 12px);
|
||||
padding-right: max(env(safe-area-inset-right, 0), 12px);
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: bold;
|
||||
font-size: 1.4rem;
|
||||
font-size: 1.3rem;
|
||||
padding-right: 10px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.navbar-brand::before {
|
||||
content: attr(data-icon);
|
||||
margin-right: 8px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link {
|
||||
font-size: 1rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
transition: padding .18s ease, font-size .18s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* iPad/Tablet responsive adjustments */
|
||||
@media (max-width: 1024px) {
|
||||
.navbar .container-fluid {
|
||||
gap: 6px;
|
||||
padding-left: max(env(safe-area-inset-left, 0), 10px);
|
||||
padding-right: max(env(safe-area-inset-right, 0), 10px);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.1rem;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.navbar-brand::before {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small phones responsive adjustments */
|
||||
@media (max-width: 576px) {
|
||||
.navbar .container-fluid {
|
||||
gap: 4px;
|
||||
padding-left: max(env(safe-area-inset-left, 0), 8px);
|
||||
padding-right: max(env(safe-area-inset-right, 0), 8px);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 0.95rem;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.navbar-brand::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar.nav-compact .navbar-brand {
|
||||
@@ -214,6 +306,7 @@
|
||||
align-items: center;
|
||||
margin-right: 10px;
|
||||
width: min(420px, 42vw);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.function-search-form {
|
||||
@@ -221,10 +314,13 @@
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.function-search-input {
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
min-height: 38px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
@@ -245,6 +341,7 @@
|
||||
}
|
||||
|
||||
.function-search-btn {
|
||||
flex: 0 0 auto;
|
||||
min-height: 38px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||
@@ -351,18 +448,19 @@
|
||||
|
||||
/* Dropdown menus - consistent with system design */
|
||||
.dropdown-menu {
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
border: 1px solid #dee2e6;
|
||||
box-shadow: var(--ui-shadow-md);
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 0;
|
||||
min-width: 200px;
|
||||
margin-top: 5px;
|
||||
background-color: var(--ui-surface);
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 10px 16px;
|
||||
font-size: 0.9rem;
|
||||
color: #495057;
|
||||
color: var(--ui-text);
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
background: none;
|
||||
@@ -370,8 +468,8 @@
|
||||
|
||||
.dropdown-item:hover,
|
||||
.dropdown-item:focus {
|
||||
background-color: #f8f9fa;
|
||||
color: #007bff;
|
||||
background-color: var(--ui-surface-soft);
|
||||
color: var(--module-accent-color);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
@@ -434,17 +532,60 @@
|
||||
}
|
||||
|
||||
.nav-item.dropdown .dropdown-toggle {
|
||||
padding: 8px 12px;
|
||||
padding: 12px 12px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.navbar-toggler {
|
||||
padding: 0.5rem 0.65rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border-width: 2px;
|
||||
min-height: 48px;
|
||||
min-width: 48px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.navbar-collapse {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.navbar-collapse > .d-flex {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.function-search-wrap {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 8px 0 10px;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.function-search-form {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.function-search-input {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.function-search-btn {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.navbar-text {
|
||||
margin-right: 0 !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-menu-wrap {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-item {
|
||||
@@ -465,7 +606,7 @@
|
||||
|
||||
/* Account dropdown stays light theme on mobile */
|
||||
.dropdown-menu-end {
|
||||
background-color: #ffffff;
|
||||
background-color: var(--ui-surface);
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
@@ -474,7 +615,7 @@
|
||||
}
|
||||
|
||||
.dropdown-menu-end .dropdown-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
background-color: var(--ui-surface-soft);
|
||||
color: #007bff;
|
||||
}
|
||||
}
|
||||
@@ -573,17 +714,38 @@
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-start;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.navbar-toggler:active,
|
||||
.navbar-toggler:focus {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.navbar-collapse {
|
||||
order: 4;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 10px 10px 6px;
|
||||
border-radius: 14px;
|
||||
background: rgba(15, 23, 42, 0.18);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 16px;
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
max-height: calc(100vh - 90px); /* fallback */
|
||||
max-height: calc(100dvh - 90px);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.navbar-collapse.show,
|
||||
@@ -595,7 +757,7 @@
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding-top: 2px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-item {
|
||||
@@ -605,11 +767,12 @@
|
||||
.navbar-nav .nav-link {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
min-height: 46px;
|
||||
padding: 0.8rem 0.95rem;
|
||||
min-height: 48px;
|
||||
padding: 0.9rem 1.1rem;
|
||||
margin-bottom: 2px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link.nav-active,
|
||||
@@ -632,6 +795,23 @@
|
||||
margin-left: 0;
|
||||
margin-top: 6px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.08); /* Match nav-link slightly */
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.navbar-nav .dropdown-item {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin: 2px 8px;
|
||||
width: calc(100% - 16px);
|
||||
}
|
||||
|
||||
.navbar-nav .dropdown-item:hover,
|
||||
.navbar-nav .dropdown-item:focus {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.module-selector-bar {
|
||||
@@ -777,12 +957,12 @@
|
||||
{% endif %}
|
||||
{% if 'username' in session %}
|
||||
{% if current_permissions.pages.get('my_borrowed_items', True) %}
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<li class="nav-item">
|
||||
<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>
|
||||
{% endif %}
|
||||
{% if current_permissions.pages.get('tutorial_page', True) %}
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<li class="nav-item">
|
||||
<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 %}
|
||||
@@ -792,20 +972,18 @@
|
||||
<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" data-nav-fixed="true">
|
||||
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
|
||||
<span class="theme-icon-light" style="display: none;">☀️</span>
|
||||
<span class="theme-icon-dark" style="display: none;">🌙</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<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" title="Weitere Optionen">
|
||||
Mehr Optionen
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown">
|
||||
{% if 'username' in session %}
|
||||
{% if current_permissions.pages.get('my_borrowed_items', True) %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a></li>
|
||||
{% endif %}
|
||||
{% if current_permissions.pages.get('tutorial_page', True) %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
|
||||
{% endif %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
{% endif %}
|
||||
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
|
||||
<li><h6 class="dropdown-header">Verwaltung</h6></li>
|
||||
{% if current_permissions.pages.get('manage_filters', True) %}
|
||||
@@ -898,12 +1076,12 @@
|
||||
{% endif %}
|
||||
{% if 'username' in session %}
|
||||
{% if current_permissions.pages.get('my_borrowed_items', True) %}
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<li class="nav-item">
|
||||
<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>
|
||||
{% endif %}
|
||||
{% if current_permissions.pages.get('tutorial_page', True) %}
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<li class="nav-item">
|
||||
<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 %}
|
||||
@@ -913,20 +1091,18 @@
|
||||
<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" data-nav-fixed="true">
|
||||
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
|
||||
<span class="theme-icon-light" style="display: none;">☀️</span>
|
||||
<span class="theme-icon-dark" style="display: none;">🌙</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<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" title="Weitere Optionen">
|
||||
Mehr Optionen
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown">
|
||||
{% if 'username' in session %}
|
||||
{% if current_permissions.pages.get('my_borrowed_items', True) %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a></li>
|
||||
{% endif %}
|
||||
{% if current_permissions.pages.get('tutorial_page', True) %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
|
||||
{% endif %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
{% endif %}
|
||||
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
|
||||
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
|
||||
{% if current_permissions.pages.get('library_loans_admin', True) %}
|
||||
@@ -1029,7 +1205,7 @@
|
||||
|
||||
#onboarding-modal {
|
||||
width: min(680px, 100%);
|
||||
background: #ffffff;
|
||||
background: var(--ui-surface);
|
||||
border-radius: 14px;
|
||||
border: 1px solid #dbe3ee;
|
||||
box-shadow: 0 18px 35px rgba(15, 23, 42, 0.25);
|
||||
@@ -1504,8 +1680,15 @@
|
||||
const libraryNavList = document.getElementById('libraryNavList');
|
||||
const libNavOverflowAnchor = document.getElementById('lib-nav-overflow-anchor');
|
||||
|
||||
function initNavbarOverflow(navList, navOverflowAnchor) {
|
||||
if (!navList || !navOverflowAnchor) return;
|
||||
function initNavbarOverflow(navList, navOverflowAnchor, options) {
|
||||
if (!navList) return;
|
||||
|
||||
const moreToggleId = options && options.moreToggleId ? options.moreToggleId : '';
|
||||
const moreToggle = moreToggleId ? document.getElementById(moreToggleId) : null;
|
||||
const moreControlItem = moreToggle ? moreToggle.closest('li.nav-item.dropdown') : null;
|
||||
const moreMenu = moreControlItem ? moreControlItem.querySelector(':scope > ul.dropdown-menu') : null;
|
||||
const moreLabelDefault = moreToggle ? moreToggle.textContent.trim() : 'Mehr Optionen';
|
||||
const moreMenuOriginal = moreMenu ? moreMenu.innerHTML : '';
|
||||
|
||||
const navRoot = navList.closest('nav.navbar');
|
||||
const navCollapse = navList.closest('.navbar-collapse');
|
||||
@@ -1533,45 +1716,35 @@
|
||||
return Array.from(navList.children).filter(function(li){
|
||||
if (!(li instanceof HTMLElement)) return false;
|
||||
if (!li.classList.contains('nav-item')) return false;
|
||||
if (li.id === navOverflowAnchor.id) return false;
|
||||
if (navOverflowAnchor && li.id === navOverflowAnchor.id) return false;
|
||||
if (li.dataset.overflowControl === 'true') return false;
|
||||
if (li.dataset.navFixed === 'true') return false;
|
||||
if (moreControlItem && li === moreControlItem) return false;
|
||||
return li.style.display !== 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function clearOverflowControls() {
|
||||
if (!navList) return;
|
||||
navList.querySelectorAll('[data-overflow-control="true"]').forEach(function(node){
|
||||
node.remove();
|
||||
});
|
||||
if (!moreMenu) return;
|
||||
moreMenu.innerHTML = moreMenuOriginal;
|
||||
if (moreControlItem) {
|
||||
moreControlItem.style.display = '';
|
||||
}
|
||||
if (moreToggle) {
|
||||
moreToggle.textContent = moreLabelDefault;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreAllNavItems() {
|
||||
if (!navList) return;
|
||||
navList.querySelectorAll('li.nav-item').forEach(function(li){
|
||||
if (li.id === navOverflowAnchor.id) return;
|
||||
if (navOverflowAnchor && li.id === navOverflowAnchor.id) return;
|
||||
if (li.dataset.overflowControl === 'true') return;
|
||||
li.style.display = '';
|
||||
});
|
||||
clearOverflowControls();
|
||||
}
|
||||
|
||||
function createOverflowControl() {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'nav-item dropdown';
|
||||
li.dataset.overflowControl = 'true';
|
||||
|
||||
const toggleId = navOverflowAnchor.id + '-toggle';
|
||||
const menuId = navOverflowAnchor.id + '-menu';
|
||||
|
||||
li.innerHTML =
|
||||
'<a class="nav-link dropdown-toggle" href="#" id="' + toggleId + '" role="button" data-bs-toggle="dropdown" aria-expanded="false" aria-controls="' + menuId + '">⋮ Weitere</a>' +
|
||||
'<ul class="dropdown-menu" aria-labelledby="' + toggleId + '" id="' + menuId + '"></ul>';
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
function getNavCandidatePriority(item) {
|
||||
if (!(item instanceof HTMLElement)) {
|
||||
return -1;
|
||||
@@ -1654,33 +1827,70 @@
|
||||
|
||||
function rebuildOverflowControl(hiddenSources) {
|
||||
clearOverflowControls();
|
||||
if (!navList || !navOverflowAnchor || hiddenSources.length === 0) return;
|
||||
|
||||
const control = createOverflowControl();
|
||||
const menu = control.querySelector('ul.dropdown-menu');
|
||||
const controlLink = control.querySelector(':scope > a.nav-link');
|
||||
if (controlLink) {
|
||||
controlLink.textContent = '⋮ Weitere (' + hiddenSources.length + ')';
|
||||
}
|
||||
if (!moreMenu || hiddenSources.length === 0) return;
|
||||
|
||||
const overflowFragment = document.createDocumentFragment();
|
||||
hiddenSources.forEach(function(source){
|
||||
appendSourceToOverflowMenu(source, menu);
|
||||
appendSourceToOverflowMenu(source, overflowFragment);
|
||||
});
|
||||
|
||||
const trailingDivider = menu.querySelector('li:last-child .dropdown-divider');
|
||||
if (trailingDivider) {
|
||||
const overflowWrap = document.createElement('div');
|
||||
overflowWrap.appendChild(overflowFragment);
|
||||
const trailingDivider = overflowWrap.querySelector('li:last-child .dropdown-divider');
|
||||
if (trailingDivider && trailingDivider.parentElement) {
|
||||
trailingDivider.parentElement.remove();
|
||||
}
|
||||
|
||||
navList.insertBefore(control, navOverflowAnchor);
|
||||
const overflowEntries = Array.from(overflowWrap.childNodes);
|
||||
if (overflowEntries.length > 0) {
|
||||
const marker = document.createElement('li');
|
||||
marker.innerHTML = '<h6 class="dropdown-header">Ausgeblendete Navigation</h6>';
|
||||
const firstExistingNode = moreMenu.firstChild;
|
||||
moreMenu.insertBefore(marker, firstExistingNode);
|
||||
|
||||
let insertAfter = marker;
|
||||
overflowEntries.forEach(function(node){
|
||||
moreMenu.insertBefore(node, insertAfter.nextSibling);
|
||||
insertAfter = node;
|
||||
});
|
||||
|
||||
const divider = document.createElement('li');
|
||||
divider.innerHTML = '<hr class="dropdown-divider">';
|
||||
moreMenu.insertBefore(divider, insertAfter.nextSibling);
|
||||
|
||||
if (moreToggle) {
|
||||
moreToggle.textContent = moreLabelDefault + ' (' + hiddenSources.length + ')';
|
||||
}
|
||||
}
|
||||
|
||||
if (moreControlItem) {
|
||||
moreControlItem.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
function adaptNavbarByWidth() {
|
||||
if (!navList || !navOverflowAnchor) return;
|
||||
if (!navList) return;
|
||||
|
||||
const isMobileViewport = window.matchMedia('(max-width: 991.98px)').matches;
|
||||
|
||||
function isNavOverflowing(bufferPx) {
|
||||
const buffer = typeof bufferPx === 'number' ? bufferPx : 0;
|
||||
const collapseOverflow = navCollapse ? (navCollapse.scrollWidth > (navCollapse.clientWidth - buffer)) : false;
|
||||
const containerOverflow = navContainer ? (navContainer.scrollWidth > (navContainer.clientWidth - buffer)) : false;
|
||||
const listOverflow = navList.scrollWidth > (navList.clientWidth - buffer);
|
||||
return collapseOverflow || containerOverflow || listOverflow;
|
||||
}
|
||||
|
||||
applyCompactMode();
|
||||
|
||||
if (window.innerWidth < 992) {
|
||||
// Desktop/tablet-large: keep complete navigation visible.
|
||||
if (!isMobileViewport) {
|
||||
restoreAllNavItems();
|
||||
return;
|
||||
}
|
||||
|
||||
// Mobile menu is collapsed: avoid hiding links preemptively.
|
||||
if (navCollapse && !navCollapse.classList.contains('show')) {
|
||||
restoreAllNavItems();
|
||||
return;
|
||||
}
|
||||
@@ -1689,8 +1899,9 @@
|
||||
|
||||
const hiddenSources = [];
|
||||
let candidates = collectTopLevelNavSources();
|
||||
const overflowBuffer = 12;
|
||||
|
||||
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) {
|
||||
while (isNavOverflowing(overflowBuffer) && candidates.length > 0) {
|
||||
const toHide = pickNextNavItemToHide(candidates);
|
||||
if (!toHide) {
|
||||
break;
|
||||
@@ -1702,8 +1913,9 @@
|
||||
|
||||
rebuildOverflowControl(hiddenSources);
|
||||
|
||||
// One final pass in case the overflow menu label/count itself changed row width.
|
||||
candidates = collectTopLevelNavSources();
|
||||
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) {
|
||||
while (isNavOverflowing(overflowBuffer) && candidates.length > 0) {
|
||||
const toHide = pickNextNavItemToHide(candidates);
|
||||
if (!toHide) {
|
||||
break;
|
||||
@@ -1741,12 +1953,12 @@
|
||||
|
||||
// Initialize overflow control for inventory navbar
|
||||
if (navList && navOverflowAnchor) {
|
||||
initNavbarOverflow(navList, navOverflowAnchor);
|
||||
initNavbarOverflow(navList, navOverflowAnchor, { moreToggleId: 'invMoreDropdown' });
|
||||
}
|
||||
|
||||
// Initialize overflow control for library navbar
|
||||
if (libraryNavList && libNavOverflowAnchor) {
|
||||
initNavbarOverflow(libraryNavList, libNavOverflowAnchor);
|
||||
initNavbarOverflow(libraryNavList, libNavOverflowAnchor, { moreToggleId: 'libMoreDropdown' });
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -1756,5 +1968,37 @@
|
||||
|
||||
<!-- Push Notifications Script -->
|
||||
<script src="{{ url_for('static', filename='js/push-notifications.js') }}"></script>
|
||||
|
||||
<!-- Theme Toggle Script -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const toggleBtns = document.querySelectorAll('#themeToggleBtn');
|
||||
if (toggleBtns.length === 0) return;
|
||||
|
||||
function updateIcons(theme) {
|
||||
const isDark = theme === 'dark';
|
||||
document.querySelectorAll('.theme-icon-light').forEach(icon => icon.style.display = isDark ? 'inline' : 'none');
|
||||
document.querySelectorAll('.theme-icon-dark').forEach(icon => icon.style.display = isDark ? 'none' : 'inline');
|
||||
}
|
||||
|
||||
// Get current setup from initial script in head
|
||||
let currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
|
||||
updateIcons(currentTheme);
|
||||
|
||||
toggleBtns.forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
currentTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||
|
||||
document.documentElement.setAttribute('data-theme', currentTheme);
|
||||
localStorage.setItem('inventarsystem-theme', currentTheme);
|
||||
document.getElementById('meta-theme-color').setAttribute('content', currentTheme === 'dark' ? '#1a252f' : '#2c3e50');
|
||||
|
||||
updateIcons(currentTheme);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
max-width: 500px;
|
||||
margin: 50px auto;
|
||||
padding: 30px;
|
||||
background-color: #f8f9fa;
|
||||
background-color: var(--ui-surface-soft);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
+237
-1
@@ -208,12 +208,23 @@
|
||||
<script>
|
||||
// View mode persistence
|
||||
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
|
||||
const MOBILE_LIBRARY_MAX_WIDTH = 900;
|
||||
const MOBILE_LIBRARY_MIN_ITEMS = 24;
|
||||
|
||||
function isMobileViewport() {
|
||||
return window.matchMedia(`(max-width: ${MOBILE_LIBRARY_MAX_WIDTH}px)`).matches;
|
||||
}
|
||||
|
||||
function setViewMode(mode) {
|
||||
localStorage.setItem(LIBRARY_VIEW_MODE_KEY, mode);
|
||||
const container = document.getElementById('itemsContainer');
|
||||
const tableHeader = document.getElementById('tableHeader');
|
||||
const items = container.querySelectorAll('.item-card');
|
||||
const renderedItems = Array.from(container.querySelectorAll('.item-card'));
|
||||
const virtualizationState = window.mobileLibraryVirtualizationState || null;
|
||||
const detachedItems = virtualizationState && virtualizationState.active && Array.isArray(virtualizationState.items)
|
||||
? virtualizationState.items.filter(item => !container.contains(item))
|
||||
: [];
|
||||
const items = [...renderedItems, ...detachedItems.filter(item => !container.contains(item))];
|
||||
|
||||
items.forEach(item => {
|
||||
const cardContent = item.querySelector('.item-content.card-mode');
|
||||
@@ -236,6 +247,229 @@ function setViewMode(mode) {
|
||||
document.getElementById('viewModeToggle').classList.toggle('open', mode === 'table');
|
||||
}
|
||||
|
||||
function initMobileWindowedLibraryLoading() {
|
||||
const container = document.getElementById('itemsContainer');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.mobileLibraryVirtualizationState) {
|
||||
window.mobileLibraryVirtualizationState = {
|
||||
active: false,
|
||||
items: [],
|
||||
topSpacer: null,
|
||||
bottomSpacer: null,
|
||||
averageItemHeight: Math.max(Math.round(window.innerHeight * 0.35), 230),
|
||||
lastStart: -1,
|
||||
lastEnd: -1,
|
||||
framePending: false,
|
||||
scrollListener: null,
|
||||
resizeListener: null,
|
||||
initialized: false
|
||||
};
|
||||
}
|
||||
const state = window.mobileLibraryVirtualizationState;
|
||||
|
||||
function restoreAllImages() {
|
||||
state.items.forEach(item => {
|
||||
const image = item.querySelector('img.item-image');
|
||||
if (!image) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalSrc = image.dataset.mobileSrc || '';
|
||||
if (!image.getAttribute('src') && originalSrc) {
|
||||
image.setAttribute('src', originalSrc);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateImageMemory(startIndex, endIndex) {
|
||||
const imageBuffer = 4;
|
||||
state.items.forEach((item, index) => {
|
||||
const image = item.querySelector('img.item-image');
|
||||
if (!image) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!image.dataset.mobileSrc) {
|
||||
image.dataset.mobileSrc = image.getAttribute('src') || '';
|
||||
}
|
||||
image.loading = 'lazy';
|
||||
image.decoding = 'async';
|
||||
|
||||
const shouldKeepLoaded = index >= startIndex - imageBuffer && index < endIndex + imageBuffer;
|
||||
if (shouldKeepLoaded) {
|
||||
if (!image.getAttribute('src') && image.dataset.mobileSrc) {
|
||||
image.setAttribute('src', image.dataset.mobileSrc);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (image.getAttribute('src')) {
|
||||
image.removeAttribute('src');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderVisibleWindow() {
|
||||
if (!state.active || !state.topSpacer || !state.bottomSpacer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportHeight = window.innerHeight || 800;
|
||||
const scrollTop = window.scrollY || window.pageYOffset || 0;
|
||||
const renderBuffer = viewportHeight * 1.5;
|
||||
|
||||
let startIndex = Math.max(0, Math.floor((scrollTop - renderBuffer) / state.averageItemHeight));
|
||||
let endIndex = Math.min(state.items.length, Math.ceil((scrollTop + viewportHeight + renderBuffer) / state.averageItemHeight));
|
||||
|
||||
if (endIndex - startIndex < 14) {
|
||||
endIndex = Math.min(state.items.length, startIndex + 14);
|
||||
}
|
||||
|
||||
if (startIndex === state.lastStart && endIndex === state.lastEnd) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.lastStart = startIndex;
|
||||
state.lastEnd = endIndex;
|
||||
|
||||
while (state.topSpacer.nextSibling && state.topSpacer.nextSibling !== state.bottomSpacer) {
|
||||
state.topSpacer.nextSibling.remove();
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let index = startIndex; index < endIndex; index += 1) {
|
||||
fragment.appendChild(state.items[index]);
|
||||
}
|
||||
container.insertBefore(fragment, state.bottomSpacer);
|
||||
|
||||
let measuredHeightSum = 0;
|
||||
let measuredCount = 0;
|
||||
for (let index = startIndex; index < endIndex; index += 1) {
|
||||
const height = state.items[index].getBoundingClientRect().height;
|
||||
if (height > 0) {
|
||||
measuredHeightSum += height;
|
||||
measuredCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (measuredCount > 0) {
|
||||
const measuredAverage = measuredHeightSum / measuredCount;
|
||||
state.averageItemHeight = Math.round((state.averageItemHeight * 3 + measuredAverage) / 4);
|
||||
}
|
||||
|
||||
state.topSpacer.style.height = `${Math.max(0, startIndex * state.averageItemHeight)}px`;
|
||||
state.bottomSpacer.style.height = `${Math.max(0, (state.items.length - endIndex) * state.averageItemHeight)}px`;
|
||||
updateImageMemory(startIndex, endIndex);
|
||||
}
|
||||
|
||||
function scheduleWindowRender() {
|
||||
if (!state.active || state.framePending) {
|
||||
return;
|
||||
}
|
||||
state.framePending = true;
|
||||
requestAnimationFrame(() => {
|
||||
state.framePending = false;
|
||||
renderVisibleWindow();
|
||||
});
|
||||
}
|
||||
|
||||
function activateVirtualization() {
|
||||
if (state.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(state.items) || state.items.length === 0) {
|
||||
state.items = Array.from(container.querySelectorAll('.library-item'));
|
||||
}
|
||||
if (state.items.length <= MOBILE_LIBRARY_MIN_ITEMS) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.topSpacer = document.createElement('div');
|
||||
state.topSpacer.className = 'mobile-window-spacer top';
|
||||
state.bottomSpacer = document.createElement('div');
|
||||
state.bottomSpacer.className = 'mobile-window-spacer bottom';
|
||||
|
||||
state.items.forEach(item => item.remove());
|
||||
container.appendChild(state.topSpacer);
|
||||
container.appendChild(state.bottomSpacer);
|
||||
|
||||
state.lastStart = -1;
|
||||
state.lastEnd = -1;
|
||||
state.framePending = false;
|
||||
state.active = true;
|
||||
|
||||
if (!state.scrollListener) {
|
||||
state.scrollListener = () => scheduleWindowRender();
|
||||
window.addEventListener('scroll', state.scrollListener, { passive: true });
|
||||
}
|
||||
|
||||
scheduleWindowRender();
|
||||
}
|
||||
|
||||
function deactivateVirtualization() {
|
||||
if (!state.active) {
|
||||
if (!Array.isArray(state.items) || state.items.length === 0) {
|
||||
state.items = Array.from(container.querySelectorAll('.library-item'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.scrollListener) {
|
||||
window.removeEventListener('scroll', state.scrollListener);
|
||||
state.scrollListener = null;
|
||||
}
|
||||
|
||||
if (state.topSpacer && state.topSpacer.parentNode === container) {
|
||||
state.topSpacer.remove();
|
||||
}
|
||||
if (state.bottomSpacer && state.bottomSpacer.parentNode === container) {
|
||||
state.bottomSpacer.remove();
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
state.items.forEach(item => fragment.appendChild(item));
|
||||
container.appendChild(fragment);
|
||||
|
||||
restoreAllImages();
|
||||
|
||||
state.topSpacer = null;
|
||||
state.bottomSpacer = null;
|
||||
state.lastStart = -1;
|
||||
state.lastEnd = -1;
|
||||
state.framePending = false;
|
||||
state.active = false;
|
||||
|
||||
const currentMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
|
||||
setViewMode(currentMode);
|
||||
}
|
||||
|
||||
function syncVirtualizationWithViewport() {
|
||||
if (!Array.isArray(state.items) || state.items.length === 0) {
|
||||
state.items = Array.from(container.querySelectorAll('.library-item'));
|
||||
}
|
||||
|
||||
const shouldVirtualize = isMobileViewport() && state.items.length > MOBILE_LIBRARY_MIN_ITEMS;
|
||||
if (shouldVirtualize) {
|
||||
activateVirtualization();
|
||||
scheduleWindowRender();
|
||||
} else {
|
||||
deactivateVirtualization();
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.initialized) {
|
||||
state.resizeListener = () => syncVirtualizationWithViewport();
|
||||
window.addEventListener('resize', state.resizeListener, { passive: true });
|
||||
state.initialized = true;
|
||||
}
|
||||
|
||||
syncVirtualizationWithViewport();
|
||||
}
|
||||
|
||||
// Initialize view mode
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const savedMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
|
||||
@@ -311,6 +545,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (e.target === this) this.style.display = 'none';
|
||||
});
|
||||
});
|
||||
|
||||
initMobileWindowedLibraryLoading();
|
||||
});
|
||||
|
||||
function displayLibraryItemDetail(item) {
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: #fff;
|
||||
background: var(--ui-surface);
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
@@ -85,11 +85,11 @@
|
||||
padding: 11px 14px;
|
||||
border: 1px solid #d6dde6;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
background: var(--ui-surface);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #fff;
|
||||
background: var(--ui-surface);
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 18px;
|
||||
padding: 18px;
|
||||
@@ -120,7 +120,7 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
background: var(--ui-surface-soft);
|
||||
}
|
||||
|
||||
.library-table tr:hover td {
|
||||
@@ -408,7 +408,7 @@
|
||||
</div>
|
||||
|
||||
<div id="damage-invoice-modal" style="display:none; position:fixed; inset:0; background:rgba(15,23,42,0.72); z-index:9999; padding:20px; overflow:auto;">
|
||||
<div style="max-width:760px; margin:40px auto; background:#fff; border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);">
|
||||
<div style="max-width:760px; margin:40px auto; background: var(--ui-surface); border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:18px;">
|
||||
<div>
|
||||
<h2 style="margin:0;">Rechnung erstellen</h2>
|
||||
@@ -421,15 +421,15 @@
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:16px; margin-bottom:16px;">
|
||||
<div>
|
||||
<label for="damage-invoice-item" style="display:block; font-weight:700; margin-bottom:6px;">Element</label>
|
||||
<input id="damage-invoice-item" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
|
||||
<input id="damage-invoice-item" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background: var(--ui-surface-soft);">
|
||||
</div>
|
||||
<div>
|
||||
<label for="damage-invoice-borrower" style="display:block; font-weight:700; margin-bottom:6px;">Schüler</label>
|
||||
<input id="damage-invoice-borrower" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
|
||||
<input id="damage-invoice-borrower" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background: var(--ui-surface-soft);">
|
||||
</div>
|
||||
<div>
|
||||
<label for="damage-invoice-code" style="display:block; font-weight:700; margin-bottom:6px;">Element-ID / Code</label>
|
||||
<input id="damage-invoice-code" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
|
||||
<input id="damage-invoice-code" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background: var(--ui-surface-soft);">
|
||||
</div>
|
||||
<div>
|
||||
<label for="damage-invoice-amount" style="display:block; font-weight:700; margin-bottom:6px;">Preis</label>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
}
|
||||
|
||||
.invoice-card {
|
||||
background: #fff;
|
||||
background: var(--ui-surface);
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
padding: 18px;
|
||||
@@ -65,7 +65,7 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
background: var(--ui-surface-soft);
|
||||
}
|
||||
|
||||
.invoice-table tr:hover td {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
.library-header h1 {
|
||||
margin: 0 0 5px 0;
|
||||
font-size: 2em;
|
||||
color: #333;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.library-header p {
|
||||
@@ -48,7 +48,7 @@
|
||||
|
||||
/* Scanner panel */
|
||||
.library-scan-panel {
|
||||
background: #ffffff;
|
||||
background: var(--ui-surface);
|
||||
border: 1px solid #d9dde4;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
@@ -68,7 +68,7 @@
|
||||
border: 1px solid #d0d7e2;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
background: var(--ui-surface);
|
||||
}
|
||||
|
||||
.library-scan-status {
|
||||
@@ -95,7 +95,7 @@
|
||||
border: 1px solid #d9dde4;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
background: var(--ui-surface);
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
color: var(--ui-text);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
.filter-item label {
|
||||
font-weight: 600;
|
||||
font-size: 0.9em;
|
||||
color: #333;
|
||||
color: var(--ui-text);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@
|
||||
}
|
||||
|
||||
.library-items-table thead {
|
||||
background: #f8f9fa;
|
||||
background: var(--ui-surface-soft);
|
||||
border-bottom: 2px solid #ddd;
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
color: var(--ui-text);
|
||||
font-size: 0.9em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
@@ -228,7 +228,7 @@
|
||||
}
|
||||
|
||||
.library-items-table tbody tr:hover {
|
||||
background: #f8f9fa;
|
||||
background: var(--ui-surface-soft);
|
||||
}
|
||||
|
||||
.library-items-table tbody tr:last-child td {
|
||||
@@ -238,7 +238,7 @@
|
||||
/* Table cells */
|
||||
.table-title {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.table-status {
|
||||
@@ -320,7 +320,7 @@
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #fff;
|
||||
background-color: var(--ui-surface);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
max-width: 900px;
|
||||
@@ -373,7 +373,7 @@
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: #333;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@@ -494,7 +494,7 @@
|
||||
</div>
|
||||
<div class="filter-buttons">
|
||||
<button id="applyFilterBtn" class="button" style="background: #4f46e5; color: white;">Filter anwenden</button>
|
||||
<button id="clearFilterBtn" class="button" style="background: #f0f2f5; color: #333;">Zurücksetzen</button>
|
||||
<button id="clearFilterBtn" class="button" style="background: #f0f2f5; color: var(--ui-text);">Zurücksetzen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -337,7 +337,7 @@
|
||||
color: #0d6efd;
|
||||
}
|
||||
.license-content {
|
||||
background-color: #f8f9fa;
|
||||
background-color: var(--ui-surface-soft);
|
||||
padding: 20px 30px;
|
||||
border-radius: 0 0 5px 5px;
|
||||
border: 1px solid #dee2e6;
|
||||
|
||||
@@ -217,7 +217,7 @@
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
color: #333;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
|
||||
+191
-96
@@ -6,7 +6,7 @@
|
||||
|
||||
/* Calendar styles for detailed view */
|
||||
.calendar-wrapper {
|
||||
background: #fff;
|
||||
background: var(--ui-surface);
|
||||
border: 1px solid #dbe3ee;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
@@ -19,8 +19,8 @@
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e7edf5;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
background: var(--ui-surface-soft);
|
||||
}
|
||||
|
||||
.calendar-header button {
|
||||
@@ -28,15 +28,15 @@
|
||||
height: 34px;
|
||||
border: 1px solid #dbe3ee;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
background: var(--ui-surface);
|
||||
color: var(--ui-text);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-header button:hover {
|
||||
background: #f1f5f9;
|
||||
background: var(--ui-bg);
|
||||
}
|
||||
|
||||
.calendar-header span {
|
||||
@@ -60,7 +60,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: #334155;
|
||||
color: var(--ui-text);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
@@ -80,7 +80,7 @@
|
||||
}
|
||||
|
||||
.cal-cell:not(.header):not(.empty):hover {
|
||||
background: #f1f5f9;
|
||||
background: var(--ui-bg);
|
||||
}
|
||||
|
||||
.cal-cell.empty {
|
||||
@@ -109,13 +109,13 @@
|
||||
}
|
||||
|
||||
.cal-cell.selected.has-booking::after {
|
||||
background: #fff;
|
||||
background: var(--ui-surface);
|
||||
}
|
||||
|
||||
.calendar-day-details {
|
||||
border-top: 1px solid #e7edf5;
|
||||
padding: 12px;
|
||||
background: #f8fafc;
|
||||
background: var(--ui-surface-soft);
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
}
|
||||
|
||||
.cal-booking-item {
|
||||
background: #fff;
|
||||
background: var(--ui-surface);
|
||||
border: 1px solid #dbe3ee;
|
||||
border-left: 3px solid #2563eb;
|
||||
border-radius: 10px;
|
||||
@@ -173,7 +173,7 @@
|
||||
|
||||
.cal-booking-period {
|
||||
margin-left: 8px;
|
||||
color: #475569;
|
||||
color: var(--ui-text);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -225,14 +225,14 @@
|
||||
|
||||
.filter-option-group {
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.filter-group-header {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
color: #495057;
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@
|
||||
}
|
||||
|
||||
.filter-tag-group {
|
||||
color: #6c757d;
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 0.8em;
|
||||
font-style: italic;
|
||||
margin-right: 2px;
|
||||
@@ -262,7 +262,7 @@
|
||||
.filter-tag-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6c757d;
|
||||
color: var(--ui-text-muted);
|
||||
margin-left: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
@@ -296,7 +296,7 @@
|
||||
#code-search {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -321,7 +321,7 @@
|
||||
border: 1px solid #dc3545 !important;
|
||||
}
|
||||
</style>
|
||||
<div class="container">
|
||||
<div class="container admin-content-container">
|
||||
<!-- Conflict warning banner (populated by JS) -->
|
||||
<div id="conflict-banner" style="display:none; background:#fff3cd; border:1px solid #ffc107; border-left:4px solid #fd7e14; color:#856404; padding:12px 16px; border-radius:4px; margin-bottom:16px; position:relative;">
|
||||
<strong>⚠ Reservierungskonflikt</strong>
|
||||
@@ -724,7 +724,9 @@
|
||||
// Add this line to define allItems globally
|
||||
let allItems = [];
|
||||
|
||||
const MAIN_ITEMS_PAGE_SIZE = 120;
|
||||
// Dynamic page size: scan viewport/screen size to only load the exact amount needed.
|
||||
const isMobileDevice = window.matchMedia('(max-width: 768px)').matches;
|
||||
const MAIN_ITEMS_PAGE_SIZE = isMobileDevice ? 20 : 120;
|
||||
let mainItemsNextOffset = 0;
|
||||
let mainItemsHasMore = false;
|
||||
let mainItemsLoadingMore = false;
|
||||
@@ -781,10 +783,10 @@
|
||||
styles.flexDirection === 'column';
|
||||
|
||||
const distanceToEnd = isMobileLayout
|
||||
? itemsContainer.scrollHeight - (itemsContainer.scrollTop + itemsContainer.clientHeight)
|
||||
? Math.max(document.body.scrollHeight, document.documentElement.scrollHeight) - (window.scrollY + window.innerHeight)
|
||||
: itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth);
|
||||
const prefetchThreshold = isMobileLayout
|
||||
? Math.max(220, itemsContainer.clientHeight * 0.9)
|
||||
? window.innerHeight * 1.5
|
||||
: Math.max(260, itemsContainer.clientWidth * 1.4);
|
||||
if (distanceToEnd > prefetchThreshold) {
|
||||
return;
|
||||
@@ -902,7 +904,7 @@
|
||||
const filter2More = filter2Array.length > 1 ? ` (+${filter2Array.length - 1} mehr)` : '';
|
||||
const filter3More = filter3Array.length > 1 ? ` (+${filter3Array.length - 1} mehr)` : '';
|
||||
|
||||
const imagesHtml = item.Images ? item.Images.map((file, index) => {
|
||||
const imagesHtml = item.Images && item.Images.length > 0 ? item.Images.slice(0, 1).map((file, index) => {
|
||||
// Get file with correct case handling
|
||||
const image = file;
|
||||
|
||||
@@ -1003,14 +1005,14 @@
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-content" data-item-id="${item._id}">
|
||||
<h3 class="item-name-cell">${item.Name}</h3>
|
||||
<p class="item-ort-cell"><strong>Ort:</strong> ${item.Ort || '-'}</p>
|
||||
<p class="item-filter1-cell"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
|
||||
<p class="item-filter2-cell"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
|
||||
<p class="item-filter3-cell"><strong>Thema:</strong> ${filter3Display}${filter3More}</p>
|
||||
<p class="item-code-cell"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
|
||||
<p class="item-count-cell"><strong>Anzahl:</strong> ${groupedCount}</p>
|
||||
${isGroupedItem ? `<p class="item-count-cell"><strong>Verfügbar:</strong> ${availableGroupedCount}</p>` : ''}
|
||||
<h3 class="item-col-name">${item.Name}</h3>
|
||||
<p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p>
|
||||
<p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
|
||||
<p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
|
||||
<p class="item-col-filter3"><strong>Thema:</strong> ${filter3Display}${filter3More}</p>
|
||||
<p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
|
||||
<p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
|
||||
${isGroupedItem ? `<p class="item-col-count"><strong>Verfügbar:</strong> ${availableGroupedCount}</p>` : ''}
|
||||
<div class="image-container">
|
||||
${imagesHtml}
|
||||
</div>
|
||||
@@ -1167,6 +1169,7 @@
|
||||
|
||||
if (!mainItemsScrollPrefetchBound) {
|
||||
itemsContainer.addEventListener('scroll', maybePrefetchMainItems, { passive: true });
|
||||
window.addEventListener('scroll', maybePrefetchMainItems, { passive: true });
|
||||
mainItemsScrollPrefetchBound = true;
|
||||
}
|
||||
|
||||
@@ -1188,7 +1191,7 @@
|
||||
updateMainItemsLoadingIndicator();
|
||||
});
|
||||
}, {
|
||||
root: itemsContainer,
|
||||
root: isMobileLayout ? null : itemsContainer,
|
||||
threshold: 0.15,
|
||||
rootMargin: isMobileLayout ? '0px 0px 900px 0px' : '0px 900px 0px 0px'
|
||||
});
|
||||
@@ -1466,7 +1469,7 @@
|
||||
const badgeText = entry.type === 'repair' ? 'Repariert' : 'Schaden';
|
||||
const metaLine = entry.meta ? `<div style="font-size:0.84rem;color:#4b5563;">${escapeHtml(entry.meta)}</div>` : '';
|
||||
return `
|
||||
<div style="border:1px solid #dbe3ee;border-radius:8px;padding:10px;background:#fff;display:grid;gap:6px;">
|
||||
<div style="border:1px solid var(--ui-border);border-radius:8px;padding:10px;background:var(--ui-surface);display:grid;gap:6px;">
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">
|
||||
<span style="display:inline-block;padding:2px 8px;border-radius:999px;font-size:0.75rem;font-weight:700;${badgeStyle}">${badgeText}</span>
|
||||
<span style="font-size:0.84rem;color:#475569;">${entry.dateLabel}</span>
|
||||
@@ -1628,7 +1631,7 @@
|
||||
|
||||
<div class="detail-group">
|
||||
<div class="detail-label">Anschaffungskosten:</div>
|
||||
<div class="detail-value">${item.Anschaffungskosten || '-'}</div>
|
||||
<div class="detail-value">${item.Anschaffungskosten ? item.Anschaffungskosten + ' €' : '-'}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-group full-width">
|
||||
@@ -1643,7 +1646,7 @@
|
||||
<span>🛠️</span>
|
||||
<span id="toggle-damage-history-text">Historie anzeigen</span>
|
||||
</button>
|
||||
<div id="damage-history-panel" style="display:none; margin-top:8px; border:1px solid #e7edf5; border-radius:10px; padding:12px; background:#f8fafc;">
|
||||
<div id="damage-history-panel" style="display:none; margin-top:8px; border:1px solid #e7edf5; border-radius:10px; padding:12px; background: var(--ui-surface-soft);">
|
||||
<div style="display:grid; gap:10px;">${damageHistoryHtml}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1716,7 +1719,7 @@
|
||||
: `<button class="ausleihen disabled-button" disabled>${item.BlockedNow ? 'Reserviert' : 'Ausgeliehen'}</button>`
|
||||
}
|
||||
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Termin planen</button>` : ''}
|
||||
<button type="button" class="close-modal secondary-close" onclick="document.getElementById('item-modal').style.display='none';">Schließen</button>
|
||||
<button type="button" class="details-button" onclick="document.getElementById('item-modal').style.display='none';">Schließen</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -2805,7 +2808,7 @@
|
||||
}
|
||||
|
||||
.container {
|
||||
background-color: #fff;
|
||||
background-color: var(--ui-surface);
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
@@ -2830,8 +2833,8 @@
|
||||
.filter-group {
|
||||
position: relative;
|
||||
min-width: 200px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #ddd;
|
||||
background-color: var(--ui-surface);
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
}
|
||||
@@ -2844,7 +2847,7 @@
|
||||
}
|
||||
|
||||
.filter-toggle, .clear-filter {
|
||||
background-color: #f8f9fa;
|
||||
background-color: var(--ui-surface-soft);
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 3px;
|
||||
padding: 3px 8px;
|
||||
@@ -2853,7 +2856,7 @@
|
||||
|
||||
.clear-filter {
|
||||
font-size: 0.8rem;
|
||||
color: #6c757d;
|
||||
color: var(--ui-text-muted);
|
||||
}
|
||||
|
||||
.filter-dropdown {
|
||||
@@ -2865,7 +2868,7 @@
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background-color: white;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
|
||||
z-index: 100;
|
||||
@@ -2900,14 +2903,14 @@
|
||||
|
||||
.filter-option-group {
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.filter-group-header {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
color: #495057;
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -2928,7 +2931,7 @@
|
||||
}
|
||||
|
||||
.filter-tag-group {
|
||||
color: #6c757d;
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 0.8em;
|
||||
font-style: italic;
|
||||
margin-right: 2px;
|
||||
@@ -2937,7 +2940,7 @@
|
||||
.filter-tag-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6c757d;
|
||||
color: var(--ui-text-muted);
|
||||
margin-left: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
@@ -2954,7 +2957,7 @@
|
||||
#code-search {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -3025,8 +3028,8 @@
|
||||
margin-right: 8px;
|
||||
border: 1px solid #dbe3ee;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
background: var(--ui-surface-soft);
|
||||
color: var(--ui-text);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
@@ -3083,19 +3086,19 @@
|
||||
}
|
||||
|
||||
.item-card {
|
||||
background-color: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
background-color: var(--ui-surface);
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: var(--ui-radius);
|
||||
box-shadow: var(--ui-shadow-sm);
|
||||
padding: 20px;
|
||||
max-width: 300px;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
flex: 1 1 300px;
|
||||
scroll-snap-align: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
position: relative;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
|
||||
@@ -3140,7 +3143,7 @@
|
||||
.item-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
z-index: 2500;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
@@ -3191,7 +3194,7 @@
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -3235,7 +3238,7 @@
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
background-color: #6c757d;
|
||||
background-color: var(--ui-text-muted);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -3467,6 +3470,19 @@
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
/* Mobile viewport and scrolling improvements */
|
||||
html, body {
|
||||
overflow-x: hidden !important;
|
||||
-webkit-overflow-scrolling: touch !important;
|
||||
touch-action: pan-y !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
body {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -3552,8 +3568,9 @@
|
||||
display: grid !important;
|
||||
grid-template-columns: 1fr !important;
|
||||
gap: 15px !important;
|
||||
padding: 10px 0 0 !important;
|
||||
padding: 10px 0 !important;
|
||||
overflow-x: visible !important;
|
||||
scroll-snap-type: none !important;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
@@ -3561,6 +3578,9 @@
|
||||
margin: 0 !important;
|
||||
padding: 15px !important;
|
||||
scroll-snap-align: none !important;
|
||||
/* Only render when user looks at it (content-visibility virtualizes the DOM) */
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 300px;
|
||||
}
|
||||
|
||||
.item-card:hover {
|
||||
@@ -3634,7 +3654,7 @@
|
||||
display: block !important;
|
||||
margin-bottom: 8px !important;
|
||||
font-weight: 600 !important;
|
||||
color: #333 !important;
|
||||
color: var(--ui-title) !important;
|
||||
}
|
||||
|
||||
.form-group input, .form-group select, .form-group textarea {
|
||||
@@ -3705,8 +3725,8 @@
|
||||
}
|
||||
|
||||
/* Element text colors for better visibility */
|
||||
.edit-button, .duplicate-button, .generate-qr-button, .ausleihen {
|
||||
color: black !important;
|
||||
.edit-button, .duplicate-button, .generate-qr-button {
|
||||
color: var(--ui-title) !important;
|
||||
}
|
||||
|
||||
/* Standardized button styles across the application */
|
||||
@@ -3839,7 +3859,7 @@
|
||||
max-height: 400px;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background-color: #f8f9fa;
|
||||
background-color: var(--ui-surface-soft);
|
||||
}
|
||||
|
||||
/* Borrower and appointment info panels in modal */
|
||||
@@ -3867,11 +3887,11 @@
|
||||
.modal-details { margin: 20px 0; }
|
||||
.detail-group { display: flex; margin-bottom: 10px; align-items: flex-start; }
|
||||
.detail-group.full-width { flex-direction: column; margin-bottom: 15px; }
|
||||
.detail-label { font-weight: bold; color: #495057; min-width: 150px; margin-right: 10px; }
|
||||
.detail-label { font-weight: bold; color: var(--ui-text-muted); min-width: 150px; margin-right: 10px; }
|
||||
.detail-value { color: #212529; flex: 1; word-wrap: break-word; }
|
||||
.detail-group.full-width .detail-label { margin-bottom: 5px; }
|
||||
.detail-group.full-width .detail-value {
|
||||
background-color: #f8f9fa;
|
||||
background-color: var(--ui-surface-soft);
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #dee2e6;
|
||||
@@ -3935,7 +3955,7 @@
|
||||
|
||||
/* Calendar Styles - Optimized & Mobile Friendly */
|
||||
.calendar-wrapper {
|
||||
background: #fff;
|
||||
background: var(--ui-surface);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e5e7eb;
|
||||
@@ -3948,8 +3968,8 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
background-color: var(--ui-surface);
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
}
|
||||
|
||||
.calendar-header button {
|
||||
@@ -3973,7 +3993,7 @@
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
background-color: #fff;
|
||||
background-color: var(--ui-surface);
|
||||
padding: 10px;
|
||||
gap: 5px;
|
||||
}
|
||||
@@ -4035,7 +4055,7 @@
|
||||
}
|
||||
|
||||
.cal-cell.selected.has-booking::after {
|
||||
background-color: #fff;
|
||||
background-color: var(--ui-surface);
|
||||
}
|
||||
|
||||
.calendar-day-details {
|
||||
@@ -4063,7 +4083,7 @@
|
||||
|
||||
.cal-booking-item {
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
background: var(--ui-surface);
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
@@ -4094,6 +4114,7 @@
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
@@ -4115,7 +4136,7 @@
|
||||
font-size: 0.95rem !important;
|
||||
font-weight: 600 !important;
|
||||
line-height: 1.35 !important;
|
||||
color: #212529 !important;
|
||||
color: var(--ui-title) !important;
|
||||
word-break: break-word !important;
|
||||
overflow-wrap: anywhere !important;
|
||||
}
|
||||
@@ -4153,7 +4174,7 @@
|
||||
overflow-wrap: break-word !important;
|
||||
}
|
||||
|
||||
body.table-view .item-card .card-content .item-name-cell {
|
||||
body.table-view .item-card .card-content .item-col-name {
|
||||
font-size: 0.95rem !important;
|
||||
}
|
||||
|
||||
@@ -4193,7 +4214,7 @@
|
||||
display: block !important;
|
||||
margin-right: 0 !important;
|
||||
margin-bottom: 2px !important;
|
||||
color: #6c757d !important;
|
||||
color: var(--ui-text-muted) !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
|
||||
@@ -4213,8 +4234,12 @@
|
||||
}
|
||||
|
||||
.items-container {
|
||||
padding: 6px 0 !important;
|
||||
gap: 10px !important;
|
||||
display: grid !important;
|
||||
grid-template-columns: 1fr !important;
|
||||
gap: 15px !important;
|
||||
padding: 10px 0 !important;
|
||||
overflow-x: visible !important;
|
||||
scroll-snap-type: none !important;
|
||||
}
|
||||
|
||||
body.table-view .table-view-header {
|
||||
@@ -4231,16 +4256,17 @@
|
||||
}
|
||||
|
||||
.item-card {
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
border-bottom: 1px solid #e5e7eb !important;
|
||||
padding: 12px 0 !important;
|
||||
max-width: 100% !important;
|
||||
margin: 0 !important;
|
||||
padding: 15px !important;
|
||||
scroll-snap-align: none !important;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 300px;
|
||||
}
|
||||
|
||||
.item-card:hover {
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
transform: translateY(-2px) !important;
|
||||
box-shadow: var(--ui-shadow-md) !important;
|
||||
}
|
||||
|
||||
.item-card .image-container {
|
||||
@@ -4305,13 +4331,12 @@
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
height: 100vh !important;
|
||||
max-height: 100vh !important;
|
||||
margin: 0 !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
width: calc(100vw - 16px) !important;
|
||||
max-width: none !important;
|
||||
margin: 80px auto 10px !important;
|
||||
max-height: 85vh !important;
|
||||
overflow-y: auto !important;
|
||||
border-radius: 8px !important;
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
@@ -4430,7 +4455,7 @@ function openItemQuick(id){
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
background: #ffffff;
|
||||
background: var(--ui-surface);
|
||||
border: 1px solid #cfd8e3;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
@@ -4478,7 +4503,7 @@ function openItemQuick(id){
|
||||
}
|
||||
|
||||
.clear-filter {
|
||||
background: #fff;
|
||||
background: var(--ui-surface);
|
||||
color: #495868;
|
||||
}
|
||||
|
||||
@@ -4487,7 +4512,7 @@ function openItemQuick(id){
|
||||
border: 1px solid #cfd8e3;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 20px rgba(20, 35, 60, 0.12);
|
||||
background: #ffffff;
|
||||
background: var(--ui-surface);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
@@ -4573,4 +4598,74 @@ function openItemQuick(id){
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
<style>
|
||||
/* Admin/Main Content Container align */
|
||||
.admin-content-container {
|
||||
--table-columns: minmax(190px, 2fr) repeat(6, minmax(110px, 1fr));
|
||||
}
|
||||
@media screen and (min-width: 769px) and (max-width: 1024px) {
|
||||
.admin-content-container {
|
||||
width: 90% !important;
|
||||
}
|
||||
.items-container {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
gap: 20px !important;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 768px) {
|
||||
.admin-content-container {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
margin: 0 !important;
|
||||
padding: 12px 12px 18px !important;
|
||||
box-sizing: border-box !important;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
.item-card .item-image {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
max-height: 300px !important;
|
||||
display: block !important;
|
||||
object-fit: cover !important;
|
||||
}
|
||||
.item-card .image-container {
|
||||
height: 200px !important;
|
||||
overflow: hidden !important;
|
||||
position: relative !important;
|
||||
margin-bottom: 15px !important;
|
||||
}
|
||||
.actions {
|
||||
gap: 8px !important;
|
||||
border-top: none !important;
|
||||
padding-top: 8px !important;
|
||||
flex-direction: column !important;
|
||||
align-items: stretch !important;
|
||||
}
|
||||
.actions form,
|
||||
.actions button,
|
||||
.actions a {
|
||||
width: 100% !important;
|
||||
}
|
||||
.items-container, .item-card, .image-container, .item-image {
|
||||
touch-action: pan-y !important;
|
||||
-webkit-overflow-scrolling: touch !important;
|
||||
}
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
.admin-content-container {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 12px 12px 18px;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
.items-indicator {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: #495057;
|
||||
color: var(--ui-text-muted);
|
||||
margin: 6px 0 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -55,7 +55,7 @@
|
||||
max-width: 980px;
|
||||
margin: 0 auto 18px;
|
||||
border: 1px solid #dbe3ee;
|
||||
background: #ffffff;
|
||||
background: var(--ui-surface);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
|
||||
@@ -78,7 +78,7 @@
|
||||
}
|
||||
|
||||
.bulk-delete-summary {
|
||||
color: #334155;
|
||||
color: var(--ui-text);
|
||||
font-size: 0.92rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -104,8 +104,8 @@
|
||||
border-radius: 8px;
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
background: #f8fafc;
|
||||
color: #1e293b;
|
||||
background: var(--ui-surface-soft);
|
||||
color: var(--ui-title);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -138,8 +138,8 @@
|
||||
.filter-group {
|
||||
position: relative;
|
||||
min-width: 200px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #ddd;
|
||||
background-color: var(--ui-surface);
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
}
|
||||
@@ -152,7 +152,7 @@
|
||||
}
|
||||
|
||||
.filter-toggle, .clear-filter {
|
||||
background-color: #f8f9fa;
|
||||
background-color: var(--ui-surface-soft);
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 3px;
|
||||
padding: 3px 8px;
|
||||
@@ -161,7 +161,7 @@
|
||||
|
||||
.clear-filter {
|
||||
font-size: 0.8rem;
|
||||
color: #6c757d;
|
||||
color: var(--ui-text-muted);
|
||||
}
|
||||
|
||||
.filter-dropdown {
|
||||
@@ -173,7 +173,7 @@
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background-color: white;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
|
||||
z-index: 100;
|
||||
@@ -208,14 +208,14 @@
|
||||
|
||||
.filter-option-group {
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.filter-group-header {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
color: #495057;
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@
|
||||
}
|
||||
|
||||
.filter-tag-group {
|
||||
color: #6c757d;
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 0.8em;
|
||||
font-style: italic;
|
||||
margin-right: 2px;
|
||||
@@ -245,7 +245,7 @@
|
||||
.filter-tag-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6c757d;
|
||||
color: var(--ui-text-muted);
|
||||
margin-left: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
@@ -262,7 +262,7 @@
|
||||
#code-search {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -333,8 +333,8 @@
|
||||
margin-right: 8px;
|
||||
border: 1px solid #dbe3ee;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
background: var(--ui-surface-soft);
|
||||
color: var(--ui-text);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
@@ -391,16 +391,17 @@
|
||||
}
|
||||
|
||||
.item-card {
|
||||
background-color: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
background-color: var(--ui-surface);
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: var(--ui-radius);
|
||||
box-shadow: var(--ui-shadow-sm);
|
||||
padding: 20px;
|
||||
max-width: 300px;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
flex: 1 1 300px;
|
||||
scroll-snap-align: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -447,7 +448,7 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.9rem;
|
||||
color: #475569;
|
||||
color: var(--ui-text);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -581,7 +582,7 @@
|
||||
.item-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
z-index: 2500;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
@@ -632,7 +633,7 @@
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -676,7 +677,7 @@
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
background-color: #6c757d;
|
||||
background-color: var(--ui-text-muted);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -703,15 +704,15 @@
|
||||
.filter-inputs {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 5px;
|
||||
background-color: #f8f9fa;
|
||||
background-color: var(--ui-surface-soft);
|
||||
}
|
||||
|
||||
.filter-inputs h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 15px;
|
||||
color: #495057;
|
||||
color: var(--ui-text-muted);
|
||||
}
|
||||
|
||||
.multi-filter {
|
||||
@@ -723,7 +724,7 @@
|
||||
.filter-dropdown-select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 4px;
|
||||
background-color: white;
|
||||
}
|
||||
@@ -1000,16 +1001,16 @@
|
||||
|
||||
/* Disabled Button */
|
||||
.disabled-button {
|
||||
background-color: #6c757d !important;
|
||||
border-color: #6c757d !important;
|
||||
background-color: var(--ui-text-muted) !important;
|
||||
border-color: var(--ui-text-muted) !important;
|
||||
color: #fff !important;
|
||||
cursor: not-allowed !important;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.disabled-button:hover {
|
||||
background-color: #6c757d !important;
|
||||
border-color: #6c757d !important;
|
||||
background-color: var(--ui-text-muted) !important;
|
||||
border-color: var(--ui-text-muted) !important;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
@@ -1032,7 +1033,7 @@
|
||||
|
||||
.detail-label {
|
||||
font-weight: bold;
|
||||
color: #495057;
|
||||
color: var(--ui-text-muted);
|
||||
min-width: 150px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
@@ -1048,7 +1049,7 @@
|
||||
}
|
||||
|
||||
.detail-group.full-width .detail-value {
|
||||
background-color: #f8f9fa;
|
||||
background-color: var(--ui-surface-soft);
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #dee2e6;
|
||||
@@ -1068,7 +1069,7 @@
|
||||
max-height: 400px;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background-color: #f8f9fa;
|
||||
background-color: var(--ui-surface-soft);
|
||||
}
|
||||
|
||||
.modal-image {
|
||||
@@ -1351,7 +1352,7 @@
|
||||
display: block !important;
|
||||
margin-bottom: 8px !important;
|
||||
font-weight: 600 !important;
|
||||
color: #333 !important;
|
||||
color: var(--ui-title) !important;
|
||||
}
|
||||
|
||||
.form-group input, .form-group select, .form-group textarea {
|
||||
@@ -1389,7 +1390,7 @@
|
||||
/* Tab navigation improvements */
|
||||
.nav-tabs {
|
||||
flex-wrap: wrap !important;
|
||||
border-bottom: 1px solid #dee2e6 !important;
|
||||
border-bottom: 1px solid var(--ui-border) !important;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link {
|
||||
@@ -1605,6 +1606,7 @@
|
||||
gap: 15px !important;
|
||||
padding: 10px 0 !important;
|
||||
overflow-x: visible !important;
|
||||
scroll-snap-type: none !important;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
@@ -1612,6 +1614,9 @@
|
||||
margin: 0 !important;
|
||||
padding: 15px !important;
|
||||
scroll-snap-align: none !important;
|
||||
/* Only render when user looks at it (content-visibility virtualizes the DOM) */
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 300px;
|
||||
}
|
||||
|
||||
.item-card:hover {
|
||||
@@ -1685,7 +1690,7 @@
|
||||
display: block !important;
|
||||
margin-bottom: 8px !important;
|
||||
font-weight: 600 !important;
|
||||
color: #333 !important;
|
||||
color: var(--ui-title) !important;
|
||||
}
|
||||
|
||||
.form-group input, .form-group select, .form-group textarea {
|
||||
@@ -1959,8 +1964,8 @@
|
||||
}
|
||||
|
||||
/* Element text colors for better visibility */
|
||||
.edit-button, .duplicate-button, .generate-qr-button, .ausleihen {
|
||||
color: black !important;
|
||||
.edit-button, .duplicate-button, .generate-qr-button {
|
||||
color: var(--ui-title) !important;
|
||||
}
|
||||
|
||||
/* Standardized button styles across the application */
|
||||
@@ -2003,7 +2008,7 @@
|
||||
}
|
||||
/* Calendar styles for detailed view */
|
||||
.calendar-wrapper {
|
||||
background: #fff;
|
||||
background: var(--ui-surface);
|
||||
border: 1px solid #dbe3ee;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
@@ -2016,8 +2021,8 @@
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e7edf5;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
background: var(--ui-surface-soft);
|
||||
}
|
||||
|
||||
.calendar-header button {
|
||||
@@ -2025,15 +2030,15 @@
|
||||
height: 34px;
|
||||
border: 1px solid #dbe3ee;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
background: var(--ui-surface);
|
||||
color: var(--ui-text);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-header button:hover {
|
||||
background: #f1f5f9;
|
||||
background: var(--ui-bg);
|
||||
}
|
||||
|
||||
.calendar-header span {
|
||||
@@ -2057,7 +2062,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: #334155;
|
||||
color: var(--ui-text);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
@@ -2077,7 +2082,7 @@
|
||||
}
|
||||
|
||||
.cal-cell:not(.header):not(.empty):hover {
|
||||
background: #f1f5f9;
|
||||
background: var(--ui-bg);
|
||||
}
|
||||
|
||||
.cal-cell.empty {
|
||||
@@ -2106,13 +2111,13 @@
|
||||
}
|
||||
|
||||
.cal-cell.selected.has-booking::after {
|
||||
background: #fff;
|
||||
background: var(--ui-surface);
|
||||
}
|
||||
|
||||
.calendar-day-details {
|
||||
border-top: 1px solid #e7edf5;
|
||||
padding: 12px;
|
||||
background: #f8fafc;
|
||||
background: var(--ui-surface-soft);
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -2135,7 +2140,7 @@
|
||||
}
|
||||
|
||||
.cal-booking-item {
|
||||
background: #fff;
|
||||
background: var(--ui-surface);
|
||||
border: 1px solid #dbe3ee;
|
||||
border-left: 3px solid #2563eb;
|
||||
border-radius: 10px;
|
||||
@@ -2170,7 +2175,7 @@
|
||||
|
||||
.cal-booking-period {
|
||||
margin-left: 8px;
|
||||
color: #475569;
|
||||
color: var(--ui-text);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -2579,7 +2584,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
<input type="date" id="edit-year" name="anschaffungsjahr">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-cost">Anschaffungskosten:</label>
|
||||
<label for="edit-cost">Anschaffungskosten (€):</label>
|
||||
<input id="edit-cost" name="anschaffungskosten">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -3339,7 +3344,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
});
|
||||
|
||||
// Function to load items from server
|
||||
const MAIN_ADMIN_ITEMS_PAGE_SIZE = 120;
|
||||
// Dynamic page size: scan viewport/screen size to only load the exact amount needed.
|
||||
const isMobileDevice = window.matchMedia('(max-width: 768px)').matches;
|
||||
const MAIN_ADMIN_ITEMS_PAGE_SIZE = isMobileDevice ? 20 : 120;
|
||||
let mainAdminItemsNextOffset = 0;
|
||||
let mainAdminItemsHasMore = false;
|
||||
let mainAdminItemsLoadingMore = false;
|
||||
@@ -3397,10 +3404,10 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
styles.flexDirection === 'column';
|
||||
|
||||
const distanceToEnd = isMobileLayout
|
||||
? itemsContainer.scrollHeight - (itemsContainer.scrollTop + itemsContainer.clientHeight)
|
||||
? Math.max(document.body.scrollHeight, document.documentElement.scrollHeight) - (window.scrollY + window.innerHeight)
|
||||
: itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth);
|
||||
const prefetchThreshold = isMobileLayout
|
||||
? Math.max(220, itemsContainer.clientHeight * 0.9)
|
||||
? window.innerHeight * 1.5
|
||||
: Math.max(260, itemsContainer.clientWidth * 1.4);
|
||||
if (distanceToEnd > prefetchThreshold) {
|
||||
return;
|
||||
@@ -3523,7 +3530,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
const filter2More = filter2Array.length > 1 ? ` (+${filter2Array.length - 1} mehr)` : '';
|
||||
const filter3More = filter3Array.length > 1 ? ` (+${filter3Array.length - 1} mehr)` : '';
|
||||
|
||||
const imagesHtml = item.Images ? item.Images.map((image, index) => {
|
||||
const imagesHtml = item.Images && item.Images.length > 0 ? item.Images.slice(0, 1).map((image, index) => {
|
||||
// Check if the image already has a full URL path or just the filename
|
||||
const imageSrc = image.startsWith('/uploads/') || image.startsWith('http') ?
|
||||
image :
|
||||
@@ -3772,6 +3779,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
|
||||
if (!mainAdminItemsScrollPrefetchBound) {
|
||||
itemsContainer.addEventListener('scroll', maybePrefetchMainAdminItems, { passive: true });
|
||||
window.addEventListener('scroll', maybePrefetchMainAdminItems, { passive: true });
|
||||
mainAdminItemsScrollPrefetchBound = true;
|
||||
}
|
||||
|
||||
@@ -3793,7 +3801,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
updateMainAdminItemsLoadingIndicator();
|
||||
});
|
||||
}, {
|
||||
root: itemsContainer,
|
||||
root: isMobileLayout ? null : itemsContainer,
|
||||
threshold: 0.15,
|
||||
rootMargin: isMobileLayout ? '0px 0px 900px 0px' : '0px 900px 0px 0px'
|
||||
});
|
||||
@@ -4355,7 +4363,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
const badgeText = entry.type === 'repair' ? 'Repariert' : 'Schaden';
|
||||
const metaLine = entry.meta ? `<div style="font-size:0.84rem;color:#4b5563;">${escapeHtml(entry.meta)}</div>` : '';
|
||||
return `
|
||||
<div style="border:1px solid #dbe3ee;border-radius:8px;padding:10px;background:#fff;display:grid;gap:6px;">
|
||||
<div style="border:1px solid var(--ui-border);border-radius:8px;padding:10px;background:var(--ui-surface);display:grid;gap:6px;">
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">
|
||||
<span style="display:inline-block;padding:2px 8px;border-radius:999px;font-size:0.75rem;font-weight:700;${badgeStyle}">${badgeText}</span>
|
||||
<span style="font-size:0.84rem;color:#475569;">${entry.dateLabel}</span>
|
||||
@@ -4433,7 +4441,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
|
||||
<div class="detail-group">
|
||||
<div class="detail-label">Anschaffungskosten:</div>
|
||||
<div class="detail-value">${item.Anschaffungskosten || '-'}</div>
|
||||
<div class="detail-value">${item.Anschaffungskosten ? item.Anschaffungskosten + ' €' : '-'}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-group full-width">
|
||||
@@ -4448,7 +4456,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
<span>🛠️</span>
|
||||
<span id="toggle-damage-history-text">Historie anzeigen</span>
|
||||
</button>
|
||||
<div id="damage-history-panel" style="display:none; margin-top:8px; border:1px solid #e7edf5; border-radius:10px; padding:12px; background:#f8fafc;">
|
||||
<div id="damage-history-panel" style="display:none; margin-top:8px; border:1px solid #e7edf5; border-radius:10px; padding:12px; background: var(--ui-surface-soft);">
|
||||
<div style="display:grid; gap:10px;">${damageHistoryHtml}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4527,6 +4535,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
<form method="POST" action="/delete_item/${item._id}" style="display:inline;" onsubmit="return confirm('Sind Sie sicher?')">
|
||||
<button class="delete-button" type="submit">Löschen</button>
|
||||
</form>
|
||||
<button type="button" class="details-button" onclick="document.getElementById('item-modal').style.display='none';">Schließen</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -5494,7 +5503,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
background: #ffffff;
|
||||
background: var(--ui-surface);
|
||||
border: 1px solid #cfd8e3;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
@@ -5542,7 +5551,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
}
|
||||
|
||||
.clear-filter {
|
||||
background: #fff;
|
||||
background: var(--ui-surface);
|
||||
color: #495868;
|
||||
}
|
||||
|
||||
@@ -5551,7 +5560,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
border: 1px solid #cfd8e3;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 20px rgba(20, 35, 60, 0.12);
|
||||
background: #ffffff;
|
||||
background: var(--ui-surface);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
@@ -5661,7 +5670,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
display: block !important;
|
||||
margin-right: 0 !important;
|
||||
margin-bottom: 2px !important;
|
||||
color: #6c757d !important;
|
||||
color: var(--ui-text-muted) !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -78,12 +78,12 @@
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
background-color: #fff;
|
||||
background-color: var(--ui-surface);
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: #f8f9fa;
|
||||
background-color: var(--ui-surface-soft);
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
@@ -170,7 +170,7 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
background-color: #f8f9fa;
|
||||
background-color: var(--ui-surface-soft);
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #6c757d;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
bottom: 125%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: #333;
|
||||
background-color: var(--ui-text);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
@@ -405,7 +405,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
|
||||
.borrowed-item {
|
||||
background-color: #fff;
|
||||
background-color: var(--ui-surface);
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
@@ -457,7 +457,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
.exemplar-details {
|
||||
margin-top: 10px;
|
||||
background-color: #f8f9fa;
|
||||
background-color: var(--ui-surface-soft);
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
border-left: 3px solid #6c757d;
|
||||
@@ -515,7 +515,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
.no-items-message {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
background-color: #f8f9fa;
|
||||
background-color: var(--ui-surface-soft);
|
||||
border-radius: 5px;
|
||||
color: #6c757d;
|
||||
margin-top: 20px;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<div id="push-notification-settings" style="margin-bottom: 24px;"></div>
|
||||
|
||||
<div style="display:grid; grid-template-columns:1fr; gap:14px;">
|
||||
<section style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:16px;">
|
||||
<section style="background: var(--ui-surface); 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;">
|
||||
@@ -53,7 +53,7 @@
|
||||
</section>
|
||||
|
||||
{% if is_admin_user %}
|
||||
<section style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:16px;">
|
||||
<section style="background: var(--ui-surface); 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;">
|
||||
|
||||
@@ -70,27 +70,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if student_cards_module_enabled %}
|
||||
<div class="form-group">
|
||||
<label style="display:flex; align-items:center; gap:8px; color:#1f2937;">
|
||||
<input type="checkbox" id="is-student" name="is_student" style="width:auto;">
|
||||
Schülerkonto mit Ausweis
|
||||
</label>
|
||||
<div id="student-fields" style="display:none; margin-top:10px;">
|
||||
<label for="student-card-id">Ausweis-ID</label>
|
||||
<div class="input-container">
|
||||
<span class="input-icon">🪪</span>
|
||||
<input type="text" id="student-card-id" name="student_card_id" placeholder="z.B. S-2026-001">
|
||||
</div>
|
||||
<label for="max-borrow-days" style="margin-top:10px;">Standard-Ausleihdauer (Tage)</label>
|
||||
<div class="input-container">
|
||||
<span class="input-icon">📅</span>
|
||||
<input type="number" id="max-borrow-days" name="max_borrow_days" min="1" max="{{ student_max_borrow_days }}" value="{{ student_default_borrow_days }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="permission-preset">Berechtigungs-Preset</label>
|
||||
<select id="permission-preset" name="permission_preset" class="form-select">
|
||||
@@ -142,7 +121,7 @@
|
||||
--primary-dark: #2980b9;
|
||||
--success-color: #2ecc71;
|
||||
--error-color: #e74c3c;
|
||||
--text-color: #333;
|
||||
--text-color: var(--ui-text);
|
||||
--light-bg: #f9f9f9;
|
||||
--border-radius: 8px;
|
||||
--shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
@@ -184,7 +163,7 @@ body {
|
||||
}
|
||||
|
||||
.content {
|
||||
background-color: #fff;
|
||||
background-color: var(--ui-surface);
|
||||
padding: 2rem;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow);
|
||||
@@ -366,7 +345,7 @@ input::placeholder {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
background: var(--ui-surface-soft);
|
||||
}
|
||||
|
||||
.password-rules-title {
|
||||
@@ -427,7 +406,7 @@ input::placeholder {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
background: #f8fafc;
|
||||
background: var(--ui-surface-soft);
|
||||
}
|
||||
|
||||
.permission-panel h4 {
|
||||
@@ -561,25 +540,6 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
passwordInput.addEventListener('blur', updatePasswordRules);
|
||||
updatePasswordRules();
|
||||
}
|
||||
|
||||
{% if student_cards_module_enabled %}
|
||||
const studentCheckbox = document.getElementById('is-student');
|
||||
const studentFields = document.getElementById('student-fields');
|
||||
const studentCardInput = document.getElementById('student-card-id');
|
||||
|
||||
if (!studentCheckbox || !studentFields || !studentCardInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
function toggleStudentFields() {
|
||||
const isChecked = studentCheckbox.checked;
|
||||
studentFields.style.display = isChecked ? 'block' : 'none';
|
||||
studentCardInput.required = isChecked;
|
||||
}
|
||||
|
||||
studentCheckbox.addEventListener('change', toggleStudentFields);
|
||||
toggleStudentFields();
|
||||
{% endif %}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -129,7 +129,7 @@ function createLoadingIndicator(message) {
|
||||
}
|
||||
|
||||
.reset-loading-message {
|
||||
color: #333;
|
||||
color: var(--ui-text);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@
|
||||
}
|
||||
|
||||
.reset-item-info {
|
||||
background: #f8f9fa;
|
||||
background: var(--ui-surface-soft);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 25px;
|
||||
@@ -216,7 +216,7 @@
|
||||
|
||||
.reset-item-info h3 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #333;
|
||||
color: var(--ui-text);
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@
|
||||
}
|
||||
|
||||
.reset-item-details strong {
|
||||
color: #333;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.reset-warning-section {
|
||||
@@ -285,7 +285,7 @@
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.reset-action-desc {
|
||||
@@ -298,7 +298,7 @@
|
||||
.active-borrowings {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: #f8f9fa;
|
||||
background: var(--ui-surface-soft);
|
||||
border-radius: 5px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@@ -321,7 +321,7 @@
|
||||
|
||||
.reset-options-section h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #333;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.reset-option {
|
||||
@@ -365,7 +365,7 @@
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.reset-reason-section textarea {
|
||||
@@ -391,7 +391,7 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 15px;
|
||||
background: #f8f9fa;
|
||||
background: var(--ui-surface-soft);
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
color: var(--ui-text);
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
@@ -67,7 +67,7 @@
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
color: #333;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
@@ -106,7 +106,7 @@
|
||||
|
||||
.btn-secondary {
|
||||
background: #e0e0e0;
|
||||
color: #333;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
}
|
||||
|
||||
.student-card-form {
|
||||
background: #f5f5f5;
|
||||
background: var(--ui-bg);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 30px;
|
||||
@@ -43,7 +43,7 @@
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 5px;
|
||||
color: #333;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
|
||||
@@ -1601,7 +1601,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
.booking-summary {
|
||||
margin: 15px 0;
|
||||
padding: 10px;
|
||||
background-color: #f8f9fa;
|
||||
background-color: var(--ui-surface-soft);
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
|
||||
.tutorial-side {
|
||||
grid-column: span 12;
|
||||
background: #ffffff;
|
||||
background: var(--ui-surface);
|
||||
border: 1px solid #cfdbe8;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
@@ -102,7 +102,7 @@
|
||||
|
||||
.workflow-nav button {
|
||||
border: 1px solid #dbe3ee;
|
||||
background: #f8fafc;
|
||||
background: var(--ui-surface-soft);
|
||||
color: #0f172a;
|
||||
border-radius: 12px;
|
||||
padding: 11px 12px;
|
||||
@@ -133,7 +133,7 @@
|
||||
|
||||
.workflow-step {
|
||||
grid-column: span 12;
|
||||
background: #ffffff;
|
||||
background: var(--ui-surface);
|
||||
border: 1px solid #cfdbe8;
|
||||
border-radius: 16px;
|
||||
padding: 18px;
|
||||
@@ -331,17 +331,17 @@
|
||||
<h3>Tägliche Aufgaben</h3>
|
||||
<p>Das ist der gewöhnliche Arbeitsablauf während eines Schultages.</p>
|
||||
|
||||
<div style="background: #f8fafc; border: 1px solid #dbe3ee; border-radius: 12px; padding: 14px; margin: 14px 0;">
|
||||
<div style="background: var(--ui-surface-soft); border: 1px solid #dbe3ee; border-radius: 12px; padding: 14px; margin: 14px 0;">
|
||||
<p style="margin-bottom: 10px; font-weight: 700; color: #0f172a;">Morgens:</p>
|
||||
<p style="margin: 0; color: #334155;">Öffnen Sie die Startseite und prüfen Sie offene Reservierungen oder Rückgabefristen.</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f8fafc; border: 1px solid #dbe3ee; border-radius: 12px; padding: 14px; margin: 14px 0;">
|
||||
<div style="background: var(--ui-surface-soft); border: 1px solid #dbe3ee; border-radius: 12px; padding: 14px; margin: 14px 0;">
|
||||
<p style="margin-bottom: 10px; font-weight: 700; color: #0f172a;">Mittags:</p>
|
||||
<p style="margin: 0; color: #334155;">Neue Artikel oder Bücher hinzufügen oder Ausleihungen begründen.</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f8fafc; border: 1px solid #dbe3ee; border-radius: 12px; padding: 14px; margin: 14px 0;">
|
||||
<div style="background: var(--ui-surface-soft); border: 1px solid #dbe3ee; border-radius: 12px; padding: 14px; margin: 14px 0;">
|
||||
<p style="margin-bottom: 10px; font-weight: 700; color: #0f172a;">Nachmittags:</p>
|
||||
<p style="margin: 0; color: #334155;">Rückgaben buchen und kurz prüfen, ob alles in Ordnung ist.</p>
|
||||
</div>
|
||||
|
||||
@@ -78,14 +78,14 @@
|
||||
|
||||
.book-info {
|
||||
padding: 15px;
|
||||
background-color: #fff;
|
||||
background-color: var(--ui-surface);
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.book-info h4 {
|
||||
color: #333;
|
||||
color: var(--ui-text);
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
@@ -115,7 +115,7 @@
|
||||
}
|
||||
|
||||
.book-description h5 {
|
||||
color: #333;
|
||||
color: var(--ui-text);
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
@@ -125,7 +125,7 @@
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
padding: 5px;
|
||||
background-color: #f8f9fa;
|
||||
background-color: var(--ui-surface-soft);
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
@@ -187,7 +187,7 @@
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
background-color: #f8f9fa;
|
||||
background-color: var(--ui-surface-soft);
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -334,7 +334,7 @@
|
||||
}
|
||||
|
||||
.popup-content p {
|
||||
color: #333;
|
||||
color: var(--ui-text);
|
||||
margin: 15px 0;
|
||||
line-height: 1.5;
|
||||
font-size: 1em;
|
||||
@@ -380,7 +380,7 @@
|
||||
}
|
||||
|
||||
.popup-close-x:hover {
|
||||
color: #333;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
/* Upload form styles */
|
||||
@@ -388,13 +388,13 @@
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #fff;
|
||||
background-color: var(--ui-surface);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.upload-form h1 {
|
||||
color: #333;
|
||||
color: var(--ui-text);
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
font-size: 2em;
|
||||
@@ -408,7 +408,7 @@
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
@@ -428,14 +428,14 @@
|
||||
}
|
||||
|
||||
.filter-inputs {
|
||||
background-color: #f8f9fa;
|
||||
background-color: var(--ui-surface-soft);
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.filter-inputs h3 {
|
||||
color: #333;
|
||||
color: var(--ui-text);
|
||||
margin-bottom: 15px;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
@@ -863,7 +863,7 @@
|
||||
<input type="date" id="anschaffungsjahr" name="anschaffungsjahr">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="anschaffungskosten">Anschaffungskosten</label>
|
||||
<label for="anschaffungskosten">Anschaffungskosten (€)</label>
|
||||
<input id="anschaffungskosten" name="anschaffungskosten">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
||||
@@ -407,14 +407,14 @@
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
background-color: #f8f9fa;
|
||||
background-color: var(--ui-surface-soft);
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.password-requirements {
|
||||
background-color: #f8f9fa;
|
||||
background-color: var(--ui-surface-soft);
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-top: 10px;
|
||||
@@ -439,7 +439,7 @@
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #f8fafc;
|
||||
background: var(--ui-surface-soft);
|
||||
}
|
||||
|
||||
.permissions-group h6 {
|
||||
|
||||
@@ -10,11 +10,43 @@ Each tenant can support up to 20+ users with isolated data and resource pools.
|
||||
from flask import request, g, has_request_context
|
||||
from functools import wraps
|
||||
import logging
|
||||
import settings as cfg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Tenant registry: maps subdomain/tenant_id to database name
|
||||
TENANT_REGISTRY = {}
|
||||
if isinstance(getattr(cfg, 'TENANT_CONFIGS', None), dict):
|
||||
TENANT_REGISTRY.update(cfg.TENANT_CONFIGS)
|
||||
|
||||
|
||||
def _get_nested_value(source, path, default=None):
|
||||
current = source
|
||||
for key in path:
|
||||
if isinstance(current, dict) and key in current:
|
||||
current = current[key]
|
||||
else:
|
||||
return default
|
||||
return current
|
||||
|
||||
|
||||
def get_tenant_config(tenant_id=None):
|
||||
"""Return the registered config for a tenant, falling back to default."""
|
||||
if tenant_id is None:
|
||||
ctx = get_tenant_context()
|
||||
tenant_id = ctx.tenant_id if ctx and ctx.tenant_id else 'default'
|
||||
|
||||
if tenant_id in TENANT_REGISTRY:
|
||||
return TENANT_REGISTRY[tenant_id] or {}
|
||||
|
||||
return TENANT_REGISTRY.get('default', {}) or {}
|
||||
|
||||
|
||||
def is_tenant_module_enabled(module_name, tenant_id=None, default=False):
|
||||
"""Resolve whether a feature module is enabled for the current tenant."""
|
||||
config = get_tenant_config(tenant_id)
|
||||
enabled = _get_nested_value(config, ['modules', module_name, 'enabled'], default)
|
||||
return bool(enabled)
|
||||
|
||||
|
||||
class TenantContext:
|
||||
@@ -27,6 +59,7 @@ class TenantContext:
|
||||
self.tenant_id = None
|
||||
self.db_name = None
|
||||
self.subdomain = None
|
||||
self.config = {}
|
||||
|
||||
def resolve_tenant(self):
|
||||
"""
|
||||
@@ -40,6 +73,7 @@ class TenantContext:
|
||||
tenant_from_header = request.headers.get('X-Tenant-ID', '').strip()
|
||||
if tenant_from_header:
|
||||
self.tenant_id = tenant_from_header
|
||||
self.config = get_tenant_config(tenant_from_header)
|
||||
return self._get_db_name(tenant_from_header)
|
||||
|
||||
# Priority 2: Subdomain extraction
|
||||
@@ -56,10 +90,12 @@ class TenantContext:
|
||||
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
|
||||
self.subdomain = potential_subdomain
|
||||
self.tenant_id = potential_subdomain
|
||||
self.config = get_tenant_config(potential_subdomain)
|
||||
return self._get_db_name(potential_subdomain)
|
||||
|
||||
# Fallback to default tenant if no subdomain detected
|
||||
self.tenant_id = 'default'
|
||||
self.config = get_tenant_config('default')
|
||||
return self._get_db_name('default')
|
||||
|
||||
def _get_db_name(self, tenant_id):
|
||||
|
||||
@@ -33,11 +33,12 @@ LOG_FILE="${LOG_FILE:-$LOG_DIR/backup.log}"
|
||||
COMPRESSION_LEVEL="${COMPRESSION_LEVEL:-9}"
|
||||
KEEP_DAYS="${KEEP_DAYS:-7}"
|
||||
MIN_KEEP="${MIN_KEEP:-7}"
|
||||
DB_NAME="${DB_NAME:-Inventarsystem}"
|
||||
DB_NAME="${DB_NAME:-inventar_default}"
|
||||
MONGO_URI="${MONGO_URI:-mongodb://localhost:27017/}"
|
||||
INVOICE_KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}"
|
||||
INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-$BACKUP_BASE_DIR/invoice-archive}"
|
||||
BACKUP_MODE="${BACKUP_MODE:-auto}"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose-multitenant.yml}"
|
||||
DOCKER_AVAILABLE=0
|
||||
DOCKER_COMPOSE_CMD=()
|
||||
USE_NULL_OUTPUT=false
|
||||
@@ -58,6 +59,8 @@ Options:
|
||||
--keep-days <N> Age-based retention in days (default: $KEEP_DAYS; 0 disables age filter)
|
||||
--min-keep <N> Always keep at least this many backups (default: $MIN_KEEP)
|
||||
--mode <auto|host|docker> Backup mode (default: $BACKUP_MODE)
|
||||
--multitenant Use docker-compose-multitenant.yml (default)
|
||||
--singletenant Use docker-compose.yml
|
||||
-h|--help Show this help and exit
|
||||
EOF
|
||||
}
|
||||
@@ -87,6 +90,10 @@ while [[ $# -gt 0 ]]; do
|
||||
MIN_KEEP="$2"; shift 2;;
|
||||
--mode)
|
||||
BACKUP_MODE="$2"; shift 2;;
|
||||
--multitenant)
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"; shift;;
|
||||
--singletenant)
|
||||
COMPOSE_FILE="docker-compose.yml"; shift;;
|
||||
-h|--help)
|
||||
usage; exit 0;;
|
||||
*)
|
||||
@@ -102,12 +109,12 @@ log_message() {
|
||||
init_docker_compose() {
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
DOCKER_AVAILABLE=1
|
||||
DOCKER_COMPOSE_CMD=(docker compose -f "$PROJECT_DIR/docker-compose.yml")
|
||||
DOCKER_COMPOSE_CMD=(docker compose -f "$PROJECT_DIR/$COMPOSE_FILE")
|
||||
return 0
|
||||
fi
|
||||
if sudo docker compose version >/dev/null 2>&1; then
|
||||
DOCKER_AVAILABLE=1
|
||||
DOCKER_COMPOSE_CMD=(sudo docker compose -f "$PROJECT_DIR/docker-compose.yml")
|
||||
DOCKER_COMPOSE_CMD=(sudo docker compose -f "$PROJECT_DIR/$COMPOSE_FILE")
|
||||
return 0
|
||||
fi
|
||||
|
||||
|
||||
-107
@@ -1,107 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
SUDO=""
|
||||
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
|
||||
SUDO="sudo"
|
||||
fi
|
||||
|
||||
VENV_PYTHON="$SCRIPT_DIR/.venv/bin/python"
|
||||
if [ ! -x "$VENV_PYTHON" ]; then
|
||||
echo "Error: .venv python not found at $VENV_PYTHON"
|
||||
echo "Create it first, e.g.: python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$(id -u)" -ne 0 ] && [ ! -w "$SCRIPT_DIR/.venv" ]; then
|
||||
echo "Fixing ownership of .venv for current user..."
|
||||
if [ -n "$SUDO" ]; then
|
||||
$SUDO chown -R "$(id -un):$(id -gn)" "$SCRIPT_DIR/.venv"
|
||||
else
|
||||
echo "Error: .venv is not writable and sudo is unavailable."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
for path in "$SCRIPT_DIR/dist" "$SCRIPT_DIR/build"; do
|
||||
if [ -e "$path" ] && [ "$(id -u)" -ne 0 ]; then
|
||||
echo "Ensuring ownership of $(basename "$path") for current user..."
|
||||
if [ -n "$SUDO" ]; then
|
||||
$SUDO chown -R "$(id -un):$(id -gn)" "$path"
|
||||
else
|
||||
if [ ! -w "$path" ]; then
|
||||
echo "Error: $path is not writable and sudo is unavailable."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Installing Nuitka build dependencies in .venv..."
|
||||
"$VENV_PYTHON" -m pip install --upgrade pip setuptools wheel
|
||||
"$VENV_PYTHON" -m pip install --upgrade "nuitka==2.8.10" ordered-set zstandard
|
||||
|
||||
DIST_DIR="$SCRIPT_DIR/dist"
|
||||
BUILD_DIR="$SCRIPT_DIR/build"
|
||||
OUTPUT_NAME="inventarsystem"
|
||||
|
||||
mkdir -p "$DIST_DIR" "$BUILD_DIR"
|
||||
|
||||
NUITKA_DATA_ARGS=()
|
||||
|
||||
if [ -d "$SCRIPT_DIR/Web/templates" ]; then
|
||||
NUITKA_DATA_ARGS+=("--include-data-dir=$SCRIPT_DIR/Web/templates=templates")
|
||||
fi
|
||||
|
||||
if [ -d "$SCRIPT_DIR/Web/static" ]; then
|
||||
NUITKA_DATA_ARGS+=("--include-data-dir=$SCRIPT_DIR/Web/static=static")
|
||||
fi
|
||||
|
||||
if [ -d "$SCRIPT_DIR/uploads" ]; then
|
||||
NUITKA_DATA_ARGS+=("--include-data-dir=$SCRIPT_DIR/uploads=uploads")
|
||||
fi
|
||||
|
||||
ORIGINAL_PERF_PARANOID=""
|
||||
if [ -r /proc/sys/kernel/perf_event_paranoid ]; then
|
||||
ORIGINAL_PERF_PARANOID="$(cat /proc/sys/kernel/perf_event_paranoid)"
|
||||
if [ "$ORIGINAL_PERF_PARANOID" -gt 1 ]; then
|
||||
echo "Temporarily setting kernel.perf_event_paranoid=1 for Nuitka build compatibility..."
|
||||
if ! $SUDO sh -c 'echo 1 > /proc/sys/kernel/perf_event_paranoid'; then
|
||||
echo "Warning: Could not adjust kernel.perf_event_paranoid automatically."
|
||||
echo "Run: sudo sh -c 'echo 1 > /proc/sys/kernel/perf_event_paranoid' and retry."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
restore_perf_setting() {
|
||||
if [ -n "$ORIGINAL_PERF_PARANOID" ] && [ "$ORIGINAL_PERF_PARANOID" -gt 1 ]; then
|
||||
$SUDO sh -c "echo $ORIGINAL_PERF_PARANOID > /proc/sys/kernel/perf_event_paranoid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
trap restore_perf_setting EXIT
|
||||
|
||||
echo "Building standalone binary with Nuitka..."
|
||||
"$VENV_PYTHON" -m nuitka \
|
||||
--standalone \
|
||||
--assume-yes-for-downloads \
|
||||
--follow-imports \
|
||||
--output-dir="$DIST_DIR" \
|
||||
--output-filename="$OUTPUT_NAME" \
|
||||
"${NUITKA_DATA_ARGS[@]}" \
|
||||
--remove-output \
|
||||
"$SCRIPT_DIR/Web/app.py"
|
||||
|
||||
APP_DIST_DIR="$DIST_DIR/app.dist"
|
||||
if [ -d "$APP_DIST_DIR" ]; then
|
||||
echo "Nuitka build complete."
|
||||
echo "Run with: $APP_DIST_DIR/$OUTPUT_NAME"
|
||||
else
|
||||
echo "Build finished, but expected output directory not found: $APP_DIST_DIR"
|
||||
echo "Check Nuitka output above."
|
||||
exit 1
|
||||
fi
|
||||
+4
-2
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"dbg": false,
|
||||
"key": "InventarsystemSecureKey2026XYZ789abcdef012",
|
||||
"ver": "0.0.2",
|
||||
"ver": "0.6.44",
|
||||
"host": "0.0.0.0",
|
||||
"port": 443,
|
||||
"port": 8000,
|
||||
|
||||
"mongodb": {
|
||||
"host": "localhost",
|
||||
@@ -50,6 +50,8 @@
|
||||
}
|
||||
},
|
||||
|
||||
"tenants": {},
|
||||
|
||||
"allowed_extensions": [
|
||||
"png", "jpg", "jpeg", "gif",
|
||||
"hevc", "heif",
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
tunnel: homeserver
|
||||
credentials-file: /etc/cloudflared/credentials.json
|
||||
|
||||
ingress:
|
||||
# Repo 1: Inventar
|
||||
- hostname: inventar.maximiliangruendinger.de
|
||||
service: http://inventory-app:3000
|
||||
|
||||
# Repo 2: Key-Service (Buchung)
|
||||
- hostname: booking.maximiliangruendinger.de
|
||||
service: http://key-service-app:8080
|
||||
|
||||
- service: http_status:404
|
||||
@@ -8,9 +8,35 @@
|
||||
# Scale example: To support 10 tenants with 20 users each:
|
||||
# docker-compose -f docker-compose-multitenant.yml up -d --scale app=10
|
||||
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
cloudflared:
|
||||
image: cloudflare/cloudflared:latest
|
||||
container_name: cloudflared
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- cloudflare
|
||||
command: ["--config", "/etc/cloudflared/config.yml", "tunnel", "run"]
|
||||
volumes:
|
||||
- ./docker/cloudflared/config.yml:/etc/cloudflared/config.yml:ro
|
||||
- /etc/cloudflared/credentials.json:/etc/cloudflared/credentials.json:ro
|
||||
depends_on:
|
||||
nginx:
|
||||
condition: service_started
|
||||
networks:
|
||||
- inventar-net
|
||||
|
||||
# Management Container for multi-tenant scripts
|
||||
tenant-manager:
|
||||
image: docker:cli
|
||||
container_name: tenant-manager
|
||||
restart: "no"
|
||||
profiles:
|
||||
- tools
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- .:/workspace
|
||||
entrypoint: ["sh", "-c", "cd /workspace && ./manage-tenant.sh \"$$@\"", "--"]
|
||||
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: inventarsystem-nginx
|
||||
@@ -22,7 +48,7 @@ services:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "${INVENTAR_HTTP_PORT:-80}:80"
|
||||
- "${INVENTAR_HTTPS_PORT:-443}:443"
|
||||
- "${INVENTAR_HTTPS_PORT:-8000}:8000"
|
||||
volumes:
|
||||
- ./docker/nginx/multitenant.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./certs:/etc/nginx/certs:ro
|
||||
@@ -59,9 +85,8 @@ services:
|
||||
image: mongo:7.0
|
||||
container_name: inventarsystem-mongodb
|
||||
restart: unless-stopped
|
||||
command: mongod --wiredTigerCacheSizeGB 2 --journal --dbPath /data/db
|
||||
ports:
|
||||
- "27017:27017"
|
||||
expose:
|
||||
- "27017"
|
||||
volumes:
|
||||
- mongodb_data:/data/db
|
||||
- ./docker/mongo:/docker-entrypoint-initdb.d:ro
|
||||
@@ -82,6 +107,8 @@ services:
|
||||
args:
|
||||
PYTHON_VERSION: "3.13"
|
||||
OPTIMIZATION_LEVEL: 2
|
||||
working_dir: /app/Web
|
||||
command: ["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", "-"]
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
@@ -96,7 +123,6 @@ services:
|
||||
- "8000"
|
||||
volumes:
|
||||
- ./config.json:/app/config.json:ro
|
||||
- ./Web:/app/Web:ro
|
||||
- app_uploads:/app/Web/uploads:cached
|
||||
- app_thumbnails:/app/Web/thumbnails:cached
|
||||
- app_previews:/app/Web/previews:cached
|
||||
@@ -181,9 +207,6 @@ volumes:
|
||||
networks:
|
||||
inventar-net:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
|
||||
# Scale guidance:
|
||||
# 1 tenant (20 users): 1 app instance + redis + mongodb = ~500MB
|
||||
@@ -196,3 +219,4 @@ networks:
|
||||
# - Medium (5-10 tenants): 4GB RAM, 2-4 CPU cores
|
||||
# - Large (10-20 tenants): 8GB RAM, 4-8 CPU cores
|
||||
# - Jumbo (20+ tenants): 16GB+ RAM, 8+ CPU cores with clustering
|
||||
|
||||
|
||||
+26
-72
@@ -1,87 +1,41 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: inventarsystem-nginx
|
||||
app:
|
||||
build: .
|
||||
container_name: inventory-app
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- MONGO_URL=mongodb://mongodb:27017/inventar
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- BASE_URL=https://inventar.maximiliangruendinger.de #alle öffentliche subdomains
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "${INVENTAR_HTTP_PORT:-80}:80"
|
||||
- "${INVENTAR_HTTPS_PORT:-443}:443"
|
||||
volumes:
|
||||
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./certs:/etc/nginx/certs:ro
|
||||
- mongodb
|
||||
- redis
|
||||
|
||||
mongodb:
|
||||
image: mongo:7.0
|
||||
container_name: inventarsystem-mongodb
|
||||
image: mongo:latest
|
||||
container_name: mongodb
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- mongodb_data:/data/db
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
- mongo_data:/data/db
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: inventarsystem-redis
|
||||
image: redis:alpine
|
||||
container_name: redis
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--appendonly", "yes", "--save", "60", "1000"]
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: inventarsystem-app
|
||||
cloudflared:
|
||||
image: cloudflare/cloudflared:latest
|
||||
container_name: cloudflared
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
depends_on:
|
||||
mongodb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
INVENTAR_MONGODB_HOST: mongodb
|
||||
INVENTAR_MONGODB_PORT: "27017"
|
||||
INVENTAR_MONGODB_DB: Inventarsystem
|
||||
INVENTAR_REDIS_HOST: redis
|
||||
INVENTAR_REDIS_PORT: "6379"
|
||||
INVENTAR_REDIS_CACHE_DB: "1"
|
||||
INVENTAR_NOTIFICATION_STATUS_CACHE_TTL: "8"
|
||||
INVENTAR_BACKUP_FOLDER: /data/backups
|
||||
INVENTAR_LOGS_FOLDER: /data/logs
|
||||
INVENTAR_DELETED_ARCHIVE_FOLDER: /data/deleted-archives
|
||||
expose:
|
||||
- "8000"
|
||||
# Der Tunnel-Name 'homeserver' muss zu deiner credentials.json passen
|
||||
command: tunnel run homeserver
|
||||
volumes:
|
||||
- ./config.json:/app/config.json:ro
|
||||
- ./Web:/app/Web:ro
|
||||
- app_uploads:/app/Web/uploads
|
||||
- app_thumbnails:/app/Web/thumbnails
|
||||
- app_previews:/app/Web/previews
|
||||
- app_qrcodes:/app/Web/QRCodes
|
||||
- app_backups:/data/backups
|
||||
- app_logs:/data/logs
|
||||
- app_deleted_archives:/data/deleted-archives
|
||||
- ./config.yml:/etc/cloudflared/config.yml
|
||||
- ./credentials.json:/etc/cloudflared/credentials.json
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
volumes:
|
||||
mongodb_data:
|
||||
app_uploads:
|
||||
app_thumbnails:
|
||||
app_previews:
|
||||
app_qrcodes:
|
||||
app_backups:
|
||||
app_logs:
|
||||
app_deleted_archives:
|
||||
redis_data:
|
||||
mongo_data:
|
||||
@@ -0,0 +1,8 @@
|
||||
tunnel: homeserver
|
||||
credentials-file: /etc/cloudflared/credentials.json
|
||||
|
||||
ingress:
|
||||
- service: https://nginx:443
|
||||
originRequest:
|
||||
noTLSVerify: true
|
||||
- service: http_status:404
|
||||
@@ -37,7 +37,8 @@ upstream inventar_backend {
|
||||
|
||||
# Main HTTPS server block
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name ~^(?<tenant>[a-z0-9-]+)?\.?example\.com$;
|
||||
|
||||
# SSL Configuration (wildcard certificate)
|
||||
@@ -75,9 +76,9 @@ server {
|
||||
application/x-javascript application/xml+rss application/json;
|
||||
gzip_comp_level 5;
|
||||
|
||||
# Access/Error logs with tenant in filename
|
||||
access_log /var/log/nginx/inventar_access_${tenant}.log combined buffer=32k;
|
||||
error_log /var/log/nginx/inventar_error_${tenant}.log warn;
|
||||
# Access/Error logs
|
||||
access_log /var/log/nginx/inventar_access.log combined buffer=32k;
|
||||
error_log /var/log/nginx/inventar_error.log warn;
|
||||
|
||||
# Root location - proxy to app with tenant context
|
||||
location / {
|
||||
|
||||
+27
-8
@@ -22,6 +22,7 @@ LEGACY_BACKUP_ARCHIVE=""
|
||||
CLEANUP_OLD_SERVICES=true
|
||||
CLEANUP_OLD_REMOVE_CRON=false
|
||||
TMP_DIR=""
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||
|
||||
cleanup_tmp_dir() {
|
||||
if [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ]; then
|
||||
@@ -50,14 +51,13 @@ refresh_start_script_from_main() {
|
||||
pin_compose_app_image() {
|
||||
local tag="$1"
|
||||
local compose_file
|
||||
compose_file="$PROJECT_DIR/docker-compose.yml"
|
||||
|
||||
if [ ! -f "$compose_file" ]; then
|
||||
echo "Warning: $compose_file not found; cannot pin app image"
|
||||
return 0
|
||||
fi
|
||||
for compose_file in "$PROJECT_DIR/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose.yml"; do
|
||||
if [ ! -f "$compose_file" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
python3 - <<'PY' "$compose_file" "$tag"
|
||||
python3 - <<'PY' "$compose_file" "$tag"
|
||||
import re
|
||||
import sys
|
||||
|
||||
@@ -151,6 +151,8 @@ if in_app_service and build_found and app_service_indent is not None:
|
||||
with open(compose_file, "w", encoding="utf-8") as f:
|
||||
f.writelines(out)
|
||||
PY
|
||||
|
||||
done
|
||||
}
|
||||
|
||||
install_docker_if_missing() {
|
||||
@@ -173,6 +175,8 @@ Options:
|
||||
--remove-legacy-system Remove old host MongoDB/system after successful import
|
||||
--skip-cleanup-old Do not run cleanup-old.sh after install
|
||||
--cleanup-old-remove-cron Also remove matching cron entries during old-system cleanup
|
||||
--multitenant Use docker-compose-multitenant.yml (default)
|
||||
--singletenant Use docker-compose.yml
|
||||
--legacy-db-name <name> Legacy database name (default: $LEGACY_DB_NAME)
|
||||
--legacy-mongo-uri <uri> Legacy Mongo URI (default: $LEGACY_MONGO_URI)
|
||||
--legacy-system-dir <path> Optional old system directory to remove after migration
|
||||
@@ -199,6 +203,14 @@ parse_args() {
|
||||
CLEANUP_OLD_REMOVE_CRON=true
|
||||
shift
|
||||
;;
|
||||
--multitenant)
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||
shift
|
||||
;;
|
||||
--singletenant)
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
shift
|
||||
;;
|
||||
--legacy-db-name)
|
||||
LEGACY_DB_NAME="$2"
|
||||
shift 2
|
||||
@@ -280,14 +292,21 @@ backup_legacy_database() {
|
||||
|
||||
restore_legacy_backup_into_docker() {
|
||||
local i
|
||||
local compose_path
|
||||
|
||||
if [ "$MIGRATE_LEGACY_DB" != "true" ] || [ -z "$LEGACY_BACKUP_ARCHIVE" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
compose_path="$PROJECT_DIR/$COMPOSE_FILE"
|
||||
if [ ! -f "$compose_path" ]; then
|
||||
echo "Error: compose file not found: $compose_path"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Waiting for Docker MongoDB to become ready..."
|
||||
for i in $(seq 1 60); do
|
||||
if sudo docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T mongodb mongosh --quiet --eval "db.adminCommand({ping:1}).ok" >/dev/null 2>&1; then
|
||||
if sudo docker compose -f "$compose_path" exec -T mongodb mongosh --quiet --eval "db.adminCommand({ping:1}).ok" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
@@ -299,7 +318,7 @@ restore_legacy_backup_into_docker() {
|
||||
fi
|
||||
|
||||
echo "Importing legacy backup into Docker MongoDB..."
|
||||
sudo docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T mongodb mongorestore --archive --gzip --drop --nsInclude "${LEGACY_DB_NAME}.*" < "$LEGACY_BACKUP_ARCHIVE"
|
||||
sudo docker compose -f "$compose_path" exec -T mongodb mongorestore --archive --gzip --drop --nsInclude "${LEGACY_DB_NAME}.*" < "$LEGACY_BACKUP_ARCHIVE"
|
||||
echo "Legacy DB import completed."
|
||||
}
|
||||
|
||||
|
||||
Executable
+143
@@ -0,0 +1,143 @@
|
||||
#!/bin/sh
|
||||
# Script to manage multitenant deployment
|
||||
# Allows adding, removing, and restarting tenants without downtime for others
|
||||
|
||||
if [ ! -f "docker-compose-multitenant.yml" ]; then
|
||||
echo "Error: docker-compose-multitenant.yml not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
show_help() {
|
||||
echo "Usage: ./manage-tenant.sh [COMMAND] [OPTIONS]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " add <tenant_id> Add a new tenant (initializes database)"
|
||||
echo " remove <tenant_id> Remove a tenant completely (deletes data!)"
|
||||
echo " restart-tenant <id> 'Restart' a single tenant (clears cache/sessions)"
|
||||
echo " restart-all Restart all application containers (zero-downtime reload)"
|
||||
echo " list List active tenants"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " ./manage-tenant.sh add school_a"
|
||||
echo " ./manage-tenant.sh remove test_tenant"
|
||||
echo " ./manage-tenant.sh restart-all"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
show_help
|
||||
fi
|
||||
|
||||
COMMAND=$1
|
||||
TENANT_ID=$2
|
||||
|
||||
case "$COMMAND" in
|
||||
add)
|
||||
if [ -z "$TENANT_ID" ]; then
|
||||
echo "Error: Please provide a tenant_id."
|
||||
exit 1
|
||||
fi
|
||||
echo "Adding new tenant '$TENANT_ID'..."
|
||||
# Add Nginx configuration
|
||||
if [ -f "docker/nginx/multitenant.conf" ]; then
|
||||
echo "Assuming dynamic routing based on subdomain ($TENANT_ID)..."
|
||||
fi
|
||||
|
||||
# Initialize tenant database via Python inside container
|
||||
echo "Initializing database for $TENANT_ID..."
|
||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||
if [ -n "$APP_CONTAINER" ]; then
|
||||
docker exec $APP_CONTAINER python3 -c "
|
||||
import sys; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient; import hashlib
|
||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
|
||||
hashed_pw = hashlib.sha512('admin123'.encode()).hexdigest()
|
||||
db.users.insert_one({'Username': 'admin', 'Password': hashed_pw, 'Role': 'admin', 'Name': 'Admin', 'Nachname': 'User'})
|
||||
print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123')
|
||||
" "$TENANT_ID"
|
||||
echo "Tenant '$TENANT_ID' successfully added. Ready to use."
|
||||
else
|
||||
echo "Warning: Application container is not running. Please start the multi-tenant system first."
|
||||
echo "Data will be initialized upon first access by the tenant."
|
||||
fi
|
||||
;;
|
||||
|
||||
remove)
|
||||
if [ -z "$TENANT_ID" ]; then
|
||||
echo "Error: Please provide a tenant_id to remove."
|
||||
exit 1
|
||||
fi
|
||||
echo -n "WARNING: Are you sure you want to permanently delete all data for tenant '$TENANT_ID'? (y/N) "
|
||||
read confirm
|
||||
if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then
|
||||
echo "Removing tenant '$TENANT_ID'..."
|
||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||
if [ -n "$APP_CONTAINER" ]; then
|
||||
docker exec $APP_CONTAINER python3 -c "
|
||||
import sys; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient
|
||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||
client.drop_database(f'{settings.MONGODB_DB}_{sys.argv[1]}')
|
||||
print(f'Database for tenant {sys.argv[1]} dropped.')
|
||||
" "$TENANT_ID"
|
||||
echo "Tenant '$TENANT_ID' removed successfully."
|
||||
else
|
||||
echo "Error: Application container not running."
|
||||
fi
|
||||
else
|
||||
echo "Removal canceled."
|
||||
fi
|
||||
;;
|
||||
|
||||
restart-tenant)
|
||||
if [ -z "$TENANT_ID" ]; then
|
||||
echo "Error: Please provide a tenant_id."
|
||||
exit 1
|
||||
fi
|
||||
echo "Restarting tenant '$TENANT_ID' (clearing session/cache)..."
|
||||
# To restart a single tenant without restarting the global python processes,
|
||||
# we can invalidate their cache or drop their sessions collection to sign everyone out
|
||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||
if [ -n "$APP_CONTAINER" ]; then
|
||||
docker exec $APP_CONTAINER python3 -c "
|
||||
import sys; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient
|
||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
|
||||
db.sessions.drop() # Force sign-out / session clear
|
||||
print(f'Tenant {sys.argv[1]} session cache cleared. Tenant restarted.')
|
||||
" "$TENANT_ID"
|
||||
echo "Tenant '$TENANT_ID' has been refreshed without impacting others."
|
||||
else
|
||||
echo "Error: Application container not running."
|
||||
fi
|
||||
;;
|
||||
|
||||
restart-all)
|
||||
echo "Restarting all application instances with zero-downtime rolling restart..."
|
||||
docker restart $(docker ps -qf "name=app")
|
||||
echo "Global restart complete."
|
||||
;;
|
||||
|
||||
list)
|
||||
echo "Listing active tenants (Databases):"
|
||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||
if [ -n "$APP_CONTAINER" ]; then
|
||||
docker exec $APP_CONTAINER python3 -c "
|
||||
import sys; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient
|
||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||
prefix = f'{settings.MONGODB_DB}_'
|
||||
dbs = [d for d in client.list_database_names() if d.startswith(prefix)]
|
||||
for db in dbs:
|
||||
print(f'- {db.replace(prefix, \"\")}')
|
||||
"
|
||||
else
|
||||
echo "Error: Application container not running."
|
||||
fi
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Unknown command: $COMMAND"
|
||||
show_help
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
@@ -14,3 +14,4 @@ python-barcode
|
||||
openpyxl
|
||||
cryptography
|
||||
pywebpush
|
||||
py-vapid==1.9.0
|
||||
|
||||
+9
-2
@@ -1,7 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Nur die App neu bauen und starten, ohne den Tunnel oder die DB zu killen
|
||||
docker compose up -d --build app
|
||||
|
||||
# Optional: Alles aufräumen, was nicht mehr gebraucht wird
|
||||
docker image prune -f
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
|
||||
|
||||
"$SCRIPT_DIR/stop.sh"
|
||||
"$SCRIPT_DIR/start.sh"
|
||||
"$SCRIPT_DIR/stop.sh" "$@"
|
||||
"$SCRIPT_DIR/start.sh" "$@"
|
||||
+14
-3
@@ -6,12 +6,13 @@ LOG_DIR="$SCRIPT_DIR/logs"
|
||||
LOG_FILE="$LOG_DIR/restore.log"
|
||||
WORK_DIR="$(mktemp -d /tmp/inventarsystem-restore-XXXXXX)"
|
||||
|
||||
DB_NAME="${INVENTAR_MONGODB_DB:-Inventarsystem}"
|
||||
DB_NAME="${INVENTAR_MONGODB_DB:-inventar_default}"
|
||||
SOURCE_PATH=""
|
||||
BACKUP_DATE=""
|
||||
DROP_DATABASE=false
|
||||
RESTART_SERVICES=false
|
||||
STAGED_PATH=""
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||
|
||||
BACKUP_ROOT_LOCAL="$SCRIPT_DIR/backups"
|
||||
BACKUP_ROOT_SYSTEM="/var/backups"
|
||||
@@ -56,6 +57,8 @@ Options:
|
||||
- latest: newest from local/system backup roots
|
||||
--drop-database Drop target DB before import (recommended for full restore)
|
||||
--restart-services Restart stack after restore
|
||||
--multitenant Use docker-compose-multitenant.yml (default)
|
||||
--singletenant Use docker-compose.yml
|
||||
--list List detected backup candidates
|
||||
--help Show this help
|
||||
|
||||
@@ -68,12 +71,12 @@ EOF
|
||||
|
||||
setup_compose() {
|
||||
if docker compose version >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
||||
DOCKER_COMPOSE=(docker compose)
|
||||
DOCKER_COMPOSE=(docker compose -f "$SCRIPT_DIR/$COMPOSE_FILE")
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -n "$SUDO" ] && $SUDO docker compose version >/dev/null 2>&1 && $SUDO docker info >/dev/null 2>&1; then
|
||||
DOCKER_COMPOSE=($SUDO docker compose)
|
||||
DOCKER_COMPOSE=($SUDO docker compose -f "$SCRIPT_DIR/$COMPOSE_FILE")
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -459,6 +462,14 @@ parse_args() {
|
||||
RESTART_SERVICES=true
|
||||
shift
|
||||
;;
|
||||
--multitenant)
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||
shift
|
||||
;;
|
||||
--singletenant)
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
shift
|
||||
;;
|
||||
--list)
|
||||
list_backups
|
||||
exit 0
|
||||
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
# Wrapper to run tenant management fully containerized via docker-compose
|
||||
docker compose -f docker-compose-multitenant.yml --profile tools run --rm tenant-manager "$@"
|
||||
@@ -7,6 +7,7 @@ cd "$SCRIPT_DIR"
|
||||
ENV_FILE="$SCRIPT_DIR/.docker-build.env"
|
||||
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
|
||||
DIST_DIR="$SCRIPT_DIR/dist"
|
||||
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
|
||||
|
||||
SUDO=""
|
||||
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
|
||||
@@ -14,10 +15,13 @@ if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
|
||||
fi
|
||||
|
||||
NUITKA_BUILD_VALUE="0"
|
||||
HTTP_PORT_VALUE="80"
|
||||
HTTPS_PORT_VALUE="443"
|
||||
HTTP_PORT_VALUE="8001"
|
||||
HTTPS_PORT_VALUE="8443"
|
||||
CRON_SETUP_VALUE="${INVENTAR_SETUP_CRON:-1}"
|
||||
APP_IMAGE_VALUE="${INVENTAR_APP_IMAGE:-$APP_IMAGE_REPO:latest}"
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||
COMPOSE_PROFILES_VALUE=""
|
||||
MIN_DOCKER_FREE_MB="${INVENTAR_MIN_DOCKER_FREE_MB:-1024}"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
@@ -26,6 +30,8 @@ Usage: $0 [options]
|
||||
Options:
|
||||
--no-cron Do not create or update cron jobs
|
||||
--with-cron Create/update cron jobs (default)
|
||||
--multitenant Use the multi-tenant architecture deployment
|
||||
--singletenant Use the legacy single-tenant compose deployment
|
||||
-h, --help Show this help message
|
||||
EOF
|
||||
}
|
||||
@@ -41,6 +47,14 @@ parse_args() {
|
||||
CRON_SETUP_VALUE="1"
|
||||
shift
|
||||
;;
|
||||
--multitenant)
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||
shift
|
||||
;;
|
||||
--singletenant)
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
@@ -427,6 +441,16 @@ resolve_app_image() {
|
||||
fi
|
||||
}
|
||||
|
||||
configure_cloudflared_profile() {
|
||||
if [ -f /etc/cloudflared/credentials.json ]; then
|
||||
COMPOSE_PROFILES_VALUE="cloudflare"
|
||||
echo "Cloudflared tunnel: enabled (homeserver)"
|
||||
else
|
||||
COMPOSE_PROFILES_VALUE=""
|
||||
echo "Cloudflared tunnel: disabled (missing /etc/cloudflared/credentials.json)"
|
||||
fi
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local port="$1"
|
||||
|
||||
@@ -450,7 +474,7 @@ stack_owns_host_port() {
|
||||
local container_port="$2"
|
||||
local mapped_port
|
||||
|
||||
mapped_port="$(docker compose --env-file "$ENV_FILE" port nginx "$container_port" 2>/dev/null | tail -n1 || true)"
|
||||
mapped_port="$(docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" port nginx "$container_port" 2>/dev/null | tail -n1 || true)"
|
||||
if [ -z "$mapped_port" ]; then
|
||||
return 1
|
||||
fi
|
||||
@@ -493,20 +517,20 @@ configure_host_ports() {
|
||||
requested_http="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
|
||||
fi
|
||||
if [ -z "$requested_http" ]; then
|
||||
requested_http="80"
|
||||
requested_http="8001"
|
||||
fi
|
||||
|
||||
if stack_owns_host_port "$requested_http" "80"; then
|
||||
HTTP_PORT_VALUE="$requested_http"
|
||||
elif port_in_use "$requested_http"; then
|
||||
stop_host_nginx_services || true
|
||||
|
||||
|
||||
if ! port_in_use "$requested_http"; then
|
||||
HTTP_PORT_VALUE="$requested_http"
|
||||
echo "Freed HTTP port $requested_http by stopping host nginx service"
|
||||
else
|
||||
HTTP_PORT_VALUE="$(find_free_port 8080)"
|
||||
echo "HTTP port 80 is in use. Using fallback HTTP port: $HTTP_PORT_VALUE"
|
||||
echo "HTTP port is in use. Using fallback HTTP port: $HTTP_PORT_VALUE"
|
||||
fi
|
||||
else
|
||||
HTTP_PORT_VALUE="$requested_http"
|
||||
@@ -518,13 +542,13 @@ configure_host_ports() {
|
||||
fi
|
||||
|
||||
if [ -z "$requested_https" ]; then
|
||||
requested_https="443"
|
||||
requested_https="8443"
|
||||
fi
|
||||
|
||||
if stack_owns_host_port "$requested_https" "443"; then
|
||||
HTTPS_PORT_VALUE="$requested_https"
|
||||
elif port_in_use "$requested_https"; then
|
||||
stop_host_nginx_services || true
|
||||
|
||||
|
||||
if ! port_in_use "$requested_https"; then
|
||||
HTTPS_PORT_VALUE="$requested_https"
|
||||
@@ -533,12 +557,43 @@ configure_host_ports() {
|
||||
fi
|
||||
|
||||
HTTPS_PORT_VALUE="$(find_free_port 8443)"
|
||||
echo "HTTPS port 443 is in use. Using fallback HTTPS port: $HTTPS_PORT_VALUE"
|
||||
echo "HTTPS port is in use. Using fallback HTTPS port: $HTTPS_PORT_VALUE"
|
||||
else
|
||||
HTTPS_PORT_VALUE="$requested_https"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_min_docker_disk_space() {
|
||||
local docker_root available_kb available_mb
|
||||
|
||||
if ! command -v df >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
docker_root="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)"
|
||||
if [ -z "$docker_root" ]; then
|
||||
docker_root="/var/lib/docker"
|
||||
fi
|
||||
|
||||
if [ ! -d "$docker_root" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
available_kb="$(df -Pk "$docker_root" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
|
||||
if [ -z "$available_kb" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
available_mb=$((available_kb / 1024))
|
||||
|
||||
if [ "$available_mb" -lt "$MIN_DOCKER_FREE_MB" ]; then
|
||||
echo "Error: low disk space in Docker data root ($docker_root)."
|
||||
echo "Available: ${available_mb} MB; required minimum: ${MIN_DOCKER_FREE_MB} MB"
|
||||
echo "MongoDB may fail with 'No space left on device'. Free space and retry."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
write_env_file() {
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
NUITKA_BUILD=$NUITKA_BUILD_VALUE
|
||||
@@ -548,9 +603,24 @@ INVENTAR_APP_IMAGE=$APP_IMAGE_VALUE
|
||||
EOF
|
||||
}
|
||||
|
||||
write_runtime_compose_override() {
|
||||
cat > "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
|
||||
services:
|
||||
app:
|
||||
working_dir: /app/Web
|
||||
command: ["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", "-"]
|
||||
image: ${APP_IMAGE_VALUE}
|
||||
build: null
|
||||
EOF
|
||||
}
|
||||
|
||||
verify_stack_health() {
|
||||
local compose_args running_services retry_count=0
|
||||
compose_args=(--env-file "$ENV_FILE")
|
||||
compose_args=(-f "$COMPOSE_FILE")
|
||||
if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
|
||||
compose_args+=(-f "$RUNTIME_COMPOSE_OVERRIDE_FILE")
|
||||
fi
|
||||
compose_args+=(--env-file "$ENV_FILE")
|
||||
|
||||
# Try health check with optional restart on first failure
|
||||
while [[ $retry_count -lt 2 ]]; do
|
||||
@@ -600,12 +670,27 @@ ensure_runtime_config_json
|
||||
setup_scheduled_jobs
|
||||
configure_nuitka_mode
|
||||
resolve_app_image
|
||||
configure_cloudflared_profile
|
||||
configure_host_ports
|
||||
ensure_min_docker_disk_space
|
||||
ensure_app_image_loaded
|
||||
write_env_file
|
||||
write_runtime_compose_override
|
||||
|
||||
echo "Starting Inventarsystem Docker stack (app + mongodb)..."
|
||||
docker compose --env-file "$ENV_FILE" up -d --remove-orphans
|
||||
compose_up_args=(-f "$COMPOSE_FILE")
|
||||
if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
|
||||
compose_up_args+=(-f "$RUNTIME_COMPOSE_OVERRIDE_FILE")
|
||||
fi
|
||||
compose_up_args+=(--env-file "$ENV_FILE")
|
||||
if [ -n "$COMPOSE_PROFILES_VALUE" ]; then
|
||||
export COMPOSE_PROFILES="$COMPOSE_PROFILES_VALUE"
|
||||
fi
|
||||
if ! docker compose "${compose_up_args[@]}" up -d --remove-orphans; then
|
||||
echo "Docker Compose startup failed once. Waiting briefly and retrying..."
|
||||
sleep 5
|
||||
docker compose "${compose_up_args[@]}" up -d --remove-orphans
|
||||
fi
|
||||
|
||||
verify_stack_health
|
||||
|
||||
|
||||
@@ -4,12 +4,30 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--multitenant)
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||
shift
|
||||
;;
|
||||
--singletenant)
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "Error: docker command not found. Install Docker first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Stopping Inventarsystem Docker stack..."
|
||||
docker compose down
|
||||
docker compose -f "$COMPOSE_FILE" down
|
||||
|
||||
echo "Stack stopped."
|
||||
|
||||
@@ -15,6 +15,9 @@ APP_IMAGE_ASSET_PREFIX="inventarsystem-image-"
|
||||
ENV_FILE="$PROJECT_DIR/.docker-build.env"
|
||||
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
|
||||
DIST_DIR="$PROJECT_DIR/dist"
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||
MIN_ROOT_FREE_MB="${INVENTAR_MIN_ROOT_FREE_MB:-2048}"
|
||||
DIST_KEEP_COUNT="${INVENTAR_DIST_KEEP_COUNT:-2}"
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
chmod 777 "$LOG_DIR" 2>/dev/null || true
|
||||
@@ -118,6 +121,65 @@ server {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
ensure_min_root_disk_space() {
|
||||
local available_kb available_mb
|
||||
|
||||
if ! command -v df >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
available_kb="$(df -Pk "$PROJECT_DIR" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
|
||||
if [ -z "$available_kb" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
available_mb=$((available_kb / 1024))
|
||||
if [ "$available_mb" -lt "$MIN_ROOT_FREE_MB" ]; then
|
||||
log_message "ERROR: Low disk space on filesystem containing $PROJECT_DIR"
|
||||
log_message "Available: ${available_mb} MB; required minimum: ${MIN_ROOT_FREE_MB} MB"
|
||||
log_message "Free disk space and rerun update."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup_old_dist_artifacts() {
|
||||
local keep_count
|
||||
keep_count="$DIST_KEEP_COUNT"
|
||||
|
||||
if [ ! -d "$DIST_DIR" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! [[ "$keep_count" =~ ^[0-9]+$ ]]; then
|
||||
keep_count=2
|
||||
fi
|
||||
|
||||
mapfile -t archives < <(find "$DIST_DIR" -maxdepth 1 -type f \( -name 'inventarsystem-image-*.tar.gz' -o -name 'inventarsystem-image-*.tar' \) -printf '%T@ %p\n' | sort -nr | awk '{print $2}')
|
||||
if [ "${#archives[@]}" -le "$keep_count" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local index old_archive deleted=0
|
||||
for (( index=keep_count; index<${#archives[@]}; index++ )); do
|
||||
old_archive="${archives[$index]}"
|
||||
if rm -f "$old_archive"; then
|
||||
deleted=$((deleted + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$deleted" -gt 0 ]; then
|
||||
log_message "Cleaned up $deleted old dist image archive(s)"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup_docker_dangling_images() {
|
||||
if docker image prune -f >> "$LOG_FILE" 2>&1; then
|
||||
log_message "Cleaned up dangling Docker images"
|
||||
else
|
||||
log_message "WARNING: Could not prune dangling Docker images"
|
||||
fi
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name _;
|
||||
@@ -148,6 +210,41 @@ EOF
|
||||
fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 [options]
|
||||
|
||||
Options:
|
||||
--multitenant Use docker-compose-multitenant.yml (default)
|
||||
--singletenant Use docker-compose.yml
|
||||
-h, --help Show this help message
|
||||
EOF
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--multitenant)
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||
shift
|
||||
;;
|
||||
--singletenant)
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
log_message "ERROR: Unknown option: $1"
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
archive_logs() {
|
||||
log_message "Checking for monthly log archival..."
|
||||
if [ -x "$PROJECT_DIR/archive-logs.sh" ]; then
|
||||
@@ -325,18 +422,48 @@ download_and_extract_bundle() {
|
||||
cp -f "$tmp_dir/update.sh" "$PROJECT_DIR/update.sh"
|
||||
fi
|
||||
|
||||
# Neu: Multi-Tenant Ressourcen kopieren, falls vorhanden
|
||||
if [ -f "$tmp_dir/docker-compose-multitenant.yml" ]; then
|
||||
cp -f "$tmp_dir/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose-multitenant.yml"
|
||||
fi
|
||||
if [ -f "$tmp_dir/docker/nginx/multitenant.conf" ]; then
|
||||
cp -f "$tmp_dir/docker/nginx/multitenant.conf" "$PROJECT_DIR/docker/nginx/multitenant.conf"
|
||||
fi
|
||||
if [ -f "$tmp_dir/manage-tenant.sh" ]; then
|
||||
cp -f "$tmp_dir/manage-tenant.sh" "$PROJECT_DIR/manage-tenant.sh"
|
||||
fi
|
||||
if [ -f "$tmp_dir/run-tenant-cmd.sh" ]; then
|
||||
cp -f "$tmp_dir/run-tenant-cmd.sh" "$PROJECT_DIR/run-tenant-cmd.sh"
|
||||
fi
|
||||
for file in "MULTITENANT_DEPLOYMENT.md" "MULTITENANT_PYTHON_API.md"; do
|
||||
if [ -f "$tmp_dir/$file" ]; then
|
||||
cp -f "$tmp_dir/$file" "$PROJECT_DIR/$file"
|
||||
fi
|
||||
done
|
||||
|
||||
# Ensure executable permissions on all copied scripts
|
||||
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh" "$PROJECT_DIR/init-admin.sh" "$PROJECT_DIR/manage-tenant.sh" "$PROJECT_DIR/run-tenant-cmd.sh" 2>/dev/null || true 2>/dev/null || true
|
||||
chmod +x "$PROJECT_DIR"/manage-tenant.sh "$PROJECT_DIR"/run-tenant-cmd.sh 2>/dev/null || true
|
||||
|
||||
if [ ! -f "$PROJECT_DIR/config.json" ] && [ -f "$tmp_dir/config.json" ]; then
|
||||
cp -f "$tmp_dir/config.json" "$PROJECT_DIR/config.json"
|
||||
log_message "Installed default config.json from release bundle"
|
||||
fi
|
||||
|
||||
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh"
|
||||
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh" "$PROJECT_DIR/init-admin.sh" "$PROJECT_DIR/manage-tenant.sh" "$PROJECT_DIR/run-tenant-cmd.sh" 2>/dev/null || true
|
||||
}
|
||||
|
||||
deploy() {
|
||||
local tag="$1"
|
||||
local meta_file="$2"
|
||||
local app_image="${APP_IMAGE_REPO}:${tag}"
|
||||
local compose_path
|
||||
|
||||
compose_path="$PROJECT_DIR/$COMPOSE_FILE"
|
||||
if [ ! -f "$compose_path" ]; then
|
||||
log_message "ERROR: compose file not found: $compose_path"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
@@ -362,15 +489,15 @@ EOF
|
||||
fi
|
||||
fi
|
||||
|
||||
docker compose --env-file "$ENV_FILE" pull nginx mongodb >> "$LOG_FILE" 2>&1
|
||||
docker compose --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1
|
||||
docker compose -f "$compose_path" --env-file "$ENV_FILE" pull nginx mongodb >> "$LOG_FILE" 2>&1
|
||||
docker compose -f "$compose_path" --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1
|
||||
docker tag "$app_image" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true
|
||||
}
|
||||
|
||||
verify_stack_health() {
|
||||
local compose_args running_services
|
||||
local https_port
|
||||
compose_args=(--env-file "$ENV_FILE")
|
||||
compose_args=(-f "$PROJECT_DIR/$COMPOSE_FILE" --env-file "$ENV_FILE")
|
||||
https_port="$(awk -F= '/^INVENTAR_HTTPS_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ')"
|
||||
if [ -z "$https_port" ]; then
|
||||
https_port="443"
|
||||
@@ -395,6 +522,8 @@ verify_stack_health() {
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_args "$@"
|
||||
|
||||
ensure_runtime_dependencies
|
||||
ensure_tls_certificates
|
||||
ensure_nginx_config_mount_source
|
||||
@@ -404,6 +533,7 @@ main() {
|
||||
require_cmd docker
|
||||
require_cmd python3
|
||||
|
||||
ensure_min_root_disk_space
|
||||
archive_logs
|
||||
create_backup
|
||||
|
||||
@@ -491,6 +621,8 @@ main() {
|
||||
fi
|
||||
|
||||
echo "$latest_tag" > "$STATE_FILE"
|
||||
cleanup_old_dist_artifacts
|
||||
cleanup_docker_dangling_images
|
||||
log_message "Update completed successfully to release $latest_tag"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user