Compare commits

...

46 Commits

Author SHA1 Message Date
Aiirondev_dev 58dd957315 changes to the Username displayment 2026-11-02 14:59:57 +01:00
Aiirondev_dev 3afec10346 Further changes to decypher the user is to the correct username 2026-11-02 14:36:41 +01:00
Aiirondev_dev 9f0d514c38 Changes to the username displayment 2026-11-02 14:25:36 +01:00
Aiirondev_dev 1a32a078bc changes to the Bibliotheks system ame Displayment 2026-11-02 14:03:45 +01:00
Aiirondev_dev 5c4ab6f0fc Biblitoheksid is also convertet 2026-11-01 17:08:22 +01:00
Aiirondev_dev 79cf958c3c changes to the DIsplayment of the name ussage 2026-11-01 16:56:01 +01:00
Aiirondev_dev 826db109c3 Changes to the Formating 2026-11-01 16:48:28 +01:00
Aiirondev f9b991d991 Changes to fix the Barcode Search 2026-10-27 10:03:00 +00:00
Aiirondev_dev 363f0df530 changes to the student Name Processing 2026-10-20 20:07:23 +02:00
Aiirondev_dev 34bb3cf4c3 changes to the user processing in the api_item_datail Function 2026-10-20 19:20:36 +02:00
Aiirondev_dev 18706c6d1c slight change to get the right information out of a database record 2026-10-20 19:08:58 +02:00
Aiirondev_dev 9771b98075 logging implementatiion for the user displayment 2026-10-20 18:56:59 +02:00
Aiirondev_dev e380ad6092 Implementation of a nother user decryption detail displayment 2026-10-20 18:44:58 +02:00
Aiirondev_dev d6e8e08b10 Debug logging implementation and changes to the User entcryption 2026-10-20 18:32:23 +02:00
Aiirondev_dev ddb6ab82ad change of the last borrowing user displayment to correctly change the encryption to have the unencryptet name 2026-10-20 17:46:51 +02:00
Aiirondev_dev baefc55257 changes to the get_user_by_student_card function because of a faulty update regarding the searching of the student Cars 2026-10-20 17:29:07 +02:00
Aiirondev_dev 6c359089cd Including of logging information 2026-10-20 17:13:29 +02:00
Aiirondev_dev 479cf3d98c changes to the formating 2026-10-20 16:37:31 +02:00
Aiirondev_dev f00e7d8f74 changes with the Color of the Code suchen Box to red 2026-10-18 22:02:59 +02:00
Aiirondev_dev 7af729ac88 Changes tot the style and formating of the Website especialy when Highlighting the important Search fields 2026-10-18 21:46:18 +02:00
Aiirondev_dev 847349b419 changes to the style and formating of the Buttons 2026-10-18 21:35:13 +02:00
Aiirondev_dev 91a80c6330 Changes to the Button style for non mobile devices to have a smohter 2026-10-18 21:30:16 +02:00
Aiirondev_dev 530d71ac8f changes to the design 2026-10-18 21:10:56 +02:00
Aiirondev_dev 64991326e7 Die Ursache wurde behoben: Die Ausleihungen speichern den Benutzer teilweise als Benutzer-ObjectId, während das Fenster „Meine Ausleihungen“ nur den Session-Benutzernamen verglichen hat. Dadurch wurden die Datensätze herausgefiltert.
Zusätzlich wurde die Item-Suche robuster gemacht:

Vergleich gegen Benutzername
Vergleich gegen Benutzer-ObjectId
Vergleich gegen session['user_id']
Unterstützung für verschlüsselte und unverschlüsselte Benutzerwerte
Unterstützung für Item-IDs als String und als MongoDB-ObjectId
Geändert wurde:

Web/app.py
2026-10-15 22:01:23 +02:00
Aiirondev_dev dffcfdcba7 Die blockierende Ausleihverarbeitung wurde korrigiert.
Änderungen
Geplante Inventar-Ausleihen werden zum Startzeitpunkt automatisch aktiviert, wenn ein Exemplar verfügbar ist.
Bei fehlender Verfügbarkeit bleibt die Reservierung geplant.
Der reservierende Benutzer und der aktuelle Ausleiher erhalten jeweils eine Benachrichtigung.
Die automatische Aktivierung wird beim nächsten Scheduler-Lauf erneut versucht, sobald der Gegenstand zurückgegeben wurde.
Das geplante Ende beendet eine aktive Ausleihe nicht mehr automatisch.
Nach Ablauf erscheint bei „Meine Ausleihungen“ ein deutlich roter Hinweis:
RÜCKGABE ERFORDERLICH
roter, hervorgehobener Button Rückgabe bestätigen
Eine Ausleihe wird erst nach dem expliziten Bestätigen der Rückgabe auf completed gesetzt und wieder verfügbar gemacht.
Rückgabeaktionen unterstützen nun auch Datensätze, bei denen die Item-ID als ObjectId gespeichert ist.
Die Dokumentation des Ausleih-Workflows wurde aktualisiert.
Geänderte Dateien:

Web/app.py
Web/modules/database/ausleihung.py
Web/templates/my_borrowed_items.html
Legal/TEST_AUSLEIHUNG.md
Validiert mit:

Python-Syntaxprüfung erfolgreich
git diff --check erfolgreich
Im Repository wurden keine ausführbaren Python-Testdateien gefunden
2026-10-15 21:33:41 +02:00
Aiirondev_dev b3e79a65c1 Umgesetzt:
Ausleihungen verwenden jetzt user_id statt Benutzername als Funktionsargument.
Das Feld User speichert nun die Benutzer-ID statt des Benutzernamens.
Login speichert zusätzlich session['user_id'].
Normale Ausleihen, Schülerausweis-Ausleihen und Reservierungen verwenden die Benutzer-ID.
Berechtigungsprüfungen für eigene Ausleihen/Buchungen vergleichen IDs.
Kalenderbuchungen erkennen den aktuellen Benutzer jetzt ebenfalls über die ID.
update_ausleihung() und get_ausleihung_by_user() wurden angepasst.
2026-10-15 20:58:40 +02:00
Aiirondev_dev b33b4eadc6 Implemented an iPad-focused responsive styling improvement.
Changes made
Added a dedicated iPad breakpoint for 768–1180px widths.
Improved inventory card layout:
Two-column card grid on iPads.
Cleaner spacing and card sizing.
Image previews now preserve aspect ratio with object-fit: contain.
Added consistent image backgrounds and rounded borders.
Fixed first-image visibility inside video/image containers.
Improved forms:
Two-column form layout on iPad.
Larger 44px touch-friendly controls.
Better spacing for filter groups and search fields.
Improved tables:
Rounded scroll container.
Better horizontal touch scrolling.
Preserved safe-area and touch interaction behavior.
2026-10-15 20:52:14 +02:00
Aiirondev_dev 07a1cdb063 changes to the filter preset 2026-10-07 16:30:13 +02:00
Aiirondev_dev 2942bcc380 Der Jinja-Fehler in base.html ist behoben. 2026-10-07 16:12:31 +02:00
Aiirondev_dev 349337e75f changes to dont reflekt the testing database name 2026-10-07 16:03:32 +02:00
Aiirondev_dev 9bdd8261fe changes to dont include the testing information 2026-10-07 16:02:39 +02:00
Aiirondev_dev 04c8505531 Die Suche wurde angepasst:Titel und Beschreibung werden jetzt direkt gemeinsam durchsucht.
Teiltreffer funktionieren, z. B.:
Rechen findet Rechenschiffchen
ein Fragment aus der Beschreibung findet den Artikel ebenfalls
Groß-/Kleinschreibung spielt keine Rolle
Die zusätzliche lokale ID-Filterung wurde entfernt, da sie Treffer teilweise wieder ausgeblendet hat.
Die Suche verwendet nun die serverseitige MongoDB-Abfrage mit regulären Teiltreffern.
2026-10-07 15:55:07 +02:00
Aiirondev_dev c9edf4a4c8 Die Inventarsuche wurde korrigiert. 2026-10-07 15:45:47 +02:00
Aiirondev_dev 0e0a99569e Implementation of the right Code ussage 2026-10-02 15:13:46 +02:00
Aiirondev_dev 6e94ca20c4 changes to the Mahnungssystem level fix 2026-09-27 14:40:21 +02:00
Aiirondev_dev 72fdfb0898 changes to the style of the mahnungs zurücksetzungs Modal 2026-09-27 14:35:01 +02:00
Aiirondev_dev 9de09a2f41 custom Mahnungs reset implementation 2026-09-27 14:20:44 +02:00
Aiirondev_dev 3d5a81824f changes to the Student Card clearing 2026-09-25 22:02:26 +02:00
Aiirondev_dev b0344627a7 removal of unused Code 2026-09-25 17:01:56 +02:00
Aiirondev_dev 8d07577220 changes to the asv export 2026-09-24 22:21:55 +02:00
Aiirondev_dev f3c7b6cd55 implementation of password visibility changing fuinction for the own password change 2026-09-24 17:26:25 +02:00
Aiirondev_dev c2bfed710d changes to the upload function 2026-09-24 16:46:32 +02:00
Aiirondev_dev 5d90eab154 changes to the edeting filter displaying 2026-09-24 16:40:21 +02:00
Aiirondev_dev 1eadfff9e1 slight changes 2026-09-24 16:33:18 +02:00
Aiirondev_dev 18f096b3f2 changes to the filter change 2026-09-24 16:29:05 +02:00
Aiirondev_dev 4b895a52ab changes to the Liught / Darkmode Toggle to move it to the personal settings 2026-09-23 22:23:42 +02:00
17 changed files with 987 additions and 403 deletions
+8 -2
View File
@@ -20,13 +20,19 @@ pytest test_ausleihung.py -vv --tb=long
## Test Coverage
### ✅ Status Determination (5 tests)
### ✅ Status Determination
- Future borrowings marked as 'planned'
- Current borrowings marked as 'active'
- Past borrowings marked as 'completed'
- Active borrowings remain 'active' after the planned end until return confirmation
- Cancelled status never changes
- Active borrowings without end time
### ✅ Inventory borrowing workflow
- Planned inventory borrowings activate automatically at their start when the item is available
- Unavailable items remain planned and notify the requester and current holder
- Reaching the planned end creates a return warning instead of an automatic return
- The item is completed and made available only after the user confirms the return
### ✅ Create Operations (2 tests)
- Create immediately active borrowing
- Create planned/future borrowing
+394 -253
View File
File diff suppressed because it is too large Load Diff
+12 -17
View File
@@ -15,7 +15,8 @@ Hauptfunktionen:
Sammlungsstruktur:
- ausleihungen: Speichert alle Ausleihungsdatensätze mit ihrem Status
- Status-Werte: 'planned' (geplant), 'active' (aktiv), 'completed' (abgeschlossen), 'cancelled' (storniert)
- Status-Werte: 'planned' (geplant), 'active' (aktiv), 'completed' (nach bestätigter Rückgabe), 'cancelled' (storniert)
- Das geplante Ende beendet eine aktive Ausleihe nicht automatisch; es erzeugt einen Rückgabehinweis.
"""
'''
Copyright 2025-2026 AIIrondev
@@ -96,13 +97,10 @@ def get_current_status(ausleihung, log_changes=False, user=None):
ausleihung_id = str(ausleihung.get('_id', 'unknown'))[:12]
print(f"[DEBUG] Ausleihe {ausleihung_id} startet in {time_until_start/3600:.1f} Stunden ({start_time}), noch geplant")
new_status = 'planned'
# Wenn kein Enddatum gesetzt ist oder die aktuelle Zeit vor dem Enddatum liegt,
# ist die Ausleihung aktiv
elif not end_time or current_time < end_time:
new_status = 'active'
# Wenn die aktuelle Zeit nach dem Enddatum liegt, ist die Ausleihung beendet
# Eine aktive Ausleihe bleibt aktiv, bis die Rückgabe ausdrücklich bestätigt
# wurde. Das geplante Ende löst nur einen Rückgabehinweis aus.
else:
new_status = 'completed'
new_status = 'active'
# Protokollieren Sie Statusänderungen, wenn aktiviert und eine Änderung stattgefunden hat
if log_changes and new_status != original_status and '_id' in ausleihung:
@@ -185,13 +183,13 @@ def create_backup_database():
# === AUSLEIHUNG MANAGEMENT ===
def add_ausleihung(item_id, user, start_date, end_date=None, notes="", status="active", period=None, exemplar_data=None, due_date=None):
def add_ausleihung(item_id, user_id, start_date, end_date=None, notes="", status="active", period=None, exemplar_data=None, due_date=None):
"""
Add a new borrowing record for an item.
Args:
item_id (str): ID of the item borrowed
user (str): Username of the borrower
user_id (str): ID of the borrower
start_date (datetime): Start date and time of the borrowing
end_date (datetime, optional): End date and time if already returned
notes (str, optional): Additional notes about the borrowing
@@ -209,7 +207,7 @@ def add_ausleihung(item_id, user, start_date, end_date=None, notes="", status="a
ausleihung = {
'Item': item_id,
'User': dp.encrypt_text(user),
'User': str(user_id),
'Start': start_date,
'Status': status
}
@@ -256,8 +254,7 @@ def update_ausleihung(id, item_id=None, user_id=None, start=None, end=None, note
if item_id is not None:
update_data['Item'] = item_id
if user_id is not None:
# WICHTIG: Gleiche Verschlüsselung wie in add_ausleihung nutzen!
update_data['User'] = dp.encrypt_text(user_id) if hasattr(dp, 'encrypt_text') else user_id
update_data['User'] = str(user_id)
if start is not None:
update_data['Start'] = ensure_timezone_aware(start)
if end is not None:
@@ -556,9 +553,7 @@ def get_ausleihung_by_user(user_id, status=None, use_client_side_verification=Tr
db = client[cfg.MONGODB_DB]
ausleihungen = db['ausleihungen']
# Ensure proper encrypted query string if user IDs are encrypted in DB
search_user = dp.encrypt_text(user_id) if hasattr(dp, 'encrypt_text') else user_id
query = {'User': search_user, 'Status': {'$ne': 'deleted'}}
query = {'User': str(user_id), 'Status': {'$ne': 'deleted'}}
if not use_client_side_verification:
if status is not None:
@@ -864,8 +859,8 @@ def reset_item_completely(item_id):
# === KOMPATIBILITÄTSFUNKTIONEN ===
def add_planned_booking(item_id, user, start_date, end_date, notes="", period=None):
return add_ausleihung(item_id, user, start_date, end_date, notes, status='planned', period=period)
def add_planned_booking(item_id, user_id, start_date, end_date, notes="", period=None):
return add_ausleihung(item_id, user_id, start_date, end_date, notes, status='planned', period=period)
def check_booking_conflict(item_id, start_date, end_date, period=None):
+71 -6
View File
@@ -695,13 +695,42 @@ def get_user_by_student_card(student_card_id):
if not normalized:
return None
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
users = db['student_cards']
found_user = users.find_one({'SchülerName': normalized})
client.close()
try:
db = _get_tenant_db(client)
users = db['users']
return users.find_one({
'$or': [
{'StudentCardId': normalized},
{'StudentCardId': student_card_id},
]
})
finally:
client.close()
def get_user_by_student_card_record_id(card_id):
"""Return the user assigned to a student-card document ID."""
if card_id is None:
return None
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try:
db = _get_tenant_db(client)
card = None
if isinstance(card_id, ObjectId):
card = db['student_cards'].find_one({'_id': card_id})
elif ObjectId.is_valid(str(card_id)):
card = db['student_cards'].find_one({'_id': ObjectId(str(card_id))})
if not card:
return None
card_id_value = normalize_student_card_id(card.get('AusweisId'))
if not card_id_value:
return None
return db['users'].find_one({'StudentCardId': card_id_value})
finally:
client.close()
# Do not call dp.decrypt_text() here because found_user is a MongoDB dictionary.
return found_user
def get_user_by_student_ident(student_ident):
"""Return user dict by student ident by decrypting all cards and matching."""
@@ -874,6 +903,42 @@ def get_user(username):
client.close()
def get_user_by_id(user_id):
"""Retrieve a user by MongoDB document ID."""
if user_id is None:
return None
lookup_values = [str(user_id)]
if ObjectId.is_valid(str(user_id)):
lookup_values.insert(0, ObjectId(str(user_id)))
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try:
def find_in_db(database_name):
users = client[database_name]['users']
for lookup_value in lookup_values:
user = users.find_one({'_id': lookup_value})
if user:
return user
return None
tenant_db, tenant_id = _resolve_request_tenant_db()
if tenant_db:
user = find_in_db(tenant_db)
if user:
return user
if _has_tenant_configs() and tenant_db is None:
logger.warning(
"Refusing default DB fallback for user ID lookup because tenant configs exist and no tenant was resolved."
)
return None
return find_in_db(cfg.MONGODB_DB)
finally:
client.close()
def check_admin(username):
"""Check if a user has administrator privileges."""
user = get_user(username)
+36
View File
@@ -921,6 +921,42 @@ html, body {
}
}
/* iPad layout: keep content airy while using the available tablet width. */
@media screen and (min-width: 768px) and (max-width: 1180px) {
.container {
width: min(100% - 32px, 1120px);
margin: 16px auto;
padding: 20px;
}
.form-control,
.form-select,
textarea,
input,
select {
min-height: 44px;
font-size: 16px;
}
.btn,
button,
input[type="submit"],
input[type="button"] {
min-height: 44px;
touch-action: manipulation;
}
.table-responsive {
border: 1px solid var(--ui-border);
border-radius: var(--ui-radius);
-webkit-overflow-scrolling: touch;
}
.table-responsive > .table {
margin-bottom: 0;
}
}
/* ========================================================= */
/* UNIVERSAL DARK MODE OVERRIDES FOR HARDCODED HTML STYLES */
/* ========================================================= */
Binary file not shown.
+2 -2
View File
@@ -78,11 +78,11 @@
<div class="form-row">
<div class="form-group">
<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', 'Klassenstufe') }}" placeholder="z. B. Klassenstufe">
<input type="text" id="filter_name_1" name="filter_name_1" value="{{ filter_names.get('1', 'Fachgebiet') }}" placeholder="z. B. Fachgebiet">
</div>
<div class="form-group">
<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', 'Fach') }}" placeholder="z. B. Fach">
<input type="text" id="filter_name_2" name="filter_name_2" value="{{ filter_names.get('2', 'Klassenstufe') }}" placeholder="z. B. Klassenstufe">
</div>
<div class="form-group">
<label for="filter_name_3">Name Filter 3</label>
+15 -15
View File
@@ -1135,12 +1135,7 @@
<a class="nav-link quick-link-pill {% if current_path == url_for('terminplaner.configure') %}nav-active{% endif %}" href="{{ url_for('terminplaner.configure') }}" data-tutorial-tip="Neuen Terminplan erstellen und an Ihr Team versenden.">Neue Planung</a>
</li>
{% endif %}
<li class="nav-item" data-nav-fixed="true">
<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-dark" style="display: none;">🌙</span>
</button>
</li>
<li class="nav-item dropdown ms-lg-auto">
<a class="nav-link dropdown-toggle" href="#" id="termMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">Mehr Optionen</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="termMoreDropdown">
@@ -1199,6 +1194,13 @@
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
<li><hr class="dropdown-divider"></li>
<li class="dropdown-item" data-nav-fixed="true">
<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-dark" style="display: none;">🌙</span>
</button>
</li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
</ul>
</div>
@@ -1228,12 +1230,6 @@
</li>
{% endif %}
{% endif %}
<li class="nav-item" data-nav-fixed="true">
<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-dark" style="display: none;">🌙</span>
</button>
</li>
<li class="nav-item dropdown ms-lg-auto">
<a class="nav-link dropdown-toggle" href="#" id="invMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">
@@ -1305,8 +1301,6 @@
<button class="function-search-btn" type="submit">Los</button>
</form>
</div>
{% if current_tenant_db %}
<span class="navbar-text tenant-badge" title="Aktive Tenant-Datenbank">{{ current_tenant_db }}</span>
{% endif %}
<span class="navbar-text text-light me-3">{{ session['username'] }}</span>
<div class="dropdown me-2 user-menu-wrap">
@@ -1321,6 +1315,13 @@
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
<li><hr class="dropdown-divider"></li>
<li class="dropdown-item" data-nav-fixed="true">
<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-dark" style="display: none;">🌙</span>
</button>
</li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
</ul>
</div>
@@ -1329,7 +1330,6 @@
</div>
</div>
</nav>
{% endif %}
{% if library_module_enabled and CURRENT_MODULE == 'library' %}
<nav class="navbar navbar-expand-lg navbar-dark" id="libraryNavbar">
+38 -5
View File
@@ -26,18 +26,27 @@
<form method="POST" action="{{ url_for('change_password') }}">
<div class="form-group">
<label for="current_password">Aktuelles Passwort:</label>
<input type="password" id="current_password" name="current_password" required>
<div class="input-group">
<input type="password" class="form-control" id="current_password" name="current_password" required>
<button class="btn btn-outline-secondary" type="button" onclick="togglePasswordVisibility('current_password', this)">Anzeigen</button>
</div>
</div>
<div class="form-group">
<label for="new_password">Neues Passwort:</label>
<input type="password" id="new_password" name="new_password" required>
<div class="input-group">
<input type="password" class="form-control" id="new_password" name="new_password" required>
<button class="btn btn-outline-secondary" type="button" onclick="togglePasswordVisibility('new_password', this)">Anzeigen</button>
</div>
<small class="form-text text-muted">Das Passwort sollte mindestens 6 Zeichen lang sein.</small>
</div>
<div class="form-group">
<label for="confirm_password">Neues Passwort bestätigen:</label>
<input type="password" id="confirm_password" name="confirm_password" required>
<div class="input-group">
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
<button class="btn btn-outline-secondary" type="button" onclick="togglePasswordVisibility('confirm_password', this)">Anzeigen</button>
</div>
</div>
<div class="form-actions">
@@ -48,6 +57,20 @@
</div>
</div>
<script>
function togglePasswordVisibility(fieldId, button) {
var passwordInput = document.getElementById(fieldId);
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
button.textContent = 'Verbergen';
} else {
passwordInput.type = 'password';
button.textContent = 'Anzeigen';
}
}
</script>
<style>
.password-change-form {
max-width: 500px;
@@ -74,7 +97,7 @@
font-weight: bold;
}
input[type="password"] {
.form-control {
width: 100%;
padding: 10px;
border: 1px solid #ced4da;
@@ -122,6 +145,16 @@
.btn-secondary:hover {
background-color: #5a6268;
}
.btn-outline-secondary {
border: 1px solid #ced4da;
background-color: #f8f9fa;
color: #495057;
}
.btn-outline-secondary:hover {
background-color: #e2e6ea;
}
.alert {
padding: 12px;
@@ -141,4 +174,4 @@
border: 1px solid #c3e6cb;
}
</style>
{% endblock %}
{% endblock %}
+4 -4
View File
@@ -278,8 +278,8 @@
<div class="multi-filter">
{% for idx in range(4) %}
<div class="form-group">
<label for="filter1-{{ idx + 1 }}">Wert {{ idx + 1 }}:</label>
<select id="filter1-{{ idx + 1 }}" name="filter" class="filter-dropdown-select" data-selected="{{ item.Filter[idx] if item.Filter and item.Filter|length > idx else '' }}">
<label for="filter2-{{ idx + 1 }}">Wert {{ idx + 1 }}:</label>
<select id="filter2-{{ idx + 1 }}" name="filter" class="filter-dropdown-select" data-selected="{{ item.Filter[idx] if item.Filter and item.Filter|length > idx else '' }}">
<option value="">-- Optional --</option>
</select>
</div>
@@ -290,8 +290,8 @@
<div class="multi-filter">
{% for idx in range(4) %}
<div class="form-group">
<label for="filter2-{{ idx + 1 }}">Wert {{ idx + 1 }}:</label>
<select id="filter2-{{ idx + 1 }}" name="filter2" class="filter-dropdown-select" data-selected="{{ item.Filter2[idx] if item.Filter2 and item.Filter2|length > idx else '' }}">
<label for="filter1-{{ idx + 1 }}">Wert {{ idx + 1 }}:</label>
<select id="filter1-{{ idx + 1 }}" name="filter2" class="filter-dropdown-select" data-selected="{{ item.Filter2[idx] if item.Filter2 and item.Filter2|length > idx else '' }}">
<option value="">-- Optional --</option>
</select>
</div>
+1
View File
@@ -1419,6 +1419,7 @@
keyboardScannerEnabled = false;
keyboardScanBuffer = '';
keyboardLastKeyAt = 0;
setActiveStudentCard('');
document.removeEventListener('keydown', keyboardScanKeydownHandler);
const modal = document.getElementById('physicalScannerModal');
if (modal) modal.style.display = 'none';
+73 -5
View File
@@ -187,7 +187,11 @@
{% endif %}
<button type="button" class="btn btn-sm btn-outline-warning text-dark"
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 (14 Tage)
</button>
<button type="button" class="btn btn-sm btn-outline-warning text-dark"
onclick="resetMahnungCustom('{{ item.id }}', '{{ item.schueler_name }}')">
<i class="bi bi-arrow-counterclockwise"></i> Zurücksetzen mit eigener Frist
</button>
</div>
</td>
@@ -210,7 +214,7 @@
<!-- E-Mail Modal -->
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="emailModalLabel">Mahnungs-E-Mail senden</h5>
@@ -228,7 +232,7 @@
<input type="email" class="form-control" id="modalEmail" placeholder="E-Mail-Adresse eingeben...">
</div>
<div class="mb-3">
<label for="modalMessage" class="form-label">Nachricht</label>
<label for="modalMessage" class="form-label">Nachrichten-Text</label>
<textarea class="form-control" id="modalMessage" rows="4" placeholder="Optionaler Text..."></textarea>
</div>
</form>
@@ -241,6 +245,31 @@
</div>
</div>
<!-- Custom Reset Days Modal -->
<div class="modal fade" id="customResetModal" tabindex="-1" aria-labelledby="customResetModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="customResetModalLabel">Mahnung zurücksetzen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Schließen"></button>
</div>
<div class="modal-body">
<input type="hidden" id="resetModalLoanId">
<p>Möchten Sie die Mahnung für <strong id="resetModalStudentName"></strong> wirklich zurücksetzen?</p>
<p class="text-muted small">Dadurch wird die Mahnstufe auf 0 gesetzt und der Schülerausweis entsperrt.</p>
<div class="mb-3">
<label for="resetModalDays" class="form-label">Frist verlängern um (Tage):</label>
<input type="number" class="form-control" id="resetModalDays" value="14" min="1">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-warning" onclick="submitCustomReset()">Mahnung zurücksetzen</button>
</div>
</div>
</div>
</div>
<script>
function openEmailModal(loanId, studentName, studentEmail) {
document.getElementById('modalLoanId').value = loanId;
@@ -292,7 +321,7 @@ function resetMahnung(loanId, studentName) {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
fetch('/mahnungen_reset', {
fetch('/mahnungen_reset/14', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({ loan_id: loanId })
@@ -301,7 +330,46 @@ function resetMahnung(loanId, studentName) {
.then(data => {
if (data.success) {
alert(data.message);
location.reload(); // Seite neu laden, um die aktualisierte Ansicht zu sehen
location.reload();
} else {
alert('Fehler: ' + data.message);
}
})
.catch(error => {
alert('Fehler beim Senden der Anfrage.');
console.error(error);
});
}
function resetMahnungCustom(loanId, studentName) {
document.getElementById('resetModalLoanId').value = loanId;
document.getElementById('resetModalStudentName').textContent = studentName;
document.getElementById('resetModalDays').value = 14;
var customModal = new bootstrap.Modal(document.getElementById('customResetModal'));
customModal.show();
}
function submitCustomReset() {
const loanId = document.getElementById('resetModalLoanId').value;
const daysToExtend = parseInt(document.getElementById('resetModalDays').value, 10);
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
if (isNaN(daysToExtend) || daysToExtend < 1) {
alert('Bitte geben Sie eine gültige Anzahl an Tagen ein.');
return;
}
fetch(`/mahnungen_reset/${daysToExtend}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({ loan_id: loanId })
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message);
location.reload();
} else {
alert('Fehler: ' + data.message);
}
+268 -57
View File
@@ -266,6 +266,11 @@
border-radius: 4px;
}
.code-search-label {
color: #b93845;
font-weight: 700;
}
.search-button {
padding: 8px 12px;
background-color: #007bff;
@@ -284,10 +289,10 @@
}
#code-search.searching {
background-color: #fff3cd;
background-color: #fff5f5;
transition: background-color 0.3s;
border: 2px solid #ffc107;
box-shadow: 0 0 8px rgba(255, 193, 7, 0.5);
border: 2px solid #d65a62;
box-shadow: 0 0 8px rgba(190, 55, 66, 0.28);
}
/* QR Scanner styles */
@@ -562,26 +567,55 @@
align-items: center;
}
.navigation-buttons {
display: flex;
justify-content: center;
gap: 10px;
.items-navigation {
display: grid;
grid-template-columns: 42px minmax(0, 1fr) 42px;
align-items: center;
column-gap: 16px;
width: calc(100% + 116px);
margin-left: -58px;
margin-top: 20px;
}
.navigation-buttons .prev-button,
.navigation-buttons .next-button {
background-color: #007bff;
color: #fff;
border: none;
cursor: pointer;
padding: 10px;
border-radius: 5px;
.navigation-button-slot {
display: flex;
justify-content: center;
}
.navigation-buttons .prev-button:hover,
.navigation-buttons .next-button:hover {
background-color: #0056b3;
.navigation-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
padding: 0;
background: var(--ui-surface-soft);
color: var(--ui-title);
border: 1px solid var(--ui-border);
box-shadow: var(--ui-shadow-sm);
cursor: pointer;
border-radius: 10px;
font-size: 1.15rem;
font-weight: 700;
line-height: 1;
transition: transform 160ms ease, box-shadow 160ms ease, background-color 160ms ease, border-color 160ms ease;
z-index: 1;
}
.navigation-button:hover {
background: var(--ui-surface);
border-color: var(--module-accent-color);
box-shadow: var(--ui-shadow-md);
transform: translateY(-2px) scale(1.04);
}
.navigation-button:active {
transform: translateY(0) scale(0.96);
}
.navigation-button:focus-visible {
outline: 3px solid rgba(37, 99, 235, 0.35);
outline-offset: 3px;
}
/* Modal styles */
@@ -744,15 +778,15 @@
}
.code-match {
border: 2px solid #17a2b8 !important;
box-shadow: 0 0 15px rgba(23, 162, 184, 0.5) !important;
border: 2px solid #d65a62 !important;
box-shadow: 0 0 15px rgba(190, 55, 66, 0.28) !important;
position: relative;
z-index: 5;
}
.exact-code-match {
border: 3px solid #28a745 !important;
box-shadow: 0 0 20px rgba(40, 167, 69, 0.7) !important;
border: 3px solid #b93845 !important;
box-shadow: 0 0 20px rgba(185, 56, 69, 0.42) !important;
position: relative;
z-index: 11;
}
@@ -1633,7 +1667,13 @@
}
/* Navigation buttons mobile */
.navigation-buttons {
.items-navigation {
display: block !important;
width: 100% !important;
margin-left: 0 !important;
}
.navigation-button-slot {
display: none !important; /* Hide horizontal navigation on mobile */
}
@@ -1946,7 +1986,7 @@
/* Standardized button styles across the application */
.search-button, .scan-button, .filter-toggle, .clear-filter,
.add-new-btn, .popup-close-button, .prev-image-button, .next-image-button,
.navigation-buttons .prev-button, .navigation-buttons .next-button {
.navigation-button {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -1971,6 +2011,122 @@
box-sizing: border-box;
white-space: nowrap;
}
/* iPad: use the wider canvas for cards, filters, forms, and image previews. */
@media screen and (min-width: 768px) and (max-width: 1180px) {
.admin-content-container {
width: min(100%, 1120px) !important;
max-width: 1120px !important;
margin: 0 auto !important;
padding: 20px 24px 28px !important;
box-sizing: border-box !important;
}
.filter-container {
display: grid !important;
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
align-items: start;
gap: 14px !important;
}
.filter-group {
min-width: 0 !important;
width: auto !important;
padding: 14px !important;
}
.filter-group.search-field {
grid-column: span 2;
}
.search-container {
gap: 8px;
}
.search-container input,
.form-group input,
.form-group select,
.form-group textarea {
min-height: 44px;
font-size: 16px;
}
.items-container {
display: grid !important;
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
gap: 18px !important;
padding: 14px 0 24px !important;
overflow: visible !important;
scroll-snap-type: none !important;
}
.item-card {
width: auto !important;
max-width: none !important;
min-width: 0 !important;
padding: 16px !important;
scroll-snap-align: none !important;
}
.item-card .image-container {
width: 100% !important;
max-width: none !important;
height: auto !important;
aspect-ratio: 4 / 3;
margin: 10px 0 14px !important;
overflow: hidden !important;
border: 1px solid var(--ui-border);
border-radius: 12px !important;
background: var(--ui-surface-soft);
}
.item-card .image-container .item-image,
.item-card .image-container .video-container {
width: 100% !important;
height: 100% !important;
}
.item-card .image-container .item-image {
object-fit: contain !important;
object-position: center;
background: var(--ui-surface-soft);
}
.item-card .image-container .item-image[data-index="0"] {
display: block !important;
position: relative !important;
opacity: 1 !important;
}
.item-card .image-container .item-image:not([data-index="0"]) {
display: none !important;
}
.item-card .image-container .video-container {
position: relative;
display: block !important;
}
.form-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.form-actions {
flex-wrap: wrap;
gap: 12px;
}
.form-actions > * {
min-height: 44px;
}
#item-modal .modal-content {
width: min(92vw, 880px);
}
}
</style>
<style>
.bookmark-btn { position:absolute; top:8px; right:8px; width:46px; height:46px; background:rgba(255,255,255,.92); border:2px solid #ffc107; border-radius:50%; font-size:1.65rem; line-height:1; display:flex; align-items:center; justify-content:center; cursor:pointer; color:#ffc107; box-shadow:0 2px 6px rgba(0,0,0,.18); transition:background .2s, transform .15s, color .2s; z-index:6; pointer-events:auto; }
@@ -2343,7 +2499,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div class="filter-container">
<div class="filter-group">
<div class="filter-header">
<label>{{ filter_names.get('2', 'Fach') }}</label>
<label>{{ filter_names.get('1', 'Fach') }}</label>
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter1-dropdown')">▼</button>
<button type="button" class="clear-filter" onclick="clearFilter(1)">Clear</button>
</div>
@@ -2357,7 +2513,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div class="filter-group">
<div class="filter-header">
<label>{{ filter_names.get('1', 'Klassenstufe') }}</label>
<label>{{ filter_names.get('2', 'Klassenstufe') }}</label>
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter2-dropdown')">▼</button>
<button type="button" class="clear-filter" onclick="clearFilter(2)">Clear</button>
</div>
@@ -2385,7 +2541,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div class="filter-group search-field">
<div class="filter-header">
<label for="code-search">Code suchen:</label>
<label class="code-search-label" for="code-search">Code suchen:</label>
<button type="button" class="clear-filter" onclick="clearCodeSearch()">Clear</button>
</div>
<div class="search-container">
@@ -2427,12 +2583,16 @@ document.addEventListener('DOMContentLoaded', ()=>{
<span>Anzahl</span>
</div>
<div id="items-container" class="items-container">
<!-- Items will be dynamically loaded here -->
</div>
<div class="navigation-buttons">
<button class="prev-button" onclick="scrollPrev()">&#10094;</button>
<button class="next-button" onclick="scrollNext()">&#10095;</button>
<div class="items-navigation">
<div class="navigation-button-slot">
<button class="navigation-button prev-button" type="button" onclick="scrollPrev()" aria-label="Vorherige Elemente">&#10094;</button>
</div>
<div id="items-container" class="items-container">
<!-- Items will be dynamically loaded here -->
</div>
<div class="navigation-button-slot">
<button class="navigation-button next-button" type="button" onclick="scrollNext()" aria-label="Nächste Elemente">&#10095;</button>
</div>
</div>
</div>
<div id="item-modal" class="item-modal">
@@ -3206,9 +3366,13 @@ document.addEventListener('DOMContentLoaded', ()=>{
});
}
function loadItems(offset = 0, append = false) {
function loadItems(offset = 0, append = false, searchTerm = '', searchScope = 'all') {
// Keep list payload lightweight; full details are fetched on-demand in openItemQuick.
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ADMIN_ITEMS_PAGE_SIZE}&light_mode=true`)
const searchParams = searchTerm && searchScope === 'title_description'
? `&search=${encodeURIComponent(searchTerm)}&search_scope=${encodeURIComponent(searchScope)}`
: '';
const limit = (searchTerm && searchScope === 'all') ? 5000 : MAIN_ADMIN_ITEMS_PAGE_SIZE;
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${limit}&light_mode=true${searchParams}`)
.then(response => response.json())
.then(data => {
const itemsContainer = document.querySelector('#items-container');
@@ -3227,8 +3391,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
if (!append && (!pageItems || pageItems.length === 0)) {
itemsContainer.innerHTML = `
<div class="no-items-message">
<div>Keine Objekte gefunden</div>
<a class="empty-state-cta" href="{{ url_for('upload_admin') }}">Erstellen sie hier ihr erstes Item</a>
<div>Kein Item gefunden</div>
</div>
`;
if (itemsIndicator) {
@@ -3241,7 +3404,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
pageItems.sort((a, b) => {
const nameA = a.Name ? a.Name.toLowerCase() : '';
const nameB = a.Name ? a.Name.toLowerCase() : '';
const nameB = b.Name ? b.Name.toLowerCase() : '';
return nameA.localeCompare(nameB);
});
@@ -3285,6 +3448,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
card.dataset.code = (item.Code_4 !== undefined && item.Code_4 !== null) ?
String(item.Code_4).trim() : '';
card.dataset.groupCodes = JSON.stringify(Array.isArray(item.GroupedAllCodes) ? item.GroupedAllCodes : []);
card.dataset.groupedUnits = JSON.stringify(Array.isArray(item.GroupedUnitStates) ? item.GroupedUnitStates : []);
// Add filter values to their respective sets
filter1Array.forEach(value => {
@@ -3415,7 +3579,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
</div>
{% if current_permissions.actions.get('can_borrow', False) %}
${isAvailableForBorrow && !item.BlockedNow ?
`<form method="POST" action="{{ url_for('ausleihen', id='') }}${item._id}">
`<form method="POST" action="{{ url_for('ausleihen', id='') }}${item._id}" data-grouped-borrow-form>
${isGroupedItem ? `
<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>
@@ -3445,6 +3609,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
:
`<button class="ausleihen disabled-button" disabled>${item.BlockedNow ? 'Reserviert' : 'Ausgeliehen'}</button>`
}
${isGroupedItem ? '<div class="grouped-searched-action" data-grouped-searched-action style="display:none;"></div>' : ''}
{% endif %}
{% if current_permissions.actions.get('can_delete', False) %}
<form method="POST" action="{{ url_for('delete_item', id='') }}${item._id}" style="display:inline;" onsubmit="return confirm('Sind Sie sicher, dass Sie dieses Objekt löschen möchten?')">
@@ -3679,13 +3844,66 @@ document.addEventListener('DOMContentLoaded', ()=>{
card.classList.remove('highlight-item', 'code-match', 'exact-code-match');
});
applyFilters(true);
if (codeSearchTerm) {
loadItems(0, false, codeSearchTerm, 'all');
} else {
loadItems();
}
setTimeout(() => {
searchInput.classList.remove('searching');
}, 500);
}
function syncSearchedGroupedAction(card, searchTerm, exactCodeMatch) {
const searchedAction = card.querySelector('[data-grouped-searched-action]');
if (!searchedAction) return;
const borrowForm = card.querySelector('[data-grouped-borrow-form]');
searchedAction.style.display = 'none';
searchedAction.innerHTML = '';
if (!exactCodeMatch || !searchTerm) {
if (borrowForm) borrowForm.style.display = '';
return;
}
let groupedUnits = [];
try {
groupedUnits = JSON.parse(card.dataset.groupedUnits || '[]');
} catch {
groupedUnits = [];
}
const matchedUnit = groupedUnits.find(unit =>
String(unit.code || '').trim().toLowerCase() === searchTerm
);
if (!matchedUnit) return;
if (matchedUnit.available) {
if (borrowForm) borrowForm.style.display = '';
const specificSelect = borrowForm?.querySelector('[name="specific_item_id"]');
if (specificSelect) {
specificSelect.value = matchedUnit.id;
}
return;
}
if (borrowForm) borrowForm.style.display = 'none';
searchedAction.style.display = 'block';
if (matchedUnit.borrowed_by_current_user) {
searchedAction.innerHTML = `
<form method="POST" action="/zurueckgeben/${encodeURIComponent(matchedUnit.id)}">
<button class="ausleihen" type="submit">Zurückgeben (${escapeHtml(matchedUnit.code || 'Code')})</button>
</form>`;
} else {
searchedAction.innerHTML = `
<button class="ausleihen disabled-button" type="button" disabled>
${escapeHtml(matchedUnit.code || 'Code')} ausgeliehen
</button>`;
}
}
function applyFilters(isDirectSearch = false) {
const itemsContainer = document.querySelector('#items-container');
const items = itemsContainer.querySelectorAll('.item-card');
@@ -3732,6 +3950,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
if (passesAllFilters && matchesCodeSearch && matchesDesc && (!favoritesOnly || isFavoriteCard)) {
item.style.display = 'block';
visibleCount++;
syncSearchedGroupedAction(item, searchTerm, exactCodeMatch);
if (exactCodeMatch) {
item.classList.add('highlight-item', 'exact-code-match');
@@ -3756,6 +3975,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
} else {
item.style.display = 'none';
syncSearchedGroupedAction(item, '', false);
}
});
@@ -3830,7 +4050,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
card.classList.remove('highlight-item', 'code-match', 'exact-code-match');
});
applyFilters();
loadItems();
}
// Description search using backend substring match
@@ -3839,21 +4059,12 @@ document.addEventListener('DOMContentLoaded', ()=>{
const term = (input?.value || '').trim();
if (!term){
descSearchIds = null;
applyFilters(true);
loadItems(0, false, codeSearchTerm, 'all');
return;
}
input?.classList.add('searching');
fetch(`/search_word/${encodeURIComponent(term)}`)
.then(r=>r.json())
.then(data=>{
if (data && data.success && Array.isArray(data.response)){
descSearchIds = new Set(data.response.map(String));
} else {
descSearchIds = new Set();
}
applyFilters(true);
})
.catch(()=>{ descSearchIds = new Set(); applyFilters(true); })
descSearchIds = null;
loadItems(0, false, term, 'title_description')
.finally(()=>{ setTimeout(()=> input?.classList.remove('searching'), 300); });
}
@@ -3861,7 +4072,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
const input = document.getElementById('desc-search');
if (input) input.value = '';
descSearchIds = null;
applyFilters();
loadItems(0, false, codeSearchTerm, 'all');
}
// Open item modal with fresh details from backend.
@@ -4112,17 +4323,17 @@ document.addEventListener('DOMContentLoaded', ()=>{
</div>
<div class="detail-group">
<div class="detail-label">Unterrichtsfach:</div>
<div class="detail-label">{{ filter_names.get('1', 'Unterrichtsfach') }}:</div>
<div class="detail-value">${escapeHtml(filter1Array.join(', ') || '-')}</div>
</div>
<div class="detail-group">
<div class="detail-label">Jahrgangsstufe:</div>
<div class="detail-label">{{ filter_names.get('2', 'Jahrgangsstufe') }}:</div>
<div class="detail-value">${escapeHtml(filter2Array.join(', ') || '-')}</div>
</div>
<div class="detail-group">
<div class="detail-label">Schlagwort:</div>
<div class="detail-label">{{ filter_names.get('3', 'Schlagwort') }}:</div>
<div class="detail-value">${escapeHtml(filter3Array.join(', ') || '-')}</div>
</div>
+2 -4
View File
@@ -21,9 +21,8 @@
<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') }}
<i class="bi bi-funnel me-2"></i>{{ filter_names.get('2', 'Jahrgangsstufe') }}
</h2>
<span class="badge bg-primary-subtle text-primary rounded-pill">Filter 1</span>
</div>
<div class="card-body p-4">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=1) }}" class="mb-4">
@@ -87,9 +86,8 @@
<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('2', 'Fachgebiet') }}
<i class="bi bi-funnel me-2"></i>{{ filter_names.get('1', 'Fachgebiet') }}
</h2>
<span class="badge bg-primary-subtle text-primary rounded-pill">Filter 2</span>
</div>
<div class="card-body p-4">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=2) }}" class="mb-4">
+30 -1
View File
@@ -142,6 +142,9 @@
{% if item.ActiveAppointment %}
<span class="appointment-status status-active">Aktiv</span>
{% endif %}
{% if item.ReturnPending %}
<span class="return-due-badge">RÜCKGABE ERFORDERLICH</span>
{% endif %}
</div>
<div class="item-details">
@@ -202,7 +205,9 @@
<div class="action-buttons-container">
<form method="POST" action="{{ url_for('zurueckgeben', id=item._id) }}">
<input type="hidden" name="source_page" value="my_borrowed_items">
<button class="return-button" type="submit">Zurückgeben</button>
<button class="return-button{% if item.ReturnPending %} return-button-due{% endif %}" type="submit">
{% if item.ReturnPending %}Rückgabe bestätigen{% else %}Zurückgeben{% endif %}
</button>
</form>
<form method="POST" action="{{ url_for('cancel_ausleihung_route', id=item.AppointmentData.id) }}" onsubmit="return confirm('Möchten Sie diese aktive Ausleihung wirklich stornieren?');">
<button class="cancel-button" type="submit">Stornieren</button>
@@ -497,6 +502,30 @@ document.addEventListener('DOMContentLoaded', function() {
.return-button:hover {
background-color: #0069d9;
}
.return-due-badge {
background-color: #dc3545;
color: #fff;
padding: 4px 9px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 800;
margin-left: 10px;
}
.return-button-due {
background-color: #dc3545;
animation: return-due-pulse 1.8s ease-in-out infinite;
}
.return-button-due:hover {
background-color: #b02a37;
}
@keyframes return-due-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.45); }
50% { box-shadow: 0 0 0 7px rgba(220, 53, 69, 0); }
}
.return-count-group {
display: flex;
+1
View File
@@ -514,6 +514,7 @@
<p style="margin:0 0 12px 0; color:#555; font-size:13px; line-height:1.4;">
Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch (z. B. aus <strong>ASV</strong>). Erkannt werden Rufame, Nachname, Klasse*, Ausweis-ID*, lokales Differenzierungsmerkmal*, Notizen* & Ausleihdauer*.
Hinweis: Die Daten werden Validiert und müssen dann noch einmal aus sicherheits gründen hochgeladen werden!
Im folgenden ist eine export vorlage für ihre ASV-Software hinterlegt: <a href="{{ url_for('get_asv_export') }}" target="_blank" style="color:#007bff; text-decoration:underline;">ASV Export Vorlage</a>
</p>
</div>
+32 -32
View File
@@ -865,38 +865,6 @@
<!-- Normal Item Mode: Standard Filters -->
<div class="filter-inputs">
<h3>{{ filter_names.get('1', 'Jahrgangsstufe') }}</h3>
<div class="multi-filter">
<div class="form-group">
<label for="filter1-1">Wert 1:</label>
<select id="filter1-1" name="filter" class="filter-dropdown-select" required>
<option value="">-- Bitte auswählen --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="form-group">
<label for="filter1-2">Wert 2:</label>
<select id="filter1-2" name="filter" class="filter-dropdown-select">
<option value="">-- Optional --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="form-group">
<label for="filter1-3">Wert 3:</label>
<select id="filter1-3" name="filter" class="filter-dropdown-select">
<option value="">-- Optional --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="form-group">
<label for="filter1-4">Wert 4:</label>
<select id="filter1-4" name="filter" class="filter-dropdown-select">
<option value="">-- Optional --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
</div>
<h3>{{ filter_names.get('2', 'Fachgebiet') }}</h3>
<div class="multi-filter">
<div class="form-group">
<label for="filter2-1">Wert 1:</label>
@@ -928,6 +896,38 @@
</div>
</div>
<h3>{{ filter_names.get('2', 'Fachgebiet') }}</h3>
<div class="multi-filter">
<div class="form-group">
<label for="filter1-1">Wert 1:</label>
<select id="filter1-1" name="filter" class="filter-dropdown-select" required>
<option value="">-- Bitte auswählen --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="form-group">
<label for="filter1-2">Wert 2:</label>
<select id="filter1-2" name="filter" class="filter-dropdown-select">
<option value="">-- Optional --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="form-group">
<label for="filter1-3">Wert 3:</label>
<select id="filter1-3" name="filter" class="filter-dropdown-select">
<option value="">-- Optional --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="form-group">
<label for="filter1-4">Wert 4:</label>
<select id="filter1-4" name="filter" class="filter-dropdown-select">
<option value="">-- Optional --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
</div>
<h3>{{ filter_names.get('3', 'Schlagwort') }}</h3>
<div class="multi-filter">
<div class="form-group">