Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91a80c6330 | |||
| 530d71ac8f | |||
| 64991326e7 | |||
| dffcfdcba7 | |||
| b3e79a65c1 | |||
| b33b4eadc6 | |||
| 07a1cdb063 | |||
| 2942bcc380 | |||
| 349337e75f | |||
| 9bdd8261fe | |||
| 04c8505531 | |||
| c9edf4a4c8 | |||
| 0e0a99569e | |||
| 6e94ca20c4 | |||
| 72fdfb0898 | |||
| 9de09a2f41 | |||
| 3d5a81824f | |||
| b0344627a7 | |||
| 8d07577220 | |||
| f3c7b6cd55 | |||
| c2bfed710d | |||
| 5d90eab154 | |||
| 1eadfff9e1 | |||
| 18f096b3f2 | |||
| 4b895a52ab | |||
| 3a504afe37 | |||
| fb2ba86140 | |||
| 72e8a8cf5d | |||
| a158d16f62 | |||
| b8347b80f2 | |||
| 31356e7178 | |||
| 0409813cbe |
@@ -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
|
||||
|
||||
+301
-212
@@ -468,6 +468,10 @@ def _enforce_active_session_user():
|
||||
return None
|
||||
|
||||
username = session.get('username')
|
||||
current_user_id = session.get('user_id')
|
||||
if not current_user_id:
|
||||
current_user = us.get_user(username)
|
||||
current_user_id = str(current_user['_id']) if current_user and current_user.get('_id') else None
|
||||
if not username:
|
||||
return None
|
||||
|
||||
@@ -1378,6 +1382,10 @@ def update_appointment_statuses():
|
||||
for appointment in appointments_to_check:
|
||||
old_status = appointment.get('Status')
|
||||
activation_user = str(appointment.get('User') or '').strip()
|
||||
try:
|
||||
activation_user = decrypt_text(activation_user) if activation_user else ''
|
||||
except Exception:
|
||||
pass
|
||||
activation_item_name = str(appointment.get('Item') or 'Termin')
|
||||
|
||||
# is_library_item expects an item document, not the stored item id.
|
||||
@@ -1400,9 +1408,107 @@ def update_appointment_statuses():
|
||||
)
|
||||
item_is_library = it.is_library_item(item_doc_for_status)
|
||||
|
||||
# Aktuellen Status bestimmen
|
||||
# Aktuellen Status bestimmen. Eine aktive Ausleihe bleibt bis zur
|
||||
# bestätigten Rückgabe aktiv, auch wenn ihr Endzeitpunkt erreicht ist.
|
||||
new_status = au.get_current_status(appointment, log_changes=True, user='scheduler')
|
||||
|
||||
# Inventarreservierungen werden erst beim tatsächlichen Start aktiviert.
|
||||
# Ist kein Exemplar frei, bleibt die Reservierung geplant und beide
|
||||
# betroffenen Nutzer werden einmalig benachrichtigt.
|
||||
if (
|
||||
old_status == 'planned'
|
||||
and new_status == 'active'
|
||||
and not item_is_library
|
||||
and appointment.get('Item')
|
||||
):
|
||||
item_id = appointment.get('Item')
|
||||
item_doc = None
|
||||
item_queries = [{'_id': item_id}]
|
||||
try:
|
||||
item_queries.insert(0, {'_id': ObjectId(str(item_id))})
|
||||
except (InvalidId, TypeError):
|
||||
pass
|
||||
item_doc = items_col.find_one({'$or': item_queries})
|
||||
|
||||
available = bool(item_doc and item_doc.get('Verfuegbar', True))
|
||||
if item_doc and int(item_doc.get('Exemplare', 1) or 1) > 1:
|
||||
total_exemplare = int(item_doc.get('Exemplare', 1) or 1)
|
||||
borrowed_exemplare = len(item_doc.get('ExemplareStatus') or [])
|
||||
available = borrowed_exemplare < total_exemplare
|
||||
|
||||
if item_doc:
|
||||
active_query = {
|
||||
'Status': 'active',
|
||||
'_id': {'$ne': appointment['_id']},
|
||||
'$or': [{'Item': item_id}, {'Item': str(item_id)}],
|
||||
}
|
||||
total_exemplare = int(item_doc.get('Exemplare', 1) or 1)
|
||||
active_count = ausleihungen.count_documents(active_query)
|
||||
available = available and active_count < total_exemplare
|
||||
|
||||
if not available:
|
||||
holder = ''
|
||||
if item_doc and item_doc.get('User'):
|
||||
try:
|
||||
holder = decrypt_text(str(item_doc['User']))
|
||||
except Exception:
|
||||
holder = str(item_doc['User'])
|
||||
if not holder:
|
||||
active_holder = ausleihungen.find_one(
|
||||
{
|
||||
'Status': 'active',
|
||||
'$or': [{'Item': item_id}, {'Item': str(item_id)}],
|
||||
'_id': {'$ne': appointment['_id']},
|
||||
},
|
||||
{'User': 1},
|
||||
)
|
||||
if active_holder and active_holder.get('User'):
|
||||
try:
|
||||
holder = decrypt_text(str(active_holder['User']))
|
||||
except Exception:
|
||||
holder = str(active_holder['User'])
|
||||
requested_user = str(appointment.get('User') or '').strip()
|
||||
try:
|
||||
requested_user = decrypt_text(requested_user) if requested_user else ''
|
||||
except Exception:
|
||||
pass
|
||||
item_name = (item_doc or {}).get('Name') or str(item_id)
|
||||
conflict_fields = {
|
||||
'ConflictDetected': True,
|
||||
'ConflictNote': f'Keine Verfügbarkeit für "{item_name}" zum Startzeitpunkt.',
|
||||
'ConflictAt': current_time,
|
||||
'WaitingForAvailability': True,
|
||||
}
|
||||
ausleihungen.update_one(
|
||||
{'_id': appointment['_id'], 'Status': 'planned'},
|
||||
{'$set': {**conflict_fields, 'LastUpdated': current_time}}
|
||||
)
|
||||
if requested_user:
|
||||
_create_notification(
|
||||
db,
|
||||
audience='user',
|
||||
notif_type='borrowing_waiting_for_availability',
|
||||
title='Ausleihe wartet auf Verfügbarkeit',
|
||||
message=f'"{item_name}" ist zum geplanten Start noch ausgeliehen. Deine Reservierung bleibt aktiv und wird automatisch gestartet, sobald es zurückgegeben wurde.',
|
||||
target_user=requested_user,
|
||||
reference={'borrow_id': str(appointment['_id']), 'item_id': str(item_id)},
|
||||
unique_key=f'borrowing:waiting:{appointment["_id"]}:requester',
|
||||
severity='warning',
|
||||
)
|
||||
if holder and holder != requested_user:
|
||||
_create_notification(
|
||||
db,
|
||||
audience='user',
|
||||
notif_type='borrowing_blocking_return',
|
||||
title='Rückgabe benötigt',
|
||||
message=f'"{item_name}" wird für eine geplante Ausleihe benötigt. Bitte gib den Gegenstand zurück und bestätige die Rückgabe.',
|
||||
target_user=holder,
|
||||
reference={'borrow_id': str(appointment['_id']), 'item_id': str(item_id)},
|
||||
unique_key=f'borrowing:waiting:{appointment["_id"]}:holder',
|
||||
severity='warning',
|
||||
)
|
||||
continue
|
||||
|
||||
# Wenn sich der Status geändert hat, aktualisiere in der Datenbank
|
||||
if new_status != old_status and not item_is_library:
|
||||
extra_fields = {}
|
||||
@@ -1477,32 +1583,48 @@ def update_appointment_statuses():
|
||||
except Exception as e:
|
||||
app.logger.warning(f"Could not update item status to True for {appointment['_id']}: {e}")
|
||||
|
||||
if old_status == 'planned' and new_status == 'active' and activation_user:
|
||||
try:
|
||||
_create_notification(
|
||||
db,
|
||||
audience='user',
|
||||
notif_type='appointment_activated',
|
||||
title='Reservierung ist jetzt aktiv',
|
||||
message=f"Deine geplante Ausleihe für {activation_item_name} startet jetzt.",
|
||||
target_user=activation_user,
|
||||
reference={
|
||||
'appointment_id': str(appointment.get('_id')),
|
||||
'item_id': str(appointment.get('Item') or ''),
|
||||
'event': 'activated',
|
||||
},
|
||||
unique_key=f"appointment:activated:{appointment.get('_id')}",
|
||||
severity='info',
|
||||
)
|
||||
except Exception as notif_err:
|
||||
app.logger.warning(
|
||||
f"Failed to create activation notification for {appointment.get('_id')}: {notif_err}"
|
||||
)
|
||||
# Endzeitpunkt erreicht: nur einen deutlich sichtbaren
|
||||
# Rückgabehinweis setzen, niemals automatisch abschließen.
|
||||
if (
|
||||
old_status == 'active'
|
||||
and isinstance(appointment.get('End'), datetime.datetime)
|
||||
and au.ensure_timezone_aware(appointment['End']) <= current_time
|
||||
):
|
||||
ausleihungen.update_one(
|
||||
{'_id': appointment['_id'], 'Status': 'active'},
|
||||
{'$set': {
|
||||
'ReturnPending': True,
|
||||
'ReturnReminderAt': current_time,
|
||||
'LastUpdated': current_time,
|
||||
}}
|
||||
)
|
||||
|
||||
if old_status == 'planned' and new_status == 'active' and activation_user:
|
||||
try:
|
||||
_create_notification(
|
||||
db,
|
||||
audience='user',
|
||||
notif_type='appointment_activated',
|
||||
title='Reservierung ist jetzt aktiv',
|
||||
message=f"Deine geplante Ausleihe für {activation_item_name} startet jetzt.",
|
||||
target_user=activation_user,
|
||||
reference={
|
||||
'appointment_id': str(appointment.get('_id')),
|
||||
'item_id': str(appointment.get('Item') or ''),
|
||||
'event': 'activated',
|
||||
},
|
||||
unique_key=f"appointment:activated:{appointment.get('_id')}",
|
||||
severity='info',
|
||||
)
|
||||
except Exception as notif_err:
|
||||
app.logger.warning(
|
||||
f"Failed to create activation notification for {appointment.get('_id')}: {notif_err}"
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Mahnlauf für Bibliotheksartikel (Prüfung auf Überfälligkeit)
|
||||
# -----------------------------------------------------------------
|
||||
elif item_is_library:
|
||||
if item_is_library:
|
||||
appt = appointment # Verwende das aktuelle Dokument aus der Schleife
|
||||
if appt.get('Status') != 'active':
|
||||
continue
|
||||
@@ -1560,7 +1682,7 @@ def update_appointment_statuses():
|
||||
pass
|
||||
|
||||
# STUFE 2: >= 2 * student_ausleih_dauer Tage überfällig -> Ausweis sperren, Stufe 2 setzen & Admins benachrichtigen
|
||||
if days_overdue >= (int(student_ausleih_dauer) * 2) and mahnstufe < 2:
|
||||
if days_overdue >= (int(student_ausleih_dauer) * 2) and mahnstufe == 1 and card.get('is_blocked') is not True:
|
||||
ausleihungen.update_one(
|
||||
{'_id': appt['_id']},
|
||||
{'$set': {'Mahnstufe': 2, 'LastUpdated': current_time}}
|
||||
@@ -2301,7 +2423,12 @@ def _upload_student_cards_excel():
|
||||
row_errors.append('Vorname und Nachname fehlen')
|
||||
|
||||
if not ausweis_id and student_name:
|
||||
ausweis_id = generate_ausweis_id(existing_ids)
|
||||
ausweis_id = generate_ausweis_id()
|
||||
if ausweis_id in existing_ids:
|
||||
row_errors.append(f'Automatisch erzeugte Ausweis-ID {ausweis_id} existiert bereits')
|
||||
else:
|
||||
while ausweis_id in existing_ids:
|
||||
ausweis_id = generate_ausweis_id()
|
||||
validation_warnings.append((row_number, f'Ausweis-ID wurde automatisch erzeugt: {ausweis_id}'))
|
||||
existing_ids.add(ausweis_id.upper())
|
||||
elif ausweis_id and not rollover_mode:
|
||||
@@ -3501,144 +3628,6 @@ def library_loans_admin():
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
|
||||
def generate_test_ausleihen():
|
||||
"""
|
||||
Generiert Testdaten für die Überprüfung der Mahnungs- und Sperrlogik.
|
||||
Erstellt Dummy-Schülerausweise, Bibliotheks-Items und Ausleihungen mit manipulierten Daten (DueDate).
|
||||
"""
|
||||
current_time = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
|
||||
try:
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
db = client[MONGODB_DB]
|
||||
ausleihungen = db['ausleihungen']
|
||||
student_cards_col = db['student_cards']
|
||||
items_col = db['items']
|
||||
|
||||
print("Generiere Testdaten für das Mahnungssystem...")
|
||||
|
||||
# 1. Test-Schülerausweise erstellen oder aktualisieren (upsert)
|
||||
# Wir nutzen AusweisIds, die im System als Identifikation dienen
|
||||
test_students = [
|
||||
{
|
||||
"AusweisId": "TEST-AUSWEIS-01",
|
||||
"SchülerName": encrypt_text("Max Mustermann (Stufe 1 Test)"),
|
||||
"Klasse": "9a",
|
||||
"is_blocked": False
|
||||
},
|
||||
{
|
||||
"AusweisId": "TEST-AUSWEIS-02",
|
||||
"SchülerName": encrypt_text("Erika Musterfrau (Stufe 2 Test)"),
|
||||
"Klasse": "10b",
|
||||
"is_blocked": False
|
||||
},
|
||||
{
|
||||
"AusweisId": "TEST-AUSWEIS-03",
|
||||
"SchülerName": encrypt_text("Tom Frisch (Keine Mahnung Test)"),
|
||||
"Klasse": "8c",
|
||||
"is_blocked": False
|
||||
}
|
||||
]
|
||||
|
||||
for s in test_students:
|
||||
student_cards_col.update_one(
|
||||
{'AusweisId': s['AusweisId']},
|
||||
{'$set': s},
|
||||
upsert=True
|
||||
)
|
||||
|
||||
# 2. Test-Items als Bibliotheksartikel erstellen (ItemType wird gesetzt, damit it.is_library_itemgreift)
|
||||
# Passe LIBRARY_ITEM_TYPES an deine verwendeten Typen an (hier beispielhaft 'Buch' / 'Medien')
|
||||
item_type = LIBRARY_ITEM_TYPES[0] if 'LIBRARY_ITEM_TYPES' in globals() and LIBRARY_ITEM_TYPES else 'Buch'
|
||||
|
||||
item1_res = items_col.insert_one({
|
||||
"Name": "Biologie Buch (Test Stufe 1)",
|
||||
"ItemType": item_type,
|
||||
"Verfuegbar": False,
|
||||
"Exemplare": 1,
|
||||
"Code_4": "TR-9988"
|
||||
})
|
||||
item2_res = items_col.insert_one({
|
||||
"Name": "iPad Pro (Test Stufe 2)",
|
||||
"ItemType": item_type,
|
||||
"Verfuegbar": False,
|
||||
"Exemplare": 1,
|
||||
"Code_4": "TR-9988"
|
||||
})
|
||||
item3_res = items_col.insert_one({
|
||||
"Name": "Taschenrechner (Test Frisch)",
|
||||
"ItemType": item_type,
|
||||
"Verfuegbar": False,
|
||||
"Exemplare": 1,
|
||||
"Code_4": "TR-9988"
|
||||
})
|
||||
|
||||
# 3. Test-Ausleihungen generieren
|
||||
# Fall 1: 15 Tage drüber, Mahnstufe 0 -> Sollte Stufe 1 triggern (>= 14 Tage)
|
||||
due_date_stufe1 = current_time - datetime.timedelta(days=15)
|
||||
|
||||
# Fall 2: 29 Tage drüber, Mahnstufe 1 -> Sollte Stufe 2 triggern & Ausweis sperren (>= 28 Tage)
|
||||
due_date_stufe2 = current_time - datetime.timedelta(days=29)
|
||||
|
||||
# Fall 3: 5 Tage drüber, Mahnstufe 0 -> Sollte NICHTS tun (da < 14 Tage)
|
||||
due_date_frisch = current_time - datetime.timedelta(days=5)
|
||||
|
||||
# Je nachdem, ob dein System die User-Felder in Ausleihungen verschlüsselt speichert:
|
||||
# Hier wird encrypt_text verwendet, falls die Funktion im Scope liegt.
|
||||
user_val_1 = encrypt_text("TEST-AUSWEIS-01")
|
||||
user_val_2 = encrypt_text("TEST-AUSWEIS-02")
|
||||
user_val_3 = encrypt_text("TEST-AUSWEIS-03")
|
||||
|
||||
test_ausleihungen = [
|
||||
{
|
||||
"User": user_val_1,
|
||||
"Item": str(item1_res.inserted_id),
|
||||
"Status": "active",
|
||||
"DueDate": due_date_stufe1,
|
||||
"Mahnstufe": 0,
|
||||
"LastUpdated": current_time
|
||||
},
|
||||
{
|
||||
"User": user_val_2,
|
||||
"Item": str(item2_res.inserted_id),
|
||||
"Status": "active",
|
||||
"DueDate": due_date_stufe2,
|
||||
"Mahnstufe": 1, # Hat bereits Stufe 1, wartet auf Eskalation zu Stufe 2
|
||||
"LastUpdated": due_date_stufe2
|
||||
},
|
||||
{
|
||||
"User": user_val_3,
|
||||
"Item": str(item3_res.inserted_id),
|
||||
"Status": "active",
|
||||
"DueDate": due_date_frisch,
|
||||
"Mahnstufe": 2,
|
||||
"LastUpdated": current_time
|
||||
}
|
||||
]
|
||||
|
||||
# In die Datenbank einfügen
|
||||
ausleihungen.insert_many(test_ausleihungen)
|
||||
|
||||
print("Erfolgreich! Folgende Testdaten wurden für das Mahnungssystem in die DB geschrieben:")
|
||||
print(f"- Fall 1 (Mahnstufe 1 Trigger): Ausweis 'TEST-AUSWEIS-01', fällig war am {due_date_stufe1.strftime('%Y-%m-%d')} (15 Tage überfällig)")
|
||||
print(f"- Fall 2 (Mahnstufe 2 & Sperre): Ausweis 'TEST-AUSWEIS-02', fällig war am {due_date_stufe2.strftime('%Y-%m-%d')} (29 Tage überfällig)")
|
||||
print(f"- Fall 3 (Zu früh / Ignorieren): Ausweis 'TEST-AUSWEIS-03', fällig war am {due_date_frisch.strftime('%Y-%m-%d')} (5 Tage überfällig)")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Fehler beim Erstellen der Testdaten: {e}")
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
@app.route('/test_mahnungen')
|
||||
def test_mahnungen():
|
||||
# Testdaten (Mock-Daten), um alle if/else Bedingungen im HTML zu testen
|
||||
generate_test_ausleihen()
|
||||
create_return_reminders()
|
||||
return redirect(url_for('mahnungen_admin'))
|
||||
|
||||
|
||||
@app.route('/mahnungen_admin')
|
||||
def mahnungen_admin():
|
||||
"""Admin-Übersicht für überfällige Bibliotheks-Ausleihen."""
|
||||
@@ -3758,8 +3747,8 @@ def mahnungen_admin():
|
||||
email_service_enabled=cfg.EMAIL_ENABLED
|
||||
)
|
||||
|
||||
@app.route('/mahnungen_reset', methods=['POST'])
|
||||
def mahnungen_reset():
|
||||
@app.route('/mahnungen_reset/<int:days>', methods=['POST'])
|
||||
def mahnungen_reset(days):
|
||||
"""Setzt die Mahnstufe einer Ausleihe zurück, verlängert die Frist und entsperrt den Schülerausweis."""
|
||||
if 'username' not in session:
|
||||
return jsonify({'success': False, 'message': 'Nicht angemeldet.'}), 401
|
||||
@@ -3787,7 +3776,7 @@ def mahnungen_reset():
|
||||
return jsonify({'success': False, 'message': 'Ausleihe nicht gefunden.'}), 404
|
||||
|
||||
current_time = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
new_due_date = current_time + datetime.timedelta(days=14) # Frist um 14 Tage verlängern
|
||||
new_due_date = current_time + datetime.timedelta(days=days) # Frist um die angegebene Anzahl an Tagen verlängern
|
||||
|
||||
# 2. Mahnstufe zurücksetzen und Frist verlängern
|
||||
ausleihungen_col.update_one(
|
||||
@@ -3821,7 +3810,7 @@ def mahnungen_reset():
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Mahnstufe zurückgesetzt, Frist um 14 Tage verlängert und Ausweis entsperrt.'
|
||||
'message': f'Mahnstufe zurückgesetzt, Frist um {days} Tage verlängert und Ausweis entsperrt.'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
@@ -4361,9 +4350,12 @@ def api_library_scan_action():
|
||||
due_date = now + datetime.timedelta(days=borrow_duration_days)
|
||||
|
||||
it.update_item_status(item_id, False, borrower_name)
|
||||
borrower_user = us.get_user_by_student_card(student_card_id)
|
||||
if not borrower_user or not borrower_user.get('_id'):
|
||||
return jsonify({'ok': False, 'message': 'Kein Benutzer für diesen Schülerausweis gefunden.'}), 404
|
||||
au.add_ausleihung(
|
||||
item_id,
|
||||
borrower_name,
|
||||
str(borrower_user['_id']),
|
||||
now,
|
||||
end_date=due_date,
|
||||
due_date=due_date,
|
||||
@@ -4850,7 +4842,8 @@ def student_cards_admin():
|
||||
if ausweis_id:
|
||||
existing = student_cards.find_one({'AusweisId': ausweis_id})
|
||||
else:
|
||||
ausweis_id = generate_ausweis_id()
|
||||
generated_ausweis_id = generate_ausweis_id()
|
||||
ausweis_id = generated_ausweis_id
|
||||
existing = False
|
||||
if not ausweis_ident:
|
||||
ausweis_ident = ausweis_id
|
||||
@@ -4890,10 +4883,10 @@ def student_cards_admin():
|
||||
|
||||
return render_template(
|
||||
'student_cards_admin.html',
|
||||
student_cards=all_cards, # Hier all_cards übergeben
|
||||
student_cards=all_cards,
|
||||
edit_mode=edit_mode,
|
||||
form_data=form_data,
|
||||
available_classes=available_classes, # Enthält nun die lesbaren Klassen
|
||||
available_classes=available_classes,
|
||||
config=cfg.get_school_info(),
|
||||
email_service_enabled=cfg.MODULES.is_enabled('mail')
|
||||
)
|
||||
@@ -5599,6 +5592,7 @@ def login():
|
||||
if user:
|
||||
app.logger.info(f"Login success: username={encrypt_text(username)!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={encrypt_text(request.remote_addr)}")
|
||||
session['username'] = username
|
||||
session['user_id'] = str(user.get('_id'))
|
||||
is_admin_user = bool(user.get('Admin', False))
|
||||
session['admin'] = is_admin_user
|
||||
session['is_admin'] = is_admin_user
|
||||
@@ -5729,6 +5723,8 @@ def get_items():
|
||||
favorites = set(session.get('favorites', []))
|
||||
|
||||
available_only = str(request.args.get('available_only', '')).strip().lower() in ('1', 'true', 'yes', 'on')
|
||||
search_term = str(request.args.get('search', '')).strip()
|
||||
search_scope = str(request.args.get('search_scope', 'all')).strip().lower()
|
||||
offset_raw = request.args.get('offset')
|
||||
limit_raw = request.args.get('limit')
|
||||
light_mode_param = str(request.args.get('light_mode', '')).strip().lower()
|
||||
@@ -5764,6 +5760,42 @@ def get_items():
|
||||
'Deleted': {'$ne': True},
|
||||
}
|
||||
|
||||
if search_term:
|
||||
search_pattern = re.escape(search_term[:200])
|
||||
if search_scope in ('description', 'title_description'):
|
||||
search_fields = ['Name', 'Beschreibung']
|
||||
else:
|
||||
search_fields = [
|
||||
'Name', 'Beschreibung', 'Code_4', 'ISBN',
|
||||
'Author', 'Autor',
|
||||
]
|
||||
|
||||
direct_matches = [{field: {'$regex': search_pattern, '$options': 'i'}}
|
||||
for field in search_fields]
|
||||
if search_scope not in ('description', 'title_description') and search_term.isdigit():
|
||||
direct_matches.append({'Code_4': int(search_term)})
|
||||
grouped_parent_ids = [
|
||||
child.get('ParentItemId')
|
||||
for child in items_col.find(
|
||||
{
|
||||
'IsGroupedSubItem': True,
|
||||
'Deleted': {'$ne': True},
|
||||
'$or': direct_matches,
|
||||
},
|
||||
{'ParentItemId': 1},
|
||||
)
|
||||
if child.get('ParentItemId')
|
||||
]
|
||||
if grouped_parent_ids:
|
||||
direct_matches.append({'_id': {'$in': grouped_parent_ids}})
|
||||
|
||||
base_query = {
|
||||
'$and': [
|
||||
base_query,
|
||||
{'$or': direct_matches},
|
||||
]
|
||||
}
|
||||
|
||||
total_count = items_col.count_documents(base_query)
|
||||
|
||||
light_projection = {
|
||||
@@ -5830,6 +5862,7 @@ def get_items():
|
||||
'ParentItemId': 1,
|
||||
'Code_4': 1,
|
||||
'Verfuegbar': 1,
|
||||
'User': 1,
|
||||
'Name': 1,
|
||||
}
|
||||
# Full mode: complete child data with all details
|
||||
@@ -5838,6 +5871,7 @@ def get_items():
|
||||
'ParentItemId': 1,
|
||||
'Code_4': 1,
|
||||
'Verfuegbar': 1,
|
||||
'User': 1,
|
||||
'Name': 1,
|
||||
'Images': 1,
|
||||
'ThumbnailInfo': 1,
|
||||
@@ -5865,13 +5899,26 @@ def get_items():
|
||||
grouped_units = [itm] + grouped_children
|
||||
available_units = []
|
||||
grouped_all_codes = []
|
||||
grouped_unit_states = []
|
||||
for unit in grouped_units:
|
||||
unit_id = str(unit['_id']) if not isinstance(unit['_id'], str) else unit['_id']
|
||||
unit_code = unit.get('Code_4')
|
||||
if unit_code is not None and str(unit_code).strip() != '':
|
||||
grouped_all_codes.append(str(unit_code).strip())
|
||||
normalized_code = str(unit_code).strip()
|
||||
grouped_all_codes.append(normalized_code)
|
||||
else:
|
||||
normalized_code = ''
|
||||
|
||||
grouped_unit_states.append({
|
||||
'id': unit_id,
|
||||
'code': normalized_code,
|
||||
'available': bool(unit.get('Verfuegbar', True)),
|
||||
'borrowed_by_current_user': bool(
|
||||
username and str(unit.get('User') or '') == str(username)
|
||||
),
|
||||
})
|
||||
|
||||
if unit.get('Verfuegbar', True):
|
||||
unit_id = str(unit['_id']) if not isinstance(unit['_id'], str) else unit['_id']
|
||||
code = unit.get('Code_4') or '-'
|
||||
available_units.append({
|
||||
'id': unit_id,
|
||||
@@ -5884,6 +5931,7 @@ def get_items():
|
||||
itm['AvailableGroupedCount'] = len(available_units)
|
||||
itm['GroupedAvailableUnits'] = available_units
|
||||
itm['GroupedAllCodes'] = grouped_all_codes
|
||||
itm['GroupedUnitStates'] = grouped_unit_states
|
||||
if grouped_count > 1:
|
||||
itm['Verfuegbar'] = len(available_units) > 0
|
||||
if available_only and not itm.get('Verfuegbar', False):
|
||||
@@ -5945,10 +5993,7 @@ def get_bookings():
|
||||
result = []
|
||||
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)
|
||||
booking_user = str(raw_booking_user)
|
||||
start_dt = booking.get('Start')
|
||||
if not start_dt:
|
||||
continue
|
||||
@@ -5994,7 +6039,7 @@ def get_bookings():
|
||||
'userName': str(booking_user),
|
||||
'notes': str(booking.get('Notes') or ''),
|
||||
'period': period,
|
||||
'isCurrentUser': str(booking_user) == username,
|
||||
'isCurrentUser': str(booking_user) == str(current_user_id),
|
||||
'itemBorrower': item_borrower,
|
||||
})
|
||||
|
||||
@@ -6114,7 +6159,10 @@ def api_booking_conflicts():
|
||||
current_permissions = us.get_effective_permissions(session['username'])
|
||||
|
||||
if not current_permissions['actions'].get('can_view_logs', False):
|
||||
query['User'] = session['username']
|
||||
current_user = us.get_user(session['username'])
|
||||
if not current_user or not current_user.get('_id'):
|
||||
return jsonify({'error': 'User account could not be resolved'}), 401
|
||||
query['User'] = str(current_user['_id'])
|
||||
|
||||
conflicts = list(ausleihungen.find(query))
|
||||
result = []
|
||||
@@ -7455,6 +7503,11 @@ def ausleihen(id):
|
||||
return redirect(url_for('login'))
|
||||
|
||||
username = session['username']
|
||||
current_user = us.get_user(username)
|
||||
if not current_user or not current_user.get('_id'):
|
||||
flash('Benutzerkonto konnte nicht eindeutig ermittelt werden.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
effective_borrower_id = str(current_user['_id'])
|
||||
requested_return_to = (request.form.get('return_to') or '').strip().lower()
|
||||
if requested_return_to == 'library' and cfg.MODULES.is_enabled('library'):
|
||||
redirect_target = 'library_view'
|
||||
@@ -7528,6 +7581,7 @@ def ausleihen(id):
|
||||
flash('Keine Schülerin/kein Schüler mit dieser Ausweis-ID gefunden.', 'error')
|
||||
return redirect(url_for('library_view'))
|
||||
effective_borrower = student_user.get('Username') or student_user.get('username') or username
|
||||
effective_borrower_id = str(student_user['_id'])
|
||||
if borrow_duration_days is None:
|
||||
try:
|
||||
card_default = int(student_user.get('MaxBorrowDays', cfg.STUDENT_DEFAULT_BORROW_DAYS))
|
||||
@@ -7602,7 +7656,7 @@ def ausleihen(id):
|
||||
it.update_item_status(unit_id, False, effective_borrower)
|
||||
au.add_ausleihung(
|
||||
unit_id,
|
||||
effective_borrower,
|
||||
effective_borrower_id,
|
||||
start_date,
|
||||
end_date=end_date,
|
||||
due_date=end_date if is_library_item else None,
|
||||
@@ -7699,7 +7753,7 @@ def ausleihen(id):
|
||||
start_date = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
au.add_ausleihung(
|
||||
id,
|
||||
effective_borrower,
|
||||
effective_borrower_id,
|
||||
start_date,
|
||||
end_date=end_date,
|
||||
due_date=end_date if is_library_item else None,
|
||||
@@ -7750,7 +7804,7 @@ def ausleihen(id):
|
||||
exemplar_id = f"{id}_{exemplar['number']}"
|
||||
au.add_ausleihung(
|
||||
exemplar_id,
|
||||
effective_borrower,
|
||||
effective_borrower_id,
|
||||
start_date,
|
||||
end_date=end_date,
|
||||
due_date=end_date if is_library_item else None,
|
||||
@@ -7801,7 +7855,13 @@ def zurueckgeben(id):
|
||||
|
||||
current_permissions = us.get_effective_permissions(session['username'])
|
||||
|
||||
if not item.get('Verfuegbar', True) and (current_permissions['actions'].get('can_manage_users', False)) or item.get('User') == username:
|
||||
if (
|
||||
not item.get('Verfuegbar', True)
|
||||
and (
|
||||
current_permissions['actions'].get('can_manage_users', False)
|
||||
or item.get('User') == username
|
||||
)
|
||||
):
|
||||
try:
|
||||
# Get ALL active borrowing records for this item and complete them
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
@@ -7810,7 +7870,7 @@ def zurueckgeben(id):
|
||||
|
||||
# Find all active records for this item
|
||||
active_records = ausleihungen.find({
|
||||
'Item': id,
|
||||
'$or': [{'Item': id}, {'Item': ObjectId(id) if ObjectId.is_valid(id) else id}],
|
||||
'Status': 'active'
|
||||
})
|
||||
|
||||
@@ -7826,6 +7886,7 @@ def zurueckgeben(id):
|
||||
{'$set': {
|
||||
'Status': 'completed',
|
||||
'End': end_date,
|
||||
'ReturnPending': False,
|
||||
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
}}
|
||||
)
|
||||
@@ -7911,7 +7972,12 @@ def get_ausleihung_by_item_route(id):
|
||||
|
||||
current_permissions = us.get_effective_permissions(session['username'])
|
||||
|
||||
if ausleihung and (current_permissions['actions'].get('can_manage_users')) or ausleihung.get('User') == session['username']:
|
||||
current_user = us.get_user(session['username'])
|
||||
current_user_id = str(current_user['_id']) if current_user and current_user.get('_id') else ''
|
||||
if ausleihung and (
|
||||
current_permissions['actions'].get('can_manage_users')
|
||||
or ausleihung.get('User') == current_user_id
|
||||
):
|
||||
return {'ausleihung': ausleihung, 'status': 'success'}
|
||||
|
||||
item = it.get_item(id)
|
||||
@@ -8193,7 +8259,7 @@ def plan_booking():
|
||||
try:
|
||||
for _, period, period_times in requested_slots:
|
||||
booking_id = au.add_planned_booking(
|
||||
item_id, session['username'], period_times['start'],
|
||||
item_id, session.get('user_id') or str(us.get_user(session['username'])['_id']), period_times['start'],
|
||||
period_times['end'], notes, period=period
|
||||
)
|
||||
if not booking_id:
|
||||
@@ -8246,7 +8312,7 @@ def process_day_bookings(item_id, booking_date, periods, notes):
|
||||
# Create the booking
|
||||
booking_id = au.add_planned_booking(
|
||||
item_id,
|
||||
session['username'],
|
||||
session.get('user_id') or str(us.get_user(session['username'])['_id']),
|
||||
start_time,
|
||||
end_time,
|
||||
notes,
|
||||
@@ -8313,7 +8379,7 @@ def add_booking():
|
||||
# Continue with adding the booking
|
||||
booking_id = au.add_planned_booking(
|
||||
item_id=item_id,
|
||||
user=session['username'],
|
||||
user_id=session.get('user_id') or str(us.get_user(session['username'])['_id']),
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
notes=notes,
|
||||
@@ -8344,8 +8410,10 @@ def cancel_booking(id):
|
||||
current_permissions = us.get_effective_permissions(session['username'])
|
||||
|
||||
# Check if user owns this booking
|
||||
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):
|
||||
current_user = us.get_user(session['username'])
|
||||
current_user_id = str(current_user['_id']) if current_user and current_user.get('_id') else ''
|
||||
booking_user_id = str(booking.get('User') or '')
|
||||
if booking_user_id != current_user_id and not current_permissions['actions'].get('can_manage_users', False):
|
||||
return {"success": False, "error": "Not authorized to cancel this booking"}, 403
|
||||
|
||||
# Cancel the booking
|
||||
@@ -10907,7 +10975,9 @@ def search_word(word):
|
||||
if (term_lower in text.lower()) or (term_lower in title_text.lower()):
|
||||
_id = i.get("_id")
|
||||
if _id is not None:
|
||||
id_set.add(str(_id))
|
||||
item_id = str(_id)
|
||||
parent_id = i.get('ParentItemId')
|
||||
id_set.add(str(parent_id or item_id))
|
||||
|
||||
return jsonify({"success": True, "response": list(id_set)})
|
||||
except Exception as e:
|
||||
@@ -11440,6 +11510,13 @@ def my_borrowed_items():
|
||||
return redirect(url_for('login', next=request.path))
|
||||
|
||||
username = session['username']
|
||||
current_user = us.get_user(username) or {}
|
||||
user_ids = {
|
||||
username,
|
||||
str(current_user.get('_id')) if current_user.get('_id') else '',
|
||||
str(session.get('user_id') or ''),
|
||||
}
|
||||
user_ids.discard('')
|
||||
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
db = client[MONGODB_DB]
|
||||
@@ -11456,31 +11533,27 @@ def my_borrowed_items():
|
||||
|
||||
for appointment in all_ausleihungen:
|
||||
raw_user = appointment.get('User', '')
|
||||
user_values = {str(raw_user).strip()} if raw_user else set()
|
||||
try:
|
||||
decrypted_user = decrypt_text(raw_user) if raw_user else ''
|
||||
except Exception as e:
|
||||
app.logger.error(f"Entschlüsselungsfehler: {e}")
|
||||
decrypted_user = ''
|
||||
if raw_user:
|
||||
user_values.add(str(decrypt_text(raw_user)).strip())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if decrypted_user != username:
|
||||
if not user_values.intersection(user_ids):
|
||||
continue
|
||||
|
||||
item_id = appointment.get('Item')
|
||||
if not item_id:
|
||||
continue
|
||||
|
||||
try:
|
||||
if isinstance(item_id, str):
|
||||
query_id = ObjectId(item_id)
|
||||
else:
|
||||
query_id = item_id
|
||||
|
||||
item_obj = items_collection.find_one({
|
||||
'_id': query_id,
|
||||
'Deleted': {'$ne': True}
|
||||
})
|
||||
except Exception:
|
||||
item_obj = None
|
||||
item_queries = [{'_id': item_id}]
|
||||
if isinstance(item_id, str) and ObjectId.is_valid(item_id):
|
||||
item_queries.insert(0, {'_id': ObjectId(item_id)})
|
||||
item_obj = items_collection.find_one({
|
||||
'$or': item_queries,
|
||||
'Deleted': {'$ne': True}
|
||||
})
|
||||
|
||||
if item_obj:
|
||||
item_obj['_id'] = str(item_obj['_id'])
|
||||
@@ -11492,14 +11565,21 @@ def my_borrowed_items():
|
||||
'notes': appointment.get('Notes'),
|
||||
'period': appointment.get('Period'),
|
||||
'status': appointment.get('VerifiedStatus', appointment.get('Status')),
|
||||
'return_pending': bool(appointment.get('ReturnPending')),
|
||||
}
|
||||
|
||||
status = appointment.get('Status')
|
||||
if status == 'active':
|
||||
item_obj['ActiveAppointment'] = True
|
||||
item_obj['ReturnPending'] = bool(appointment.get('ReturnPending'))
|
||||
if str(item_obj['_id']) not in processed_item_ids:
|
||||
active_items.append(item_obj)
|
||||
processed_item_ids.add(str(item_obj['_id']))
|
||||
elif item_obj['ReturnPending']:
|
||||
for existing_item in active_items:
|
||||
if str(existing_item.get('_id')) == str(item_obj['_id']):
|
||||
existing_item['ReturnPending'] = True
|
||||
break
|
||||
elif status == 'planned':
|
||||
planned_items.append(item_obj)
|
||||
|
||||
@@ -11510,12 +11590,14 @@ def my_borrowed_items():
|
||||
|
||||
for item in all_borrowed_items:
|
||||
raw_item_user = item.get('User', '')
|
||||
item_user_values = {str(raw_item_user).strip()} if raw_item_user else set()
|
||||
try:
|
||||
dec_item_user = decrypt_text(raw_item_user) if raw_item_user else ''
|
||||
if raw_item_user:
|
||||
item_user_values.add(str(decrypt_text(raw_item_user)).strip())
|
||||
except Exception:
|
||||
dec_item_user = ''
|
||||
pass
|
||||
|
||||
if dec_item_user == username and str(item['_id']) not in processed_item_ids:
|
||||
if item_user_values.intersection(user_ids) and str(item['_id']) not in processed_item_ids:
|
||||
item['_id'] = str(item['_id'])
|
||||
item['ActiveAppointment'] = True
|
||||
item['AppointmentData'] = {'status': 'active (no document)'}
|
||||
@@ -12275,7 +12357,7 @@ def schedule_appointment():
|
||||
# Use add_ausleihung directly to set the correct initial status
|
||||
appointment_id = au.add_ausleihung(
|
||||
item_id=item_id,
|
||||
user=session['username'],
|
||||
user_id=session.get('user_id') or str(us.get_user(session['username'])['_id']),
|
||||
start_date=start_datetime,
|
||||
end_date=end_datetime,
|
||||
notes=notes,
|
||||
@@ -13769,6 +13851,13 @@ def upload_csv_batch():
|
||||
"images_failed": error_count
|
||||
}), 200
|
||||
|
||||
@app.route('/get/asv_export', methods=['GET'])
|
||||
def get_asv_export():
|
||||
if 'username' not in session:
|
||||
flash('Bitte melden Sie sich an.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
return send_from_directory(app.static_folder, 'img/asv_export_invario_2026.exfj')
|
||||
|
||||
"""
|
||||
@app.route('/test_email')
|
||||
def test_email():
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
@@ -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>
|
||||
|
||||
+23
-22
@@ -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">
|
||||
@@ -1342,7 +1342,7 @@
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="libraryNavList">
|
||||
{% if current_permissions.pages.get('library_view', False) %}
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<a class="nav-link {% if current_path == url_for('library_view') %}nav-active{% endif %}" href="{{ url_for('library_view') }}" data-tutorial-tip="Die Bibliothek zeigt Ihnen alle Medien und verfügbaren Bücher.">Medien/Ausleihe</a>
|
||||
<a class="nav-link {% if current_path == url_for('library_view') %}nav-active{% endif %}" href="{{ url_for('library_view') }}" data-tutorial-tip="Die Bibliothek zeigt Ihnen alle Medien und verfügbaren Bücher.">Ausleihe/Rückgabe</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if 'username' in session %}
|
||||
@@ -1359,12 +1359,6 @@
|
||||
<a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}" data-tutorial-tip="Neue Bibliotheksmedien können hier aufgenommen werden.">📖 Hochladen</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="libMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">
|
||||
@@ -1431,6 +1425,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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -133,6 +133,34 @@
|
||||
background: var(--ui-surface);
|
||||
}
|
||||
|
||||
/* Blue edge styling for inputs and action button */
|
||||
#activeStudentCard,
|
||||
#manualItemCode {
|
||||
border: 2px solid #3b82f6 !important;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
#activeStudentCard:focus,
|
||||
#manualItemCode:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb !important;
|
||||
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.25) !important;
|
||||
}
|
||||
|
||||
#manualActionBtn {
|
||||
background: #4f46e5 !important;
|
||||
color: white !important;
|
||||
border: 2px solid #3b82f6 !important;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
#manualActionBtn:hover {
|
||||
background: #4338ca !important;
|
||||
border-color: #2563eb !important;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
|
||||
.library-scan-status {
|
||||
margin-top: 10px;
|
||||
font-size: 0.92em;
|
||||
@@ -605,7 +633,6 @@
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
|
||||
.detail-gallery-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -676,7 +703,7 @@
|
||||
<button id="physicalScannerBtn" class="button" type="button" style="margin-left:6px;">
|
||||
Physischer Scanner starten
|
||||
</button>
|
||||
<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;">Code verarbeiten</button>
|
||||
</div>
|
||||
|
||||
<div class="library-scan-reader-wrap" id="scanReaderWrap" style="display: none; margin-top: 15px;">
|
||||
@@ -1392,6 +1419,7 @@
|
||||
keyboardScannerEnabled = false;
|
||||
keyboardScanBuffer = '';
|
||||
keyboardLastKeyAt = 0;
|
||||
setActiveStudentCard('');
|
||||
document.removeEventListener('keydown', keyboardScanKeydownHandler);
|
||||
const modal = document.getElementById('physicalScannerModal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+220
-40
@@ -562,25 +562,30 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navigation-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
.items-navigation {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr) 44px;
|
||||
align-items: center;
|
||||
column-gap: 18px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.navigation-buttons .prev-button,
|
||||
.navigation-buttons .next-button {
|
||||
.navigation-button-slot {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.navigation-button {
|
||||
background-color: #007bff;
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.navigation-buttons .prev-button:hover,
|
||||
.navigation-buttons .next-button:hover {
|
||||
.navigation-button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
@@ -1633,7 +1638,11 @@
|
||||
}
|
||||
|
||||
/* Navigation buttons mobile */
|
||||
.navigation-buttons {
|
||||
.items-navigation {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.navigation-button-slot {
|
||||
display: none !important; /* Hide horizontal navigation on mobile */
|
||||
}
|
||||
|
||||
@@ -1946,7 +1955,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 +1980,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 +2468,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 +2482,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>
|
||||
@@ -2427,12 +2552,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()">❮</button>
|
||||
<button class="next-button" onclick="scrollNext()">❯</button>
|
||||
<div class="items-navigation">
|
||||
<div class="navigation-button-slot">
|
||||
<button class="navigation-button prev-button" type="button" onclick="scrollPrev()" aria-label="Vorherige Elemente">❮</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">❯</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="item-modal" class="item-modal">
|
||||
@@ -3206,9 +3335,12 @@ 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
|
||||
? `&search=${encodeURIComponent(searchTerm)}&search_scope=${encodeURIComponent(searchScope)}`
|
||||
: '';
|
||||
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ADMIN_ITEMS_PAGE_SIZE}&light_mode=true${searchParams}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
@@ -3241,7 +3373,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 +3417,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 +3548,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 +3578,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 +3813,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 +3919,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 +3944,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
}
|
||||
} else {
|
||||
item.style.display = 'none';
|
||||
syncSearchedGroupedAction(item, '', false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3830,7 +4019,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
card.classList.remove('highlight-item', 'code-match', 'exact-code-match');
|
||||
});
|
||||
|
||||
applyFilters();
|
||||
loadItems();
|
||||
}
|
||||
|
||||
// Description search using backend substring match
|
||||
@@ -3839,21 +4028,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 +4041,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 +4292,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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user