Compare commits

...

74 Commits

Author SHA1 Message Date
Aiirondev_dev 7d0147061a changes to the Email processing 2026-09-17 21:04:03 +02:00
Aiirondev_dev 76efa85419 changes to the sending test_mail profile 2026-09-17 20:39:10 +02:00
Aiirondev_dev a4cf9626cd changes to the send testmail 2026-09-17 20:30:48 +02:00
Aiirondev_dev fa55717f7f changes to have the test email function 2026-09-17 20:22:30 +02:00
Aiirondev_dev 4943aff944 changes to the email processing 2026-09-17 20:13:26 +02:00
Aiirondev_dev ee1683abe1 fix of faulty spelling 2026-09-17 19:59:35 +02:00
Aiirondev_dev 320acb5256 changes to the email sending 2026-09-17 19:36:48 +02:00
Aiirondev_dev 7b2a6d7bc1 test mail sender 2026-09-17 19:28:24 +02:00
Aiirondev_dev 027cef257d changes to the client building for the email requests 2026-09-17 17:42:00 +02:00
Aiirondev_dev cbacda34ca temporary removal of the tutorial Page 2026-09-17 16:46:31 +02:00
Aiirondev_dev cbc805919a changes to the Email behaivior 2026-09-16 17:45:16 +02:00
Aiirondev 31e8b7ccb4 revert 92c86334e9
revert Refactor email configuration handling and improve logging for email delivery
2026-09-16 15:37:25 +00:00
Aiirondev 5d908b6142 revert 92c86334e9
revert Refactor email configuration handling and improve logging for email delivery
2026-09-16 15:37:12 +00:00
Aiirondev aa9eb4996a revert eba20b7e2e
revert Fixes for the Secret deployment
2026-09-16 15:36:35 +00:00
Aiirondev 43f963d0d3 revert 7ab2899e92
revert changes to the Secret changes
2026-09-16 15:36:10 +00:00
Aiirondev 6589b8d275 revert 8673c0f05c
revert secrets processibg chanbged
2026-09-16 15:35:55 +00:00
Aiirondev 612ce2e6e6 revert d49ec9fd34
revert changes to the env processing
2026-09-16 15:35:36 +00:00
Aiirondev 210483d250 revert 6b3c35f895
revert changes tp implement the env file
2026-09-16 15:35:21 +00:00
Aiirondev 943b48ce16 revert 1dbe5709f5
revert changes to some settings
2026-09-16 15:35:12 +00:00
Aiirondev 476dfee0ff revert d002c5f9da
revert changes
2026-09-16 15:35:04 +00:00
Aiirondev_dev d002c5f9da changes 2026-09-16 17:14:37 +02:00
Aiirondev_dev 1dbe5709f5 changes to some settings 2026-09-16 15:43:17 +02:00
Aiirondev_dev 6b3c35f895 changes tp implement the env file 2026-09-16 15:27:07 +02:00
Aiirondev_dev d49ec9fd34 changes to the env processing 2026-09-16 15:16:31 +02:00
Aiirondev_dev 8673c0f05c secrets processibg chanbged 2026-09-16 15:02:00 +02:00
Aiirondev_dev 7ab2899e92 changes to the Secret changes 2026-09-16 14:36:04 +02:00
Aiirondev_dev eba20b7e2e Fixes for the Secret deployment 2026-09-16 14:16:13 +02:00
Aiirondev_dev 92c86334e9 Refactor email configuration handling and improve logging for email delivery
Release Inventarsystem / release-docker (push) Successful in 2m25s
2026-09-15 22:48:26 +02:00
Aiirondev_dev 98b1e39abb Improve modal scrolling behavior and height constraints for better usability
Release Inventarsystem / release-docker (push) Successful in 2m27s
2026-09-15 21:45:34 +02:00
Aiirondev_dev e9aea7e039 implementation of the Email add on
Release Inventarsystem / release-docker (push) Successful in 2m24s
2026-09-15 20:53:18 +02:00
Aiirondev_dev 00d45a9d1b Enhance booking user decryption and improve item modal styling
Release Inventarsystem / release-docker (push) Successful in 2m25s
2026-09-15 20:39:06 +02:00
Aiirondev_dev 9ad81d9b6d some more improvements for the appointment borrowing function
Release Inventarsystem / release-docker (push) Successful in 2m30s
2026-09-15 20:20:52 +02:00
Aiirondev_dev a8f3907f34 Fix of some timezone problems
Release Inventarsystem / release-docker (push) Successful in 3m18s
2026-09-15 10:51:00 +02:00
Aiirondev_dev 6311e7710a changes to the deisgn
Release Inventarsystem / release-docker (push) Successful in 3m28s
2026-09-14 11:02:11 +02:00
Aiirondev_dev e71567db11 changes toi the darkmode style to have iot be a better edesign
Release Inventarsystem / release-docker (push) Successful in 2m26s
2026-09-14 10:54:19 +02:00
Aiirondev_dev 2c6043399e changes to the style
Release Inventarsystem / release-docker (push) Successful in 2m27s
2026-09-14 10:24:45 +02:00
Aiirondev_dev acfb633cda changes to the Scanning processing
Release Inventarsystem / release-docker (push) Successful in 2m27s
2026-09-14 09:40:59 +02:00
Aiirondev_dev 23dfb7d719 changes to the authentification
Release Inventarsystem / release-docker (push) Successful in 2m27s
2026-09-14 09:23:09 +02:00
Aiirondev_dev 6b9cf8a024 changes to th the authentification for the page
Release Inventarsystem / release-docker (push) Successful in 2m25s
2026-09-14 09:13:43 +02:00
Aiirondev_dev 69bb02b7dc changes to the fuplicate import
Release Inventarsystem / release-docker (push) Successful in 13m24s
2026-09-14 09:03:02 +02:00
Aiirondev_dev 6a3a5d6373 Changes to the library scanning function
Release Inventarsystem / release-docker (push) Successful in 3m11s
2026-09-10 19:42:32 +02:00
Aiirondev_dev 09efedad69 changes to the Mahnungssystem
Release Inventarsystem / release-docker (push) Successful in 9m58s
2026-09-09 20:36:08 +02:00
Aiirondev_dev b05d238a60 changes to the Schüler id getting
Release Inventarsystem / release-docker (push) Successful in 2m18s
2026-09-09 20:09:44 +02:00
Aiirondev_dev 63a7b64150 change to the Hinweis that is shown
Release Inventarsystem / release-docker (push) Successful in 2m19s
2026-09-09 19:02:19 +02:00
Aiirondev_dev d5f4558b70 fixes to the physical scanner functionality
Release Inventarsystem / release-docker (push) Successful in 3m15s
2026-09-09 18:50:14 +02:00
Aiirondev_dev 0e85337fc5 changes to the scanning functrionality
Release Inventarsystem / release-docker (push) Successful in 2m22s
2026-09-08 18:36:34 +02:00
Aiirondev_dev 0114fec928 changes to the physical scanner processing
Release Inventarsystem / release-docker (push) Successful in 12m48s
2026-09-08 18:19:40 +02:00
Aiirondev_dev b16ad56b98 New password option fo the tenant adding and heatlth check
Release Inventarsystem / release-docker (push) Successful in 3m16s
2026-08-27 11:31:07 +02:00
Aiirondev_dev d2c21bc519 New password option fo the tenant adding and heatlth check
Release Inventarsystem / release-docker (push) Successful in 3m9s
2026-08-26 22:55:56 +02:00
Aiirondev_dev 4461644cb6 New password option fo the tenant adding
Release Inventarsystem / release-docker (push) Successful in 3m22s
2026-08-25 19:47:29 +02:00
Aiirondev_dev 73db5c5fe0 changes to the removal process
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-25 00:07:06 +02:00
Aiirondev_dev d609775eb6 Implementation of the switch between Anzahl oder Code
Release Inventarsystem / release-docker (push) Successful in 3m7s
2026-08-24 18:24:32 +02:00
Aiirondev_dev c1a6a145dc The School year rollover button is placed in the school settings.
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-24 18:00:35 +02:00
Aiirondev_dev 1091854797 change of the manage_filters.html design and removal of irelevant csv info modal
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-24 17:27:49 +02:00
Aiirondev_dev a5954ce8cb Correction of the filter naming in the upload_admin.html and edit_library.html
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-24 17:16:59 +02:00
Aiirondev_dev 06318d0d2d Correction of the filter naming in the upload_admin.html and edit_library.html
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-24 17:16:15 +02:00
Aiirondev_dev 5ed3c906dd Correction of the implementation of the Filter_names in the manage_filters.html.
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-24 16:53:40 +02:00
Aiirondev_dev 8ce74d3e62 Correction of the implementation of the Filter_names in the manage_filters.html.
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-24 16:42:15 +02:00
Aiirondev_dev ee25c46ff3 Correction of the table library view
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-24 16:15:47 +02:00
Aiirondev_dev f7fe2fcc11 Correction of an Miss placed Label
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-24 16:13:04 +02:00
Aiirondev_dev 4449007a15 Default Filternames Changes for the main_admin.html
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-24 16:08:24 +02:00
Aiirondev_dev 6770d42f99 Default Filternames Changed
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-24 16:00:58 +02:00
Aiirondev_dev 333f9fc89a New Modal Overlay
Release Inventarsystem / release-docker (push) Successful in 2m18s
2026-08-24 15:45:08 +02:00
Aiirondev_dev a286236834 New Modal Overlay
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-24 15:41:33 +02:00
Aiirondev_dev 84cc8de833 Reverse of faulty changes
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-24 14:52:50 +02:00
Aiirondev_dev 4b3c7e646f Replaced the Browser side pop ups with custom ones from the site itsself
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-24 14:45:21 +02:00
Aiirondev_dev f9b013508c Change of the Schaden melden button to be correctly considert a button
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-24 14:12:28 +02:00
Aiirondev_dev 2a6c40eb5a Fix for the CRSF Token implementation callability
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-24 12:35:16 +02:00
Aiirondev_dev 432d6da798 Fix for the CRSF Token implementation
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-24 12:12:55 +02:00
Aiirondev_dev 2cdf0d2169 implementation of the export for users
Release Inventarsystem / release-docker (push) Successful in 3m8s
2026-08-24 11:56:03 +02:00
Aiirondev_dev 9a4d10def7 implementation of the Email add on check
Release Inventarsystem / release-docker (push) Successful in 2m19s
2026-08-23 22:14:54 +02:00
Aiirondev_dev 10ae98245c Make the Mahnungen accueally dependent on the student_ausleih_dauer
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-23 21:59:16 +02:00
Aiirondev_dev 359a8f8ab1 Make the Mahnungen accueally dependent on the student_ausleih_dauer
Release Inventarsystem / release-docker (push) Successful in 2m19s
2026-08-23 21:45:02 +02:00
Aiirondev_dev 3171455f55 Fix of a logik error
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-23 21:14:04 +02:00
29 changed files with 2984 additions and 672 deletions
+386 -185
View File
@@ -17,7 +17,6 @@ Features:
- Booking and reservation of items - Booking and reservation of items
""" """
from random import random from random import random
from flask import Flask, render_template, request, redirect, url_for, session, flash, send_from_directory, get_flashed_messages, jsonify, Response, make_response, send_file, abort from flask import Flask, render_template, request, redirect, url_for, session, flash, send_from_directory, get_flashed_messages, jsonify, Response, make_response, send_file, abort
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
@@ -49,7 +48,7 @@ import Web.modules.log.audit_log as al
import push_notifications as pn import push_notifications as pn
import Web.modules.inventarsystem.pdf_export as pdf_export import Web.modules.inventarsystem.pdf_export as pdf_export
import Web.modules.inventarsystem.excel_export as excel_export import Web.modules.inventarsystem.excel_export as excel_export
from Web.modules.emailservice.email import send from Web.modules.emailservice.email import send, send_pdf
import datetime import datetime
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from bson.objectid import ObjectId, InvalidId from bson.objectid import ObjectId, InvalidId
@@ -228,7 +227,6 @@ def rollover_student_card_classes(dry_run=False, *, max_class=None, graduate_lab
client.close() client.close()
# Admin route to trigger rollover manually
@app.route('/admin/trigger_school_year_rollover', methods=['POST']) @app.route('/admin/trigger_school_year_rollover', methods=['POST'])
def admin_trigger_school_year_rollover(): def admin_trigger_school_year_rollover():
if 'username' not in session: if 'username' not in session:
@@ -543,7 +541,8 @@ def _enforce_module_access():
msg = { msg = {
'library': "Bibliotheks-Modul ist deaktiviert.", 'library': "Bibliotheks-Modul ist deaktiviert.",
'inventory': "Inventar-Modul ist deaktiviert.", 'inventory': "Inventar-Modul ist deaktiviert.",
'student_cards': "Schülerausweis-Modul ist deaktiviert." 'student_cards': "Schülerausweis-Modul ist deaktiviert.",
'terminplaner': "Termin-Modul ist deaktiviert."
}.get(name, f"{name.capitalize()}-Modul ist deaktiviert.") }.get(name, f"{name.capitalize()}-Modul ist deaktiviert.")
if request.path.startswith('/api/') or request.is_json: if request.path.startswith('/api/') or request.is_json:
@@ -555,6 +554,8 @@ def _enforce_module_access():
return redirect(url_for('home_admin')) return redirect(url_for('home_admin'))
elif name != 'library' and cfg.MODULES.is_enabled('library'): elif name != 'library' and cfg.MODULES.is_enabled('library'):
return redirect('/library') return redirect('/library')
elif name != 'terminplaner' and cfg.MODULES.is_enabled('terminplaner'):
return redirect('/terminplaner')
return redirect(url_for('my_borrowed_items')) return redirect(url_for('my_borrowed_items'))
@@ -707,7 +708,7 @@ def _action_access_allowed(permissions, action_key):
def _permission_denied_fallback_endpoint(permissions, current_endpoint=None): def _permission_denied_fallback_endpoint(permissions, current_endpoint=None):
username = session.get('username') username = session.get('username')
for candidate in ('home_admin', 'my_borrowed_items', 'tutorial_page', 'notifications_view', 'impressum'): for candidate in ('home_admin', 'library_view', 'terminplaner', 'my_borrowed_items', 'tutorial_page', 'notifications_view', 'impressum'):
if current_endpoint and candidate == current_endpoint: if current_endpoint and candidate == current_endpoint:
continue continue
if _page_access_allowed(permissions, candidate): if _page_access_allowed(permissions, candidate):
@@ -1379,11 +1380,31 @@ def update_appointment_statuses():
activation_user = str(appointment.get('User') or '').strip() activation_user = str(appointment.get('User') or '').strip()
activation_item_name = str(appointment.get('Item') or 'Termin') activation_item_name = str(appointment.get('Item') or 'Termin')
# is_library_item expects an item document, not the stored item id.
item_doc_for_status = None
item_id_for_status = appointment.get('Item')
if item_id_for_status:
item_lookup = [{'_id': item_id_for_status}]
try:
item_lookup.insert(0, {'_id': ObjectId(str(item_id_for_status))})
except (InvalidId, TypeError):
pass
try:
item_doc_for_status = items_col.find_one(
{'$or': item_lookup},
{'ItemType': 1, 'is_library': 1}
)
except Exception as item_lookup_error:
app.logger.warning(
f"Could not resolve item type for appointment {appointment.get('_id')}: {item_lookup_error}"
)
item_is_library = it.is_library_item(item_doc_for_status)
# Aktuellen Status bestimmen # Aktuellen Status bestimmen
new_status = au.get_current_status(appointment, log_changes=True, user='scheduler') new_status = au.get_current_status(appointment, log_changes=True, user='scheduler')
# Wenn sich der Status geändert hat, aktualisiere in der Datenbank # Wenn sich der Status geändert hat, aktualisiere in der Datenbank
if new_status != old_status and not it.is_library_item(appointment.get('Item')): if new_status != old_status and not item_is_library:
extra_fields = {} extra_fields = {}
# --- Conflict resolver: planned → active transition --- # --- Conflict resolver: planned → active transition ---
@@ -1481,15 +1502,22 @@ def update_appointment_statuses():
# ----------------------------------------------------------------- # -----------------------------------------------------------------
# Mahnlauf für Bibliotheksartikel (Prüfung auf Überfälligkeit) # Mahnlauf für Bibliotheksartikel (Prüfung auf Überfälligkeit)
# ----------------------------------------------------------------- # -----------------------------------------------------------------
elif it.is_library_item(appointment.get('Item')): elif item_is_library:
appt = appointment # Verwende das aktuelle Dokument aus der Schleife appt = appointment # Verwende das aktuelle Dokument aus der Schleife
if appt.get('Status') != 'active': if appt.get('Status') != 'active':
continue continue
due_date_obj = appt.get('DueDate') due_date_obj = appt.get('DueDate') or appt.get('End')
if not due_date_obj: if not due_date_obj:
continue continue
# Backfill the deadline for loans created before DueDate was stored.
if not appt.get('DueDate') and appt.get('End'):
ausleihungen.update_one(
{'_id': appt['_id'], 'DueDate': {'$exists': False}},
{'$set': {'DueDate': due_date_obj}}
)
due_date_naive = due_date_obj.replace(tzinfo=None) if due_date_obj.tzinfo else due_date_obj due_date_naive = due_date_obj.replace(tzinfo=None) if due_date_obj.tzinfo else due_date_obj
days_overdue = (current_time_naive - due_date_naive).days days_overdue = (current_time_naive - due_date_naive).days
@@ -1516,6 +1544,7 @@ def update_appointment_statuses():
student_card = _decrypt_student_card_doc(card) if '_decrypt_student_card_doc' in globals() else card student_card = _decrypt_student_card_doc(card) if '_decrypt_student_card_doc' in globals() else card
student_name = student_card.get('SchülerName', target_ausweis_id) student_name = student_card.get('SchülerName', target_ausweis_id)
student_class = student_card.get('Klasse', '—') student_class = student_card.get('Klasse', '—')
student_ausleih_dauer = student_card.get('StandardAusleihdauer', 14)
# Gegenstandsdetails laden # Gegenstandsdetails laden
item_name = "Unbekannter Artikel" item_name = "Unbekannter Artikel"
@@ -1530,8 +1559,8 @@ def update_appointment_statuses():
except Exception: except Exception:
pass pass
# STUFE 2: >= 28 Tage überfällig -> Ausweis sperren, Stufe 2 setzen & Admins benachrichtigen # STUFE 2: >= 2 * student_ausleih_dauer Tage überfällig -> Ausweis sperren, Stufe 2 setzen & Admins benachrichtigen
if days_overdue >= 28 and mahnstufe < 2: if days_overdue >= (int(student_ausleih_dauer) * 2) and mahnstufe < 2:
ausleihungen.update_one( ausleihungen.update_one(
{'_id': appt['_id']}, {'_id': appt['_id']},
{'$set': {'Mahnstufe': 2, 'LastUpdated': current_time}} {'$set': {'Mahnstufe': 2, 'LastUpdated': current_time}}
@@ -1563,17 +1592,10 @@ def update_appointment_statuses():
except Exception as n_err: except Exception as n_err:
app.logger.warning(f"Fehler beim Erstellen der Admin-Notif (Stufe 2): {n_err}") app.logger.warning(f"Fehler beim Erstellen der Admin-Notif (Stufe 2): {n_err}")
# 2. Web-Push Notification für Admins
if 'create_return_reminders' in globals():
try:
create_return_reminders(title=title, body=body, url=target_url)
except Exception as p_err:
app.logger.error(f"Fehler beim Senden der Admin-Push (Stufe 2): {p_err}")
app.logger.warning(f"Mahnstufe 2 & Ausweis-Sperre für Schülerausweis '{student_name}' ({target_ausweis_id}) gesetzt.") app.logger.warning(f"Mahnstufe 2 & Ausweis-Sperre für Schülerausweis '{student_name}' ({target_ausweis_id}) gesetzt.")
# STUFE 1: >= 14 Tage überfällig -> Stufe 1 setzen & Admins benachrichtigen # STUFE 1: >= 14 Tage überfällig -> Stufe 1 setzen & Admins benachrichtigen
elif days_overdue >= 14 and mahnstufe == 0: elif days_overdue >= int(student_ausleih_dauer) and mahnstufe == 0:
ausleihungen.update_one( ausleihungen.update_one(
{'_id': appt['_id']}, {'_id': appt['_id']},
{'$set': {'Mahnstufe': 1, 'LastUpdated': current_time}} {'$set': {'Mahnstufe': 1, 'LastUpdated': current_time}}
@@ -1594,13 +1616,6 @@ def update_appointment_statuses():
except Exception as n_err: except Exception as n_err:
app.logger.warning(f"Fehler beim Erstellen der Admin-Notif (Stufe 1): {n_err}") app.logger.warning(f"Fehler beim Erstellen der Admin-Notif (Stufe 1): {n_err}")
# 2. Web-Push Notification für Admins
if 'create_return_reminders' in globals():
try:
create_return_reminders(title=title, body=body, url=target_url)
except Exception as p_err:
app.logger.error(f"Fehler beim Senden der Admin-Push (Stufe 1): {p_err}")
app.logger.info(f"Mahnstufe 1 für Schülerausweis '{student_name}' ({target_ausweis_id}) gesetzt.") app.logger.info(f"Mahnstufe 1 für Schülerausweis '{student_name}' ({target_ausweis_id}) gesetzt.")
if updated_count > 0: if updated_count > 0:
@@ -2173,7 +2188,7 @@ def _upload_student_cards_excel():
current_permissions = us.get_effective_permissions(session['username']) current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['actions'].get('can_manage_user', False): if not current_permissions['actions'].get('can_manage_users', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error') flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
return redirect(url_for('library_view')) return redirect(url_for('library_view'))
@@ -3037,42 +3052,6 @@ def optimized_image(filename):
app.logger.error(f"Error serving optimized image {filename}: {str(e)}") app.logger.error(f"Error serving optimized image {filename}: {str(e)}")
return Response("Optimized image not found", status=404) return Response("Optimized image not found", status=404)
# @app.route('/QRCodes/<filename>')
# def qrcode_file(filename):
# """
# Serve QR code files from the QRCodes directory.
#
# Args:
# filename (str): Name of the QR code file to serve
#
# Returns:
# flask.Response: The requested QR code file or placeholder image if not found
# """
# try:
# # Check production path first
# prod_path = "/var/Inventarsystem/Web/QRCodes"
# dev_path = app.config['QR_CODE_FOLDER']
# if os.path.exists(os.path.join(prod_path, filename)):
# return send_from_directory(prod_path, filename)
# if os.path.exists(os.path.join(dev_path, filename)):
# return send_from_directory(dev_path, filename)
#
# # Use a placeholder image if file not found - first try SVG, then PNG
# svg_placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.svg')
# png_placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.png')
#
# if os.path.exists(svg_placeholder_path):
# return send_from_directory(app.static_folder, 'img/no-image.svg')
# elif os.path.exists(png_placeholder_path):
# return send_from_directory(app.static_folder, 'img/no-image.png')
# else:
# return send_from_directory(app.static_folder, 'favicon.ico')
# except Exception as e:
# print(f"Error serving QR code {filename}: {str(e)}")
# return Response("QR code not found", status=404)
@app.route('/<path:filename>') @app.route('/<path:filename>')
def catch_all_files(filename): def catch_all_files(filename):
""" """
@@ -3135,32 +3114,6 @@ def test_connection():
""" """
return {'status': 'success', 'message': 'Connection successful', 'status_code': 200} return {'status': 'success', 'message': 'Connection successful', 'status_code': 200}
""" if sucess in deployment the funktion can be removed
@app.route('/user_status')
def user_status():
API endpoint to get the current user's status (username, admin status).
Used by JavaScript in templates to personalize the UI.
Returns:
JSON: User status information or error if not authenticated
if 'username' in session:
is_admin = us.check_admin(session['username'])
return jsonify({
'authenticated': True,
'username': session['username'],
'is_admin': is_admin
})
else:
return jsonify({
'authenticated': False,
'error': 'Not logged in'
}), 401
"""
##################################################### changes to be made to account for the new account permison managment system ##############################
@app.route('/') @app.route('/')
def home(): def home():
@@ -3202,6 +3155,8 @@ def home_admin():
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('library_view')) return redirect(url_for('library_view'))
filter_names = it.get_filter_names()
return render_template( return render_template(
'main_admin.html', 'main_admin.html',
username=session['username'], username=session['username'],
@@ -3211,6 +3166,7 @@ def home_admin():
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS, student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS, student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS,
school_info=_get_school_info_for_export(), school_info=_get_school_info_for_export(),
filter_names=filter_names,
open_item=request.args.get('open_item') open_item=request.args.get('open_item')
) )
@@ -3716,7 +3672,10 @@ def mahnungen_admin():
overdue_records = list(ausleihungen_col.find({ overdue_records = list(ausleihungen_col.find({
'Status': 'active', 'Status': 'active',
'DueDate': {'$lt': current_time} '$or': [
{'DueDate': {'$lt': current_time}},
{'DueDate': {'$exists': False}, 'End': {'$lt': current_time}},
]
}).sort('DueDate', 1)) }).sort('DueDate', 1))
overdue_list = [] overdue_list = []
@@ -3751,8 +3710,6 @@ def mahnungen_admin():
item_name = item_doc.get('Name', item_id) item_name = item_doc.get('Name', item_id)
item_code = item_doc.get('Code_4', '') item_code = item_doc.get('Code_4', '')
# ENTFERNT: item_name = f"{item_name} ({item_code})" - Wir übergeben es separat ans Frontend
raw_user = str(record.get('User') or '') raw_user = str(record.get('User') or '')
decrypted_user = decrypt_text(raw_user) decrypted_user = decrypt_text(raw_user)
ausweis_id = (decrypted_user if decrypted_user else raw_user).strip().upper() ausweis_id = (decrypted_user if decrypted_user else raw_user).strip().upper()
@@ -3763,7 +3720,7 @@ def mahnungen_admin():
student_email = student_card.get('email') or student_card.get('Email', '') student_email = student_card.get('email') or student_card.get('Email', '')
is_blocked = student_card.get('is_blocked', False) is_blocked = student_card.get('is_blocked', False)
due_date_obj = record.get('DueDate') due_date_obj = record.get('DueDate') or record.get('End')
if due_date_obj: if due_date_obj:
due_date_naive = due_date_obj.replace(tzinfo=None) if due_date_obj.tzinfo else due_date_obj due_date_naive = due_date_obj.replace(tzinfo=None) if due_date_obj.tzinfo else due_date_obj
days_overdue = (current_time_naive - due_date_naive).days days_overdue = (current_time_naive - due_date_naive).days
@@ -3783,14 +3740,6 @@ def mahnungen_admin():
'is_blocked': is_blocked 'is_blocked': is_blocked
}) })
return render_template(
'mahnungen_admin.html',
overdue_items=overdue_list,
# KORRIGIERT: 'overdue_items' statt 'overdue_list', damit es zum HTML passt
library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
)
except Exception as e: except Exception as e:
app.logger.error(f"Fehler beim Laden der Mahnungsverwaltung: {e}") app.logger.error(f"Fehler beim Laden der Mahnungsverwaltung: {e}")
flash('Fehler beim Laden der Mahnungsverwaltung.', 'error') flash('Fehler beim Laden der Mahnungsverwaltung.', 'error')
@@ -3799,6 +3748,14 @@ def mahnungen_admin():
if client: if client:
client.close() client.close()
return render_template(
'mahnungen_admin.html',
overdue_items=overdue_list,
library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
email_service_enabled=cfg.EMAIL_ENABLED
)
@app.route('/mahnungen_reset', methods=['POST']) @app.route('/mahnungen_reset', methods=['POST'])
def mahnungen_reset(): def mahnungen_reset():
"""Setzt die Mahnstufe einer Ausleihe zurück, verlängert die Frist und entsperrt den Schülerausweis.""" """Setzt die Mahnstufe einer Ausleihe zurück, verlängert die Frist und entsperrt den Schülerausweis."""
@@ -4319,6 +4276,7 @@ def api_library_scan_action():
- scan student card id - scan student card id
- scan media code (ISBN/Code_4) - scan media code (ISBN/Code_4)
- borrow if available, otherwise return (toggle behavior) - borrow if available, otherwise return (toggle behavior)
- action='borrow' can be used by scan modes that must never return an item
""" """
if 'username' not in session: if 'username' not in session:
return jsonify({'ok': False, 'message': 'Nicht angemeldet.'}), 401 return jsonify({'ok': False, 'message': 'Nicht angemeldet.'}), 401
@@ -4328,6 +4286,7 @@ def api_library_scan_action():
return jsonify({'ok': False, 'message': 'Schülerausweis-Modul ist deaktiviert.'}), 403 return jsonify({'ok': False, 'message': 'Schülerausweis-Modul ist deaktiviert.'}), 403
payload = request.get_json(silent=True) or {} payload = request.get_json(silent=True) or {}
requested_action = str(payload.get('action') or 'toggle').strip().lower()
student_card_id = us.normalize_student_card_id(payload.get('student_card_id') or payload.get('card_id')) student_card_id = us.normalize_student_card_id(payload.get('student_card_id') or payload.get('card_id'))
item_code_raw = str(payload.get('item_code') or payload.get('code') or '').strip() item_code_raw = str(payload.get('item_code') or payload.get('code') or '').strip()
duration_raw = str(payload.get('borrow_duration_days') or '').strip() duration_raw = str(payload.get('borrow_duration_days') or '').strip()
@@ -4400,7 +4359,13 @@ def api_library_scan_action():
due_date = now + datetime.timedelta(days=borrow_duration_days) due_date = now + datetime.timedelta(days=borrow_duration_days)
it.update_item_status(item_id, False, borrower_name) it.update_item_status(item_id, False, borrower_name)
au.add_ausleihung(item_id, borrower_name, now, due_date) au.add_ausleihung(
item_id,
borrower_name,
now,
end_date=due_date,
due_date=due_date,
)
_append_audit_event_standalone( _append_audit_event_standalone(
event_type='ausleihung_borrowed', event_type='ausleihung_borrowed',
@@ -4425,6 +4390,12 @@ def api_library_scan_action():
'message': f"{item_doc.get('Name', 'Medium')} wurde ausgeliehen." 'message': f"{item_doc.get('Name', 'Medium')} wurde ausgeliehen."
}), 200 }), 200
if requested_action == 'borrow':
return jsonify({
'ok': False,
'message': f"Medium ist bereits ausgeliehen und wurde nicht verändert.",
}), 409
# Toggle back: item is currently borrowed -> return # Toggle back: item is currently borrowed -> return
current_borrower = str(item_doc.get('User') or '').strip() current_borrower = str(item_doc.get('User') or '').strip()
current_permissions = us.get_effective_permissions(session['username']) current_permissions = us.get_effective_permissions(session['username'])
@@ -4708,12 +4679,15 @@ def upload_admin():
if not _action_access_allowed(permissions, 'can_insert'): if not _action_access_allowed(permissions, 'can_insert'):
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('login')) return redirect(url_for('login'))
filter_names = it.get_filter_names()
return render_template( return render_template(
'upload_admin.html', 'upload_admin.html',
username=session['username'], username=session['username'],
library_module_enabled=cfg.MODULES.is_enabled('library'), library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'), student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
filter_names=filter_names,
show_library_features=False, show_library_features=False,
upload_mode='item', upload_mode='item',
page_title='Artikel hochladen', page_title='Artikel hochladen',
@@ -4918,7 +4892,8 @@ def student_cards_admin():
edit_mode=edit_mode, edit_mode=edit_mode,
form_data=form_data, form_data=form_data,
available_classes=available_classes, # Enthält nun die lesbaren Klassen available_classes=available_classes, # Enthält nun die lesbaren Klassen
config=cfg.get_school_info() config=cfg.get_school_info(),
email_service_enabled=cfg.MODULES.is_enabled('mail')
) )
@@ -5195,7 +5170,6 @@ def student_card_class_barcode_download():
""" """
Download PDF with student card barcodes filtered by a specific class from dropdown. Download PDF with student card barcodes filtered by a specific class from dropdown.
""" """
from flask import request, session, redirect, url_for, send_file, flash
if 'username' not in session: if 'username' not in session:
return redirect(url_for('login')) return redirect(url_for('login'))
@@ -5380,7 +5354,7 @@ def student_card_class_barcode_download():
flash('Fehler beim PDF-Download', 'error') flash('Fehler beim PDF-Download', 'error')
return redirect(url_for('student_cards_admin')) return redirect(url_for('student_cards_admin'))
@app.route('/student_card_single_barcode_download/<card_id>', methods=['GET']) @app.route('/student_card_single_barcode_download/<card_id>', methods=['GET', 'POST'])
def student_card_single_barcode_download(card_id): def student_card_single_barcode_download(card_id):
""" """
Download PDF with single student card barcode. Download PDF with single student card barcode.
@@ -5389,7 +5363,7 @@ def student_card_single_barcode_download(card_id):
return redirect(url_for('login')) return redirect(url_for('login'))
current_permissions = us.get_effective_permissions(session['username']) current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['actions'].get('can_manage_users', False): if not current_permissions['actions'].get('can_manage_settings', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error') flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
return redirect(url_for('library_view')) return redirect(url_for('library_view'))
if not cfg.MODULES.is_enabled('student_cards'): if not cfg.MODULES.is_enabled('student_cards'):
@@ -5560,11 +5534,34 @@ def student_card_single_barcode_download(card_id):
c.save() c.save()
pdf_buffer.seek(0) pdf_buffer.seek(0)
pdf_filename = f'ausweis_{card["AusweisId"]}.pdf'
if request.method == 'POST':
recipient_email = (request.form.get('recipient_email') or '').strip()
if not recipient_email:
flash('Bitte eine Empfänger-E-Mail-Adresse eingeben oder nur PDF auswählen.', 'error')
return redirect(url_for('student_cards_admin'))
if not cfg.MODULES.is_enabled('mail'):
flash('Das E-Mail-Add-on ist deaktiviert. Der Ausweis kann weiterhin als PDF heruntergeladen werden.', 'warning')
return redirect(url_for('student_cards_admin'))
sent = send_pdf(
recipient_email,
f'Bibliotheksausweis {card["AusweisId"]}',
f'Anbei erhalten Sie den Bibliotheksausweis für {card.get("SchülerName", "")} als PDF.',
'Bibliotheksverwaltung',
pdf_buffer.getvalue(),
pdf_filename,
)
flash(
'Bibliotheksausweis wurde per E-Mail versendet.' if sent else 'Bibliotheksausweis konnte nicht per E-Mail versendet werden.',
'success' if sent else 'error',
)
return redirect(url_for('student_cards_admin'))
return send_file( return send_file(
pdf_buffer, pdf_buffer,
mimetype='application/pdf', mimetype='application/pdf',
as_attachment=True, as_attachment=True,
download_name=f'ausweis_{card["AusweisId"]}.pdf' download_name=pdf_filename
) )
except Exception as e: except Exception as e:
app.logger.error(f"Error occurred while generating PDF for card {card['AusweisId']}: {e}") app.logger.error(f"Error occurred while generating PDF for card {card['AusweisId']}: {e}")
@@ -5945,6 +5942,11 @@ def get_bookings():
result = [] result = []
for booking in bookings: for booking in bookings:
raw_booking_user = booking.get('User') or ''
try:
booking_user = decrypt_text(raw_booking_user) if raw_booking_user else ''
except Exception:
booking_user = str(raw_booking_user)
start_dt = booking.get('Start') start_dt = booking.get('Start')
if not start_dt: if not start_dt:
continue continue
@@ -5987,10 +5989,10 @@ def get_bookings():
'end': end_dt.isoformat() if isinstance(end_dt, datetime.datetime) else str(end_dt), 'end': end_dt.isoformat() if isinstance(end_dt, datetime.datetime) else str(end_dt),
'status': status, 'status': status,
'itemId': item_id, 'itemId': item_id,
'userName': str(booking.get('User') or ''), 'userName': str(booking_user),
'notes': str(booking.get('Notes') or ''), 'notes': str(booking.get('Notes') or ''),
'period': period, 'period': period,
'isCurrentUser': str(booking.get('User') or '') == username, 'isCurrentUser': str(booking_user) == username,
'itemBorrower': item_borrower, 'itemBorrower': item_borrower,
}) })
@@ -7006,12 +7008,15 @@ def item_edit(id):
current_item['IndividualCodes'] = '\n'.join(individual_codes) current_item['IndividualCodes'] = '\n'.join(individual_codes)
filter_names = it.get_filter_names()
return render_template( return render_template(
'edit_library.html', 'edit_library.html',
username=session['username'], username=session['username'],
item=current_item, item=current_item,
show_library_features=show_library_features, show_library_features=show_library_features,
library_module_enabled=library_module_active, library_module_enabled=library_module_active,
filter_names=filter_names,
page_title=f"Bearbeiten: {current_item.get('Name', '')}" page_title=f"Bearbeiten: {current_item.get('Name', '')}"
) )
@@ -7593,7 +7598,13 @@ def ausleihen(id):
for unit in selected_units: for unit in selected_units:
unit_id = str(unit.get('_id')) unit_id = str(unit.get('_id'))
it.update_item_status(unit_id, False, effective_borrower) it.update_item_status(unit_id, False, effective_borrower)
au.add_ausleihung(unit_id, effective_borrower, start_date, end_date=end_date) au.add_ausleihung(
unit_id,
effective_borrower,
start_date,
end_date=end_date,
due_date=end_date if is_library_item else None,
)
_append_audit_event_standalone( _append_audit_event_standalone(
event_type='ausleihung_returned', event_type='ausleihung_returned',
@@ -7684,7 +7695,13 @@ def ausleihen(id):
if total_exemplare <= 1: if total_exemplare <= 1:
it.update_item_status(id, False, effective_borrower) it.update_item_status(id, False, effective_borrower)
start_date = datetime.datetime.now(ZoneInfo("Europe/Berlin")) start_date = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
au.add_ausleihung(id, effective_borrower, start_date, end_date=end_date) au.add_ausleihung(
id,
effective_borrower,
start_date,
end_date=end_date,
due_date=end_date if is_library_item else None,
)
_append_audit_event_standalone( _append_audit_event_standalone(
event_type='ausleihung_returned', event_type='ausleihung_returned',
payload={ payload={
@@ -7729,10 +7746,17 @@ def ausleihen(id):
start_date = datetime.datetime.now(ZoneInfo("Europe/Berlin")) start_date = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
for exemplar in new_borrowed_exemplars: for exemplar in new_borrowed_exemplars:
exemplar_id = f"{id}_{exemplar['number']}" exemplar_id = f"{id}_{exemplar['number']}"
au.add_ausleihung(exemplar_id, effective_borrower, start_date, end_date=end_date, exemplar_data={ au.add_ausleihung(
exemplar_id,
effective_borrower,
start_date,
end_date=end_date,
due_date=end_date if is_library_item else None,
exemplar_data={
'parent_id': id, 'parent_id': id,
'exemplar_number': exemplar['number'] 'exemplar_number': exemplar['number']
}) }
)
_append_audit_event_standalone( _append_audit_event_standalone(
event_type='ausleihung_returned', event_type='ausleihung_returned',
@@ -7916,9 +7940,14 @@ def get_planned_bookings(item_id):
cursor = ausleihungen.find({'Item': item_id, 'Status': 'planned'}).sort('Start', 1) cursor = ausleihungen.find({'Item': item_id, 'Status': 'planned'}).sort('Start', 1)
bookings = [] bookings = []
for r in cursor: for r in cursor:
raw_user = r.get('User') or ''
try:
booking_user = decrypt_text(raw_user) if raw_user else ''
except Exception:
booking_user = str(raw_user)
bookings.append({ bookings.append({
'id': str(r.get('_id')), 'id': str(r.get('_id')),
'user': r.get('User', ''), 'user': booking_user,
'period': r.get('Period'), 'period': r.get('Period'),
'start': r.get('Start').isoformat() if r.get('Start') else None, 'start': r.get('Start').isoformat() if r.get('Start') else None,
'end': r.get('End').isoformat() if r.get('End') else None, 'end': r.get('End').isoformat() if r.get('End') else None,
@@ -7945,7 +7974,13 @@ def get_planned_bookings_public(item_id):
cursor = ausleihungen.find({'Item': item_id, 'Status': 'planned'}).sort('Start', 1) cursor = ausleihungen.find({'Item': item_id, 'Status': 'planned'}).sort('Start', 1)
bookings = [] bookings = []
for r in cursor: for r in cursor:
raw_user = r.get('User') or ''
try:
booking_user = decrypt_text(raw_user) if raw_user else ''
except Exception:
booking_user = str(raw_user)
bookings.append({ bookings.append({
'user': booking_user,
'period': r.get('Period'), 'period': r.get('Period'),
'start': r.get('Start').isoformat() if r.get('Start') else None, 'start': r.get('Start').isoformat() if r.get('Start') else None,
'end': r.get('End').isoformat() if r.get('End') else None 'end': r.get('End').isoformat() if r.get('End') else None
@@ -7995,8 +8030,12 @@ def check_availability():
items_col = db['items'] items_col = db['items']
# Collect potential conflicts (planned and active) for this day # Collect potential conflicts (planned and active) for this day
same_day_start = datetime.datetime.combine(booking_date.date(), datetime.time.min) same_day_start = datetime.datetime.combine(
same_day_end = datetime.datetime.combine(booking_date.date(), datetime.time.max) booking_date.date(), datetime.time.min, tzinfo=ZoneInfo("Europe/Berlin")
)
same_day_end = datetime.datetime.combine(
booking_date.date(), datetime.time.max, tzinfo=ZoneInfo("Europe/Berlin")
)
candidates = list(ausleihungen.find({ candidates = list(ausleihungen.find({
'Item': item_id, 'Item': item_id,
'Status': {'$in': ['planned', 'active']}, 'Status': {'$in': ['planned', 'active']},
@@ -8014,6 +8053,8 @@ def check_availability():
if r_start is None: if r_start is None:
r_start = same_day_start r_start = same_day_start
# Overlap check: req_start < r_end and req_end > r_start # Overlap check: req_start < r_end and req_end > r_start
r_start = au.ensure_timezone_aware(r_start)
r_end = au.ensure_timezone_aware(r_end)
if req_start < r_end and req_end > r_start: if req_start < r_end and req_end > r_start:
conflicts.append({ conflicts.append({
'id': str(r.get('_id')), 'id': str(r.get('_id')),
@@ -8057,18 +8098,20 @@ def plan_booking():
# Validate inputs # Validate inputs
if not all([item_id, start_date_str, period_start]): if not all([item_id, start_date_str, period_start]):
return {"success": False, "error": "Missing required fields"}, 400 return {"success": False, "error": "Missing required fields"}, 400
if booking_type not in {'single', 'range'}:
return {"success": False, "error": "Invalid booking type"}, 400
if not end_date_str: if not end_date_str:
end_date_str = start_date_str end_date_str = start_date_str
# Parse dates # Parse dates
try: try:
if start_date_str: if start_date_str:
start_date = datetime.datetime.fromisoformat(start_date_str) start_date = datetime.datetime.fromisoformat(start_date_str).replace(tzinfo=None)
else: else:
return {"success": False, "error": "Missing start date"}, 400 return {"success": False, "error": "Missing start date"}, 400
if end_date_str: if end_date_str:
end_date = datetime.datetime.fromisoformat(end_date_str) end_date = datetime.datetime.fromisoformat(end_date_str).replace(tzinfo=None)
else: else:
return {"success": False, "error": "Missing end date"}, 400 return {"success": False, "error": "Missing end date"}, 400
@@ -8090,16 +8133,23 @@ def plan_booking():
# Handle period range # Handle period range
periods = [] periods = []
if period_start: try:
period_start_num = int(period_start) period_start_num = int(period_start)
else: except (TypeError, ValueError):
period_start_num = 1 # Default if None return {"success": False, "error": "Invalid start period"}, 400
if not 1 <= period_start_num <= 10:
return {"success": False, "error": "Invalid start period"}, 400
# If period_end is provided, it's a range of periods # If period_end is provided, it's a range of periods
if period_end: if period_end:
period_end_num = int(period_end) try:
period_end_num = int(period_end)
except (TypeError, ValueError):
return {"success": False, "error": "Invalid end period"}, 400
# Validate period range # Validate period range
if not 1 <= period_end_num <= 10:
return {"success": False, "error": "Invalid end period"}, 400
if period_end_num < period_start_num: if period_end_num < period_start_num:
return {"success": False, "error": "End period cannot be before start period"}, 400 return {"success": False, "error": "End period cannot be before start period"}, 400
@@ -8109,51 +8159,50 @@ def plan_booking():
# Single period booking # Single period booking
periods = [period_start_num] periods = [period_start_num]
# For date range bookings, we'll process each date separately if end_date < start_date:
booking_ids = [] return {"success": False, "error": "End date cannot be before start date"}, 400
errors = [] if booking_type == 'single' and start_date.date() != end_date.date():
return {"success": False, "error": "Single bookings must use one date"}, 400
# If it's a range of days
if booking_type == 'range' and start_date != end_date: requested_slots = []
current_date = start_date current_date = start_date
while current_date <= end_date: last_date = end_date if booking_type == 'range' else start_date
# For each day in the range while current_date.date() <= last_date.date():
day_booking_ids, day_errors = process_day_bookings( for period in periods:
item_id, period_times = get_period_times(current_date, period)
current_date, if not period_times:
periods, return {"success": False, "error": f"Invalid period {period}"}, 400
notes requested_slots.append((current_date.date(), period, period_times))
) current_date += datetime.timedelta(days=1)
booking_ids.extend(day_booking_ids)
errors.extend(day_errors) # Preflight the complete request so conflicts never create partial ranges.
for index, (booking_date, period, period_times) in enumerate(requested_slots):
# Move to next day if au.check_booking_conflict(item_id, period_times['start'], period_times['end'], period):
current_date += datetime.timedelta(days=1)
else:
# Single day with multiple periods
booking_ids, errors = process_day_bookings(
item_id,
start_date,
periods,
notes
)
# Return results
if errors:
if booking_ids:
# Some succeeded, some failed
return { return {
"success": True, "success": False,
"partial": True, "error": "Booking conflict",
"booking_ids": booking_ids, "conflicts": [{"date": booking_date.isoformat(), "period": period}],
"errors": errors }, 409
} for previous_date, previous_period, _ in requested_slots[:index]:
else: if previous_date == booking_date and previous_period == period:
# All failed return {"success": False, "error": "Duplicate booking period"}, 400
return {"success": False}, 500
else: booking_ids = []
# All succeeded try:
return {"success": True, "booking_ids": booking_ids} for _, period, period_times in requested_slots:
booking_id = au.add_planned_booking(
item_id, session['username'], period_times['start'],
period_times['end'], notes, period=period
)
if not booking_id:
raise RuntimeError(f"Failed to create booking for period {period}")
booking_ids.append(str(booking_id))
except Exception:
for booking_id in booking_ids:
au.cancel_ausleihung(booking_id)
raise
return {"success": True, "booking_ids": booking_ids}
except Exception as e: except Exception as e:
import traceback import traceback
@@ -8213,7 +8262,7 @@ def add_booking():
if 'username' not in session: if 'username' not in session:
return jsonify({'success': False, 'error': 'Not logged in'}) return jsonify({'success': False, 'error': 'Not logged in'})
item_id = html.escape(request.form.get('item_id')) item_id = html.escape((request.form.get('item_id') or '').strip())
# Check if item exists and is reservable # Check if item exists and is reservable
item = it.get_item(item_id) item = it.get_item(item_id)
@@ -8228,18 +8277,36 @@ def add_booking():
period = request.form.get('period') period = request.form.get('period')
notes = request.form.get('notes', '') notes = request.form.get('notes', '')
# Parse dates as naive datetime objects # Form timestamps represent local school time.
try: try:
# Simple datetime parsing without timezone
if start_date_str: if start_date_str:
start_date = datetime.datetime.strptime(start_date_str, '%Y-%m-%d %H:%M:%S') start_date = datetime.datetime.strptime(
start_date_str, '%Y-%m-%d %H:%M:%S'
).replace(tzinfo=ZoneInfo("Europe/Berlin"))
else: else:
return jsonify({'success': False, 'error': 'Missing start date'}) return jsonify({'success': False, 'error': 'Missing start date'})
if end_date_str: if end_date_str:
end_date = datetime.datetime.strptime(end_date_str, '%Y-%m-%d %H:%M:%S') end_date = datetime.datetime.strptime(
end_date_str, '%Y-%m-%d %H:%M:%S'
).replace(tzinfo=ZoneInfo("Europe/Berlin"))
else: else:
end_date = None return jsonify({'success': False, 'error': 'Missing end date'}), 400
if end_date <= start_date:
return jsonify({'success': False, 'error': 'End date must be after start date'}), 400
period_value = None
if period not in (None, ''):
try:
period_value = int(period)
except (TypeError, ValueError):
return jsonify({'success': False, 'error': 'Invalid period'}), 400
if not 1 <= period_value <= 10:
return jsonify({'success': False, 'error': 'Invalid period'}), 400
if au.check_booking_conflict(item_id, start_date, end_date, period_value):
return jsonify({'success': False, 'error': 'Booking conflict'}), 409
# Continue with adding the booking # Continue with adding the booking
booking_id = au.add_planned_booking( booking_id = au.add_planned_booking(
@@ -8248,12 +8315,15 @@ def add_booking():
start_date=start_date, start_date=start_date,
end_date=end_date, end_date=end_date,
notes=notes, notes=notes,
period=period period=period_value
) )
if not booking_id:
return jsonify({'success': False, 'error': 'Failed to create booking'}), 500
return jsonify({'success': True, 'booking_id': str(booking_id)}) return jsonify({'success': True, 'booking_id': str(booking_id)})
except Exception as e: except Exception as e:
return jsonify({'success': False}) app.logger.error(f"Error creating booking: {e}")
return jsonify({'success': False, 'error': 'Invalid booking data'}), 400
@app.route('/cancel_booking/<id>', methods=['POST']) @app.route('/cancel_booking/<id>', methods=['POST'])
def cancel_booking(id): def cancel_booking(id):
@@ -8272,11 +8342,12 @@ def cancel_booking(id):
current_permissions = us.get_effective_permissions(session['username']) current_permissions = us.get_effective_permissions(session['username'])
# Check if user owns this booking # Check if user owns this booking
if booking.get('User') != session['username'] and not current_permissions['actions'].get('can_manage_users', False): booking_user = au.dp.decrypt_text(booking.get('User')) if booking.get('User') else ''
if booking_user != session['username'] and not current_permissions['actions'].get('can_manage_users', False):
return {"success": False, "error": "Not authorized to cancel this booking"}, 403 return {"success": False, "error": "Not authorized to cancel this booking"}, 403
# Cancel the booking # Cancel the booking
result = au.cancel_booking(id) result = au.cancel_ausleihung(id)
if result: if result:
return {"success": True} return {"success": True}
@@ -8629,6 +8700,72 @@ def register_csv():
mimetype='application/pdf' mimetype='application/pdf'
) )
@app.route('/export_users_csv', methods=['POST'])
def export_users_csv():
"""
Exports a filtered list of users to a CSV file.
Receives a JSON array of usernames from the frontend.
"""
if 'username' not in session:
flash('Ihnen ist es nicht gestattet, diese Aktion auszuführen.', 'error')
return redirect(url_for('login'))
usernames_json = request.form.get('usernames')
if not usernames_json:
flash('Keine Benutzer zum Exportieren ausgewählt.', 'error')
return redirect(url_for('user_del'))
try:
target_usernames = json.loads(usernames_json)
except json.JSONDecodeError:
flash('Ungültige Daten beim Export übermittelt.', 'error')
return redirect(url_for('user_del'))
# Fetch all users
all_users = us.get_all_users()
# Create CSV in memory
si = io.StringIO()
# Delimiter set to semicolon (standard for Excel in German locales)
cw = csv.writer(si, delimiter=';')
# Write headers
cw.writerow(['Benutzername', 'Vorname', 'Nachname', 'Administrator', 'Rechte-Preset'])
for user in all_users:
encrypted_username = user.get('Username')
if not encrypted_username:
continue
uname = decrypt_text(encrypted_username)
# Only process users that were present in the frontend's filtered view
if uname in target_usernames:
try:
permissions_payload = us.get_effective_permissions(uname)
preset = permissions_payload.get('preset', 'standard_user')
except Exception:
preset = 'standard_user'
try:
name = us.get_name(uname) or ""
last_name = us.get_last_name(uname) or ""
except Exception:
name = ""
last_name = ""
admin_status = "Ja" if user.get('Admin', False) else "Nein"
cw.writerow([uname, name, last_name, admin_status, preset])
# Prefix with BOM (Byte Order Mark) to ensure Excel reads UTF-8 (Umlauts) correctly
output = '\ufeff' + si.getvalue()
return Response(
output,
mimetype="text/csv",
headers={"Content-disposition": "attachment; filename=benutzer_export.csv"}
)
@app.route('/user_del', methods=['GET']) @app.route('/user_del', methods=['GET'])
def user_del(): def user_del():
@@ -8846,7 +8983,8 @@ def admin_borrowings():
entries=entries, entries=entries,
library_module_enabled=cfg.MODULES.is_enabled('library'), library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'), student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
mail_module_enabled=cfg.MODULES.is_enabled('mail') mail_module_enabled=cfg.MODULES.is_enabled('mail'),
email_service_enabled=cfg.MODULES.is_enabled('mail')
) )
"""-----------------------------------------------------------Audit Routes-------------------------------------------------------""" """-----------------------------------------------------------Audit Routes-------------------------------------------------------"""
@@ -8938,7 +9076,7 @@ def admin_audit_dashboard():
if client: if client:
client.close() client.close()
@app.route('/admin/audit/export/pdf/official', methods=['GET']) @app.route('/admin/audit/export/pdf/official', methods=['GET', 'POST'])
def admin_audit_export_pdf_official(): def admin_audit_export_pdf_official():
"""Export audit report as professional PDF (Official Report - full DIN 5008 compliant).""" """Export audit report as professional PDF (Official Report - full DIN 5008 compliant)."""
if 'username' not in session: if 'username' not in session:
@@ -9005,6 +9143,29 @@ def admin_audit_export_pdf_official():
export_type='official', export_type='official',
school_info=school_info school_info=school_info
) )
if request.method == 'POST':
recipient_email = (request.form.get('recipient_email') or '').strip()
if not recipient_email:
flash('Bitte eine Empfänger-E-Mail-Adresse eingeben oder nur PDF auswählen.', 'error')
return redirect(url_for('admin_audit_dashboard'))
if not cfg.MODULES.is_enabled('mail'):
flash('Das E-Mail-Add-on ist deaktiviert. Der Auditbericht kann weiterhin als PDF heruntergeladen werden.', 'warning')
return redirect(url_for('admin_audit_dashboard'))
filename = f'audit-official-report-{datetime.datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d-%H%M%S")}.pdf'
sent = send_pdf(
recipient_email,
'Amtlicher Auditbericht',
'Anbei erhalten Sie den aktuellen amtlichen Auditbericht des Inventarsystems als PDF.',
'Inventarsystem',
pdf_content,
filename,
)
flash(
'Auditbericht wurde per E-Mail versendet.' if sent else 'Auditbericht konnte nicht per E-Mail versendet werden.',
'success' if sent else 'error',
)
return redirect(url_for('admin_audit_dashboard'))
response = make_response(pdf_content) response = make_response(pdf_content)
response.headers['Content-Type'] = 'application/pdf' response.headers['Content-Type'] = 'application/pdf'
@@ -9379,11 +9540,33 @@ def admin_create_invoice(borrow_id):
) )
pdf_buffer = pdf_export._build_invoice_pdf(invoice_data) pdf_buffer = pdf_export._build_invoice_pdf(invoice_data)
pdf_filename = f'rechnung_{invoice_number}.pdf'
if request.form.get('delivery') == 'email':
recipient_email = (request.form.get('recipient_email') or '').strip()
if not recipient_email:
flash('Bitte eine Empfänger-E-Mail-Adresse eingeben oder nur PDF auswählen.', 'error')
return redirect(url_for('admin_borrowings'))
if not cfg.MODULES.is_enabled('mail'):
flash('Das E-Mail-Add-on ist deaktiviert. Die PDF-Rechnung kann weiterhin heruntergeladen werden.', 'warning')
return redirect(url_for('admin_borrowings'))
sent = send_pdf(
recipient_email,
f'Rechnung {invoice_number} - {item_name}',
f'Anbei erhalten Sie die Rechnung {invoice_number} zum Element {item_name}.',
'Bibliotheksverwaltung',
pdf_buffer.getvalue(),
pdf_filename,
)
flash(
'PDF-Rechnung wurde per E-Mail versendet.' if sent else 'PDF-Rechnung konnte nicht per E-Mail versendet werden.',
'success' if sent else 'error',
)
return redirect(url_for('admin_borrowings'))
return send_file( return send_file(
pdf_buffer, pdf_buffer,
mimetype='application/pdf', mimetype='application/pdf',
as_attachment=True, as_attachment=True,
download_name=f'rechnung_{invoice_number}.pdf' download_name=pdf_filename
) )
except Exception as e: except Exception as e:
app.logger.error(f"Error creating damage invoice for borrow {borrow_id}: {e}") app.logger.error(f"Error creating damage invoice for borrow {borrow_id}: {e}")
@@ -11213,12 +11396,14 @@ def get_period_times(booking_date, period_num):
# Create datetime objects for start and end times # Create datetime objects for start and end times
start_datetime = datetime.datetime.combine( start_datetime = datetime.datetime.combine(
booking_date.date(), booking_date.date(),
datetime.time(start_hour, start_min) datetime.time(start_hour, start_min),
tzinfo=ZoneInfo("Europe/Berlin")
) )
end_datetime = datetime.datetime.combine( end_datetime = datetime.datetime.combine(
booking_date.date(), booking_date.date(),
datetime.time(end_hour, end_min) datetime.time(end_hour, end_min),
tzinfo=ZoneInfo("Europe/Berlin")
) )
return { return {
@@ -12212,7 +12397,11 @@ def cancel_ausleihung_route(id):
# Log ausleihung details for debugging # Log ausleihung details for debugging
ausleihung_status = ausleihung.get('Status', 'unknown') ausleihung_status = ausleihung.get('Status', 'unknown')
ausleihung_user = ausleihung.get('User', 'unknown') raw_ausleihung_user = ausleihung.get('User', '')
try:
ausleihung_user = decrypt_text(raw_ausleihung_user) if raw_ausleihung_user else ''
except Exception:
ausleihung_user = str(raw_ausleihung_user or '')
print(f"Found ausleihung: ID={id}, Status={ausleihung_status}") print(f"Found ausleihung: ID={id}, Status={ausleihung_status}")
current_permissions = us.get_effective_permissions(session['username']) current_permissions = us.get_effective_permissions(session['username'])
@@ -13152,7 +13341,7 @@ def get_optimal_image_quality(img, target_size_kb=80):
@app.route('/health') @app.route('/health')
def health_check(): def health_check():
return 'OK', 200 return jsonify({"status": "healthy"}), 200
@app.route('/api/push/subscribe', methods=['POST']) @app.route('/api/push/subscribe', methods=['POST'])
@@ -13566,4 +13755,16 @@ def upload_csv_batch():
"images_processed": processed_count, "images_processed": processed_count,
"images_deduplicated": dedup_count, "images_deduplicated": dedup_count,
"images_failed": error_count "images_failed": error_count
}), 200 }), 200
@app.route('/test_email')
def test_email():
#Test endpoint to send a sample email.
#This is for development purposes only.
try:
send(email="maximiliangruendinger@gmail.com", subject="Test Email from Inventarsystem", note="This is a test email sent from the Inventarsystem application.", sender="Inventarsystem")
return "Test email sent successfully."
except Exception as e:
return f"Failed to send test email: {str(e)}", 500
+16 -6
View File
@@ -42,12 +42,12 @@ def _get_client():
return MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) return MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
# Add this helper function after imports # Add this helper function after imports
def ensure_timezone_aware(dt): def ensure_timezone_aware(dt):
"""Ensures a datetime is timezone-aware, using UTC if naive""" """Return a timezone-aware datetime, treating naive DB values as UTC."""
if dt is None: if dt is None:
return None return None
if dt.tzinfo is None: if dt.tzinfo is None:
# Treat naive datetimes as UTC # PyMongo returns BSON datetimes as naive UTC unless tz_aware is enabled.
return dt.replace(tzinfo=None) return dt.replace(tzinfo=datetime.timezone.utc)
return dt return dt
def get_current_status(ausleihung, log_changes=False, user=None): def get_current_status(ausleihung, log_changes=False, user=None):
@@ -82,8 +82,8 @@ def get_current_status(ausleihung, log_changes=False, user=None):
return 'completed' return 'completed'
current_time = datetime.datetime.now(ZoneInfo("Europe/Berlin")) current_time = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
start_time = ausleihung.get('Start') start_time = ensure_timezone_aware(ausleihung.get('Start'))
end_time = ausleihung.get('End') end_time = ensure_timezone_aware(ausleihung.get('End'))
# Wenn kein Startdatum vorhanden ist, Status auf 'planned' setzen # Wenn kein Startdatum vorhanden ist, Status auf 'planned' setzen
if not start_time: if not start_time:
@@ -353,7 +353,7 @@ def cancel_ausleihung(id):
# Mark the booking as cancelled # Mark the booking as cancelled
result = ausleihungen.update_one( result = ausleihungen.update_one(
{'_id': ObjectId(id)}, {'_id': ObjectId(id), 'Status': {'$in': ['planned', 'active']}},
{'$set': { {'$set': {
'Status': 'cancelled', 'Status': 'cancelled',
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin")) 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
@@ -367,6 +367,16 @@ def cancel_ausleihung(id):
return False return False
def get_booking(id):
"""Compatibility wrapper for the booking route."""
return get_ausleihung(id)
def cancel_booking(id):
"""Compatibility wrapper for the booking route."""
return cancel_ausleihung(id)
def remove_ausleihung(id): def remove_ausleihung(id):
""" """
Markiert einen Ausleihungsdatensatz als gelöscht (Soft-Delete). Markiert einen Ausleihungsdatensatz als gelöscht (Soft-Delete).
+3 -3
View File
@@ -911,9 +911,9 @@ def get_filter_names():
if names_doc and 'names' in names_doc: if names_doc and 'names' in names_doc:
return names_doc['names'] return names_doc['names']
return { return {
'1': 'Fach/Kategorie', '1': 'Jahrgangsstufe',
'2': 'System/Bereich', '2': 'Fachgebiet',
'3': 'Typ/Art' '3': 'Schlagwort'
} }
def set_filter_name(filter_num, name): def set_filter_name(filter_num, name):
+1 -1
View File
@@ -685,7 +685,7 @@ def student_card_exists(student_card_id):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client) db = _get_tenant_db(client)
users = db['student_cards'] users = db['student_cards']
exists = users.find_one({'SchülerName': normalized}) is not None exists = users.find_one({'AusweisId': normalized}) is not None
client.close() client.close()
return exists return exists
+44 -24
View File
@@ -1,5 +1,6 @@
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
import smtplib import smtplib
import time import time
@@ -8,27 +9,34 @@ import Web.modules.database.settings as cfg
def _build_smtp_client(): def _build_smtp_client():
smtp = smtplib.SMTP( smtp = smtplib.SMTP(
cfg.EMAIL_SMTP_HOST, "mail.invario-software.de",
cfg.EMAIL_SMTP_PORT, 587,
timeout=cfg.EMAIL_TIMEOUT_SECONDS, timeout=10,
) )
smtp.ehlo() smtp.ehlo()
if cfg.EMAIL_USE_TLS: if True:
smtp.starttls() smtp.starttls()
smtp.ehlo() smtp.ehlo()
if cfg.EMAIL_USERNAME: smtp.login("no-reply@invario-software.de", "eSpage,65,{")
smtp.login("no-reply@invario-software.de", "#,EATwIn,68" or "")
return smtp return smtp
def send(email: list | str, subject: str, note: str, sender: str) -> bool:
"""Sends the email with the link to the Clients.""" def _normalize_recipients(email: list | str) -> list[str]:
if isinstance(email, str):
email = email.replace(';', ',').split(',')
return [str(recipient).strip() for recipient in (email or []) if str(recipient).strip()]
def _send_message(email: list | str, subject: str, note: str, sender: str, attachment=None) -> bool:
"""Send a plain/HTML message, optionally with one PDF attachment."""
if not cfg.MODULES.is_enabled("mail"): if not cfg.MODULES.is_enabled("mail"):
print("Debug: Module not enabled") print("Debug: Module not enabled")
return False return False
if isinstance(email, str): recipients = _normalize_recipients(email)
email = [email] if not recipients:
return False
body_message = note body_message = note
@@ -63,25 +71,27 @@ def send(email: list | str, subject: str, note: str, sender: str) -> bool:
try: try:
smtp = _build_smtp_client() smtp = _build_smtp_client()
for i, recipient in enumerate(email): for i, recipient in enumerate(recipients):
start_time = time.time() start_time = time.time()
msg = MIMEMultipart("alternative") msg = MIMEMultipart("mixed" if attachment else "alternative")
msg["Subject"] = str(subject) msg["Subject"] = str(subject)
msg["From"] = f"{sender} <no-reply@invario-software.de>" from_address = "no-reply@invario-software.de"
msg["From"] = f"{sender} <{from_address}>"
msg["To"] = str(recipient) msg["To"] = str(recipient)
msg.attach(MIMEText(text_content, "plain")) body = MIMEMultipart("alternative") if attachment else msg
msg.attach(MIMEText(html_content, "html")) body.attach(MIMEText(text_content, "plain"))
body.attach(MIMEText(html_content, "html"))
#smtp.sendmail( if attachment:
# from_addr=cfg.EMAIL_USERNAME, msg.attach(body)
# to_addrs=[recipient], attachment_payload, filename = attachment
# msg=msg.as_string() pdf_part = MIMEApplication(attachment_payload, _subtype="pdf")
#) pdf_part.add_header("Content-Disposition", "attachment", filename=filename)
msg.attach(pdf_part)
smtp.sendmail( smtp.sendmail(
from_addr="no-reply@invario-software.de", from_addr=from_address,
to_addrs=[recipient], to_addrs=[recipient],
msg=msg.as_string() msg=msg.as_string()
) )
@@ -89,7 +99,7 @@ def send(email: list | str, subject: str, note: str, sender: str) -> bool:
elapsed_time = time.time() - start_time elapsed_time = time.time() - start_time
sleep_time = interval - elapsed_time sleep_time = interval - elapsed_time
if sleep_time > 0 and i < len(email) - 1: if sleep_time > 0 and i < len(recipients) - 1:
time.sleep(sleep_time) time.sleep(sleep_time)
return True return True
@@ -101,4 +111,14 @@ def send(email: list | str, subject: str, note: str, sender: str) -> bool:
try: try:
smtp.quit() smtp.quit()
except Exception: except Exception:
pass pass
def send(email: list | str, subject: str, note: str, sender: str) -> bool:
"""Send an HTML/plain email without an attachment."""
return _send_message(email, subject, note, sender)
def send_pdf(email: list | str, subject: str, note: str, sender: str, pdf_bytes: bytes, filename: str) -> bool:
"""Send an email with a PDF attachment."""
return _send_message(email, subject, note, sender, attachment=(pdf_bytes, filename))
+28 -3
View File
@@ -3,6 +3,7 @@ import Web.modules.terminplaner.backend_server as appointment_service
import Web.modules.database.settings as cfg import Web.modules.database.settings as cfg
import Web.modules.database.termine as termin import Web.modules.database.termine as termin
import Web.modules.database.user as us import Web.modules.database.user as us
from Web.modules.emailservice.email import send, send_pdf
from Web.modules.terminplaner.backend_server import _resolve_public_base_url from Web.modules.terminplaner.backend_server import _resolve_public_base_url
import csv import csv
import io import io
@@ -409,7 +410,7 @@ def client_slot_calendar_export(appointment_id):
return response return response
@appoint_bp.route('/export_pdf_brief/<plan_id>', methods=['GET']) @appoint_bp.route('/export_pdf_brief/<plan_id>', methods=['GET', 'POST'])
def export_pdf_brief(plan_id): def export_pdf_brief(plan_id):
# 1. Daten holen (Hier als Beispiel, passe dies auf deine Datenbank an) # 1. Daten holen (Hier als Beispiel, passe dies auf deine Datenbank an)
# terminplan = Terminplan.query.get(plan_id) # terminplan = Terminplan.query.get(plan_id)
@@ -532,12 +533,35 @@ def export_pdf_brief(plan_id):
# Buffer auf Anfang zurücksetzen # Buffer auf Anfang zurücksetzen
pdf_buffer.seek(0) pdf_buffer.seek(0)
pdf_filename = f"Einladung_{plan_daten['titel'].replace(' ', '_')}.pdf"
if request.method == 'POST':
recipient_email = (request.form.get('recipient_email') or '').strip()
if not recipient_email:
flash('Bitte eine Empfänger-E-Mail-Adresse eingeben oder nur PDF auswählen.', 'error')
return redirect(url_for('terminplaner.main', tenant=tenant_id or None))
if not cfg.MODULES.is_enabled('mail'):
flash('Das E-Mail-Add-on ist deaktiviert. Der Einladungsbrief kann weiterhin als PDF heruntergeladen werden.', 'warning')
return redirect(url_for('terminplaner.main', tenant=tenant_id or None))
sent = send_pdf(
recipient_email,
f'Einladung zur Terminbuchung: {plan_daten["titel"]}',
f'Anbei erhalten Sie den Einladungsbrief für {plan_daten["titel"]} als PDF.',
'Terminplanungssystem',
pdf_buffer.getvalue(),
pdf_filename,
)
flash(
'Einladungsbrief wurde per E-Mail versendet.' if sent else 'Einladungsbrief konnte nicht per E-Mail versendet werden.',
'success' if sent else 'error',
)
return redirect(url_for('terminplaner.main', tenant=tenant_id or None))
# An Nutzer ausliefern # An Nutzer ausliefern
return send_file( return send_file(
pdf_buffer, pdf_buffer,
mimetype='application/pdf', mimetype='application/pdf',
as_attachment=True, as_attachment=True,
download_name=f"Einladung_{plan_daten['titel'].replace(' ', '_')}.pdf" download_name=pdf_filename
) )
@appoint_bp.route('/') @appoint_bp.route('/')
@@ -557,5 +581,6 @@ def main():
current_user=current_user, current_user=current_user,
upcoming_events=upcoming_events, upcoming_events=upcoming_events,
tenant_id=tenant_id, tenant_id=tenant_id,
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan') appointment_module_enabled=cfg.MODULES.is_enabled('terminplan'),
mail_service_enabled=cfg.MODULES.is_enabled('mail')
) )
+4
View File
@@ -227,6 +227,10 @@ def send_push_notification(username, title, body, icon=None, url='/', reference=
sent_count = 0 sent_count = 0
for subscription in subscriptions: for subscription in subscriptions:
from Web.modules.emailservice.email import send, send_pdf
if cfg.MODULES.is_enabled('mail'):
#implement the sending
pass
success = _send_to_subscription( success = _send_to_subscription(
subscription, subscription,
title, title,
+108
View File
@@ -31,6 +31,9 @@
--ui-text: #f8fafc; --ui-text: #f8fafc;
--ui-text-muted: #94a3b8; --ui-text-muted: #94a3b8;
--ui-title: #ffffff; --ui-title: #ffffff;
--ui-overlay: #020617;
--ui-overlay-surface: #1e293b;
--ui-overlay-border: #64748b;
--ui-shadow-sm: 0 4px 12px rgba(0, 0, 0, 0.4), inset 0 0 0 1px rgba(255, 255, 255, 0.05); --ui-shadow-sm: 0 4px 12px rgba(0, 0, 0, 0.4), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
--ui-shadow-md: 0 10px 30px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(255, 255, 255, 0.05); --ui-shadow-md: 0 10px 30px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
@@ -39,6 +42,10 @@
--ui-btn-radius: 99px; /* Pillenform für Buttons */ --ui-btn-radius: 99px; /* Pillenform für Buttons */
} }
:root[data-theme="dark"] {
color-scheme: dark;
}
/* Safe Area Support for iPad notches, Dynamic Island, and rounded corners */ /* Safe Area Support for iPad notches, Dynamic Island, and rounded corners */
html { html {
/* Ensure viewport-fit is respected */ /* Ensure viewport-fit is respected */
@@ -1002,3 +1009,104 @@ html, body {
:root[data-theme="dark"] .favorite-item { :root[data-theme="dark"] .favorite-item {
outline: 2px solid #f59e0b !important; outline: 2px solid #f59e0b !important;
} }
/* Keep overlays and dialogs sufficiently opaque for readable content. */
:root[data-theme="dark"] .modal,
:root[data-theme="dark"] .modal-backdrop,
:root[data-theme="dark"] .ui-modal-overlay,
:root[data-theme="dark"] .item-modal,
:root[data-theme="dark"] .reset-modal-overlay,
:root[data-theme="dark"] #onboarding-overlay,
:root[data-theme="dark"] [id$="-modal"][style*="inset"],
:root[data-theme="dark"] [id$="Modal"][style*="inset"] {
background-color: var(--ui-overlay) !important;
}
:root[data-theme="dark"] .modal-content,
:root[data-theme="dark"] .ui-modal-surface,
:root[data-theme="dark"] .reset-modal-container,
:root[data-theme="dark"] #onboarding-modal,
:root[data-theme="dark"] [id$="-modal"] > div,
:root[data-theme="dark"] [id$="Modal"] > div {
background-color: var(--ui-overlay-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-overlay-border) !important;
}
/* Inline light surfaces must not become translucent or low-contrast in dark mode. */
:root[data-theme="dark"] [style*="background: white"],
:root[data-theme="dark"] [style*="background:#fff"],
:root[data-theme="dark"] [style*="background: #fff"],
:root[data-theme="dark"] [style*="background-color: white"],
:root[data-theme="dark"] [style*="background-color:#fff"],
:root[data-theme="dark"] [style*="background-color: #fff"] {
background-color: var(--ui-surface) !important;
color: var(--ui-text) !important;
}
/* Shared dark-mode surfaces for every template that extends base.html. */
:root[data-theme="dark"] .container,
:root[data-theme="dark"] .card,
:root[data-theme="dark"] .card-body,
:root[data-theme="dark"] .card-header,
:root[data-theme="dark"] .card-footer,
:root[data-theme="dark"] .content,
:root[data-theme="dark"] .form-card,
:root[data-theme="dark"] .form-container,
:root[data-theme="dark"] .upload-container,
:root[data-theme="dark"] .edit-container,
:root[data-theme="dark"] .admin-content-container,
:root[data-theme="dark"] .calendar-container,
:root[data-theme="dark"] .table-container,
:root[data-theme="dark"] .notice-card,
:root[data-theme="dark"] .settings-card,
:root[data-theme="dark"] .summary-card,
:root[data-theme="dark"] .invoice-card,
:root[data-theme="dark"] .student-card-form,
:root[data-theme="dark"] .user-management-container,
:root[data-theme="dark"] .error-container,
:root[data-theme="dark"] .library-filter-panel,
:root[data-theme="dark"] .library-items-table,
:root[data-theme="dark"] .info-box {
background-color: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .table,
:root[data-theme="dark"] .table-responsive,
:root[data-theme="dark"] .table > :not(caption) > *,
:root[data-theme="dark"] .table > :not(caption) > * > *,
:root[data-theme="dark"] .library-items-table td,
:root[data-theme="dark"] .library-items-table th {
background-color: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .table-striped > tbody > tr:nth-of-type(odd) > *,
:root[data-theme="dark"] .table-light,
:root[data-theme="dark"] .table-light > *,
:root[data-theme="dark"] .bg-white,
:root[data-theme="dark"] .bg-light,
:root[data-theme="dark"] .bg-body,
:root[data-theme="dark"] .bg-body-secondary {
background-color: var(--ui-surface-soft) !important;
color: var(--ui-text) !important;
}
:root[data-theme="dark"] .form-text,
:root[data-theme="dark"] .text-muted,
:root[data-theme="dark"] small,
:root[data-theme="dark"] .library-load-hint {
color: var(--ui-text-muted) !important;
}
/* White translucent template decorations become stable, readable surfaces. */
:root[data-theme="dark"] [style*="background: rgba(255,255,255"],
:root[data-theme="dark"] [style*="background: rgba(255, 255, 255"],
:root[data-theme="dark"] [style*="background-color: rgba(255,255,255"],
:root[data-theme="dark"] [style*="background-color: rgba(255, 255, 255"] {
background-color: var(--ui-surface-soft) !important;
color: var(--ui-text) !important;
}
+106 -9
View File
@@ -1,12 +1,103 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}Audit Dashboard - {{ APP_VERSION }}{% endblock %} {% block title %}Audit Dashboard - {{ APP_VERSION }}{% endblock %}
{% block content %} {% block content %}
<style>
.audit-panel,
.audit-stat-card,
.audit-table-panel {
border: 1px solid var(--ui-border);
border-radius: 10px;
background: var(--ui-surface);
color: var(--ui-text);
}
.audit-panel p,
.audit-stat-label,
.audit-table-panel th {
color: var(--ui-text-muted);
}
.audit-table-panel {
padding: 12px;
}
.audit-table-scroll {
max-height: 560px;
overflow: auto;
}
.audit-table-panel table {
width: 100%;
border-collapse: collapse;
}
.audit-table-panel th,
.audit-table-panel td {
border-bottom: 1px solid var(--ui-border) !important;
}
.audit-table-panel tbody tr:hover td {
background: var(--ui-bg-accent) !important;
}
.audit-mismatch-panel {
border-color: #f87171;
background: #fff7f7;
}
:root[data-theme="dark"] .audit-info-panel {
background: #082f49 !important;
border-left-color: #38bdf8 !important;
}
:root[data-theme="dark"] .audit-info-panel p {
color: #bae6fd !important;
}
:root[data-theme="dark"] .audit-panel,
:root[data-theme="dark"] .audit-stat-card,
:root[data-theme="dark"] .audit-table-panel {
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .audit-mismatch-panel {
background: #451a1a !important;
border-color: #f87171 !important;
}
:root[data-theme="dark"] .audit-mismatch-panel h3 {
color: #fecaca !important;
}
:root[data-theme="dark"] .audit-table-panel th,
:root[data-theme="dark"] .audit-table-panel td,
:root[data-theme="dark"] .audit-table-panel pre {
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
@media (max-width: 700px) {
.audit-table-panel {
padding: 8px;
}
.audit-table-scroll {
overflow-x: auto;
}
.audit-table-panel table {
min-width: 720px;
}
}
</style>
<div class="container" style="max-width:1400px; margin:0 auto; padding:18px;"> <div class="container" style="max-width:1400px; margin:0 auto; padding:18px;">
<h1>Audit Dashboard</h1> <h1>Audit Dashboard</h1>
<p>Integritätsstatus der Audit-Chain und letzte Audit-Ereignisse (max. 200 Einträge).</p> <p>Integritätsstatus der Audit-Chain und letzte Audit-Ereignisse (max. 200 Einträge).</p>
<!-- Information Box for DIN 5008 Compliance --> <!-- Information Box for DIN 5008 Compliance -->
<div style="background:#e8f4f8; border-left:4px solid #0284c7; padding:12px; margin:0 0 16px 0; border-radius:4px;"> <div class="audit-panel audit-info-panel" style="background:#e8f4f8; border-left:4px solid #0284c7; padding:12px; margin:0 0 16px 0; border-radius:4px;">
<p style="margin:0; font-size:0.95rem; color:#1e40af;"> <p style="margin:0; font-size:0.95rem; color:#1e40af;">
<strong>✓ Professionelle PDF-Exporte:</strong> Die neuen DIN 5008 konformen Berichte sind speziell für Schulträger, <strong>✓ Professionelle PDF-Exporte:</strong> Die neuen DIN 5008 konformen Berichte sind speziell für Schulträger,
Rechnungsprüfungsämter und Behörden optimiert. Sie enthalten Revisionssicherheit, Barrierefreiheit (BFSG) und Rechnungsprüfungsämter und Behörden optimiert. Sie enthalten Revisionssicherheit, Barrierefreiheit (BFSG) und
@@ -16,20 +107,26 @@
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; margin:10px 0 18px;"> <div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; margin:10px 0 18px;">
<!-- PDF Export Section --> <!-- PDF Export Section -->
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);"> <div class="audit-panel" style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);">
<h4 style="margin:0 0 10px 0; color:#1a1a1a;">📄 PDF-Export (DIN 5008 konform)</h4> <h4 style="margin:0 0 10px 0; color:#1a1a1a;">📄 PDF-Export (DIN 5008 konform)</h4>
<p style="margin:0 0 10px 0; font-size:0.9rem; color:#666;">Professionelle Berichte für Schulträger und Behörden</p> <p style="margin:0 0 10px 0; font-size:0.9rem; color:#666;">Professionelle Berichte für Schulträger und Behörden</p>
<div style="display:flex; gap:8px; flex-wrap:wrap;"> <div style="display:flex; gap:8px; flex-wrap:wrap; align-items:end;">
<a class="btn btn-primary" href="{{ url_for('admin_audit_export_pdf_official') }}" style="flex:1; text-align:center;"> <a class="btn btn-primary" href="{{ url_for('admin_audit_export_pdf_official') }}" style="flex:1; text-align:center;">
📋 Amtlicher Bericht (DIN 5008) 📋 Amtlicher Bericht (DIN 5008)
</a> </a>
{% if mail_module_enabled %}
<form method="post" action="{{ url_for('admin_audit_export_pdf_official') }}" style="display:flex; gap:8px; flex:2; flex-wrap:wrap;">
<input type="email" name="recipient_email" required placeholder="Empfänger-E-Mail" aria-label="Empfänger-E-Mail für den Auditbericht" style="flex:1; min-width:220px; padding:8px; border:1px solid #cbd5e1; border-radius:6px;">
<button class="btn btn-outline-primary" type="submit">PDF per E-Mail senden</button>
</form>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; margin:16px 0 20px;"> <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: var(--ui-surface);"> <div class="audit-stat-card" 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> <div style="font-size:0.85rem; color:#64748b;">Chain Status</div>
{% if verify_result.ok %} {% if verify_result.ok %}
<div style="font-size:1.35rem; font-weight:700; color:#166534;">OK</div> <div style="font-size:1.35rem; font-weight:700; color:#166534;">OK</div>
@@ -37,11 +134,11 @@
<div style="font-size:1.35rem; font-weight:700; color:#991b1b;">FEHLER</div> <div style="font-size:1.35rem; font-weight:700; color:#991b1b;">FEHLER</div>
{% endif %} {% endif %}
</div> </div>
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);"> <div class="audit-stat-card" 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:0.85rem; color:#64748b;">Einträge</div>
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.count }}</div> <div style="font-size:1.35rem; font-weight:700;">{{ verify_result.count }}</div>
</div> </div>
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);"> <div class="audit-stat-card" 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:0.85rem; color:#64748b;">Letzter Index</div>
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.last_chain_index }}</div> <div style="font-size:1.35rem; font-weight:700;">{{ verify_result.last_chain_index }}</div>
</div> </div>
@@ -52,7 +149,7 @@
</div> </div>
{% if verify_result.mismatches %} {% if verify_result.mismatches %}
<div style="margin-bottom:20px; border:1px solid #fecaca; background:#fff7f7; border-radius:10px; padding:12px;"> <div class="audit-table-panel audit-mismatch-panel" style="margin-bottom:20px; border:1px solid #fecaca; background:#fff7f7; border-radius:10px; padding:12px;">
<h3 style="margin-top:0; color:#991b1b;">Integritätsabweichungen</h3> <h3 style="margin-top:0; color:#991b1b;">Integritätsabweichungen</h3>
<div style="max-height:280px; overflow:auto;"> <div style="max-height:280px; overflow:auto;">
<table class="table" style="width:100%; border-collapse:collapse;"> <table class="table" style="width:100%; border-collapse:collapse;">
@@ -79,9 +176,9 @@
</div> </div>
{% endif %} {% endif %}
<div style="border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface); padding:12px;"> <div class="audit-table-panel" style="border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface); padding:12px;">
<h3 style="margin-top:0;">Letzte Audit-Ereignisse</h3> <h3 style="margin-top:0;">Letzte Audit-Ereignisse</h3>
<div style="max-height:560px; overflow:auto;"> <div class="audit-table-scroll" style="max-height:560px; overflow:auto;">
<table class="table" style="width:100%; border-collapse:collapse;"> <table class="table" style="width:100%; border-collapse:collapse;">
<thead> <thead>
<tr> <tr>
+11 -1
View File
@@ -195,6 +195,13 @@
<textarea id="damage-reason" name="damage_reason" rows="5" required style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; resize:vertical;" placeholder="Beschreiben Sie kurz den Schaden oder die Zerstörung."></textarea> <textarea id="damage-reason" name="damage_reason" rows="5" required style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; resize:vertical;" placeholder="Beschreiben Sie kurz den Schaden oder die Zerstörung."></textarea>
</div> </div>
{% if email_service_enabled %}
<div style="margin-bottom:16px;">
<label for="invoice-recipient-email" style="display:block; font-weight:700; margin-bottom:6px;">Empfänger-E-Mail für den PDF-Versand</label>
<input id="invoice-recipient-email" name="recipient_email" type="email" style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px;" placeholder="name@beispiel.de">
</div>
{% endif %}
<div style="display:flex; flex-wrap:wrap; gap:16px; align-items:center; margin-bottom:18px;"> <div style="display:flex; flex-wrap:wrap; gap:16px; align-items:center; margin-bottom:18px;">
<label style="display:flex; align-items:center; gap:8px;"> <label style="display:flex; align-items:center; gap:8px;">
<input type="checkbox" name="mark_destroyed" checked> <input type="checkbox" name="mark_destroyed" checked>
@@ -208,7 +215,10 @@
<div style="display:flex; justify-content:flex-end; gap:10px;"> <div style="display:flex; justify-content:flex-end; gap:10px;">
<button type="button" class="btn btn-secondary" onclick="closeInvoiceModal()">Abbrechen</button> <button type="button" class="btn btn-secondary" onclick="closeInvoiceModal()">Abbrechen</button>
<button type="submit" class="btn btn-danger">PDF-Rechnung erstellen</button> <button type="submit" class="btn btn-outline-danger" name="delivery" value="pdf">Nur PDF herunterladen</button>
{% if email_service_enabled %}
<button type="submit" class="btn btn-danger" name="delivery" value="email">PDF per E-Mail senden</button>
{% endif %}
</div> </div>
</form> </form>
</div> </div>
+86 -6
View File
@@ -78,15 +78,15 @@
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="filter_name_1">Name Filter 1</label> <label for="filter_name_1">Name Filter 1</label>
<input type="text" id="filter_name_1" name="filter_name_1" value="{{ filter_names.get('1', 'Fach/Kategorie') }}" placeholder="z. B. Fach/Kategorie"> <input type="text" id="filter_name_1" name="filter_name_1" value="{{ filter_names.get('1', 'Klassenstufe') }}" placeholder="z. B. Klassenstufe">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="filter_name_2">Name Filter 2</label> <label for="filter_name_2">Name Filter 2</label>
<input type="text" id="filter_name_2" name="filter_name_2" value="{{ filter_names.get('2', 'System/Bereich') }}" placeholder="z. B. System/Bereich"> <input type="text" id="filter_name_2" name="filter_name_2" value="{{ filter_names.get('2', 'Fach') }}" placeholder="z. B. Fach">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="filter_name_3">Name Filter 3</label> <label for="filter_name_3">Name Filter 3</label>
<input type="text" id="filter_name_3" name="filter_name_3" value="{{ filter_names.get('3', 'Typ/Art') }}" placeholder="z. B. Typ/Art"> <input type="text" id="filter_name_3" name="filter_name_3" value="{{ filter_names.get('3', 'Schlagwort') }}" placeholder="z. B. Schlagwort">
</div> </div>
</div> </div>
@@ -120,9 +120,64 @@
<p>Diese Angaben erscheinen künftig im amtlichen Audit-PDF und in anderen Behördenberichten.</p> <p>Diese Angaben erscheinen künftig im amtlichen Audit-PDF und in anderen Behördenberichten.</p>
</div> </div>
</div> </div>
<!-- Danger Zone: Schuljahreswechsel -->
<div class="card danger-zone" style="grid-column: 1 / -1; border-color: #fca5a5;">
<div class="card-header" style="background: #fef2f2; border-bottom: 1px solid #fca5a5;">
<h2 style="color: #991b1b;">Erweiterte Aktionen</h2>
</div>
<div class="card-body" style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px;">
<div>
<strong style="color: #7f1d1d; display: block; font-size: 1.05rem; margin-bottom: 4px;">Schuljahreswechsel durchführen</strong>
<span style="color: #991b1b; font-size: 0.9rem;">Diese Aktion bereitet das System auf das neue Schuljahr vor (z. B. Hochstufen von Klassen).</span>
</div>
<button type="button"
id="btn-rollover"
class="btn btn-danger"
onclick="triggerSchoolYearRollover()">
Schuljahreswechsel auslösen
</button>
</div>
</div>
</div> </div>
</div> </div>
<script>
async function triggerSchoolYearRollover() {
if (!confirm('Möchtest du den Schuljahreswechsel wirklich durchführen? Dies kann nicht rückgängig gemacht werden!')) {
return;
}
const btn = document.getElementById('btn-rollover');
btn.disabled = true;
btn.textContent = 'Wird ausgeführt...';
try {
const response = await fetch('/admin/trigger_school_year_rollover', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (response.ok && data.ok) {
alert('Schuljahreswechsel erfolgreich durchgeführt!\n\nZusammenfassung:\n' + JSON.stringify(data.summary, null, 2));
location.reload();
} else {
alert('Fehler: ' + (data.message || 'Schuljahreswechsel konnte nicht durchgeführt werden.'));
}
} catch (error) {
console.error('Rollover error:', error);
alert('Netzwerk- oder Serverfehler beim Ausführen des Schuljahreswechsels.');
} finally {
btn.disabled = false;
btn.textContent = 'Schuljahreswechsel auslösen';
}
}
</script>
<style> <style>
.container { .container {
max-width: 1100px; max-width: 1100px;
@@ -245,12 +300,17 @@
cursor: pointer; cursor: pointer;
} }
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary { .btn-primary {
background: #2563eb; background: #2563eb;
color: #fff; color: #fff;
} }
.btn-primary:hover { .btn-primary:hover:not(:disabled) {
background: #1d4ed8; background: #1d4ed8;
color: #fff; color: #fff;
} }
@@ -261,11 +321,21 @@
border-color: #d1d5db; border-color: #d1d5db;
} }
.btn-secondary:hover { .btn-secondary:hover:not(:disabled) {
background: #e5e7eb; background: #e5e7eb;
color: #111827; color: #111827;
} }
.btn-danger {
background: #ef4444;
color: #fff;
}
.btn-danger:hover:not(:disabled) {
background: #dc2626;
color: #fff;
}
.preview-card p { .preview-card p {
margin: 0 0 10px; margin: 0 0 10px;
line-height: 1.45; line-height: 1.45;
@@ -283,5 +353,15 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@media (max-width: 600px) {
.danger-zone .card-body {
flex-direction: column;
align-items: flex-start;
}
.danger-zone .btn {
width: 100%;
}
}
</style> </style>
{% endblock %} {% endblock %}
+236 -62
View File
@@ -1136,7 +1136,7 @@
</li> </li>
{% endif %} {% endif %}
<li class="nav-item" data-nav-fixed="true"> <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"> <button type="button" class="btn btn-link nav-link px-3" data-theme-toggle aria-label="Dark Mode umschalten" aria-pressed="false" title="Theme umschalten">
<span class="theme-icon-light" style="display: none;">☀️</span> <span class="theme-icon-light" style="display: none;">☀️</span>
<span class="theme-icon-dark" style="display: none;">🌙</span> <span class="theme-icon-dark" style="display: none;">🌙</span>
</button> </button>
@@ -1229,7 +1229,7 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
<li class="nav-item" data-nav-fixed="true"> <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"> <button type="button" class="btn btn-link nav-link px-3" data-theme-toggle aria-label="Dark Mode umschalten" aria-pressed="false" title="Theme umschalten">
<span class="theme-icon-light" style="display: none;">☀️</span> <span class="theme-icon-light" style="display: none;">☀️</span>
<span class="theme-icon-dark" style="display: none;">🌙</span> <span class="theme-icon-dark" style="display: none;">🌙</span>
</button> </button>
@@ -1346,11 +1346,13 @@
</li> </li>
{% endif %} {% endif %}
{% if 'username' in session %} {% if 'username' in session %}
<!--
{% if current_permissions.pages.get('tutorial_page', False) %} {% if current_permissions.pages.get('tutorial_page', False) %}
<li class="nav-item"> <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') }}" data-tutorial-tip="Nutzen Sie das Tutorial, um die Bibliotheksfunktionen kennenzulernen.">Tutorial</a> <a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}" data-tutorial-tip="Nutzen Sie das Tutorial, um die Bibliotheksfunktionen kennenzulernen.">Tutorial</a>
</li> </li>
{% endif %} {% endif %}
-->
{% endif %} {% endif %}
{% if 'username' in session and current_permissions.actions.get('can_insert', False) and current_permissions.pages.get('library_admin', False) %} {% if 'username' in session and current_permissions.actions.get('can_insert', False) and current_permissions.pages.get('library_admin', False) %}
<li class="nav-item"> <li class="nav-item">
@@ -1358,7 +1360,7 @@
</li> </li>
{% endif %} {% endif %}
<li class="nav-item" data-nav-fixed="true"> <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"> <button type="button" class="btn btn-link nav-link px-3" data-theme-toggle aria-label="Dark Mode umschalten" aria-pressed="false" title="Theme umschalten">
<span class="theme-icon-light" style="display: none;">☀️</span> <span class="theme-icon-light" style="display: none;">☀️</span>
<span class="theme-icon-dark" style="display: none;">🌙</span> <span class="theme-icon-dark" style="display: none;">🌙</span>
</button> </button>
@@ -1376,7 +1378,7 @@
<li><a class="dropdown-item" href="{{ url_for('mahnungen_admin') }}">Mahnungen</a></li> <li><a class="dropdown-item" href="{{ url_for('mahnungen_admin') }}">Mahnungen</a></li>
{% endif %} {% endif %}
{% if student_cards_module_enabled %} {% if student_cards_module_enabled %}
{% if current_permissions.actions.get('can_manage_users', False) %} {% if current_permissions.actions.get('can_manage_settings', False) %}
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li> <li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li>
{% endif %} {% endif %}
{% endif %} {% endif %}
@@ -1630,6 +1632,8 @@
</div> </div>
</div> </div>
<!--
<div id="onboarding-overlay" role="dialog" aria-modal="true" aria-label="Tutorial Vorschlag"> <div id="onboarding-overlay" role="dialog" aria-modal="true" aria-label="Tutorial Vorschlag">
<div id="onboarding-modal"> <div id="onboarding-modal">
<h3>Wollen Sie eine Vorstellung des Produkts?</h3> <h3>Wollen Sie eine Vorstellung des Produkts?</h3>
@@ -1643,6 +1647,7 @@
</div> </div>
</div> </div>
</div> </div>
-->
<div id="notification-toast" class="notification-toast" role="status" aria-live="polite"> <div id="notification-toast" class="notification-toast" role="status" aria-live="polite">
<strong id="notification-toast-title">Neue Benachrichtigung</strong> <strong id="notification-toast-title">Neue Benachrichtigung</strong>
@@ -1662,7 +1667,7 @@
<option value="Benachrichtigungen"></option> <option value="Benachrichtigungen"></option>
{% endif %} {% endif %}
{% if current_permissions.pages.get('tutorial_page', False) %} {% if current_permissions.pages.get('tutorial_page', False) %}
<option value="Tutorial"></option> <!--<option value="Tutorial"></option>-->
{% endif %} {% endif %}
{% endif %} {% endif %}
@@ -1765,10 +1770,10 @@
}); });
const username = {{ (session['username'] if 'username' in session else '')|tojson }}; const username = {{ (session['username'] if 'username' in session else '')|tojson }};
const isTutorialPage = window.location.pathname === {{ url_for('tutorial_page')|tojson }}; //const isTutorialPage = window.location.pathname === {{ url_for('tutorial_page')|tojson }};
const isLoginPage = window.location.pathname === {{ url_for('login')|tojson }}; const isLoginPage = window.location.pathname === {{ url_for('login')|tojson }};
const notificationsPagePath = {{ url_for('notifications_view')|tojson }}; const notificationsPagePath = {{ url_for('notifications_view')|tojson }};
const onboardingKey = username ? ('inventarsystem_tutorial_prompt_v1_' + username) : null; //const onboardingKey = username ? ('inventarsystem_tutorial_prompt_v1_' + username) : null;
const onboardingOverlay = document.getElementById('onboarding-overlay'); const onboardingOverlay = document.getElementById('onboarding-overlay');
const notificationButtons = Array.from(document.querySelectorAll('[data-notification-button="true"]')); const notificationButtons = Array.from(document.querySelectorAll('[data-notification-button="true"]'));
const notificationToast = document.getElementById('notification-toast'); const notificationToast = document.getElementById('notification-toast');
@@ -1789,9 +1794,9 @@
{% if current_permissions.pages.get('notifications_view', False) %} {% if current_permissions.pages.get('notifications_view', False) %}
{ label: 'Benachrichtigungen', keywords: ['benachrichtigungen', 'nachrichten', 'notifications'], url: {{ url_for('notifications_view')|tojson }} }, { label: 'Benachrichtigungen', keywords: ['benachrichtigungen', 'nachrichten', 'notifications'], url: {{ url_for('notifications_view')|tojson }} },
{% endif %} {% endif %}
{% if current_permissions.pages.get('tutorial_page', False) %} //{% if current_permissions.pages.get('tutorial_page', False) %}
{ label: 'Tutorial', keywords: ['tutorial', 'hilfe', 'anleitung'], url: {{ url_for('tutorial_page')|tojson }} }, //{ label: 'Tutorial', keywords: ['tutorial', 'hilfe', 'anleitung'], url: {{ url_for('tutorial_page')|tojson }} },
{% endif %} //{% endif %}
{% endif %} {% endif %}
{ label: 'Impressum', keywords: ['impressum'], url: {{ url_for('impressum')|tojson }} }, { label: 'Impressum', keywords: ['impressum'], url: {{ url_for('impressum')|tojson }} },
@@ -2042,11 +2047,11 @@
window.setInterval(pollNotificationStatus, 30000); window.setInterval(pollNotificationStatus, 30000);
} }
function showOnboarding(){ //function showOnboarding(){
if (onboardingOverlay) { // if (onboardingOverlay) {
onboardingOverlay.style.display = 'flex'; // onboardingOverlay.style.display = 'flex';
} // }
} //}
function hideOnboarding(){ function hideOnboarding(){
if (onboardingOverlay) { if (onboardingOverlay) {
@@ -2054,19 +2059,19 @@
} }
} }
if (onboardingKey && !isTutorialPage && !isLoginPage) { //if (onboardingKey && !isTutorialPage && !isLoginPage) {
const decision = localStorage.getItem(onboardingKey); // const decision = localStorage.getItem(onboardingKey);
if (!decision || decision === 'later') { // if (!decision || decision === 'later') {
showOnboarding(); // showOnboarding();
} // }
} //}
document.getElementById('onboarding-start')?.addEventListener('click', function(){ //document.getElementById('onboarding-start')?.addEventListener('click', function(){
if (onboardingKey) { // if (onboardingKey) {
localStorage.setItem(onboardingKey, 'started'); // localStorage.setItem(onboardingKey, 'started');
} // }
window.location.href = {{ url_for('tutorial_page')|tojson }}; // window.location.href = {{ url_for('tutorial_page')|tojson }};
}); //});
document.getElementById('onboarding-later')?.addEventListener('click', function(){ document.getElementById('onboarding-later')?.addEventListener('click', function(){
if (onboardingKey) { if (onboardingKey) {
@@ -2345,56 +2350,225 @@
<!-- Theme Toggle Script --> <!-- Theme Toggle Script -->
<script> <script>
document.addEventListener('DOMContentLoaded', () => { (function () {
const toggleBtns = document.querySelectorAll('#themeToggleBtn'); const root = document.documentElement;
if (toggleBtns.length === 0) return; const metaThemeColor = document.getElementById('meta-theme-color');
const themeToggleSelector = '[data-theme-toggle]';
function updateIcons(theme) {
const isDark = theme === 'dark'; function getTheme() {
document.querySelectorAll('.theme-icon-light').forEach(icon => icon.style.display = isDark ? 'inline' : 'none'); return root.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
document.querySelectorAll('.theme-icon-dark').forEach(icon => icon.style.display = isDark ? 'none' : 'inline');
} }
// Get current setup from initial script in head function updateThemeUi(theme) {
let currentTheme = document.documentElement.getAttribute('data-theme') || 'light'; const isDark = theme === 'dark';
updateIcons(currentTheme); 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';
});
document.querySelectorAll(themeToggleSelector).forEach(button => {
button.setAttribute('aria-pressed', String(isDark));
button.setAttribute('aria-label', isDark ? 'Light Mode einschalten' : 'Dark Mode einschalten');
button.setAttribute('title', isDark ? 'Light Mode einschalten' : 'Dark Mode einschalten');
});
if (metaThemeColor) {
metaThemeColor.setAttribute('content', isDark ? '#1a252f' : '#2c3e50');
}
}
toggleBtns.forEach(btn => { function applyTheme(theme, persist) {
btn.addEventListener('click', (e) => { const normalizedTheme = theme === 'dark' ? 'dark' : 'light';
e.preventDefault(); root.setAttribute('data-theme', normalizedTheme);
currentTheme = currentTheme === 'light' ? 'dark' : 'light'; if (persist) {
try {
document.documentElement.setAttribute('data-theme', currentTheme); localStorage.setItem('inventarsystem-theme', normalizedTheme);
localStorage.setItem('inventarsystem-theme', currentTheme); } catch (error) {
document.getElementById('meta-theme-color').setAttribute('content', currentTheme === 'dark' ? '#1a252f' : '#2c3e50'); console.warn('Theme konnte nicht gespeichert werden:', error);
}
updateIcons(currentTheme); }
updateThemeUi(normalizedTheme);
}
document.addEventListener('DOMContentLoaded', function () {
updateThemeUi(getTheme());
document.addEventListener('click', function (event) {
const button = event.target.closest(themeToggleSelector);
if (!button) return;
event.preventDefault();
applyTheme(getTheme() === 'dark' ? 'light' : 'dark', true);
}); });
}); });
}); })();
</script> </script>
<script> <script>
(function () { (function () {
const username = {{ (session['username'] if 'username' in session else '')|tojson }}; const username = {{ (session['username'] if 'username' in session else '')|tojson }};
const tooltipModeKey = username ? ('inventarsystem_tutorial_tooltips_enabled_' + username) : 'inventarsystem_tutorial_tooltips_enabled'; //const tooltipModeKey = username ? ('inventarsystem_tutorial_tooltips_enabled_' + username) : 'inventarsystem_tutorial_tooltips_enabled';
const enabled = localStorage.getItem(tooltipModeKey) === '1'; const enabled = localStorage.getItem(tooltipModeKey) === '1';
function applyTooltipMode(active) { //function applyTooltipMode(active) {
document.body.classList.toggle('tutorial-tooltips-active', active); // document.body.classList.toggle('tutorial-tooltips-active', active);
let indicator = document.getElementById('tutorialTooltipIndicator'); // let indicator = document.getElementById('tutorialTooltipIndicator');
if (!indicator) { // if (!indicator) {
indicator = document.createElement('div'); // indicator = document.createElement('div');
indicator.id = 'tutorialTooltipIndicator'; // indicator.id = 'tutorialTooltipIndicator';
indicator.className = 'tutorial-tooltip-indicator'; // indicator.className = 'tutorial-tooltip-indicator';
indicator.textContent = 'Tutorial-Modus aktiv: Tooltips sind eingeschaltet'; // indicator.textContent = 'Tutorial-Modus aktiv: Tooltips sind eingeschaltet';
document.body.appendChild(indicator); // document.body.appendChild(indicator);
} // }
indicator.style.display = active ? 'inline-flex' : 'none'; // indicator.style.display = active ? 'inline-flex' : 'none';
} //}
applyTooltipMode(enabled); applyTooltipMode(enabled);
})(); })();
</script> </script>
<style id="dark-mode-component-overrides">
:root[data-theme="dark"] .mahnungen-head,
:root[data-theme="dark"] .mahnungen-card,
:root[data-theme="dark"] .tutorial-side,
:root[data-theme="dark"] .workflow-step,
:root[data-theme="dark"] .tutorial-note,
:root[data-theme="dark"] .tutorial-tooltip-toggle,
:root[data-theme="dark"] .bulk-delete-drawer,
:root[data-theme="dark"] .filter-group,
:root[data-theme="dark"] .filter-dropdown,
:root[data-theme="dark"] .calendar-wrapper,
:root[data-theme="dark"] .calendar-day-details,
:root[data-theme="dark"] .cal-booking-item,
:root[data-theme="dark"] .edit-form,
:root[data-theme="dark"] .password-change-form,
:root[data-theme="dark"] .upload-form,
:root[data-theme="dark"] .book-info-container,
:root[data-theme="dark"] .student-card-form,
:root[data-theme="dark"] .user-management-container {
background: var(--ui-surface) !important;
background-color: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .mahnungen-table th,
:root[data-theme="dark"] .calendar-header,
:root[data-theme="dark"] .filter-inputs,
:root[data-theme="dark"] .items-loading-indicator,
:root[data-theme="dark"] .detail-group.full-width .detail-value,
:root[data-theme="dark"] .library-filter-panel,
:root[data-theme="dark"] .card-header,
:root[data-theme="dark"] .modal-header,
:root[data-theme="dark"] .modal-footer {
background: var(--ui-surface-soft) !important;
background-color: var(--ui-surface-soft) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .mahnungen-table tr:hover td,
:root[data-theme="dark"] .filter-option:hover,
:root[data-theme="dark"] .calendar-header button:hover,
:root[data-theme="dark"] .workflow-nav button:hover {
background: var(--ui-bg-accent) !important;
background-color: var(--ui-bg-accent) !important;
}
:root[data-theme="dark"] .mahnungen-head h1,
:root[data-theme="dark"] .tutorial-side h2,
:root[data-theme="dark"] .workflow-step h3,
:root[data-theme="dark"] .calendar-header span,
:root[data-theme="dark"] .calendar-day-details h5,
:root[data-theme="dark"] .detail-value,
:root[data-theme="dark"] .cal-booking-time,
:root[data-theme="dark"] .cal-booking-user,
:root[data-theme="dark"] .cal-booking-note,
:root[data-theme="dark"] .form-group label,
:root[data-theme="dark"] .page-header h1,
:root[data-theme="dark"] .card-header h2 {
color: var(--ui-title) !important;
}
:root[data-theme="dark"] .mahnungen-head p,
:root[data-theme="dark"] .tutorial-side p,
:root[data-theme="dark"] .workflow-step p,
:root[data-theme="dark"] .workflow-step li,
:root[data-theme="dark"] .tutorial-tooltip-toggle label,
:root[data-theme="dark"] .tooltip-description,
:root[data-theme="dark"] .page-header p,
:root[data-theme="dark"] .form-group small,
:root[data-theme="dark"] .cal-booking-label,
:root[data-theme="dark"] .cal-empty-state {
color: var(--ui-text-muted) !important;
}
:root[data-theme="dark"] .workflow-nav button,
:root[data-theme="dark"] .bulk-delete-actions button,
:root[data-theme="dark"] .filter-toggle,
:root[data-theme="dark"] .clear-filter {
background: var(--ui-surface-soft) !important;
background-color: var(--ui-surface-soft) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .workflow-nav button.active,
:root[data-theme="dark"] .filter-toggle.is-open {
background: #1d4ed8 !important;
color: #ffffff !important;
border-color: #60a5fa !important;
}
:root[data-theme="dark"] .badge-light,
:root[data-theme="dark"] .badge.bg-light,
:root[data-theme="dark"] .badge.bg-secondary,
:root[data-theme="dark"] .filter-tag,
:root[data-theme="dark"] .tutorial-pill {
background: var(--ui-surface-soft) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .form-control,
:root[data-theme="dark"] .form-select,
:root[data-theme="dark"] input,
:root[data-theme="dark"] select,
:root[data-theme="dark"] textarea {
background-color: var(--ui-bg) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] ::placeholder {
color: #b7c4d6 !important;
opacity: 1 !important;
}
:root[data-theme="dark"] .modal-content,
:root[data-theme="dark"] .modal-dialog-white,
:root[data-theme="dark"] .modal-body,
:root[data-theme="dark"] .modal-content-margin {
background: var(--ui-overlay-surface) !important;
background-color: var(--ui-overlay-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-overlay-border) !important;
}
:root[data-theme="dark"] .modal-content h1,
:root[data-theme="dark"] .modal-content h2,
:root[data-theme="dark"] .modal-content h3,
:root[data-theme="dark"] .modal-content h4,
:root[data-theme="dark"] .modal-content h5,
:root[data-theme="dark"] .modal-content h6,
:root[data-theme="dark"] .modal-content label,
:root[data-theme="dark"] .modal-content p,
:root[data-theme="dark"] .modal-content span {
color: var(--ui-text) !important;
}
:root[data-theme="dark"] .guest-impressum-footer a {
color: #dbeafe !important;
border-bottom-color: #93c5fd !important;
}
</style>
</body> </body>
</html> </html>
+3 -3
View File
@@ -274,7 +274,7 @@
{% if not show_library_features %} {% if not show_library_features %}
<!-- ================= SYSTEM FILTERS 1-3 (INVENTORY / OTHER ITEMS ONLY) ================= --> <!-- ================= SYSTEM FILTERS 1-3 (INVENTORY / OTHER ITEMS ONLY) ================= -->
<div class="filter-inputs"> <div class="filter-inputs">
<h3>Unterrichtsfach (Filter 1):</h3> <h3>{{ filter_names.get('1', 'Jahrgangsstufe') }}</h3>
<div class="multi-filter"> <div class="multi-filter">
{% for idx in range(4) %} {% for idx in range(4) %}
<div class="form-group"> <div class="form-group">
@@ -286,7 +286,7 @@
{% endfor %} {% endfor %}
</div> </div>
<h3>Jahrgangsstufe (Filter 2):</h3> <h3>{{ filter_names.get('2', 'Fachgebiet') }}</h3>
<div class="multi-filter"> <div class="multi-filter">
{% for idx in range(4) %} {% for idx in range(4) %}
<div class="form-group"> <div class="form-group">
@@ -298,7 +298,7 @@
{% endfor %} {% endfor %}
</div> </div>
<h3>Schlagwort (Filter 3):</h3> <h3>{{ filter_names.get('3', 'Schlagwort') }}</h3>
<div class="multi-filter"> <div class="multi-filter">
{% for idx in range(4) %} {% for idx in range(4) %}
<div class="form-group"> <div class="form-group">
+168 -40
View File
@@ -122,7 +122,7 @@
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: #64748b; color: #64748b;
background: var(--ui-surface-soft); background: var(--ui-surface-soft);
user-select: none; /* Verhindert Textmarkierung beim Klicken */ user-select: none;
} }
.library-table tr:hover td { .library-table tr:hover td {
@@ -151,7 +151,7 @@
.badge-open { background: #fee2e2; color: #991b1b; } .badge-open { background: #fee2e2; color: #991b1b; }
.badge-paid { background: #dcfce7; color: #166534; } .badge-paid { background: #dcfce7; color: #166534; }
.badge-damaged { background: #fee2e2; color: #991b1b; } .badge-damaged { background: #fee2e2; color: #991b1b; }
.badge-class { background: #f1f5f9; color: #475569; border: 1px solid #cbd5e1; } /* Neues Badge für Klasse */ .badge-class { background: #f1f5f9; color: #475569; border: 1px solid #cbd5e1; }
.row-actions { .row-actions {
display: flex; display: flex;
@@ -168,6 +168,32 @@
white-space: nowrap; white-space: nowrap;
} }
.row-actions .btn-outline-danger {
color: #dc2626;
background-color: #fef2f2; /* Light red tint so it doesn't blend into the white table */
border: 2px solid #dc2626; /* Thicker, clear border */
padding: 6px 14px;
border-radius: 6px;
font-weight: bold;
font-size: 0.85rem;
cursor: pointer;
box-shadow: 0 2px 4px rgba(220, 38, 38, 0.1); /* Subtle shadow for depth */
transition: all 0.2s ease;
}
.row-actions .btn-outline-danger:hover {
background-color: #dc2626;
color: #ffffff;
box-shadow: 0 4px 8px rgba(220, 38, 38, 0.25);
transform: translateY(-1px); /* Slight lift effect on hover */
}
.row-actions .btn-outline-danger:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(220, 38, 38, 0.15); /* Pressed state */
}
/* ------------------------------------------------------ */
.muted { .muted {
color: #6b7280; color: #6b7280;
font-size: 0.92rem; font-size: 0.92rem;
@@ -179,6 +205,122 @@
color: #6b7280; color: #6b7280;
} }
:root[data-theme="dark"] .library-admin-hero {
background: linear-gradient(135deg, #172554 0%, #1e293b 100%);
border-color: var(--ui-border);
box-shadow: var(--ui-shadow-md);
}
:root[data-theme="dark"] .library-admin-hero p,
:root[data-theme="dark"] .summary-card .label,
:root[data-theme="dark"] .muted,
:root[data-theme="dark"] .empty-state {
color: var(--ui-text-muted) !important;
}
:root[data-theme="dark"] .summary-card,
:root[data-theme="dark"] .panel {
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
box-shadow: var(--ui-shadow-sm);
}
:root[data-theme="dark"] .summary-card .value,
:root[data-theme="dark"] .panel h2 {
color: var(--ui-title) !important;
}
:root[data-theme="dark"] .filter-bar input,
:root[data-theme="dark"] .filter-bar select {
background: var(--ui-bg) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .filter-bar input:focus,
:root[data-theme="dark"] .filter-bar select:focus {
outline: none;
border-color: #60a5fa !important;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.22);
}
:root[data-theme="dark"] .library-table,
:root[data-theme="dark"] .library-table td {
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .library-table th {
background: var(--ui-surface-soft) !important;
color: var(--ui-text-muted) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .library-table tr:hover td {
background: var(--ui-bg-accent) !important;
}
:root[data-theme="dark"] .badge-active {
background: #172554;
color: #bfdbfe;
}
:root[data-theme="dark"] .badge-planned {
background: #422006;
color: #fde68a;
}
:root[data-theme="dark"] .badge-completed,
:root[data-theme="dark"] .badge-paid {
background: #123524;
color: #b7f7ce;
}
:root[data-theme="dark"] .badge-open,
:root[data-theme="dark"] .badge-damaged {
background: #451a1a;
color: #fecaca;
}
:root[data-theme="dark"] .badge-class {
background: var(--ui-surface-soft);
color: var(--ui-text);
border-color: var(--ui-border);
}
:root[data-theme="dark"] .row-actions .btn-outline-danger {
background: #451a1a;
color: #fca5a5;
border-color: #f87171;
}
:root[data-theme="dark"] .row-actions .btn-outline-danger:hover {
background: #dc2626;
color: #ffffff;
}
:root[data-theme="dark"] #damage-invoice-modal > div,
:root[data-theme="dark"] #repair-action-modal > div {
background: var(--ui-overlay-surface) !important;
color: var(--ui-text) !important;
border: 1px solid var(--ui-overlay-border);
}
:root[data-theme="dark"] #damage-invoice-modal input,
:root[data-theme="dark"] #damage-invoice-modal textarea,
:root[data-theme="dark"] #repair-action-modal input {
background: var(--ui-bg) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] #damage-invoice-modal p,
:root[data-theme="dark"] #repair-action-modal p {
color: var(--ui-text-muted) !important;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.library-admin-hero { .library-admin-hero {
flex-direction: column; flex-direction: column;
@@ -193,6 +335,11 @@
overflow-x: auto; overflow-x: auto;
white-space: nowrap; white-space: nowrap;
} }
.library-admin-shell {
padding-left: 10px;
padding-right: 10px;
}
} }
</style> </style>
@@ -227,7 +374,7 @@
<div class="filter-bar"> <div class="filter-bar">
<input id="library-search" type="text" placeholder="Nach Element, Benutzer, Klasse, Ausweis oder Rechnung suchen..."> <input id="library-search" type="text" placeholder="Nach Element, Benutzer, Klasse, Ausweis oder Rechnung suchen...">
<!-- NEU: Klassen-Filter --> <!-- Klassen-Filter -->
<select id="class-filter"> <select id="class-filter">
<option value="all">Alle Klassen</option> <option value="all">Alle Klassen</option>
{% set classes = [] %} {% set classes = [] %}
@@ -260,7 +407,6 @@
<table class="library-table" id="loans-table"> <table class="library-table" id="loans-table">
<thead> <thead>
<tr> <tr>
<!-- NEU: onClick Handler zum Sortieren hinzugefügt -->
<th style="cursor: pointer;" onclick="sortTable('loans-table', 0)">Status ↕</th> <th style="cursor: pointer;" onclick="sortTable('loans-table', 0)">Status ↕</th>
<th style="cursor: pointer;" onclick="sortTable('loans-table', 1)">Element ↕</th> <th style="cursor: pointer;" onclick="sortTable('loans-table', 1)">Element ↕</th>
<th style="cursor: pointer;" onclick="sortTable('loans-table', 2)">Benutzer ↕</th> <th style="cursor: pointer;" onclick="sortTable('loans-table', 2)">Benutzer ↕</th>
@@ -272,7 +418,6 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<!-- NEU: data-klasse hinzugefügt -->
{% for e in loan_entries %} {% for e in loan_entries %}
<tr class="loan-row" <tr class="loan-row"
data-borrow-id="{{ e.id }}" data-borrow-id="{{ e.id }}"
@@ -305,7 +450,6 @@
<td> <td>
<div><strong>{{ e.user }}</strong></div> <div><strong>{{ e.user }}</strong></div>
</td> </td>
<!-- NEU: Klasse Spalte -->
<td> <td>
{% if e.klasse %} {% if e.klasse %}
<span class="badge-pill badge-class">{{ e.klasse }}</span> <span class="badge-pill badge-class">{{ e.klasse }}</span>
@@ -322,9 +466,6 @@
{% if e.invoice_number %} {% if e.invoice_number %}
<div class="mono">{{ e.invoice_number }}</div> <div class="mono">{{ e.invoice_number }}</div>
<div class="muted">{{ e.invoice_amount }}</div> <div class="muted">{{ e.invoice_amount }}</div>
<!--{% if e.invoice_corrections_count %}
<div class="muted" style="color:#7c2d12;">{{ e.invoice_corrections_count }} Korrektur(en)</div>
{% endif %} -->
<div style="margin-top:6px;"> <div style="margin-top:6px;">
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_view_invoice_pdf', borrow_id=e.id) }}" target="_blank" rel="noopener">PDF öffnen</a> <a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_view_invoice_pdf', borrow_id=e.id) }}" target="_blank" rel="noopener">PDF öffnen</a>
</div> </div>
@@ -359,18 +500,9 @@
{% endif %} {% endif %}
{% if e.has_damage %} {% if e.has_damage %}
<!-- Aufruf des Reparatur-Auswahl-Modals -->
<button type="button" class="btn btn-warning btn-sm" onclick="openRepairModal('{{ e.item_id }}', '{{ e.item_code }}')">Reparieren / Ersetzen</button> <button type="button" class="btn btn-warning btn-sm" onclick="openRepairModal('{{ e.item_id }}', '{{ e.item_code }}')">Reparieren / Ersetzen</button>
{% endif %} {% endif %}
<!--{% if e.invoice_number %}
<form method="post" action="{{ url_for('admin_add_invoice_correction', borrow_id=e.id) }}" onsubmit="return confirm('Korrekturbuchung hinzufügen?');" style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
<input type="text" name="correction_reason" value="Korrektur zu {{ e.invoice_number }}" placeholder="Korrekturgrund" required style="padding:6px; border:1px solid #ddd; border-radius:6px; min-width:160px; max-width:180px;">
<input type="text" name="amount_delta" placeholder="z.B. -{{ e.invoice_amount }}" style="padding:6px; border:1px solid #ddd; border-radius:6px; width:120px;">
<button type="submit" class="btn btn-outline-danger btn-sm">Korrektur</button>
</form>
{% endif %}-->
{% if e.status in ['active', 'planned'] %} {% if e.status in ['active', 'planned'] %}
<form method="post" action="{{ url_for('admin_reset_borrowing', borrow_id=e.id) }}" onsubmit="return confirm('Ausleihe zurücksetzen?');"> <form method="post" action="{{ url_for('admin_reset_borrowing', borrow_id=e.id) }}" onsubmit="return confirm('Ausleihe zurücksetzen?');">
<button type="submit" class="btn btn-secondary btn-sm">Zurücksetzen</button> <button type="submit" class="btn btn-secondary btn-sm">Zurücksetzen</button>
@@ -394,7 +526,6 @@
<table class="library-table" id="damaged-table"> <table class="library-table" id="damaged-table">
<thead> <thead>
<tr> <tr>
<!-- NEU: onClick Handler zum Sortieren hinzugefügt -->
<th style="cursor: pointer;" onclick="sortTable('damaged-table', 0)">Element ↕</th> <th style="cursor: pointer;" onclick="sortTable('damaged-table', 0)">Element ↕</th>
<th style="cursor: pointer;" onclick="sortTable('damaged-table', 1)">Code ↕</th> <th style="cursor: pointer;" onclick="sortTable('damaged-table', 1)">Code ↕</th>
<th style="cursor: pointer;" onclick="sortTable('damaged-table', 2)">Schaden ↕</th> <th style="cursor: pointer;" onclick="sortTable('damaged-table', 2)">Schaden ↕</th>
@@ -427,7 +558,6 @@
<div class="row-actions" style="margin-bottom:8px;"> <div class="row-actions" style="margin-bottom:8px;">
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('library_item_invoices', item_id=item.id) }}">Rechnungen</a> <a class="btn btn-outline-secondary btn-sm" href="{{ url_for('library_item_invoices', item_id=item.id) }}">Rechnungen</a>
</div> </div>
<!-- Aufruf des Reparatur-Auswahl-Modals -->
<button type="button" class="btn btn-warning btn-sm" onclick="openRepairModal('{{ item.id }}', '{{ item.code }}')">Reparieren / Ersetzen</button> <button type="button" class="btn btn-warning btn-sm" onclick="openRepairModal('{{ item.id }}', '{{ item.code }}')">Reparieren / Ersetzen</button>
</td> </td>
</tr> </tr>
@@ -480,6 +610,13 @@
<textarea id="damage-invoice-reason" name="damage_reason" rows="5" required style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; resize:vertical;" placeholder="Beschreiben Sie kurz den Schaden oder die Zerstörung."></textarea> <textarea id="damage-invoice-reason" name="damage_reason" rows="5" required style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; resize:vertical;" placeholder="Beschreiben Sie kurz den Schaden oder die Zerstörung."></textarea>
</div> </div>
{% if email_service_enabled %}
<div style="margin-bottom:16px;">
<label for="damage-invoice-recipient-email" style="display:block; font-weight:700; margin-bottom:6px;">Empfänger-E-Mail für den PDF-Versand</label>
<input id="damage-invoice-recipient-email" name="recipient_email" type="email" style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px;" placeholder="name@beispiel.de">
</div>
{% endif %}
<div style="display:flex; flex-wrap:wrap; gap:16px; align-items:center; margin-bottom:18px;"> <div style="display:flex; flex-wrap:wrap; gap:16px; align-items:center; margin-bottom:18px;">
<label style="display:flex; align-items:center; gap:8px;"> <label style="display:flex; align-items:center; gap:8px;">
<input id="damage-invoice-destroyed" type="checkbox" name="mark_destroyed" checked> <input id="damage-invoice-destroyed" type="checkbox" name="mark_destroyed" checked>
@@ -493,13 +630,16 @@
<div style="display:flex; justify-content:flex-end; gap:10px;"> <div style="display:flex; justify-content:flex-end; gap:10px;">
<button type="button" class="btn btn-secondary" onclick="closeDamageInvoiceModal()">Abbrechen</button> <button type="button" class="btn btn-secondary" onclick="closeDamageInvoiceModal()">Abbrechen</button>
<button type="submit" class="btn btn-danger">PDF-Rechnung erstellen</button> <button type="submit" class="btn btn-outline-danger" name="delivery" value="pdf">Nur PDF herunterladen</button>
{% if email_service_enabled %}
<button type="submit" class="btn btn-danger" name="delivery" value="email">PDF per E-Mail senden</button>
{% endif %}
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<!-- Neues Modal: Reparatur-Optionen (Reparieren, Ersetzen mit neuem Code_4, Löschen) --> <!-- Modal: Reparatur-Optionen -->
<div id="repair-action-modal" role="dialog" aria-modal="true" aria-labelledby="repair-modal-title" style="display:none; position:fixed; inset:0; background:rgba(15,23,42,0.72); z-index:9999; padding:20px; overflow:auto;"> <div id="repair-action-modal" role="dialog" aria-modal="true" aria-labelledby="repair-modal-title" 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:540px; margin:60px auto; background: var(--ui-surface); border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);"> <div style="max-width:540px; margin:60px 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:16px;"> <div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:16px;">
@@ -529,7 +669,7 @@
<script> <script>
(function() { (function() {
const searchInput = document.getElementById('library-search'); const searchInput = document.getElementById('library-search');
const classFilter = document.getElementById('class-filter'); // NEU: Klassenfilter hinzugefügt const classFilter = document.getElementById('class-filter');
const statusFilter = document.getElementById('loan-status-filter'); const statusFilter = document.getElementById('loan-status-filter');
const damageFilter = document.getElementById('damage-filter'); const damageFilter = document.getElementById('damage-filter');
const loanRows = Array.from(document.querySelectorAll('.loan-row')); const loanRows = Array.from(document.querySelectorAll('.loan-row'));
@@ -546,10 +686,8 @@
const damageInvoiceReason = document.getElementById('damage-invoice-reason'); const damageInvoiceReason = document.getElementById('damage-invoice-reason');
const damageInvoiceReplaceBtn = document.getElementById('damage-invoice-replace-btn'); const damageInvoiceReplaceBtn = document.getElementById('damage-invoice-replace-btn');
// NEU: Globale Sortier-Richtungsobjekte
let sortDirections = {}; let sortDirections = {};
// NEU: Sortier-Funktion
window.sortTable = function(tableId, columnIndex) { window.sortTable = function(tableId, columnIndex) {
const table = document.getElementById(tableId); const table = document.getElementById(tableId);
const tbody = table.tBodies[0]; const tbody = table.tBodies[0];
@@ -567,7 +705,6 @@
const isAscending = sortDirections[sortKey]; const isAscending = sortDirections[sortKey];
const multiplier = isAscending ? 1 : -1; const multiplier = isAscending ? 1 : -1;
// Pfeil im Header aktualisieren
const headers = table.querySelectorAll("th"); const headers = table.querySelectorAll("th");
headers.forEach(th => { headers.forEach(th => {
if(th.innerHTML.includes('↕') || th.innerHTML.includes('▲') || th.innerHTML.includes('▼')) { if(th.innerHTML.includes('↕') || th.innerHTML.includes('▲') || th.innerHTML.includes('▼')) {
@@ -579,19 +716,15 @@
currentTh.innerHTML = currentTh.innerHTML.replace('↕', isAscending ? '▲' : '▼'); currentTh.innerHTML = currentTh.innerHTML.replace('↕', isAscending ? '▲' : '▼');
} }
// Zeilen sortieren
rows.sort((a, b) => { rows.sort((a, b) => {
const cellA = a.cells[columnIndex].textContent.trim(); const cellA = a.cells[columnIndex].textContent.trim();
const cellB = b.cells[columnIndex].textContent.trim(); const cellB = b.cells[columnIndex].textContent.trim();
return cellA.localeCompare(cellB, 'de', { numeric: true, sensitivity: 'base' }) * multiplier; return cellA.localeCompare(cellB, 'de', { numeric: true, sensitivity: 'base' }) * multiplier;
}); });
// Neu einfügen
rows.forEach(row => tbody.appendChild(row)); rows.forEach(row => tbody.appendChild(row));
}; };
// Funktionen für das Reparatur-Modal
window.openRepairModal = function(itemId, currentCode) { window.openRepairModal = function(itemId, currentCode) {
const modal = document.getElementById('repair-action-modal'); const modal = document.getElementById('repair-action-modal');
const form = document.getElementById('repair-action-form'); const form = document.getElementById('repair-action-form');
@@ -724,26 +857,25 @@
}); });
} }
// NEU: Kombinierte Filter-Funktion (Suche + Status + Schaden + Klasse)
function applyFilters() { function applyFilters() {
const search = (searchInput.value || '').trim().toLowerCase(); const search = (searchInput.value || '').trim().toLowerCase();
const status = statusFilter.value; const status = statusFilter.value;
const damage = damageFilter.value; const damage = damageFilter.value;
const klasse = classFilter.value; // NEU const klasse = classFilter.value;
let visibleLoans = 0; let visibleLoans = 0;
loanRows.forEach(row => { loanRows.forEach(row => {
const haystack = row.dataset.search || ''; const haystack = row.dataset.search || '';
const rowStatus = row.dataset.status || ''; const rowStatus = row.dataset.status || '';
const rowKlasse = row.dataset.klasse || ''; // NEU const rowKlasse = row.dataset.klasse || '';
const hasDamage = row.dataset.hasDamage === '1'; const hasDamage = row.dataset.hasDamage === '1';
const searchMatch = !search || haystack.includes(search); const searchMatch = !search || haystack.includes(search);
const statusMatch = !status || rowStatus === status; const statusMatch = !status || rowStatus === status;
const classMatch = klasse === 'all' || rowKlasse === klasse; // NEU const classMatch = klasse === 'all' || rowKlasse === klasse;
const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage); const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage);
const show = searchMatch && statusMatch && classMatch && damageMatch; // NEU const show = searchMatch && statusMatch && classMatch && damageMatch;
row.style.display = show ? '' : 'none'; row.style.display = show ? '' : 'none';
if (show) visibleLoans++; if (show) visibleLoans++;
}); });
@@ -758,10 +890,6 @@
const searchMatch = !search || haystack.includes(search); const searchMatch = !search || haystack.includes(search);
const statusMatch = !status || rowStatus === status || status === ''; const statusMatch = !status || rowStatus === status || status === '';
const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage); const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage);
// Defekte-Medien-Tabelle hat keine verknüpfte "Klasse", deshalb blenden wir sie nur bei Klassen-Filter "all" ein,
// oder wenn gar nicht nach Klasse gefiltert wird, damit sie nicht verschwindet.
// Falls sie bei aktiver Klassensuche komplett verschwinden soll, passe die Bedingung an:
const classMatch = klasse === 'all'; const classMatch = klasse === 'all';
const show = searchMatch && statusMatch && damageMatch && classMatch; const show = searchMatch && statusMatch && damageMatch && classMatch;
@@ -772,7 +900,7 @@
} }
searchInput.addEventListener('input', applyFilters); searchInput.addEventListener('input', applyFilters);
classFilter.addEventListener('change', applyFilters); // NEU classFilter.addEventListener('change', applyFilters);
statusFilter.addEventListener('change', applyFilters); statusFilter.addEventListener('change', applyFilters);
damageFilter.addEventListener('change', applyFilters); damageFilter.addEventListener('change', applyFilters);
applyFilters(); applyFilters();
+66
View File
@@ -112,7 +112,73 @@
padding: 36px 20px; padding: 36px 20px;
} }
:root[data-theme="dark"] .invoice-history-head {
background: linear-gradient(135deg, #172554 0%, #1e293b 100%);
border-color: var(--ui-border);
box-shadow: var(--ui-shadow-md);
}
:root[data-theme="dark"] .head-meta,
:root[data-theme="dark"] .muted,
:root[data-theme="dark"] .empty-state {
color: var(--ui-text-muted) !important;
}
:root[data-theme="dark"] .section-header {
color: var(--ui-title) !important;
}
:root[data-theme="dark"] .invoice-card {
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
box-shadow: var(--ui-shadow-sm);
}
:root[data-theme="dark"] .invoice-table,
:root[data-theme="dark"] .invoice-table td {
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .invoice-table th {
background: var(--ui-surface-soft) !important;
color: var(--ui-text-muted) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .invoice-table tr:hover td {
background: var(--ui-bg-accent) !important;
}
:root[data-theme="dark"] .badge-open {
background: #451a1a;
color: #fecaca;
}
:root[data-theme="dark"] .badge-paid,
:root[data-theme="dark"] .badge-completed {
background: #123524;
color: #b7f7ce;
}
:root[data-theme="dark"] .badge-active {
background: #172554;
color: #bfdbfe;
}
:root[data-theme="dark"] .badge-planned {
background: #422006;
color: #fde68a;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.invoice-history-shell {
padding-left: 10px;
padding-right: 10px;
}
.invoice-table { .invoice-table {
display: block; display: block;
overflow-x: auto; overflow-x: auto;
+537 -123
View File
@@ -95,10 +95,18 @@
flex: 1; flex: 1;
min-width: 250px; min-width: 250px;
padding: 12px 16px; padding: 12px 16px;
border: 1px solid #ddd; border: 1px solid var(--ui-border);
border-radius: 6px; border-radius: 10px;
font-size: 16px; font-size: 16px;
background: #fff; background: var(--ui-surface);
color: var(--ui-text);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.library-search-input:focus {
outline: none;
border-color: #60a5fa;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2);
} }
.library-scan-panel { .library-scan-panel {
@@ -138,34 +146,35 @@
/* Filters */ /* Filters */
.library-filter-toggle-btn { .library-filter-toggle-btn {
padding: 10px 16px; padding: 10px 16px;
background: #f0f2f5; background: var(--ui-surface-soft);
border: 1px solid #ddd; border: 1px solid var(--ui-border);
border-radius: 6px; border-radius: 10px;
font-weight: 500; font-weight: 700;
color: var(--ui-text); color: var(--ui-text);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
} }
.library-filter-toggle-btn:hover { .library-filter-toggle-btn:hover {
background: #e4e6eb; background: var(--ui-bg-accent);
border-color: #999; border-color: #60a5fa;
} }
.library-filter-toggle-btn.active { .library-filter-toggle-btn.active {
background: #e0e7ff; background: var(--module-primary-color);
border-color: #4f46e5; border-color: var(--module-primary-color);
color: #4f46e5; color: #ffffff;
box-shadow: 0 0 0 3px var(--module-accent-light);
} }
.library-filter-panel { .library-filter-panel {
display: none; display: none;
background: #fff; background: var(--ui-surface);
border: 1px solid #ddd; border: 1px solid var(--ui-border);
border-radius: 8px; border-radius: 12px;
padding: 20px; padding: 18px;
margin-bottom: 20px; margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08); box-shadow: var(--ui-shadow-sm);
} }
.library-filter-panel.open { .library-filter-panel.open {
@@ -198,34 +207,53 @@
.filter-item input, .filter-item input,
.filter-item select { .filter-item select {
padding: 8px 12px; padding: 8px 12px;
border: 1px solid #ddd; border: 1px solid var(--ui-border);
border-radius: 4px; border-radius: 8px;
font-size: 14px; font-size: 14px;
background: #fff; background: var(--ui-bg);
color: var(--ui-text);
transition: border-color 0.2s ease, box-shadow 0.2s ease; transition: border-color 0.2s ease, box-shadow 0.2s ease;
} }
.filter-item input:focus, .filter-item input:focus,
.filter-item select:focus { .filter-item select:focus {
outline: none; outline: none;
border-color: #4f46e5; border-color: #60a5fa;
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1); box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2);
} }
.filter-buttons { .filter-buttons {
display: flex; display: flex;
flex-wrap: wrap;
gap: 10px; gap: 10px;
margin-top: 15px; margin-top: 15px;
padding-top: 15px; padding-top: 15px;
border-top: 1px solid #eee; border-top: 1px solid var(--ui-border);
} }
.filter-buttons .button { .filter-buttons .button {
flex: 1; flex: 0 1 auto;
padding: 8px 16px; padding: 8px 16px;
font-size: 14px; font-size: 14px;
} }
#clearFilterBtn {
background: var(--ui-surface-soft) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
.filter-buttons > div[style*="flex-grow"] {
flex: 1 1 100%;
min-width: 12px;
}
@media (min-width: 900px) {
.filter-buttons > div[style*="flex-grow"] {
flex: 1 1 auto;
}
}
/* ========================================= /* =========================================
4. Tables & Data Display 4. Tables & Data Display
========================================= */ ========================================= */
@@ -451,6 +479,27 @@
height: 100%; height: 100%;
} }
.physical-scanner-modal {
z-index: 1100;
}
.physical-scanner-modal .modal-content {
max-width: 520px;
text-align: center;
}
.physical-scanner-code {
min-height: 28px;
margin: 18px 0 8px;
padding: 12px;
border: 1px dashed #cbd5e1;
border-radius: 6px;
color: var(--ui-text);
font-family: monospace;
font-size: 1.2em;
letter-spacing: 2px;
}
.detail-gallery-container { .detail-gallery-container {
display: flex; display: flex;
@@ -518,10 +567,9 @@
<input type="text" id="manualItemCode" placeholder="Manueller Mediencode (optional)" style="min-width:180px;"> <input type="text" id="manualItemCode" placeholder="Manueller Mediencode (optional)" style="min-width:180px;">
<button id="resetCardBtn" class="button" type="button">Feld zurücksetzen</button> <button id="resetCardBtn" class="button" type="button">Feld zurücksetzen</button>
<button id="toggleScannerBtn" class="button" type="button">Scanner starten</button> <button id="toggleScannerBtn" class="button" type="button">Scanner starten</button>
<label style="display:flex; align-items:center; gap:8px; margin-left:6px;"> <button id="physicalScannerBtn" class="button" type="button" style="margin-left:6px;">
<input type="checkbox" id="keyboardScannerToggle"> Physischer Scanner starten
<span style="font-size:0.9em;">Physischer Scanner</span> </button>
</label>
<button id="manualActionBtn" class="button" type="button" style="margin-left:6px; background:#4f46e5; color:white;">Code verarbeiten</button> <button id="manualActionBtn" class="button" type="button" style="margin-left:6px; background:#4f46e5; color:white;">Code verarbeiten</button>
</div> </div>
<div class="library-scan-reader-wrap" id="scanReaderWrap" style="display: none; margin-top: 15px;"> <div class="library-scan-reader-wrap" id="scanReaderWrap" style="display: none; margin-top: 15px;">
@@ -612,8 +660,213 @@
<div id="detailContent"></div> <div id="detailContent"></div>
</div> </div>
</div> </div>
<div id="physicalScannerModal" class="modal physical-scanner-modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="physicalScannerTitle">
<div class="modal-content">
<span class="close" id="closePhysicalScannerBtn" role="button" tabindex="0" aria-label="Physischen Scanner schließen">&times;</span>
<h2 id="physicalScannerTitle">Physischer Scanner</h2>
<p id="physicalScannerInstruction">Scanner ist bereit. Bitte den angezeigten Code scannen.</p>
<div id="physicalScannerCode" class="physical-scanner-code" tabindex="0" aria-label="Eingehender Scan" aria-live="polite"></div>
<p id="physicalScannerStatus" class="library-scan-status" aria-live="polite">Warte auf Scan...</p>
<button id="stopPhysicalScannerBtn" class="button" type="button">Scanner schließen</button>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script> <script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
<script> <script>
// =========================================================================
// CUSTOM MODAL HELPERS (Replaces native browser alert, confirm & prompt)
// =========================================================================
function createModalOverlay() {
const overlay = document.createElement('div');
overlay.className = 'ui-modal-overlay';
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100vw';
overlay.style.height = '100vh';
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.6)';
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
overlay.style.zIndex = '999999';
return overlay;
}
function createModalBox() {
const box = document.createElement('div');
box.className = 'ui-modal-surface';
box.style.backgroundColor = '#fff';
box.style.padding = '24px';
box.style.borderRadius = '8px';
box.style.boxShadow = '0 10px 25px rgba(0,0,0,0.2)';
box.style.fontFamily = 'sans-serif';
box.style.minWidth = '320px';
box.style.maxWidth = '90%';
box.style.textAlign = 'left';
box.style.color = '#333';
return box;
}
function customAlert(message) {
return new Promise((resolve) => {
const overlay = createModalOverlay();
const box = createModalBox();
const text = document.createElement('p');
text.textContent = message;
text.style.marginBottom = '20px';
text.style.lineHeight = '1.5';
text.style.whiteSpace = 'pre-wrap';
const btnContainer = document.createElement('div');
btnContainer.style.display = 'flex';
btnContainer.style.justifyContent = 'flex-end';
const btn = document.createElement('button');
btn.textContent = 'OK';
btn.style.padding = '8px 16px';
btn.style.cursor = 'pointer';
btn.style.border = 'none';
btn.style.backgroundColor = '#007bff';
btn.style.color = '#fff';
btn.style.borderRadius = '4px';
btn.onclick = () => {
document.body.removeChild(overlay);
resolve();
};
btnContainer.appendChild(btn);
box.appendChild(text);
box.appendChild(btnContainer);
overlay.appendChild(box);
document.body.appendChild(overlay);
btn.focus();
});
}
function customConfirm(message) {
return new Promise((resolve) => {
const overlay = createModalOverlay();
const box = createModalBox();
const text = document.createElement('p');
text.textContent = message;
text.style.marginBottom = '20px';
text.style.lineHeight = '1.5';
text.style.whiteSpace = 'pre-wrap';
const btnContainer = document.createElement('div');
btnContainer.style.display = 'flex';
btnContainer.style.justifyContent = 'flex-end';
btnContainer.style.gap = '10px';
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Abbrechen';
cancelBtn.style.padding = '8px 16px';
cancelBtn.style.cursor = 'pointer';
cancelBtn.style.border = '1px solid #ccc';
cancelBtn.style.backgroundColor = '#f8f9fa';
cancelBtn.style.color = '#333';
cancelBtn.style.borderRadius = '4px';
const okBtn = document.createElement('button');
okBtn.textContent = 'OK';
okBtn.style.padding = '8px 16px';
okBtn.style.cursor = 'pointer';
okBtn.style.border = 'none';
okBtn.style.backgroundColor = '#007bff';
okBtn.style.color = '#fff';
okBtn.style.borderRadius = '4px';
const closeAndResolve = (val) => {
document.body.removeChild(overlay);
resolve(val);
};
cancelBtn.onclick = () => closeAndResolve(false);
okBtn.onclick = () => closeAndResolve(true);
btnContainer.appendChild(cancelBtn);
btnContainer.appendChild(okBtn);
box.appendChild(text);
box.appendChild(btnContainer);
overlay.appendChild(box);
document.body.appendChild(overlay);
okBtn.focus();
});
}
function customPrompt(message, defaultValue = '') {
return new Promise((resolve) => {
const overlay = createModalOverlay();
const box = createModalBox();
const text = document.createElement('p');
text.textContent = message;
text.style.marginBottom = '15px';
text.style.lineHeight = '1.5';
text.style.whiteSpace = 'pre-wrap';
const input = document.createElement('input');
input.type = 'text';
input.value = defaultValue;
input.style.width = '100%';
input.style.marginBottom = '20px';
input.style.padding = '10px';
input.style.boxSizing = 'border-box';
input.style.border = '1px solid #ccc';
input.style.borderRadius = '4px';
const btnContainer = document.createElement('div');
btnContainer.style.display = 'flex';
btnContainer.style.justifyContent = 'flex-end';
btnContainer.style.gap = '10px';
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Abbrechen';
cancelBtn.style.padding = '8px 16px';
cancelBtn.style.cursor = 'pointer';
cancelBtn.style.border = '1px solid #ccc';
cancelBtn.style.backgroundColor = '#f8f9fa';
cancelBtn.style.color = '#333';
cancelBtn.style.borderRadius = '4px';
const okBtn = document.createElement('button');
okBtn.textContent = 'OK';
okBtn.style.padding = '8px 16px';
okBtn.style.cursor = 'pointer';
okBtn.style.border = 'none';
okBtn.style.backgroundColor = '#007bff';
okBtn.style.color = '#fff';
okBtn.style.borderRadius = '4px';
const closeAndResolve = (val) => {
document.body.removeChild(overlay);
resolve(val);
};
cancelBtn.onclick = () => closeAndResolve(null);
okBtn.onclick = () => closeAndResolve(input.value);
input.onkeydown = (e) => {
if (e.key === 'Enter') closeAndResolve(input.value);
if (e.key === 'Escape') closeAndResolve(null);
};
btnContainer.appendChild(cancelBtn);
btnContainer.appendChild(okBtn);
box.appendChild(text);
box.appendChild(input);
box.appendChild(btnContainer);
overlay.appendChild(box);
document.body.appendChild(overlay);
input.focus();
input.select();
});
}
// ========================================================================= // =========================================================================
// 1. GLOBAL STATE DEFINITIONS // 1. GLOBAL STATE DEFINITIONS
// ========================================================================= // =========================================================================
@@ -649,7 +902,10 @@
let keyboardScannerEnabled = false; let keyboardScannerEnabled = false;
let keyboardScanBuffer = ''; let keyboardScanBuffer = '';
let keyboardLastKeyAt = 0; let keyboardLastKeyAt = 0;
const KEYBOARD_SCAN_INTERCHAR_MS = 100; // max time between keystrokes to consider them one scan let physicalScannerModalOpen = false;
const KEYBOARD_SCAN_INTERCHAR_MS = 500; // max time between slower scanner keystrokes
const physicalScanQueue = [];
let keyboardScanProcessing = false;
let editLibraryState = { let editLibraryState = {
itemId: '', itemId: '',
seriesGroupId: '', seriesGroupId: '',
@@ -884,7 +1140,7 @@
name: "Live", name: "Live",
type: "LiveStream", type: "LiveStream",
// This MUST match the inner div where the video should appear // This MUST match the inner div where the video should appear
target: document.querySelector('.library-scan-reader'), target: document.querySelector('.library-scan-reader'),
constraints: { constraints: {
width: 640, width: 640,
height: 480, height: 480,
@@ -897,7 +1153,7 @@
}, },
decoder: { decoder: {
// Keep only the barcode types you actually use to improve performance // Keep only the barcode types you actually use to improve performance
readers: ["code_128_reader", "ean_reader", "code_39_reader"] readers: ["code_128_reader", "ean_reader", "code_39_reader"]
}, },
locate: true locate: true
}, function(err) { }, function(err) {
@@ -991,29 +1247,86 @@
// ========================================================================= // =========================================================================
// Keyboard scanner handling (physical scanners that send chars then Enter) // Keyboard scanner handling (physical scanners that send chars then Enter)
// ========================================================================= // =========================================================================
function getPhysicalScannerInstruction() {
const mode = document.getElementById('scanModeSelect')?.value || 'card_only';
if (mode === 'return_only') return 'Hinweis: Benötigt wird ein Mediencode zur Rückgabe.';
if (mode === 'card_only') return 'Hinweis: Benötigt wird ein Bibliotheksausweis.';
if (mode === 'quick_toggle' && !activeStudentCardId) {
return 'Hinweis: Zuerst wird ein Bibliotheksausweis benötigt.';
}
if (mode === 'quick_toggle') return 'Hinweis: Jetzt wird ein Mediencode benötigt.';
if (mode === 'continuous' && !activeStudentCardId) {
return 'Hinweis: Zuerst wird ein Bibliotheksausweis benötigt.';
}
return 'Hinweis: Jetzt wird der nächste Mediencode benötigt.';
}
function updatePhysicalScannerModal(message, kind = '') {
const instruction = document.getElementById('physicalScannerInstruction');
const status = document.getElementById('physicalScannerStatus');
if (instruction) instruction.textContent = getPhysicalScannerInstruction();
if (status) {
status.textContent = message;
status.classList.remove('ok', 'warn', 'error');
if (kind) status.classList.add(kind);
}
}
function openPhysicalScannerModal() {
const modal = document.getElementById('physicalScannerModal');
if (!modal) return;
physicalScannerModalOpen = true;
keyboardScannerEnabled = true;
keyboardScanBuffer = '';
keyboardLastKeyAt = 0;
modal.style.display = 'flex';
updatePhysicalScannerModal('Warte auf Scan...', 'warn');
document.addEventListener('keydown', keyboardScanKeydownHandler);
document.getElementById('physicalScannerCode')?.focus();
}
function closePhysicalScannerModal() {
physicalScannerModalOpen = false;
keyboardScannerEnabled = false;
keyboardScanBuffer = '';
keyboardLastKeyAt = 0;
document.removeEventListener('keydown', keyboardScanKeydownHandler);
const modal = document.getElementById('physicalScannerModal');
if (modal) modal.style.display = 'none';
const code = document.getElementById('physicalScannerCode');
if (code) code.textContent = '';
}
function keyboardScanKeydownHandler(e) { function keyboardScanKeydownHandler(e) {
// Only active when explicitly enabled // Only active when explicitly enabled
if (!keyboardScannerEnabled) return; if (!keyboardScannerEnabled || !physicalScannerModalOpen) return;
// Ignore if focus is in an input/textarea/contenteditable to avoid interfering with typing if (e.key === 'Escape') {
const active = document.activeElement; e.preventDefault();
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) return; closePhysicalScannerModal();
return;
}
const now = Date.now(); const now = Date.now();
// If Enter/Return pressed -> finalize buffer // If Enter/Return pressed -> finalize buffer
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault();
const code = keyboardScanBuffer.trim(); const code = keyboardScanBuffer.trim();
keyboardScanBuffer = ''; keyboardScanBuffer = '';
keyboardLastKeyAt = 0; keyboardLastKeyAt = 0;
if (!code) return; if (!code) return;
// Process exactly like a camera scan - centralized logic handles the rest! // Scanner keystrokes must not be submitted into the focused form field.
handleScanSuccess(code); const displayCode = document.getElementById('physicalScannerCode');
if (displayCode) displayCode.textContent = code;
updatePhysicalScannerModal('Enter erkannt. Code wird verarbeitet...', 'warn');
processPhysicalScan(code);
return; return;
} }
// Only accept common printable characters; ignore modifier keys // Accept all printable scanner characters, including letters and punctuation.
if (e.key.length === 1) { if (e.key.length === 1) {
// If time gap too big, start new buffer // If time gap too big, start new buffer
if (keyboardLastKeyAt && (now - keyboardLastKeyAt) > KEYBOARD_SCAN_INTERCHAR_MS) { if (keyboardLastKeyAt && (now - keyboardLastKeyAt) > KEYBOARD_SCAN_INTERCHAR_MS) {
@@ -1021,12 +1334,32 @@
} }
keyboardScanBuffer += e.key; keyboardScanBuffer += e.key;
keyboardLastKeyAt = now; keyboardLastKeyAt = now;
// Prevent default so scanner input doesn't accidentally move focus or trigger shortcuts const displayCode = document.getElementById('physicalScannerCode');
if (displayCode) displayCode.textContent = keyboardScanBuffer;
// Prevent default so scanner input is captured even while an input has focus.
e.preventDefault(); e.preventDefault();
} }
} }
function handleScanSuccess(decodedText) { async function processPhysicalScan(code) {
physicalScanQueue.push(code);
if (keyboardScanProcessing) return;
keyboardScanProcessing = true;
try {
while (physicalScanQueue.length > 0) {
await handleScanSuccess(physicalScanQueue.shift());
if (physicalScannerModalOpen) {
const displayCode = document.getElementById('physicalScannerCode');
if (displayCode) displayCode.textContent = '';
}
}
} finally {
keyboardScanProcessing = false;
}
}
async function handleScanSuccess(decodedText) {
const scannedCode = typeof normalizeScannedCode === "function" ? normalizeScannedCode(decodedText) : decodedText; const scannedCode = typeof normalizeScannedCode === "function" ? normalizeScannedCode(decodedText) : decodedText;
if (!scannedCode) return; if (!scannedCode) return;
@@ -1042,26 +1375,28 @@
// 1. Modus: Nur Rückgabe // 1. Modus: Nur Rückgabe
if (mode === 'return_only') { if (mode === 'return_only') {
returnByCode(scannedCode); await returnByCode(scannedCode);
return; return;
} }
// 2. Modus: Nur Ausweis // 2. Modus: Nur Ausweis
if (mode === 'card_only') { if (mode === 'card_only') {
setActiveStudentCard(scannedCode); setActiveStudentCard(scannedCode);
setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}`, 'ok'); setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}. Bitte den nächsten Ausweis scannen.`, 'ok');
showSmallConfirm(`Ausweis erkannt: ${activeStudentCardId}. Sie können den nächsten scannen.`, 'ok');
updatePhysicalScannerModal(`Ausweis erkannt. Bereit für den nächsten Scan.`, 'ok');
return; return;
} }
// 3. Modus: Schnellmodus (1x Ausweis, 1x Buch) // 3. Modus: Schnellmodus (1x Ausweis, 1x Buch)
if (mode === 'quick_toggle') { if (mode === 'quick_toggle') {
processQuickToggleScan(scannedCode); await processQuickToggleScan(scannedCode);
return; return;
} }
// 4. Modus: Dauermodus (1x Ausweis, Nx Bücher) // 4. Modus: Dauermodus (1x Ausweis, Nx Bücher)
if (mode === 'continuous') { if (mode === 'continuous') {
processContinuousScan(scannedCode); await processContinuousScan(scannedCode);
return; return;
} }
} }
@@ -1075,9 +1410,10 @@
setActiveStudentCard(scannedCode); setActiveStudentCard(scannedCode);
setScanStatus(`Neuer Ausweis gesetzt: ${activeStudentCardId}. Bitte Medien scannen.`, 'ok'); setScanStatus(`Neuer Ausweis gesetzt: ${activeStudentCardId}. Bitte Medien scannen.`, 'ok');
showSmallConfirm(`Benutzer gewechselt zu: ${activeStudentCardId}`, 'ok'); showSmallConfirm(`Benutzer gewechselt zu: ${activeStudentCardId}`, 'ok');
updatePhysicalScannerModal('Ausweis erkannt. Jetzt Mediencodes scannen.', 'ok');
setTimeout(() => { setTimeout(() => {
if (document.getElementById('scanModeSelect').value === 'continuous') { if (!physicalScannerModalOpen && document.getElementById('scanModeSelect').value === 'continuous') {
startScanner(); startScanner();
} }
}, 1000); }, 1000);
@@ -1087,15 +1423,84 @@
if (!activeStudentCardId) { if (!activeStudentCardId) {
setScanStatus('Kein Ausweis aktiv! Bitte zuerst einen Schülerausweis scannen.', 'error'); setScanStatus('Kein Ausweis aktiv! Bitte zuerst einen Schülerausweis scannen.', 'error');
showSmallConfirm('Bitte zuerst Ausweis scannen', 'error'); showSmallConfirm('Bitte zuerst Ausweis scannen', 'error');
updatePhysicalScannerModal('Kein Ausweis aktiv. Bitte zuerst den Ausweis scannen.', 'error');
setTimeout(() => { setTimeout(() => {
if (document.getElementById('scanModeSelect').value === 'continuous') { if (!physicalScannerModalOpen && document.getElementById('scanModeSelect').value === 'continuous') {
startScanner(); startScanner();
} }
}, 2000); }, 2000);
return; return;
} }
try {
setScanStatus('Verarbeite Mediencode...', 'warn');
const response = await fetch('/api/library_scan_action', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}',
'X-CSRF-Token': '{{ csrf_token }}'
},
body: JSON.stringify({
student_card_id: activeStudentCardId,
item_code: scannedCode,
action: 'borrow'
})
});
const result = await response.json();
if (!response.ok || !result.ok) {
setScanStatus(result.message || 'Scan-Aktion fehlgeschlagen.', 'error');
showSmallConfirm(result.message || 'Aktion fehlgeschlagen.', 'error');
updatePhysicalScannerModal(result.message || 'Scan-Aktion fehlgeschlagen.', 'error');
} else if (result.action === 'borrowed') {
setScanStatus(`Ausgeliehen: ${result.item_name}`, 'ok');
showSmallConfirm(`Ausgeliehen: ${result.item_name}`, 'ok', {
actionLabel: 'Mit nächstem Ausweis starten',
onAction: startNextStudentCardScan
});
updatePhysicalScannerModal('Ausleihe erfolgreich. Bereit für den nächsten Mediencode.', 'ok');
} else if (result.action === 'returned') {
setScanStatus(`Zurückgegeben: ${result.item_name}`, 'ok');
showSmallConfirm(`Zurückgegeben: ${result.item_name}`, 'ok');
updatePhysicalScannerModal('Rückgabe erfolgreich. Bereit für den nächsten Mediencode.', 'ok');
} else {
setScanStatus(result.message || 'Aktion durchgeführt.', 'ok');
showSmallConfirm(result.message || 'Erfolgreich', 'ok');
updatePhysicalScannerModal('Aktion erfolgreich. Bereit für den nächsten Mediencode.', 'ok');
}
// Tabellen-Ansicht aktualisieren
if (typeof loadLibraryItems === "function") {
await loadLibraryItems();
}
} catch (err) {
console.error('Continuous scan action failed:', err);
setScanStatus('Fehler beim Verarbeiten des Scans.', 'error');
showSmallConfirm('Fehler im Netzwerk/System', 'error');
updatePhysicalScannerModal('Fehler beim Verarbeiten. Bitte erneut scannen.', 'error');
}
setTimeout(() => {
if (!physicalScannerModalOpen && document.getElementById('scanModeSelect').value === 'continuous') {
startScanner();
}
}, 1500);
}
async function processQuickToggleScan(scannedCode) {
// 2. Wenn kein Ausweis gesetzt ist, wird der Code als Ausweis interpretiert
if (!activeStudentCardId) {
setActiveStudentCard(scannedCode);
setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}. Bitte den nächsten Mediencode scannen.`, 'ok');
showSmallConfirm(`Ausweis erkannt: ${activeStudentCardId}. Bitte jetzt den nächsten Mediencode scannen.`, 'ok');
updatePhysicalScannerModal('Ausweis erkannt. Jetzt den Mediencode scannen.', 'ok');
return;
}
// 3. Ausleihe/Rückgabe verarbeiten (wenn Ausweis vorhanden)
try { try {
setScanStatus('Verarbeite Mediencode...', 'warn'); setScanStatus('Verarbeite Mediencode...', 'warn');
const response = await fetch('/api/library_scan_action', { const response = await fetch('/api/library_scan_action', {
@@ -1114,77 +1519,32 @@
const result = await response.json(); const result = await response.json();
if (!response.ok || !result.ok) { if (!response.ok || !result.ok) {
setScanStatus(result.message || 'Scan-Aktion fehlgeschlagen.', 'error'); setScanStatus(result.message || 'Scan-Aktion fehlgeschlagen.', 'error');
showSmallConfirm(result.message || 'Aktion fehlgeschlagen.', 'error'); updatePhysicalScannerModal(result.message || 'Scan-Aktion fehlgeschlagen.', 'error');
} else if (result.action === 'borrowed') {
setScanStatus(`Ausgeliehen: ${result.item_name}`, 'ok');
showSmallConfirm(`Ausgeliehen: ${result.item_name}`, 'ok');
} else if (result.action === 'returned') {
setScanStatus(`Zurückgegeben: ${result.item_name}`, 'ok');
showSmallConfirm(`Zurückgegeben: ${result.item_name}`, 'ok');
} else {
setScanStatus(result.message || 'Aktion durchgeführt.', 'ok');
showSmallConfirm(result.message || 'Erfolgreich', 'ok');
}
// Tabellen-Ansicht aktualisieren
if (typeof loadLibraryItems === "function") {
await loadLibraryItems();
}
} catch (err) {
console.error('Continuous scan action failed:', err);
setScanStatus('Fehler beim Verarbeiten des Scans.', 'error');
showSmallConfirm('Fehler im Netzwerk/System', 'error');
}
setTimeout(() => {
if (document.getElementById('scanModeSelect').value === 'continuous') {
startScanner();
}
}, 1500);
}
async function processQuickToggleScan(scannedCode) {
// 2. Wenn kein Ausweis gesetzt ist, wird der Code als Ausweis interpretiert
if (!activeStudentCardId) {
setActiveStudentCard(scannedCode);
setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}`, 'ok');
return;
}
// 3. Ausleihe/Rückgabe verarbeiten (wenn Ausweis vorhanden)
try {
setScanStatus('Verarbeite Mediencode...', 'warn');
const response = await fetch('/api/library_scan_action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
student_card_id: activeStudentCardId,
item_code: scannedCode
})
});
const result = await response.json();
if (!response.ok || !result.ok) {
setScanStatus(result.message || 'Scan-Aktion fehlgeschlagen.', 'error');
return; return;
} }
if (result.action === 'borrowed') { if (result.action === 'borrowed') {
setScanStatus(`Ausgeliehen: ${result.item_name}`, 'ok'); setScanStatus(`Ausgeliehen: ${result.item_name}`, 'ok');
showSmallConfirm(`Ausgeliehen: ${result.item_name}`, 'ok'); showSmallConfirm(`Ausgeliehen: ${result.item_name}`, 'ok', {
actionLabel: 'Mit nächstem Ausweis starten',
onAction: startNextStudentCardScan
});
updatePhysicalScannerModal('Ausleihe erfolgreich. Bereit für den nächsten Mediencode.', 'ok');
} else if (result.action === 'returned') { } else if (result.action === 'returned') {
setScanStatus(`Zurückgegeben: ${result.item_name}`, 'ok'); setScanStatus(`Zurückgegeben: ${result.item_name}`, 'ok');
showSmallConfirm(`Zurückgegeben: ${result.item_name}`, 'ok'); showSmallConfirm(`Zurückgegeben: ${result.item_name}`, 'ok');
updatePhysicalScannerModal('Rückgabe erfolgreich. Bereit für den nächsten Mediencode.', 'ok');
} else { } else {
setScanStatus(result.message || 'Aktion durchgeführt.', 'ok'); setScanStatus(result.message || 'Aktion durchgeführt.', 'ok');
showSmallConfirm(result.message || 'Aktion durchgeführt.', 'ok'); showSmallConfirm(result.message || 'Aktion durchgeführt.', 'ok');
updatePhysicalScannerModal('Aktion erfolgreich. Bereit für den nächsten Mediencode.', 'ok');
} }
await loadLibraryItems(); await loadLibraryItems();
} catch (err) { } catch (err) {
console.error('Quick scan action failed:', err); console.error('Quick scan action failed:', err);
setScanStatus('Fehler beim Verarbeiten des Scans.', 'error'); setScanStatus('Fehler beim Verarbeiten des Scans.', 'error');
updatePhysicalScannerModal('Fehler beim Verarbeiten. Bitte erneut scannen.', 'error');
} }
} }
@@ -1194,23 +1554,30 @@
try { try {
const resp = await fetch('/api/library_return_by_code', { const resp = await fetch('/api/library_return_by_code', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}',
'X-CSRF-Token': '{{ csrf_token }}'
},
body: JSON.stringify({ item_code: code }) body: JSON.stringify({ item_code: code })
}); });
const result = await resp.json(); const result = await resp.json();
if (!resp.ok || !result.ok) { if (!resp.ok || !result.ok) {
setScanStatus(result.message || 'Rückgabe fehlgeschlagen.', 'error'); setScanStatus(result.message || 'Rückgabe fehlgeschlagen.', 'error');
showSmallConfirm(result.message || 'Rückgabe fehlgeschlagen.', 'error'); showSmallConfirm(result.message || 'Rückgabe fehlgeschlagen.', 'error');
updatePhysicalScannerModal(result.message || 'Rückgabe fehlgeschlagen.', 'error');
return false; return false;
} }
setScanStatus(result.message || `Zurückgegeben: ${result.item_name || ''}`, 'ok'); setScanStatus(result.message || `Zurückgegeben: ${result.item_name || ''}`, 'ok');
showSmallConfirm(result.message || `Zurückgegeben: ${result.item_name || ''}`, 'ok'); showSmallConfirm(result.message || `Zurückgegeben: ${result.item_name || ''}`, 'ok');
updatePhysicalScannerModal('Rückgabe erfolgreich. Bereit für den nächsten Mediencode.', 'ok');
await loadLibraryItems(); await loadLibraryItems();
return true; return true;
} catch (err) { } catch (err) {
console.error('Return by code failed:', err); console.error('Return by code failed:', err);
setScanStatus('Fehler bei Rückgabe.', 'error'); setScanStatus('Fehler bei Rückgabe.', 'error');
showSmallConfirm('Fehler bei Rückgabe.', 'error'); showSmallConfirm('Fehler bei Rückgabe.', 'error');
updatePhysicalScannerModal('Fehler bei Rückgabe. Bitte erneut scannen.', 'error');
return false; return false;
} }
} }
@@ -1243,31 +1610,39 @@
// ========================================================================= // =========================================================================
// 4. UI INTERACTIONS & UTILITIES // 4. UI INTERACTIONS & UTILITIES
// ========================================================================= // =========================================================================
function borrowItem(itemId) { async function borrowItem(itemId) {
const selectedItem = (libraryItems || []).find(item => item._id === itemId); const selectedItem = (libraryItems || []).find(item => item._id === itemId);
if (selectedItem && selectedItem.LibraryDisplayStatus === 'damaged') { if (selectedItem && selectedItem.LibraryDisplayStatus === 'damaged') {
alert('Dieses Medium ist als defekt/zerstört markiert und kann nicht ausgeliehen werden.'); await customAlert('Dieses Medium ist als defekt/zerstört markiert und kann nicht ausgeliehen werden.');
return; return;
} }
const defaultCardId = activeStudentCardId || ''; const defaultCardId = activeStudentCardId || '';
const cardId = (window.prompt('Bitte Bibliotheksausweis-ID eingeben:', defaultCardId) || '').trim().toUpperCase(); const promptCardResult = await customPrompt('Bitte Bibliotheksausweis-ID eingeben:', defaultCardId);
if (promptCardResult === null) return;
const cardId = promptCardResult.trim().toUpperCase();
if (!cardId) { if (!cardId) {
alert('Ausleihe abgebrochen: Für Bibliotheksmedien ist eine gültige Bibliotheksausweis-ID erforderlich.'); await customAlert('Ausleihe abgebrochen: Für Bibliotheksmedien ist eine gültige Bibliotheksausweis-ID erforderlich.');
return; return;
} }
setActiveStudentCard(cardId); setActiveStudentCard(cardId);
const durationInput = (window.prompt('Ausleihdauer in Tagen (optional):') || '').trim(); const promptDurationResult = await customPrompt('Ausleihdauer in Tagen (optional):');
if (promptDurationResult === null) return;
const durationInput = promptDurationResult.trim();
const maxAvailable = Math.max(1, parseInt(selectedItem?.AvailableGroupedCount || selectedItem?.Quantity || 1, 10) || 1); const maxAvailable = Math.max(1, parseInt(selectedItem?.AvailableGroupedCount || selectedItem?.Quantity || 1, 10) || 1);
const countPrompt = (window.prompt(`Anzahl ausleihen? (Standard: 1, verfügbar: ${maxAvailable})`, '1') || '').trim(); const countPromptResult = await customPrompt(`Anzahl ausleihen? (Standard: 1, verfügbar: ${maxAvailable})`, '1');
let borrowCount = parseInt(countPrompt || '1', 10); if (countPromptResult === null) return;
let borrowCount = parseInt(countPromptResult.trim() || '1', 10);
if (!Number.isFinite(borrowCount) || borrowCount < 1) { if (!Number.isFinite(borrowCount) || borrowCount < 1) {
borrowCount = 1; borrowCount = 1;
} }
if (borrowCount > maxAvailable) { if (borrowCount > maxAvailable) {
alert(`Es sind nur ${maxAvailable} Exemplar(e) verfügbar.`); await customAlert(`Es sind nur ${maxAvailable} Exemplar(e) verfügbar.`);
return; return;
} }
@@ -1319,21 +1694,51 @@
if (kind) el.classList.add(kind); if (kind) el.classList.add(kind);
} }
function showSmallConfirm(message, kind='ok') { function showSmallConfirm(message, kind='ok', action = null) {
// Append the small helper text in German // Append the small helper text in German
const helper = 'Sie können fortfahren. Dies ist nur eine kleine Benachrichtigung.'; const helper = 'Sie können fortfahren. Dies ist nur eine kleine Benachrichtigung.';
const el = document.createElement('div'); const el = document.createElement('div');
el.className = `small-popup ${kind === 'error' ? 'error' : 'ok'}`; el.className = `small-popup ${kind === 'error' ? 'error' : 'ok'}`;
el.innerHTML = `<div>${escapeHtml(String(message || ''))}</div><div style="opacity:0.9; margin-left:8px; font-size:0.85em;">${helper}</div><div class="close-x">&times;</div>`; el.innerHTML = `<div>${escapeHtml(String(message || ''))}</div><div style="opacity:0.9; margin-left:8px; font-size:0.85em;">${helper}</div>`;
if (action && typeof action.onAction === 'function') {
const actionButton = document.createElement('button');
actionButton.type = 'button';
actionButton.textContent = action.actionLabel || 'Weiter';
actionButton.style.marginLeft = '8px';
actionButton.style.padding = '6px 10px';
actionButton.style.cursor = 'pointer';
actionButton.addEventListener('click', () => {
if (el.parentNode) el.parentNode.removeChild(el);
action.onAction();
});
el.appendChild(actionButton);
}
const closeButton = document.createElement('div');
closeButton.className = 'close-x';
closeButton.innerHTML = '&times;';
el.appendChild(closeButton);
document.body.appendChild(el); document.body.appendChild(el);
// close handler // close handler
el.querySelector('.close-x').addEventListener('click', () => { closeButton.addEventListener('click', () => {
if (el && el.parentNode) el.parentNode.removeChild(el); if (el && el.parentNode) el.parentNode.removeChild(el);
}); });
// auto remove after 3 seconds // auto remove after 3 seconds
setTimeout(() => { try { if (el && el.parentNode) el.parentNode.removeChild(el); } catch(e){} }, 3000); setTimeout(() => { try { if (el && el.parentNode) el.parentNode.removeChild(el); } catch(e){} }, 3000);
} }
async function startNextStudentCardScan() {
setActiveStudentCard('');
setScanStatus('Bereit für den nächsten Ausweis.', 'warn');
updatePhysicalScannerModal('Bereit für den nächsten Ausweis.', 'warn');
const mode = document.getElementById('scanModeSelect')?.value;
if (!physicalScannerModalOpen && (mode === 'quick_toggle' || mode === 'continuous') && !scannerRunning) {
await startScanner();
}
}
function setActiveStudentCard(cardId) { function setActiveStudentCard(cardId) {
activeStudentCardId = (cardId || '').trim().toUpperCase(); activeStudentCardId = (cardId || '').trim().toUpperCase();
const input = document.getElementById('activeStudentCard'); const input = document.getElementById('activeStudentCard');
@@ -1448,7 +1853,7 @@
const toggleBtn = document.getElementById('toggleScannerBtn'); const toggleBtn = document.getElementById('toggleScannerBtn');
const resetBtn = document.getElementById('resetCardBtn'); const resetBtn = document.getElementById('resetCardBtn');
const modeSelect = document.getElementById('scanModeSelect'); const modeSelect = document.getElementById('scanModeSelect');
const keyboardToggle = document.getElementById('keyboardScannerToggle'); const physicalScannerBtn = document.getElementById('physicalScannerBtn');
if (toggleBtn) { if (toggleBtn) {
toggleBtn.addEventListener('click', async () => { toggleBtn.addEventListener('click', async () => {
@@ -1469,6 +1874,9 @@
if (modeSelect) { if (modeSelect) {
modeSelect.addEventListener('change', () => { modeSelect.addEventListener('change', () => {
if (physicalScannerModalOpen) {
updatePhysicalScannerModal('Warte auf Scan...', 'warn');
}
if (modeSelect.value === 'quick_toggle' && !activeStudentCardId) { if (modeSelect.value === 'quick_toggle' && !activeStudentCardId) {
setScanStatus('Schnellmodus: zuerst Bibliotheksausweis scannen.', 'warn'); setScanStatus('Schnellmodus: zuerst Bibliotheksausweis scannen.', 'warn');
} else if (modeSelect.value === 'card_only') { } else if (modeSelect.value === 'card_only') {
@@ -1477,18 +1885,24 @@
}); });
} }
if (keyboardToggle) { if (physicalScannerBtn) {
keyboardToggle.addEventListener('change', () => { physicalScannerBtn.addEventListener('click', openPhysicalScannerModal);
keyboardScannerEnabled = !!keyboardToggle.checked; }
if (keyboardScannerEnabled) {
document.addEventListener('keydown', keyboardScanKeydownHandler); const closePhysicalScannerBtn = document.getElementById('closePhysicalScannerBtn');
setScanStatus('Physischer Scanner aktiv (Schnellmodus empfohlen).', 'ok'); const stopPhysicalScannerBtn = document.getElementById('stopPhysicalScannerBtn');
} else { if (closePhysicalScannerBtn) {
document.removeEventListener('keydown', keyboardScanKeydownHandler); closePhysicalScannerBtn.addEventListener('click', closePhysicalScannerModal);
setScanStatus('Physischer Scanner deaktiviert.', 'warn'); closePhysicalScannerBtn.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
closePhysicalScannerModal();
} }
}); });
} }
if (stopPhysicalScannerBtn) {
stopPhysicalScannerBtn.addEventListener('click', closePhysicalScannerModal);
}
} }
// Run when DOM structure is entirely ready // Run when DOM structure is entirely ready
+66 -6
View File
@@ -174,29 +174,33 @@
<style> <style>
#legalTabs .nav-link { #legalTabs .nav-link {
color: #495057; color: var(--ui-text-muted);
border-color: transparent;
} }
#legalTabs .nav-link.active { #legalTabs .nav-link.active {
font-weight: 600; font-weight: 600;
color: #0d6efd; color: #60a5fa;
background: var(--ui-surface-soft);
border-color: var(--ui-border) var(--ui-border) var(--ui-surface-soft);
} }
.license-content { .license-content {
background-color: var(--ui-surface-soft); background-color: var(--ui-surface-soft);
padding: 20px 30px; padding: 20px 30px;
border-radius: 0 0 5px 5px; border-radius: 0 0 5px 5px;
border: 1px solid #dee2e6; border: 1px solid var(--ui-border);
border-top: none; border-top: none;
color: var(--ui-text);
} }
.license-content h2 { .license-content h2 {
margin-top: 10px; margin-top: 10px;
} }
.license-content h3 { .license-content h3 {
margin-top: 1.4em; margin-top: 1.4em;
color: #343a40; color: var(--ui-title);
} }
.license-content h4 { .license-content h4 {
margin-top: 1.1em; margin-top: 1.1em;
color: #495057; color: var(--ui-text);
} }
.license-content ol, .license-content ol,
.license-content ul { .license-content ul {
@@ -208,7 +212,7 @@
} }
.license-content hr { .license-content hr {
margin: 25px 0; margin: 25px 0;
border-color: #dee2e6; border-color: var(--ui-border);
} }
.license-exception-notice { .license-exception-notice {
margin: 20px 0; margin: 20px 0;
@@ -229,5 +233,61 @@
font-weight: bold; font-weight: bold;
color: #0d6efd; color: #0d6efd;
} }
:root[data-theme="dark"] #legalTabs {
border-bottom-color: var(--ui-border);
}
:root[data-theme="dark"] #legalTabs .nav-link:hover {
color: #bfdbfe;
background: var(--ui-bg-accent);
border-color: var(--ui-border);
}
:root[data-theme="dark"] #legalTabs .nav-link.active {
color: #bfdbfe;
background: var(--ui-surface-soft);
border-color: var(--ui-border) var(--ui-border) var(--ui-surface-soft);
}
:root[data-theme="dark"] .license-content {
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .license-content p,
:root[data-theme="dark"] .license-content li {
color: var(--ui-text-muted);
}
:root[data-theme="dark"] .license-content strong,
:root[data-theme="dark"] .license-content h2,
:root[data-theme="dark"] .license-content h3,
:root[data-theme="dark"] .license-content h4 {
color: var(--ui-title) !important;
}
:root[data-theme="dark"] .license-content hr {
border-color: var(--ui-border);
}
:root[data-theme="dark"] .license-exception-notice {
background: #422006 !important;
border-color: #f59e0b !important;
border-left-color: #fb923c !important;
color: #fde68a !important;
}
:root[data-theme="dark"] .license-exception-notice h3,
:root[data-theme="dark"] .license-exception-notice p,
:root[data-theme="dark"] .license-exception-notice a {
color: #fde68a !important;
}
:root[data-theme="dark"] .license-content code {
background: #082f49;
color: #bae6fd;
}
</style> </style>
{% endblock %} {% endblock %}
+75 -5
View File
@@ -229,9 +229,11 @@
#searchInput { #searchInput {
padding: 8px; padding: 8px;
border: 1px solid #ddd; border: 1px solid var(--ui-border);
border-radius: 4px; border-radius: 4px;
width: 200px; width: 200px;
background: var(--ui-bg);
color: var(--ui-text);
} }
.date-range { .date-range {
@@ -242,8 +244,17 @@
.date-range input { .date-range input {
padding: 8px; padding: 8px;
border: 1px solid #ddd; border: 1px solid var(--ui-border);
border-radius: 4px; border-radius: 4px;
background: var(--ui-bg);
color: var(--ui-text);
}
#searchInput:focus,
.date-range input:focus {
outline: none;
border-color: #60a5fa;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.22);
} }
.status-badge { .status-badge {
display: inline-block; display: inline-block;
@@ -308,13 +319,15 @@
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1); background: var(--ui-surface);
color: var(--ui-text);
box-shadow: var(--ui-shadow-sm);
} }
th, td { th, td {
padding: 12px 15px; padding: 12px 15px;
text-align: left; text-align: left;
border-bottom: 1px solid #ddd; border-bottom: 1px solid var(--ui-border);
} }
th { th {
@@ -328,7 +341,7 @@
} }
tbody tr:hover { tbody tr:hover {
background-color: #f5f5f5; background-color: var(--ui-bg-accent);
} }
.navigation-buttons { .navigation-buttons {
@@ -358,6 +371,52 @@
.export-button:hover { .export-button:hover {
background-color: #218838; background-color: #218838;
} }
:root[data-theme="dark"] .conflict-summary-banner {
background: #422006 !important;
border-color: #f59e0b !important;
border-left-color: #fb923c !important;
color: #fde68a !important;
}
:root[data-theme="dark"] .banner-close {
color: #fde68a;
}
:root[data-theme="dark"] .banner-close:hover {
color: #fff7ed;
}
:root[data-theme="dark"] .conflict-badge {
background: #422006 !important;
border-color: #f59e0b !important;
color: #fde68a !important;
}
:root[data-theme="dark"] #logsTable {
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
}
:root[data-theme="dark"] #logsTable th {
background: #1d4ed8 !important;
color: #ffffff !important;
border-color: #60a5fa !important;
}
:root[data-theme="dark"] #logsTable th:hover {
background: #2563eb !important;
}
:root[data-theme="dark"] #logsTable td {
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] #logsTable tbody tr:hover td {
background: var(--ui-bg-accent) !important;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.filter-controls { .filter-controls {
@@ -373,6 +432,17 @@
width: 100%; width: 100%;
justify-content: space-between; justify-content: space-between;
} }
.navigation-buttons {
gap: 10px;
flex-wrap: wrap;
}
.navigation-buttons .button,
.navigation-buttons .export-button {
flex: 1 1 180px;
text-align: center;
}
} }
</style> </style>
{% endblock %} {% endblock %}
+7 -2
View File
@@ -179,10 +179,12 @@
</td> </td>
<td class="text-end"> <td class="text-end">
<div class="btn-group" role="group"> <div class="btn-group" role="group">
{% if mail_service_enabled %}
<button type="button" class="btn btn-sm btn-outline-primary" <button type="button" class="btn btn-sm btn-outline-primary"
onclick="openEmailModal('{{ item.id }}', '{{ item.schueler_name }}', '{{ item.email }}')"> onclick="openEmailModal('{{ item.id }}', '{{ item.schueler_name }}', '{{ item.email }}')">
<i class="bi bi-envelope"></i> E-Mail <i class="bi bi-envelope"></i> E-Mail
</button> </button>
{% endif %}
<button type="button" class="btn btn-sm btn-outline-warning text-dark" <button type="button" class="btn btn-sm btn-outline-warning text-dark"
onclick="resetMahnung('{{ item.id }}', '{{ item.schueler_name }}')"> onclick="resetMahnung('{{ item.id }}', '{{ item.schueler_name }}')">
<i class="bi bi-arrow-counterclockwise"></i> Zurücksetzen <i class="bi bi-arrow-counterclockwise"></i> Zurücksetzen
@@ -254,6 +256,7 @@ function submitEmailMahnung() {
const loanId = document.getElementById('modalLoanId').value; const loanId = document.getElementById('modalLoanId').value;
const email = document.getElementById('modalEmail').value; const email = document.getElementById('modalEmail').value;
const message = document.getElementById('modalMessage').value; const message = document.getElementById('modalMessage').value;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
if (!email) { if (!email) {
alert('Bitte geben Sie eine gültige E-Mail-Adresse ein.'); alert('Bitte geben Sie eine gültige E-Mail-Adresse ein.');
@@ -262,7 +265,7 @@ function submitEmailMahnung() {
fetch('/mahnungen_send_email', { fetch('/mahnungen_send_email', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({ loan_id: loanId, email: email, message: message }) body: JSON.stringify({ loan_id: loanId, email: email, message: message })
}) })
.then(response => response.json()) .then(response => response.json())
@@ -287,9 +290,11 @@ function resetMahnung(loanId, studentName) {
return; return;
} }
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
fetch('/mahnungen_reset', { fetch('/mahnungen_reset', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({ loan_id: loanId }) body: JSON.stringify({ loan_id: loanId })
}) })
.then(response => response.json()) .then(response => response.json())
+357 -30
View File
@@ -2343,7 +2343,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div class="filter-container"> <div class="filter-container">
<div class="filter-group"> <div class="filter-group">
<div class="filter-header"> <div class="filter-header">
<label>Unterrichtsfach:</label> <label>{{ filter_names.get('2', 'Fach') }}</label>
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter1-dropdown')">▼</button> <button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter1-dropdown')">▼</button>
<button type="button" class="clear-filter" onclick="clearFilter(1)">Clear</button> <button type="button" class="clear-filter" onclick="clearFilter(1)">Clear</button>
</div> </div>
@@ -2357,7 +2357,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div class="filter-group"> <div class="filter-group">
<div class="filter-header"> <div class="filter-header">
<label>Jahrgangsstufe:</label> <label>{{ filter_names.get('1', 'Klassenstufe') }}</label>
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter2-dropdown')">▼</button> <button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter2-dropdown')">▼</button>
<button type="button" class="clear-filter" onclick="clearFilter(2)">Clear</button> <button type="button" class="clear-filter" onclick="clearFilter(2)">Clear</button>
</div> </div>
@@ -2371,7 +2371,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div class="filter-group"> <div class="filter-group">
<div class="filter-header"> <div class="filter-header">
<label>Schlagwort:</label> <label>{{ filter_names.get('3', 'Schlagwort') }}</label>
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')">▼</button> <button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')">▼</button>
<button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button> <button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button>
</div> </div>
@@ -2420,9 +2420,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div id="table-view-header" class="table-view-header" aria-hidden="true"> <div id="table-view-header" class="table-view-header" aria-hidden="true">
<span>Name</span> <span>Name</span>
<span>Ort</span> <span>Ort</span>
<span>Unterrichtsfach</span> <span>{{ filter_names.get('2', 'Fach') }}</span>
<span>Jahrgangsstufe</span> <span>{{ filter_names.get('1', 'Jahrgangsstufe') }}</span>
<span>Schlagwort</span> <span>{{ filter_names.get('3', 'Schlagwort') }}</span>
<span>Barcode</span> <span>Barcode</span>
<span>Anzahl</span> <span>Anzahl</span>
</div> </div>
@@ -3418,14 +3418,24 @@ document.addEventListener('DOMContentLoaded', ()=>{
`<form method="POST" action="{{ url_for('ausleihen', id='') }}${item._id}"> `<form method="POST" action="{{ url_for('ausleihen', id='') }}${item._id}">
${isGroupedItem ? ` ${isGroupedItem ? `
<div class="grouped-borrow-controls" style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:8px; align-items:center;"> <div class="grouped-borrow-controls" style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:8px; align-items:center;">
<label style="font-size:0.85rem; margin:0;">Anzahl:</label> <label style="font-size:0.85rem; margin:0;">Anzahl:</label>
<input type="number" name="exemplare_count" min="1" max="${availableGroupedCount}" value="1" style="width:74px; padding:4px;"> <input type="number"
<label style="font-size:0.85rem; margin:0;">oder Code:</label> name="exemplare_count"
<select id="specific-item-card-${item._id}" name="specific_item_id" style="max-width:190px; padding:4px;"> min="1"
<option value="">Automatisch wählen</option> max="${availableGroupedCount}"
${groupedAvailableUnits.map(unit => `<option value="${unit.id}">${unit.code}</option>`).join('')} value="1"
</select> style="width:74px; padding:4px;"
</div>` : ''} oninput="if(this.value){ this.form.querySelector('[name=specific_item_id]').value = ''; }">
<label style="font-size:0.85rem; margin:0;">oder Code:</label>
<select id="specific-item-card-${item._id}"
name="specific_item_id"
style="max-width:190px; padding:4px;"
onchange="if(this.value !== ''){ this.form.querySelector('[name=exemplare_count]').value = '1'; }">
<option value="">Automatisch wählen</option>
${groupedAvailableUnits.map(unit => `<option value="${unit.id}">${unit.code}</option>`).join('')}
</select>
</div>` : ''}
<button class="ausleihen" type="submit">Ausleihen</button> <button class="ausleihen" type="submit">Ausleihen</button>
</form>` </form>`
: isBorrowedByMe ? : isBorrowedByMe ?
@@ -4053,21 +4063,29 @@ document.addEventListener('DOMContentLoaded', ()=>{
const badgeText = entry.type === 'repair' ? 'Repariert' : 'Schaden'; const badgeText = entry.type === 'repair' ? 'Repariert' : 'Schaden';
const metaLine = entry.meta ? `<div style="font-size:0.84rem;color:#4b5563;">${escapeHtml(entry.meta)}</div>` : ''; const metaLine = entry.meta ? `<div style="font-size:0.84rem;color:#4b5563;">${escapeHtml(entry.meta)}</div>` : '';
return ` return `
<div style="border:1px solid var(--ui-border);border-radius:8px;padding:10px;background:var(--ui-surface);display:grid;gap:6px;"> <div class="damage-history-entry ${entry.type === 'repair' ? 'is-repair' : 'is-report'}">
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;"> <div class="damage-history-entry-head">
<span style="display:inline-block;padding:2px 8px;border-radius:999px;font-size:0.75rem;font-weight:700;${badgeStyle}">${badgeText}</span> <span class="damage-history-badge" style="${badgeStyle}">${badgeText}</span>
<span style="font-size:0.84rem;color:#475569;">${entry.dateLabel}</span> <time>${entry.dateLabel}</time>
</div> </div>
<div style="font-size:0.9rem;color:#0f172a;"><strong>Von:</strong> ${entry.actor}</div> <div class="damage-history-actor"><strong>Von</strong>${entry.actor}</div>
<div style="font-size:0.92rem;color:#1f2937;">${entry.description}</div> <div class="damage-history-description">${entry.description}</div>
${metaLine} ${entry.meta ? `<div class="damage-history-meta">${escapeHtml(entry.meta)}</div>` : ''}
</div> </div>
`; `;
}).join('') }).join('')
: '<div style="font-size:0.92rem;color:#64748b;">Keine Beschädigungs-Historie vorhanden.</div>'; : '<div style="font-size:0.92rem;color:#64748b;">Keine Beschädigungs-Historie vorhanden.</div>';
modalContent.innerHTML = ` modalContent.innerHTML = `
<h2>${escapeHtml(item.Name || '')}</h2> <div class="item-modal-heading">
<div>
<span class="item-modal-eyebrow">Objektdetails</span>
<h2>${escapeHtml(item.Name || '')}</h2>
</div>
<span class="item-modal-status ${isBorrowed ? 'is-borrowed' : 'is-available'}">
<span class="item-modal-status-dot"></span>${isBorrowed ? 'Ausgeliehen' : 'Verfügbar'}
</span>
</div>
${borrowerInfoHtml} ${borrowerInfoHtml}
${appointmentInfoHtml} ${appointmentInfoHtml}
@@ -4080,7 +4098,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
` : ''} ` : ''}
</div> </div>
<div class="modal-details"> <div class="modal-details item-modal-details">
<div class="detail-group"> <div class="detail-group">
<div class="detail-label">Ort:</div> <div class="detail-label">Ort:</div>
<div class="detail-value">${escapeHtml(item.Ort || '-')}</div> <div class="detail-value">${escapeHtml(item.Ort || '-')}</div>
@@ -4135,22 +4153,22 @@ document.addEventListener('DOMContentLoaded', ()=>{
</div> </div>
{% if current_permissions.actions.get('can_view_logs', False) %} {% if current_permissions.actions.get('can_view_logs', False) %}
<div class="detail-group full-width" style="margin-top:12px;"> <div class="detail-group full-width item-modal-history" style="margin-top:12px;">
<div class="detail-label" style="font-weight:600; color:#374151;">Beschädigungs-Historie</div> <div class="detail-label" style="font-weight:600; color:#374151;">Beschädigungs-Historie</div>
<div class="detail-value"> <div class="detail-value">
<button id="toggle-damage-history" class="calendar-toggle-btn" style="margin-bottom:12px; padding:10px 16px; border-radius:6px; background:#f3f4f6; border:1px solid #d1d5db; font-weight:500; cursor:pointer; display:inline-flex; align-items:center; gap:8px; transition:all 0.2s ease;"> <button id="toggle-damage-history" class="calendar-toggle-btn modal-section-toggle" style="margin-bottom:12px;">
<span>🛠️</span> <span>🛠️</span>
<span id="toggle-damage-history-text">Historie anzeigen</span> <span id="toggle-damage-history-text">Historie anzeigen</span>
</button> </button>
<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 id="damage-history-panel" class="modal-history-panel" style="display:none;">
<div style="display:grid; gap:10px;">${damageHistoryHtml}</div> <div class="damage-history-list">${damageHistoryHtml}</div>
</div> </div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
<div class="detail-group full-width" style="margin-top:12px; padding:10px; border:1px solid #e3e3e3; border-radius:8px;"> <div class="detail-group full-width" style="margin-top:12px; padding:10px; border:1px solid #e3e3e3; border-radius:8px;">
<div class="detail-label">Verfügbarkeit prüfen:</div> <div class="detail-label">Verfügbarkeit prüfen</div>
<div class="detail-value"> <div class="detail-value">
<div style="display:flex; flex-wrap:wrap; gap:8px; align-items:center;"> <div style="display:flex; flex-wrap:wrap; gap:8px; align-items:center;">
<input type="date" id="avail-date" style="padding:6px;"> <input type="date" id="avail-date" style="padding:6px;">
@@ -4170,10 +4188,10 @@ document.addEventListener('DOMContentLoaded', ()=>{
</div> </div>
</div> </div>
<div class="detail-group full-width" style="margin-top:15px;"> <div class="detail-group full-width item-modal-bookings" style="margin-top:15px;">
<div class="detail-label">Geplante Ausleihen:</div> <div class="detail-label">Geplante Ausleihen:</div>
<div class="detail-value"> <div class="detail-value">
<button id="toggle-bookings" class="calendar-toggle-btn" style="margin-bottom:12px; padding:10px 16px; border-radius:6px; background:#f3f4f6; border:1px solid #d1d5db; font-weight:500; cursor:pointer; display:inline-flex; align-items:center; gap:8px; transition:all 0.2s ease;"> <button id="toggle-bookings" class="calendar-toggle-btn modal-section-toggle" style="margin-bottom:12px;">
<span>📅</span> <span>📅</span>
<span id="toggle-bookings-text">Kalender anzeigen</span> <span id="toggle-bookings-text">Kalender anzeigen</span>
</button> </button>
@@ -5371,5 +5389,314 @@ document.addEventListener('DOMContentLoaded', ()=>{
border-radius: 10px !important; border-radius: 10px !important;
} }
} }
/* Detailed item modal: compact hierarchy and calm information density. */
#item-modal .modal-content {
width: min(94vw, 880px);
max-width: 880px;
margin: 4vh auto;
padding: 0;
max-height: 92vh;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
border: 1px solid #d8e1eb;
border-radius: 16px;
background: var(--ui-surface);
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.22);
}
#item-modal .modal-content > *:not(.item-modal-heading):not(.modal-image-container):not(.modal-details):not(.modal-actions) {
margin-left: 28px;
margin-right: 28px;
}
#item-modal .item-modal-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 26px 30px 22px;
border-bottom: 1px solid #e6edf4;
background: linear-gradient(180deg, #f8fbfe 0%, var(--ui-surface) 100%);
}
#item-modal .item-modal-eyebrow {
display: block;
margin-bottom: 5px;
color: #708197;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
#item-modal .item-modal-heading h2 {
margin: 0;
color: #172536;
font-size: clamp(1.25rem, 2.5vw, 1.7rem);
line-height: 1.2;
text-align: left;
overflow-wrap: anywhere;
}
#item-modal .item-modal-status {
display: inline-flex;
align-items: center;
gap: 7px;
flex: 0 0 auto;
margin-top: 5px;
padding: 6px 10px;
border: 1px solid;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 800;
white-space: nowrap;
}
#item-modal .item-modal-status.is-available {
color: #17633b;
border-color: #b9e2ca;
background: #effaf3;
}
#item-modal .item-modal-status.is-borrowed {
color: #9b3d32;
border-color: #f2c5bf;
background: #fff4f2;
}
#item-modal .item-modal-status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
#item-modal .modal-image-container {
min-height: 180px;
margin: 0;
padding: 22px 28px;
border-radius: 0;
background: #f4f7fa;
}
#item-modal .modal-image {
max-height: 320px;
border-radius: 10px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
}
#item-modal .item-modal-details {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 22px;
margin: 0;
padding: 24px 28px 4px;
}
#item-modal .item-modal-details .detail-group {
display: block;
min-width: 0;
margin: 0;
padding: 12px 0;
border-bottom: 1px solid #edf1f5;
}
#item-modal .item-modal-details .detail-label {
min-width: 0;
margin: 0 0 4px;
color: #718096;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
#item-modal .item-modal-details .detail-value {
min-width: 0;
color: #243447;
font-size: 0.95rem;
line-height: 1.45;
overflow-wrap: anywhere;
}
#item-modal .item-modal-details .detail-group.full-width,
#item-modal .item-modal-details .item-modal-history,
#item-modal .item-modal-details .item-modal-bookings {
grid-column: 1 / -1;
}
#item-modal .item-modal-details .detail-group.full-width .detail-value {
padding: 12px 14px;
border: 1px solid #e1e8ef;
border-radius: 9px;
background: #f8fafc;
}
#item-modal .item-modal-details .item-modal-history,
#item-modal .item-modal-details .item-modal-bookings {
margin-top: 12px !important;
padding: 16px;
border: 1px solid #dfe7ef;
border-radius: 12px;
background: #fbfcfd;
}
#item-modal .modal-section-toggle {
min-height: 38px;
margin: 0 0 12px !important;
padding: 8px 12px;
border: 1px solid #cdd8e3;
border-radius: 8px;
background: var(--ui-surface);
color: #36516c;
font-size: 0.86rem;
font-weight: 750;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
#item-modal .modal-section-toggle:hover {
border-color: #9db6cc;
background: #f1f6fa;
}
#item-modal .modal-history-panel {
margin-top: 4px;
padding: 12px;
border: 1px solid #e2e9f0;
border-radius: 10px;
background: #f5f8fb;
}
#item-modal .damage-history-list {
display: grid;
gap: 9px;
}
#item-modal .damage-history-entry {
position: relative;
display: grid;
gap: 7px;
padding: 12px 14px 12px 17px;
border: 1px solid #dce5ed;
border-radius: 9px;
background: var(--ui-surface);
}
#item-modal .damage-history-entry::before {
position: absolute;
inset: 0 auto 0 0;
width: 3px;
content: '';
border-radius: 9px 0 0 9px;
background: #e35d54;
}
#item-modal .damage-history-entry.is-repair::before {
background: #3aa76d;
}
#item-modal .damage-history-entry-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
#item-modal .damage-history-badge {
padding: 3px 8px;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 800;
}
#item-modal .damage-history-entry time,
#item-modal .damage-history-meta {
color: #738196;
font-size: 0.78rem;
}
#item-modal .damage-history-actor {
display: flex;
gap: 5px;
color: #425466;
font-size: 0.84rem;
}
#item-modal .damage-history-actor strong {
color: #718096;
font-weight: 700;
}
#item-modal .damage-history-description {
color: #243447;
font-size: 0.91rem;
line-height: 1.4;
}
#item-modal .modal-actions {
justify-content: flex-end;
gap: 9px;
margin: 0;
padding: 18px 28px 24px;
border-top: 1px solid #e6edf4;
background: #fbfcfd;
}
@media (max-width: 640px) {
#item-modal .modal-content {
width: calc(100vw - 18px);
margin: 9px auto;
max-height: calc(100dvh - 18px);
overflow-y: auto;
border-radius: 13px;
}
#item-modal .item-modal-heading {
align-items: flex-start;
flex-direction: column;
gap: 10px;
padding: 21px 20px 17px;
}
#item-modal .item-modal-status {
margin-top: 0;
}
#item-modal .modal-image-container {
padding: 16px 18px;
}
#item-modal .item-modal-details {
grid-template-columns: 1fr;
gap: 0;
padding: 16px 18px 0;
}
#item-modal .item-modal-details .detail-group.full-width,
#item-modal .item-modal-details .item-modal-history,
#item-modal .item-modal-details .item-modal-bookings {
grid-column: auto;
}
#item-modal .item-modal-details .item-modal-history,
#item-modal .item-modal-details .item-modal-bookings {
padding: 13px;
}
#item-modal .modal-actions {
justify-content: stretch;
padding: 15px 18px 19px;
}
#item-modal .modal-actions > *,
#item-modal .modal-actions form,
#item-modal .modal-actions button {
flex: 1 1 100%;
width: 100%;
}
}
</style> </style>
{% endblock %} {% endblock %}
+105 -67
View File
@@ -1,135 +1,173 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Filter verwalten{% endblock %} {% block title %}Filter verwalten{% endblock %}
{% block content %} {% block content %}
<div class="container"> <div class="container py-4">
<h1 class="mb-4">Filterwerte verwalten</h1> <div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center pb-3 mb-4 border-bottom gap-3">
<div>
<div class="row"> <h1 class="h2 fw-bold mb-1">Filterwerte verwalten</h1>
<!-- Filter 1 --> <p class="text-muted mb-0">Verwalten und Bearbeiten Sie die Zuordnungswerte für das Inventar.</p>
<div class="col-md-4"> </div>
<div class="card mb-4"> <div>
<div class="card-header"> <a href="{{ url_for('home_admin') }}" class="btn btn-outline-secondary d-inline-flex align-items-center">
<h2 class="card-title h5 mb-0">{{ filter_names.get('1', 'Jahrgang') }} (Filter 1)</h2> <i class="bi bi-arrow-left me-2"></i> Zurück zur Admin-Übersicht
</a>
</div>
</div>
<div class="row g-4">
<div class="col-lg-6">
<div class="card shadow-sm border-0 h-100 rounded-3">
<div class="card-header bg-white border-bottom py-3 d-flex justify-content-between align-items-center">
<h2 class="card-title h5 mb-0 fw-semibold text-primary">
<i class="bi bi-funnel me-2"></i>{{ filter_names.get('1', 'Jahrgangsstufe') }}
</h2>
<span class="badge bg-primary-subtle text-primary rounded-pill">Filter 1</span>
</div> </div>
<div class="card-body"> <div class="card-body p-4">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=1) }}" class="mb-4"> <form method="POST" action="{{ url_for('add_filter_value', filter_num=1) }}" class="mb-4">
<label class="form-label fw-medium text-secondary">Neuen Wert hinzufügen</label>
<div class="input-group"> <div class="input-group">
<input type="text" name="value" class="form-control" placeholder="Neuer Wert..." required> <input type="text" name="value" class="form-control" placeholder="Wert eingeben..." required>
<button type="submit" class="btn btn-primary">Hinzufügen</button> <button type="submit" class="btn btn-primary d-inline-flex align-items-center">
<i class="bi bi-plus-lg me-1"></i> Hinzufügen
</button>
</div> </div>
</form> </form>
<h5 class="mb-3">Vorhandene Werte</h5> <div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="h6 text-uppercase text-muted fw-bold mb-0">Vorhandene Werte</h3>
<span class="badge bg-secondary rounded-pill">{{ filter1_values|length if filter1_values else 0 }}</span>
</div>
{% if filter1_values %} {% if filter1_values %}
<div class="list-group"> <div class="list-group list-group-flush border rounded-3 overflow-hidden">
{% for value in filter1_values %} {% for value in filter1_values %}
<div class="list-group-item"> <div class="list-group-item p-3">
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center">
<span>{{ value }}</span> <span class="fw-medium">{{ value }}</span>
<div> <div class="btn-group btn-group-sm">
<button type="button" class="btn btn-sm btn-secondary me-1" onclick="toggleEdit('edit-1-{{ loop.index }}')">Bearbeiten</button> <button type="button" class="btn btn-outline-secondary" onclick="toggleEdit('edit-1-{{ loop.index }}')">
<i class="bi bi-pencil me-1"></i> Bearbeiten
</button>
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=1, value=value) }}" class="d-inline"> <form method="POST" action="{{ url_for('remove_filter_value', filter_num=1, value=value) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger" <button type="submit" class="btn btn-outline-danger"
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');"> onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
Entfernen <i class="bi bi-trash me-1"></i> Entfernen
</button> </button>
</form> </form>
</div> </div>
</div> </div>
<form id="edit-1-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=1, old_value=value) }}" style="display: none;" class="mt-2">
<form id="edit-1-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=1, old_value=value) }}" style="display: none;" class="mt-3 pt-3 border-top">
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<input type="text" name="new_value" class="form-control" value="{{ value }}" required> <input type="text" name="new_value" class="form-control" value="{{ value }}" required>
<button type="submit" class="btn btn-primary" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">Speichern</button> <button type="submit" class="btn btn-success" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">
<button type="button" class="btn btn-secondary" onclick="toggleEdit('edit-1-{{ loop.index }}')">Abbrechen</button> <i class="bi bi-check-lg me-1"></i> Speichern
</button>
<button type="button" class="btn btn-outline-secondary" onclick="toggleEdit('edit-1-{{ loop.index }}')">
Abbrechen
</button>
</div> </div>
</form> </form>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<div class="alert alert-info">Keine Werte definiert.</div> <div class="alert alert-light border text-center text-muted mb-0">
<i class="bi bi-info-circle me-1"></i> Keine Werte definiert.
</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
<!-- Filter 2 --> <div class="col-lg-6">
<div class="col-md-4"> <div class="card shadow-sm border-0 h-100 rounded-3">
<div class="card mb-4"> <div class="card-header bg-white border-bottom py-3 d-flex justify-content-between align-items-center">
<div class="card-header"> <h2 class="card-title h5 mb-0 fw-semibold text-primary">
<h2 class="card-title h5 mb-0">{{ filter_names.get('2', 'Fach') }} (Filter 2)</h2> <i class="bi bi-funnel me-2"></i>{{ filter_names.get('2', 'Fachgebiet') }}
</h2>
<span class="badge bg-primary-subtle text-primary rounded-pill">Filter 2</span>
</div> </div>
<div class="card-body"> <div class="card-body p-4">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=2) }}" class="mb-4"> <form method="POST" action="{{ url_for('add_filter_value', filter_num=2) }}" class="mb-4">
<label class="form-label fw-medium text-secondary">Neuen Wert hinzufügen</label>
<div class="input-group"> <div class="input-group">
<input type="text" name="value" class="form-control" placeholder="Neuer Wert..." required> <input type="text" name="value" class="form-control" placeholder="Wert eingeben..." required>
<button type="submit" class="btn btn-primary">Hinzufügen</button> <button type="submit" class="btn btn-primary d-inline-flex align-items-center">
<i class="bi bi-plus-lg me-1"></i> Hinzufügen
</button>
</div> </div>
</form> </form>
<h5 class="mb-3">Vorhandene Werte</h5> <div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="h6 text-uppercase text-muted fw-bold mb-0">Vorhandene Werte</h3>
<span class="badge bg-secondary rounded-pill">{{ filter2_values|length if filter2_values else 0 }}</span>
</div>
{% if filter2_values %} {% if filter2_values %}
<div class="list-group"> <div class="list-group list-group-flush border rounded-3 overflow-hidden">
{% for value in filter2_values %} {% for value in filter2_values %}
<div class="list-group-item"> <div class="list-group-item p-3">
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center">
<span>{{ value }}</span> <span class="fw-medium">{{ value }}</span>
<div> <div class="btn-group btn-group-sm">
<button type="button" class="btn btn-sm btn-secondary me-1" onclick="toggleEdit('edit-2-{{ loop.index }}')">Bearbeiten</button> <button type="button" class="btn btn-outline-secondary" onclick="toggleEdit('edit-2-{{ loop.index }}')">
<i class="bi bi-pencil me-1"></i> Bearbeiten
</button>
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=2, value=value) }}" class="d-inline"> <form method="POST" action="{{ url_for('remove_filter_value', filter_num=2, value=value) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger" <button type="submit" class="btn btn-outline-danger"
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');"> onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
Entfernen <i class="bi bi-trash me-1"></i> Entfernen
</button> </button>
</form> </form>
</div> </div>
</div> </div>
<form id="edit-2-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=2, old_value=value) }}" style="display: none;" class="mt-2">
<form id="edit-2-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=2, old_value=value) }}" style="display: none;" class="mt-3 pt-3 border-top">
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<input type="text" name="new_value" class="form-control" value="{{ value }}" required> <input type="text" name="new_value" class="form-control" value="{{ value }}" required>
<button type="submit" class="btn btn-primary" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">Speichern</button> <button type="submit" class="btn btn-success" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">
<button type="button" class="btn btn-secondary" onclick="toggleEdit('edit-2-{{ loop.index }}')">Abbrechen</button> <i class="bi bi-check-lg me-1"></i> Speichern
</button>
<button type="button" class="btn btn-outline-secondary" onclick="toggleEdit('edit-2-{{ loop.index }}')">
Abbrechen
</button>
</div> </div>
</form> </form>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<div class="alert alert-info">Keine Werte definiert.</div> <div class="alert alert-light border text-center text-muted mb-0">
<i class="bi bi-info-circle me-1"></i> Keine Werte definiert.
</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="alert alert-warning"> <div class="alert alert-warning border-0 shadow-sm d-flex align-items-center mt-4 rounded-3" role="alert">
<strong>Hinweis:</strong> Beim Entfernen eines Filterwertes wird dieser nicht aus bestehenden Objekten entfernt. <i class="bi bi-exclamation-triangle-fill fs-4 me-3 text-warning"></i>
Bereits verwendete Werte bleiben in den Objekten erhalten. <div>
</div> <strong>Hinweis:</strong> Beim Entfernen eines Filterwertes wird dieser nicht automatisch aus bestehenden Objekten gelöscht. Bereits zugewiesene Werte bleiben in bestehenden Datensätzen erhalten.
</div>
<div class="mt-4">
<a href="{{ url_for('home_admin') }}" class="btn btn-secondary">Zurück zur Admin-Übersicht</a>
</div> </div>
</div> </div>
<script> <script>
function toggleEdit(id) { function toggleEdit(id) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el.style.display === 'none') { if (el.style.display === 'none' || el.style.display === '') {
el.style.display = 'block'; el.style.display = 'block';
const input = el.querySelector('input[type="text"]');
if (input) input.focus();
} else { } else {
el.style.display = 'none'; el.style.display = 'none';
} }
} }
</script> </script>
{% endblock %} {% endblock %}
+42 -8
View File
@@ -71,27 +71,27 @@
h1 { h1 {
text-align: center; text-align: center;
margin-bottom: 30px; margin-bottom: 30px;
color: #343a40; color: var(--ui-title);
} }
.card { .card {
border-radius: 5px; border-radius: 5px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1); box-shadow: var(--ui-shadow-sm);
margin-bottom: 20px; margin-bottom: 20px;
background-color: var(--ui-surface); background-color: var(--ui-surface);
border: 1px solid #e9ecef; border: 1px solid var(--ui-border);
} }
.card-header { .card-header {
background-color: var(--ui-surface-soft); background-color: var(--ui-surface-soft);
padding: 15px 20px; padding: 15px 20px;
border-bottom: 1px solid #e9ecef; border-bottom: 1px solid var(--ui-border);
} }
.card-header h2 { .card-header h2 {
margin: 0; margin: 0;
font-size: 1.25rem; font-size: 1.25rem;
color: #495057; color: var(--ui-title);
} }
.card-body { .card-body {
@@ -111,8 +111,16 @@
.form-group input { .form-group input {
width: 100%; width: 100%;
padding: 8px 12px; padding: 8px 12px;
border: 1px solid #ced4da; border: 1px solid var(--ui-border);
border-radius: 4px; border-radius: 4px;
background: var(--ui-bg);
color: var(--ui-text);
}
.form-group input:focus {
outline: none;
border-color: #60a5fa;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.22);
} }
.btn { .btn {
@@ -171,17 +179,25 @@
align-items: center; align-items: center;
padding: 10px; padding: 10px;
background-color: var(--ui-surface-soft); background-color: var(--ui-surface-soft);
border: 1px solid var(--ui-border);
border-radius: 4px; border-radius: 4px;
border-left: 3px solid #6c757d; border-left: 3px solid #6c757d;
transition: background-color 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
}
.location-value-item:hover {
background-color: var(--ui-bg-accent);
border-color: #60a5fa;
transform: translateY(-1px);
} }
.location-value { .location-value {
font-weight: 500; font-weight: 500;
color: #495057; color: var(--ui-text);
} }
.no-values-message { .no-values-message {
color: #6c757d; color: var(--ui-text-muted);
font-style: italic; font-style: italic;
text-align: center; text-align: center;
padding: 20px; padding: 20px;
@@ -196,5 +212,23 @@
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
} }
@media (max-width: 576px) {
.location-values-list {
grid-template-columns: 1fr;
}
.location-value-item {
gap: 10px;
}
.actions-container {
justify-content: stretch;
}
.actions-container .btn {
width: 100%;
}
}
</style> </style>
{% endblock %} {% endblock %}
+104 -1
View File
@@ -334,7 +334,7 @@ document.addEventListener('DOMContentLoaded', function () {
body { body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: var(--light-bg); background-color: var(--ui-bg);
margin: 0; margin: 0;
padding: 0; padding: 0;
color: var(--text-color); color: var(--text-color);
@@ -719,5 +719,108 @@ code {
font-size: 1.8rem; font-size: 1.8rem;
} }
} }
/* Registration-specific dark mode styling. */
:root[data-theme="dark"] {
--light-bg: var(--ui-bg);
--text-color: var(--ui-text);
--shadow: var(--ui-shadow-md);
}
:root[data-theme="dark"] .header-section {
border-bottom-color: var(--ui-border);
}
:root[data-theme="dark"] .subtitle,
:root[data-theme="dark"] .anonymize-hint,
:root[data-theme="dark"] .form-group label,
:root[data-theme="dark"] .permission-check,
:root[data-theme="dark"] .input-icon {
color: var(--ui-text-muted) !important;
}
:root[data-theme="dark"] .content,
:root[data-theme="dark"] .form-card,
:root[data-theme="dark"] .permission-panel,
:root[data-theme="dark"] .password-rules {
background: var(--ui-surface) !important;
background-color: var(--ui-surface) !important;
border-color: var(--ui-border) !important;
color: var(--ui-text) !important;
box-shadow: var(--ui-shadow-sm);
}
:root[data-theme="dark"] .form-card h2,
:root[data-theme="dark"] .permission-panel h4,
:root[data-theme="dark"] .password-rules-title {
color: var(--ui-title) !important;
}
:root[data-theme="dark"] input[type="text"],
:root[data-theme="dark"] input[type="password"],
:root[data-theme="dark"] input[type="file"],
:root[data-theme="dark"] .form-select {
background: var(--ui-bg) !important;
background-color: var(--ui-bg) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] input[type="text"]:focus,
:root[data-theme="dark"] input[type="password"]:focus,
:root[data-theme="dark"] input[type="file"]:focus,
:root[data-theme="dark"] .form-select:focus {
border-color: #60a5fa !important;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.25) !important;
}
:root[data-theme="dark"] input[type="file"]::file-selector-button {
background: var(--ui-surface-soft);
color: var(--ui-text);
border-color: var(--ui-border);
}
:root[data-theme="dark"] input[type="file"]::file-selector-button:hover,
:root[data-theme="dark"] .btn-secondary:hover {
background: var(--ui-bg-accent);
}
:root[data-theme="dark"] .password-rules {
background: var(--ui-surface-soft) !important;
}
:root[data-theme="dark"] .pw-rule {
color: #fca5a5;
}
:root[data-theme="dark"] .pw-rule.ok {
color: #86efac;
}
:root[data-theme="dark"] .anonymize-hint {
background: #082f49 !important;
border-color: #38bdf8 !important;
color: #bae6fd !important;
}
:root[data-theme="dark"] .permission-panel {
background: var(--ui-surface-soft) !important;
}
:root[data-theme="dark"] .btn-secondary {
background: var(--ui-surface-soft);
color: var(--ui-text);
border-color: var(--ui-border);
}
:root[data-theme="dark"] .flash.success {
background: #123524;
color: #b7f7ce;
}
:root[data-theme="dark"] .flash.error {
background: #451a1a;
color: #fecaca;
}
</style> </style>
{% endblock %} {% endblock %}
+88
View File
@@ -254,6 +254,88 @@
margin: 10px 0; margin: 10px 0;
} }
:root[data-theme="dark"] .student-card-form,
:root[data-theme="dark"] .import-card {
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
box-shadow: var(--ui-shadow-sm);
}
:root[data-theme="dark"] .import-card {
background: var(--ui-surface-soft) !important;
}
:root[data-theme="dark"] .import-card p,
:root[data-theme="dark"] .rollover-hint,
:root[data-theme="dark"] .empty-state,
:root[data-theme="dark"] .empty-state p {
color: var(--ui-text-muted) !important;
}
:root[data-theme="dark"] .rollover-box {
background: #422006 !important;
border-color: #f59e0b !important;
}
:root[data-theme="dark"] .rollover-label,
:root[data-theme="dark"] .rollover-hint {
color: #fde68a !important;
}
:root[data-theme="dark"] .form-group input,
:root[data-theme="dark"] .form-group select,
:root[data-theme="dark"] .form-group textarea,
:root[data-theme="dark"] .export-buttons form,
:root[data-theme="dark"] .export-buttons select {
background: var(--ui-bg) !important;
background-color: var(--ui-bg) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .form-group input:focus,
:root[data-theme="dark"] .form-group select:focus,
:root[data-theme="dark"] .form-group textarea:focus,
:root[data-theme="dark"] .export-buttons select:focus {
border-color: #60a5fa !important;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.22) !important;
}
:root[data-theme="dark"] .cards-table {
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
box-shadow: var(--ui-shadow-sm);
}
:root[data-theme="dark"] .cards-table thead {
background: #1d4ed8 !important;
color: #ffffff !important;
}
:root[data-theme="dark"] .cards-table th,
:root[data-theme="dark"] .cards-table td {
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .cards-table tbody tr:hover td {
background: var(--ui-bg-accent) !important;
}
@media (max-width: 768px) {
.export-buttons {
width: 100%;
}
.export-buttons form,
.export-buttons > a {
flex: 1 1 100%;
}
}
@media (max-width: 992px) { @media (max-width: 992px) {
.dashboard-grid { .dashboard-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -447,6 +529,12 @@
<button type="submit" class="btn-edit">Bearbeiten</button> <button type="submit" class="btn-edit">Bearbeiten</button>
</form> </form>
<a href="{{ url_for('student_card_single_barcode_download', card_id=card._id) }}" class="btn-export">📥 PDF</a> <a href="{{ url_for('student_card_single_barcode_download', card_id=card._id) }}" class="btn-export">📥 PDF</a>
{% if email_service_enabled %}
<form method="POST" action="{{ url_for('student_card_single_barcode_download', card_id=card._id) }}" style="display:flex; gap:4px; align-items:center;">
<input type="email" name="recipient_email" required placeholder="E-Mail" aria-label="E-Mail für {{ card.SchülerName }}" style="width:150px; padding:5px;">
<button type="submit" class="btn-export">PDF senden</button>
</form>
{% endif %}
<form method="POST" style="display: inline;" onsubmit="return confirm('Wirklich löschen?');"> <form method="POST" style="display: inline;" onsubmit="return confirm('Wirklich löschen?');">
<input type="hidden" name="action" value="delete"> <input type="hidden" name="action" value="delete">
<input type="hidden" name="card_id" value="{{ card._id }}"> <input type="hidden" name="card_id" value="{{ card._id }}">
+74 -1
View File
@@ -6,7 +6,7 @@
<div class="container py-4"> <div class="container py-4">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-12 col-lg-10 col-xl-8"> <div class="col-12 col-lg-10 col-xl-8">
<div class="card border-0 shadow-lg rounded-4 overflow-hidden"> <div class="card termin-configure-card border-0 shadow-lg rounded-4 overflow-hidden">
<div class="card-header text-white" style="background: linear-gradient(135deg, #0f4c5c, #16697a);"> <div class="card-header text-white" style="background: linear-gradient(135deg, #0f4c5c, #16697a);">
<h1 class="h3 mb-1 fw-bold">Terminplan konfigurieren</h1> <h1 class="h3 mb-1 fw-bold">Terminplan konfigurieren</h1>
<p class="mb-0 opacity-75">Erstellen Sie einen neuen Plan und verschicken Sie anschließend den Buchungslink.</p> <p class="mb-0 opacity-75">Erstellen Sie einen neuen Plan und verschicken Sie anschließend den Buchungslink.</p>
@@ -139,6 +139,79 @@
</div> </div>
</div> </div>
</div> </div>
<style>
.termin-configure-card .card-body {
color: var(--ui-text);
}
.termin-configure-card .form-text {
color: var(--ui-text-muted) !important;
}
.termin-configure-card .form-control,
.termin-configure-card .form-select {
color: var(--ui-text);
background-color: var(--ui-surface);
border-color: var(--ui-border);
}
.termin-configure-card .form-control:focus,
.termin-configure-card .form-select:focus {
border-color: #60a5fa;
box-shadow: 0 0 0 0.25rem rgba(96, 165, 250, 0.25);
}
.termin-configure-card #slots_amounts_display {
background-color: #dbeafe !important;
color: #1d4ed8 !important;
border-color: #93c5fd !important;
}
:root[data-theme="dark"] .termin-configure-card .card-header {
background: linear-gradient(135deg, #0b3440, #155e75) !important;
}
:root[data-theme="dark"] .termin-configure-card .card-body {
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
}
:root[data-theme="dark"] .termin-configure-card .bg-light-subtle,
:root[data-theme="dark"] .termin-configure-card .bg-white,
:root[data-theme="dark"] .termin-configure-card [data-day-row] {
background: var(--ui-surface-soft) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .termin-configure-card #slots_amounts_display {
background: #172554 !important;
color: #bfdbfe !important;
border-color: #3b82f6 !important;
}
:root[data-theme="dark"] .termin-configure-card .text-primary {
color: #93c5fd !important;
}
:root[data-theme="dark"] .termin-configure-card .text-danger {
color: #fca5a5 !important;
}
:root[data-theme="dark"] .termin-configure-card .border-danger-subtle {
border-color: #f87171 !important;
}
:root[data-theme="dark"] .termin-configure-card .alert-success {
background: #123524 !important;
border-color: #2f9e62 !important;
color: #b7f7ce !important;
}
:root[data-theme="dark"] .termin-configure-card .alert-success a {
color: #bfdbfe !important;
}
</style>
<script> <script>
(function () { (function () {
const startDateInput = document.getElementById('start_date'); const startDateInput = document.getElementById('start_date');
+6
View File
@@ -42,6 +42,12 @@
<a href="{{ url_for('terminplaner.export_pdf_brief', plan_id=plan_id) }}" class="btn btn-outline-success btn-sm"> <a href="{{ url_for('terminplaner.export_pdf_brief', plan_id=plan_id) }}" class="btn btn-outline-success btn-sm">
📄 Brief als PDF herunterladen 📄 Brief als PDF herunterladen
</a> </a>
{% if mail_service_enabled %}
<form method="post" action="{{ url_for('terminplaner.export_pdf_brief', plan_id=plan_id) }}" class="d-flex flex-wrap gap-2 mt-2">
<input type="email" name="recipient_email" required placeholder="Empfänger-E-Mail" aria-label="Empfänger-E-Mail für den Einladungsbrief" class="form-control" style="max-width:280px;">
<button type="submit" class="btn btn-outline-primary btn-sm">PDF per E-Mail senden</button>
</form>
{% endif %}
</div> </div>
{% if calendar_link %} {% if calendar_link %}
+89 -6
View File
@@ -647,12 +647,96 @@
cursor: pointer; cursor: pointer;
margin-left: 10px; margin-left: 10px;
} }
/* Upload-specific dark mode surfaces and readable helper text. */
:root[data-theme="dark"] .upload-container {
background: var(--ui-surface) !important;
border: 1px solid var(--ui-border);
box-shadow: var(--ui-shadow-md);
}
:root[data-theme="dark"] .upload-import-panel,
:root[data-theme="dark"] #range_generator_group,
:root[data-theme="dark"] .filter-inputs,
:root[data-theme="dark"] .book-info-container,
:root[data-theme="dark"] .book-cover-preview,
:root[data-theme="dark"] .video-preview {
background: var(--ui-surface-soft) !important;
background-color: var(--ui-surface-soft) !important;
border-color: var(--ui-border) !important;
color: var(--ui-text) !important;
}
:root[data-theme="dark"] .upload-import-panel p,
:root[data-theme="dark"] .upload-import-panel small,
:root[data-theme="dark"] #isbn-scan-status,
:root[data-theme="dark"] #code4-scan-status,
:root[data-theme="dark"] .image-loading,
:root[data-theme="dark"] .book-info p,
:root[data-theme="dark"] .book-cover-caption {
color: var(--ui-text-muted) !important;
}
:root[data-theme="dark"] .upload-import-panel h3,
:root[data-theme="dark"] .book-info h4,
:root[data-theme="dark"] .book-description h5,
:root[data-theme="dark"] #range_generator_group label {
color: var(--ui-title) !important;
}
:root[data-theme="dark"] .upload-form input,
:root[data-theme="dark"] .upload-form select,
:root[data-theme="dark"] .upload-form textarea,
:root[data-theme="dark"] .filter-dropdown-select,
:root[data-theme="dark"] .upload-import-panel input[type="file"] {
background: var(--ui-bg) !important;
background-color: var(--ui-bg) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .upload-form input:focus,
:root[data-theme="dark"] .upload-form select:focus,
:root[data-theme="dark"] .upload-form textarea:focus,
:root[data-theme="dark"] .filter-dropdown-select:focus {
border-color: #60a5fa !important;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.25) !important;
}
:root[data-theme="dark"] .book-info,
:root[data-theme="dark"] .book-description p,
:root[data-theme="dark"] .video-placeholder {
background: var(--ui-bg) !important;
background-color: var(--ui-bg) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .success-message {
background: #123524 !important;
border-color: #2f9e62 !important;
color: #b7f7ce !important;
}
:root[data-theme="dark"] .error-message,
:root[data-theme="dark"] .error,
:root[data-theme="dark"] .error-message-container {
background: #451a1a !important;
border-color: #f87171 !important;
color: #fecaca !important;
}
:root[data-theme="dark"] .warning-message,
:root[data-theme="dark"] .warning {
background: #422006 !important;
border-color: #f59e0b !important;
color: #fde68a !important;
}
</style> </style>
<div class="upload-container"> <div class="upload-container">
{% if show_library_features %} {% if show_library_features %}
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;"> <div class="upload-import-panel" style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
<h3 style="margin:0 0 8px 0;">Excel-Import Bibliothek (Mehrere Bücher)</h3> <h3 style="margin:0 0 8px 0;">Excel-Import Bibliothek (Mehrere Bücher)</h3>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, ISBN, Code, Anzahl). Für den Bibliotheksimport ist eine gültige ISBN je Zeile erforderlich.</p> <p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, ISBN, Code, Anzahl). Für den Bibliotheksimport ist eine gültige ISBN je Zeile erforderlich.</p>
<form method="POST" action="{{ url_for('upload_library_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;"> <form method="POST" action="{{ url_for('upload_library_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
@@ -664,13 +748,12 @@
</form> </form>
</div> </div>
{% else %} {% else %}
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;"> <div class="upload-import-panel" style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
<h3 style="margin:0 0 8px 0;">Excel-Import Inventar (Mehrere Artikel)</h3> <h3 style="margin:0 0 8px 0;">Excel-Import Inventar (Mehrere Artikel)</h3>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, Filter1/2/3, Kosten, Jahr, Code, Anzahl).</p> <p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, Filter1/2/3, Kosten, Jahr, Code, Anzahl).</p>
<form method="POST" action="{{ url_for('upload_inventory_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;"> <form method="POST" action="{{ url_for('upload_inventory_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="inventory_excel" accept=".xlsx,.csv" required> <input type="file" name="inventory_excel" accept=".xlsx,.csv" required>
<button type="button" class="btn btn-link" onclick="downloadSampleCsv('inventory')">Beispiel-CSV herunterladen</button> <button type="button" class="btn btn-link" onclick="downloadSampleCsv('inventory')">Beispiel-CSV herunterladen</button>
<button type="button" class="btn btn-outline-secondary" title="Format-Hilfe" onclick="showCsvHelp('inventory')">?</button>
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button> <button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
<button type="submit" class="btn btn-primary" name="excel_action" value="import">Inventar importieren</button> <button type="submit" class="btn btn-primary" name="excel_action" value="import">Inventar importieren</button>
</form> </form>
@@ -781,7 +864,7 @@
{% else %} {% else %}
<!-- Normal Item Mode: Standard Filters --> <!-- Normal Item Mode: Standard Filters -->
<div class="filter-inputs"> <div class="filter-inputs">
<h3>Unterrichtsfach:</h3> <h3>{{ filter_names.get('1', 'Jahrgangsstufe') }}</h3>
<div class="multi-filter"> <div class="multi-filter">
<div class="form-group"> <div class="form-group">
<label for="filter1-1">Wert 1:</label> <label for="filter1-1">Wert 1:</label>
@@ -813,7 +896,7 @@
</div> </div>
</div> </div>
<h3>Jahrgangsstufe:</h3> <h3>{{ filter_names.get('2', 'Fachgebiet') }}</h3>
<div class="multi-filter"> <div class="multi-filter">
<div class="form-group"> <div class="form-group">
<label for="filter2-1">Wert 1:</label> <label for="filter2-1">Wert 1:</label>
@@ -845,7 +928,7 @@
</div> </div>
</div> </div>
<h3>Schlagwort:</h3> <h3>{{ filter_names.get('3', 'Schlagwort') }}</h3>
<div class="multi-filter"> <div class="multi-filter">
<div class="form-group"> <div class="form-group">
<label for="filter3-1">Wert 1:</label> <label for="filter3-1">Wert 1:</label>
+62 -8
View File
@@ -53,8 +53,11 @@
<option value="desc">Absteigend</option> <option value="desc">Absteigend</option>
</select> </select>
</div> </div>
<div class="col-md-2"> <div class="col-md-3">
<button class="btn btn-secondary w-100" onclick="resetFilters()">Filter zurücksetzen</button> <div class="d-flex gap-2">
<button class="btn btn-secondary flex-grow-1" onclick="resetFilters()">Zurücksetzen</button>
<button class="btn btn-success flex-grow-1" onclick="exportFilteredUsers()">Exportieren</button>
</div>
</div> </div>
</div> </div>
<div class="mt-2 text-muted small" id="filter-count"></div> <div class="mt-2 text-muted small" id="filter-count"></div>
@@ -82,19 +85,20 @@
<td>{{ permission_presets.get(user.permission_preset, {}).get('label', user.permission_preset) }}</td> <td>{{ permission_presets.get(user.permission_preset, {}).get('label', user.permission_preset) }}</td>
<td class="actions"> <td class="actions">
<form method="POST" action="{{ url_for('delete_user') }}" class="d-inline"> <form method="POST" action="{{ url_for('delete_user') }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="username" value="{{ user.username }}"> <input type="hidden" name="username" value="{{ user.username }}">
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Sind Sie sicher, dass Sie den Benutzer {{ user.username }} löschen möchten?')"> <button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Sind Sie sicher, dass Sie den Benutzer {{ user.username }} löschen möchten?')">
Löschen Löschen
</button> </button>
</form> </form>
<button type="button" class="btn btn-primary btn-sm" <button type="button" class="btn btn-primary btn-sm"
data-username="{{ user.username }}" data-username="{{ user.username }}"
data-firstname="{{ user.name if user.name else '' }}" data-firstname="{{ user.name if user.name else '' }}"
data-lastname="{{ user.last_name if user.last_name else '' }}" data-lastname="{{ user.last_name if user.last_name else '' }}"
onclick="openEditUserModal(this)"> onclick="openEditUserModal(this)">
Bearbeiten Bearbeiten
</button> </button>
<button type="button" class="btn btn-warning btn-sm" <button type="button" class="btn btn-warning btn-sm"
onclick="openResetPasswordModal('{{ user.username }}')"> onclick="openResetPasswordModal('{{ user.username }}')">
Passwort zurücksetzen Passwort zurücksetzen
</button> </button>
@@ -124,6 +128,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<form method="POST" action="{{ url_for('admin_update_user_permissions') }}"> <form method="POST" action="{{ url_for('admin_update_user_permissions') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="modal-body permissions-modal-body"> <div class="modal-body permissions-modal-body">
<input type="hidden" id="perm-username" name="username"> <input type="hidden" id="perm-username" name="username">
<div class="mb-3"> <div class="mb-3">
@@ -180,6 +185,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<form method="POST" action="{{ url_for('admin_update_user_name') }}"> <form method="POST" action="{{ url_for('admin_update_user_name') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="modal-body"> <div class="modal-body">
<input type="hidden" id="edit-username" name="username"> <input type="hidden" id="edit-username" name="username">
<div class="mb-3"> <div class="mb-3">
@@ -213,6 +219,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<form method="POST" action="{{ url_for('admin_reset_user_password') }}"> <form method="POST" action="{{ url_for('admin_reset_user_password') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="modal-body"> <div class="modal-body">
<input type="hidden" id="reset-username" name="username"> <input type="hidden" id="reset-username" name="username">
<div class="mb-3"> <div class="mb-3">
@@ -313,6 +320,54 @@
applyFilters(); applyFilters();
} }
function exportFilteredUsers() {
var table = document.getElementById('user-table');
var tbody = table.querySelector('tbody');
var rows = Array.from(tbody.querySelectorAll('tr'));
var visibleUsernames = [];
// Sammle alle Benutzernamen, die durch den Filter aktuell NICHT versteckt sind
rows.forEach(function(row) {
if (row.style.display !== 'none') {
var cells = row.querySelectorAll('td');
if (cells.length > 0) {
visibleUsernames.push(cells[0].textContent.trim());
}
}
});
if (visibleUsernames.length === 0) {
alert('Keine Benutzer zum Exportieren vorhanden.');
return;
}
// Dynamisches Formular erstellen
var form = document.createElement('form');
form.method = 'POST';
form.action = "{{ url_for('export_users_csv') }}";
// 1. CSRF-Token als Wert (ohne Klammern) übergeben
var csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = "{{ csrf_token }}";
form.appendChild(csrfInput);
// 2. Gefilterte Benutzernamen hinzufügen
var input = document.createElement('input');
input.type = 'hidden';
input.name = 'usernames';
input.value = JSON.stringify(visibleUsernames);
form.appendChild(input);
document.body.appendChild(form);
form.submit();
// Aufräumen
document.body.removeChild(form);
}
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
applyFilters(); applyFilters();
document.getElementById('filter-search').addEventListener('input', applyFilters); document.getElementById('filter-search').addEventListener('input', applyFilters);
@@ -332,12 +387,12 @@
var username = button.getAttribute('data-username'); var username = button.getAttribute('data-username');
var name = button.getAttribute('data-firstname'); var name = button.getAttribute('data-firstname');
var lastName = button.getAttribute('data-lastname'); var lastName = button.getAttribute('data-lastname');
document.getElementById('edit-username').value = username; document.getElementById('edit-username').value = username;
document.getElementById('edit-username-display').value = username; document.getElementById('edit-username-display').value = username;
document.getElementById('edit-name').value = name; document.getElementById('edit-name').value = name;
document.getElementById('edit-last-name').value = lastName; document.getElementById('edit-last-name').value = lastName;
var modal = new bootstrap.Modal(document.getElementById('editUserModal')); var modal = new bootstrap.Modal(document.getElementById('editUserModal'));
modal.show(); modal.show();
} }
@@ -345,8 +400,7 @@
function openResetPasswordModal(username) { function openResetPasswordModal(username) {
document.getElementById('reset-username').value = username; document.getElementById('reset-username').value = username;
document.getElementById('username-display').value = username; document.getElementById('username-display').value = username;
// Open modal using Bootstrap
var modal = new bootstrap.Modal(document.getElementById('resetPasswordModal')); var modal = new bootstrap.Modal(document.getElementById('resetPasswordModal'));
modal.show(); modal.show();
} }
+106 -72
View File
@@ -430,6 +430,7 @@ PY
initialize_tenant_database() { initialize_tenant_database() {
local tenant_id="$1" local tenant_id="$1"
local mode="$2" local mode="$2"
local admin_password="${3:-admin123}"
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1) APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -z "$APP_CONTAINER" ]; then if [ -z "$APP_CONTAINER" ]; then
@@ -438,7 +439,8 @@ initialize_tenant_database() {
return 0 return 0
fi fi
docker exec -i "$APP_CONTAINER" python3 - "$tenant_id" "$mode" <<'PY' # Fix: We inject the password securely via a temporary environment variable (-e ADMIN_PASS)
docker exec -i -e ADMIN_PASS="$admin_password" "$APP_CONTAINER" python3 - "$tenant_id" "$mode" <<'PY'
import sys, os, re, datetime, hashlib import sys, os, re, datetime, hashlib
sys.path.insert(0, "/app") sys.path.insert(0, "/app")
sys.path.insert(0, "/app/Web") sys.path.insert(0, "/app/Web")
@@ -449,6 +451,13 @@ import Web.modules.database.user as us
tenant_id = sys.argv[1].lower() tenant_id = sys.argv[1].lower()
mode = sys.argv[2] mode = sys.argv[2]
# Fix: Read the password directly from the environment variable instead of sys.argv
admin_password = os.environ.get("ADMIN_PASS", "admin123")
# Failsafe in case of empty string
if not admin_password.strip():
admin_password = "admin123"
sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_") sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_")
db_name = f"inventar_{sanitized}" if sanitized else settings.MONGODB_DB db_name = f"inventar_{sanitized}" if sanitized else settings.MONGODB_DB
@@ -458,7 +467,7 @@ users = db["users"]
permission_defaults = us.build_default_permission_payload("full_access") permission_defaults = us.build_default_permission_payload("full_access")
admin_doc = { admin_doc = {
"Username": "admin", "Username": "admin",
"Password": us.hashing("admin123"), "Password": us.hashing(admin_password),
"Admin": True, "Admin": True,
"active_ausleihung": None, "active_ausleihung": None,
"name": dp.encrypt_text("admin"), "name": dp.encrypt_text("admin"),
@@ -488,7 +497,7 @@ if mode == "trial":
client.close() client.close()
print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123") print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / {admin_password}")
PY PY
} }
@@ -812,12 +821,26 @@ case "$COMMAND" in
exit 1 exit 1
fi fi
PORT_ARG="${3:-}" ARG3="${3:-}"
if [ -n "$PORT_ARG" ]; then ARG4="${4:-}"
if ! printf '%s\n' "$PORT_ARG" | grep -qE '^[0-9]+$'; then
echo "Error: Port must be a numeric value." PORT_ARG=""
exit 1 PASSWORD_ARG="admin123"
# Check if the 3rd argument is numeric (Port) or text (Password)
if [ -n "$ARG3" ]; then
if printf '%s\n' "$ARG3" | grep -qE '^[0-9]+$'; then
PORT_ARG="$ARG3"
if [ -n "$ARG4" ]; then
PASSWORD_ARG="$ARG4"
fi
else
# If ARG3 is not numeric, treat it as the password without setting a port
PASSWORD_ARG="$ARG3"
fi fi
fi
if [ -n "$PORT_ARG" ]; then
register_tenant_port "$TENANT_ID" "$PORT_ARG" register_tenant_port "$TENANT_ID" "$PORT_ARG"
update_runtime_ports "$PORT_ARG" update_runtime_ports "$PORT_ARG"
sync_tenant_port_map sync_tenant_port_map
@@ -829,11 +852,9 @@ case "$COMMAND" in
fi fi
fi fi
echo "Adding new tenant '$TENANT_ID'..." echo "Adding new tenant '$TENANT_ID'..."
echo "Initializing database for $TENANT_ID..." echo "Initializing database for $TENANT_ID..."
initialize_tenant_database "$TENANT_ID" "standard" initialize_tenant_database "$TENANT_ID" "standard" "$PASSWORD_ARG"
echo "Tenant '$TENANT_ID' successfully added. Ready to use." echo "Tenant '$TENANT_ID' successfully added. Ready to use."
;; ;;
@@ -872,81 +893,94 @@ case "$COMMAND" in
;; ;;
remove) remove)
FORCE_REMOVE=false FORCE_REMOVE=false
TENANT_ARG="${2:-}"
if [ "$TENANT_ARG" = "--yes" ] || [ "$TENANT_ARG" = "-y" ]; then # Fix: Prüfe sowohl Position 2 als auch 3 auf das -y Flag
FORCE_REMOVE=true if [ "${2:-}" = "--yes" ] || [ "${2:-}" = "-y" ]; then
TENANT_ID="${3:-}" FORCE_REMOVE=true
else TENANT_ID="${3:-}"
TENANT_ID="$TENANT_ARG" elif [ "${3:-}" = "--yes" ] || [ "${3:-}" = "-y" ]; then
FORCE_REMOVE=true
TENANT_ID="${2:-}"
else
TENANT_ID="${2:-}"
fi
if [ -z "$TENANT_ID" ]; then
echo "Error: Please provide a tenant_id to remove."
exit 1
fi
if [ "$FORCE_REMOVE" != true ]; then
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 "Removal canceled."
exit 0
fi fi
fi
if [ -z "$TENANT_ID" ]; then echo "Removing tenant '$TENANT_ID'..."
echo "Error: Please provide a tenant_id to remove." APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
exit 1 port_to_remove=""
fi
if [ "$FORCE_REMOVE" != true ]; then if [ -n "$APP_CONTAINER" ]; then
echo -n "WARNING: Are you sure you want to permanently delete all data for tenant '$TENANT_ID'? (y/N) " # MongoDB-Datenbank via PyMongo direkt im Container droppen
read confirm # Fix 1: Wir hängen '|| true' an, damit das Skript nicht abstürzt, falls der Befehl fehlschlägt.
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then docker exec -i "$APP_CONTAINER" python3 -c '
echo "Removal canceled." import sys, os
exit 0 try:
fi import pymongo
fi tenant_id = sys.argv[1]
# In Docker Compose heißt der Host oft "mongodb" statt "localhost"
mongo_uri = os.environ.get("MONGO_URI", "mongodb://mongodb:27017/")
client = pymongo.MongoClient(mongo_uri, serverSelectionTimeoutMS=2000)
echo "Removing tenant '$TENANT_ID'..." # Fix 2: Wir versuchen beide Formate zu löschen, da "list" zeigt, dass sie "test" und nicht "inventar_test" heißen.
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1) dbs_to_drop = [f"inventar_{tenant_id}", tenant_id]
port_to_remove="" existing_dbs = client.list_database_names()
if [ -n "$APP_CONTAINER" ]; then for db_name in dbs_to_drop:
# MongoDB-Datenbank via PyMongo direkt im Container droppen if db_name in existing_dbs:
docker exec -i "$APP_CONTAINER" python3 -c '
import sys, os
try:
import pymongo
tenant_id = sys.argv[1]
mongo_uri = os.environ.get("MONGO_URI", "mongodb://localhost:27017/")
client = pymongo.MongoClient(mongo_uri, serverSelectionTimeoutMS=2000)
db_name = f"inventar_{tenant_id}"
if db_name in client.list_database_names():
client.drop_database(db_name) client.drop_database(db_name)
print(f"Dropped database: {db_name}") print(f"Dropped database: {db_name}")
except Exception as e: except Exception as e:
print(f"Error dropping database: {e}", file=sys.stderr) print(f"Error dropping database: {e}", file=sys.stderr)
' "$TENANT_ID" > /dev/null 2>&1 ' "$TENANT_ID" > /dev/null 2>&1 || true
# Konfiguration und Port via Host-Funktion bereinigen und Port ermitteln # Konfiguration und Port via Host-Funktion bereinigen und Port ermitteln
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
: :
else
port_to_remove=""
fi
echo "Tenant '$TENANT_ID' database and config removed."
else else
echo "Warning: Application container not running. Tenant database may still exist in MongoDB." port_to_remove=""
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
:
else
echo "Warning: tenant '$TENANT_ID' was not configured in config.json or could not be removed."
fi
fi fi
if [ -n "$port_to_remove" ]; then echo "Tenant '$TENANT_ID' database and config removed."
remove_runtime_port "$port_to_remove" else
fi echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
remove_tenant_nginx_config "$TENANT_ID" if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
sync_tenant_port_map :
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container
fi
if [ -n "$port_to_remove" ]; then
echo "Removed tenant '$TENANT_ID' and cleaned runtime port $port_to_remove."
else else
echo "Removed tenant '$TENANT_ID'. No port mapping was present." echo "Warning: tenant '$TENANT_ID' was not configured in config.json or could not be removed."
fi fi
fi
if [ -n "$port_to_remove" ]; then
remove_runtime_port "$port_to_remove" 2>/dev/null || true
fi
remove_tenant_nginx_config "$TENANT_ID"
sync_tenant_port_map
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container || true
fi
if [ -n "$port_to_remove" ]; then
echo "Removed tenant '$TENANT_ID' and cleaned runtime port $port_to_remove."
else
echo "Removed tenant '$TENANT_ID'. No port mapping was present."
fi
;; ;;
restart-tenant) restart-tenant)