Compare commits

...

59 Commits

Author SHA1 Message Date
Aiirondev_dev 2cdf0d2169 implementation of the export for users
Release Inventarsystem / release-docker (push) Successful in 3m8s
2026-08-24 11:56:03 +02:00
Aiirondev_dev 9a4d10def7 implementation of the Email add on check
Release Inventarsystem / release-docker (push) Successful in 2m19s
2026-08-23 22:14:54 +02:00
Aiirondev_dev 10ae98245c Make the Mahnungen accueally dependent on the student_ausleih_dauer
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-23 21:59:16 +02:00
Aiirondev_dev 359a8f8ab1 Make the Mahnungen accueally dependent on the student_ausleih_dauer
Release Inventarsystem / release-docker (push) Successful in 2m19s
2026-08-23 21:45:02 +02:00
Aiirondev_dev 3171455f55 Fix of a logik error
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-23 21:14:04 +02:00
Aiirondev_dev a35bb3e048 Test data fixes
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-23 21:06:46 +02:00
Aiirondev_dev a0409a03dc Test data fixes
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-23 21:01:20 +02:00
Aiirondev_dev 627b1b76c4 Test data fixes
Release Inventarsystem / release-docker (push) Successful in 2m19s
2026-08-23 20:59:02 +02:00
Aiirondev_dev cd199f105d Test data
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-23 20:46:00 +02:00
Aiirondev_dev f77f7024dd Push notification fix
Release Inventarsystem / release-docker (push) Successful in 2m18s
2026-08-23 20:31:20 +02:00
Aiirondev_dev 5202c5e25e Push notification fix
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-23 20:19:21 +02:00
Aiirondev_dev 2b1b01f4c1 Fix of an dispalying issue
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-23 20:16:44 +02:00
Aiirondev_dev 55c21ac851 Test data error
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-23 19:57:05 +02:00
Aiirondev_dev 8069c6011c Change of a naming error
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-23 19:54:42 +02:00
Aiirondev_dev 4535800a34 change of the datetime implementation
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-23 19:38:08 +02:00
Aiirondev_dev e3aea734bb Change of the notification-reminder
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-23 19:36:06 +02:00
Aiirondev_dev ad77514574 Implementation for the test data
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-23 19:29:54 +02:00
Aiirondev_dev 9870726c76 changes to the testdataset used to test the system
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-23 19:14:48 +02:00
Aiirondev_dev 683059f3de changes to the mahnungssystem in generell and especialy for the overview
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-23 19:05:59 +02:00
Aiirondev_dev ffab9b7fdf changes to the mahnungen_admin
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-23 17:42:12 +02:00
Aiirondev_dev 2f2c71d0a4 slight changes
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-23 17:28:43 +02:00
Aiirondev_dev 5388562b46 slight changes
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-23 17:26:35 +02:00
Aiirondev_dev df9367a19d implementation of the right time zones and blocked user by mahnung as a blocker for the library ausleih process
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-23 12:26:36 +02:00
Aiirondev_dev 0f0f0ebefd impolementation of standardised time zone
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-23 11:29:42 +02:00
Aiirondev_dev 65fadf7f5f impolementation of standardised time zone
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-23 11:28:53 +02:00
Aiirondev_dev 6f9994c4ce Addtion of missing import
Release Inventarsystem / release-docker (push) Successful in 3m9s
2026-08-23 10:27:36 +02:00
Aiirondev_dev 6496716572 Slight changes to the Design
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-22 16:13:45 +02:00
Aiirondev_dev f750739b51 Testing of the mahnugnssystem
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-22 16:02:39 +02:00
Aiirondev_dev 4c3545f4ba Design changes
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-22 15:44:29 +02:00
Aiirondev_dev c0bf0c0b8e Mahnungswesen implementierung
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-22 15:38:36 +02:00
Aiirondev_dev 2cd95ef808 Removal again
Release Inventarsystem / release-docker (push) Successful in 3m6s
2026-08-22 15:14:26 +02:00
Aiirondev_dev 17b58ff7ca Removal again
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-22 00:08:38 +02:00
Aiirondev_dev c67f64a45a Try again
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-22 00:02:47 +02:00
Aiirondev_dev 7727bfd94a Revert of changes
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-21 23:44:26 +02:00
Aiirondev_dev 55f34e0462 changes to the info importing for the library item
Release Inventarsystem / release-docker (push) Successful in 2m27s
2026-08-21 23:28:52 +02:00
Aiirondev_dev 5a3a180b88 Removal of the Korrektion function to remove an unneedet administrative ioverhead, but an option to add it back in later
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-21 23:04:37 +02:00
Aiirondev_dev 07ad592bd9 Improvement of naming
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-21 22:22:37 +02:00
Aiirondev_dev fa299cde06 Redirect issue with the Bibiliothek öffnen Button in the library_item_invoices.html
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-21 21:53:08 +02:00
Aiirondev_dev bb2c3f52e8 Implementation for the Damage history for the library items
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-21 18:10:31 +02:00
Aiirondev_dev 58ea4e5716 Implementation for the Damage history for the library items
Release Inventarsystem / release-docker (push) Successful in 2m22s
2026-08-21 17:45:55 +02:00
Aiirondev_dev e46ee80cf2 feat(library): add damage history view alongside invoices for items
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-21 13:51:16 +02:00
Aiirondev_dev f0b112b34c Klass implementation
Release Inventarsystem / release-docker (push) Successful in 2m19s
2026-08-21 13:20:34 +02:00
Aiirondev_dev 01637c31ee feat(library): add class filtering and sorting to library loans admin view
Release Inventarsystem / release-docker (push) Successful in 2m17s
- Add class filter dropdown and class column to borrowings table
- Implement interactive client-side column sorting for tables
- Update backend route to resolve and map user class information
2026-08-21 12:32:53 +02:00
Aiirondev_dev 52b2628adb Miss of an damage_unset
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-21 12:08:21 +02:00
Aiirondev_dev a51c4c4cbe fix: resolve item repair logic and clear mongo damage flags
Release Inventarsystem / release-docker (push) Successful in 2m20s
- Fixed incorrect form field key (`action_type` -> `action`) in admin route.
- Switched to MongoDB `$unset` to completely remove damage fields (`HasDamage`, `DamageText`, etc.) from the items collection instead of just setting them to false.
- Added cleanup logic to `$unset` damage flags from related active loans in the `borrowings` collection.
- Ensures repaired/replaced items correctly disappear from the damaged items UI.
2026-08-21 11:59:50 +02:00
Aiirondev_dev e4b27c2d62 Fix of a naming error in the repair resolver
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-21 11:48:09 +02:00
Aiirondev_dev bf85d406ac Fix of a naming error
Release Inventarsystem / release-docker (push) Successful in 3m11s
2026-08-21 11:02:21 +02:00
Aiirondev_dev 06e0c6dfb7 Fix of an redirect
Release Inventarsystem / release-docker (push) Successful in 3m8s
2026-08-20 23:12:43 +02:00
Aiirondev_dev fc25c2cd90 smal fix
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-19 23:19:11 +02:00
Aiirondev_dev c672ffcb12 methode not allowed issue path
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-19 23:12:27 +02:00
Aiirondev_dev 2bafc55e00 Redirection issue solved
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-19 22:45:36 +02:00
Aiirondev_dev 7e20790bac Fix of an encryption issue
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-19 22:40:25 +02:00
Aiirondev_dev 3aa065d039 Fixes to the updates for the invpoice processing, Fix is a duplication error for admin_mark_invoice_paid
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-19 22:34:19 +02:00
Aiirondev_dev 56d79610aa Fixes to the updates for the invpoice processing, Fix is a duplication error for admin_mark_invoice_paid
Release Inventarsystem / release-docker (push) Successful in 2m18s
2026-08-19 22:28:01 +02:00
Aiirondev_dev cd03bbd2e5 feat(inventory): add repair resolution workflow and invoice correction logic
Release Inventarsystem / release-docker (push) Successful in 2m14s
- Add repair resolution mechanism (`resolve_repaired_item`) allowing users to:
  * Just repair: Return item to normal state (`Verfuegbar: True`)
  * Replace: Update `Code_4` (inventory/barcode) and restore availability
  * Delete: Remove item and update series counts (`SeriesCount`) and parent/child references
- Support `Code_4` updates in `update_item` for specific physical items
- Implement invoice correction route (`admin_add_invoice_correction`) with audit logging
- Add modal UI in library admin view to handle repair actions and code replacements
2026-08-19 22:05:32 +02:00
Aiirondev_dev 78c215b234 slight database mixup fix
Release Inventarsystem / release-docker (push) Successful in 2m44s
2026-08-19 20:14:23 +02:00
Aiirondev_dev 9e77b4604d slight database mixup fix
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-19 19:57:13 +02:00
Aiirondev_dev c06473644a slight database mixup fix
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-19 18:55:30 +02:00
Aiirondev_dev 6c855ed9d9 slight database mixup fix
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-19 18:49:36 +02:00
14 changed files with 2124 additions and 450 deletions
+1334 -306
View File
File diff suppressed because it is too large Load Diff
+19 -14
View File
@@ -33,6 +33,8 @@ import json
import Web.modules.database.settings as cfg import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient from Web.modules.database.settings import MongoClient
import Web.modules.inventarsystem.data_protection as dp import Web.modules.inventarsystem.data_protection as dp
import Web.modules.database.user as us
from zoneinfo import ZoneInfo
def _get_client(): def _get_client():
@@ -79,7 +81,7 @@ def get_current_status(ausleihung, log_changes=False, user=None):
if original_status == 'completed': if original_status == 'completed':
return 'completed' return 'completed'
current_time = datetime.datetime.now() current_time = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
start_time = ausleihung.get('Start') start_time = ausleihung.get('Start')
end_time = ausleihung.get('End') end_time = ausleihung.get('End')
@@ -137,7 +139,7 @@ def create_backup_database():
os.makedirs(backup_dir) os.makedirs(backup_dir)
# Aktuelles Datum für den Dateinamen # Aktuelles Datum für den Dateinamen
current_date = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S') current_date = datetime.datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d_%H-%M-%S')
backup_file = os.path.join(backup_dir, f'ausleihungen_backup_{current_date}.json') backup_file = os.path.join(backup_dir, f'ausleihungen_backup_{current_date}.json')
# Ausleihungen abrufen und als JSON speichern # Ausleihungen abrufen und als JSON speichern
@@ -175,7 +177,7 @@ def create_backup_database():
log_file = os.path.join(log_dir, 'ausleihungen_error.log') log_file = os.path.join(log_dir, 'ausleihungen_error.log')
with open(log_file, 'a', encoding='utf-8') as f: with open(log_file, 'a', encoding='utf-8') as f:
f.write(f"{datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}: Backup-Fehler: {str(e)}\n") f.write(f"{datetime.datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d_%H-%M-%S')}: Backup-Fehler: {str(e)}\n")
print(f"Fehler beim Erstellen des Backups: {e}") print(f"Fehler beim Erstellen des Backups: {e}")
return False return False
@@ -183,7 +185,7 @@ def create_backup_database():
# === AUSLEIHUNG MANAGEMENT === # === AUSLEIHUNG MANAGEMENT ===
def add_ausleihung(item_id, user, start_date, end_date=None, notes="", status="active", period=None, exemplar_data=None): def add_ausleihung(item_id, user, start_date, end_date=None, notes="", status="active", period=None, exemplar_data=None, due_date=None):
""" """
Add a new borrowing record for an item. Add a new borrowing record for an item.
@@ -204,7 +206,7 @@ def add_ausleihung(item_id, user, start_date, end_date=None, notes="", status="a
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
ausleihungen = db['ausleihungen'] ausleihungen = db['ausleihungen']
ausleihung = { ausleihung = {
'Item': item_id, 'Item': item_id,
'User': dp.encrypt_text(user), 'User': dp.encrypt_text(user),
@@ -223,6 +225,9 @@ def add_ausleihung(item_id, user, start_date, end_date=None, notes="", status="a
if exemplar_data: if exemplar_data:
ausleihung['ExemplarData'] = exemplar_data ausleihung['ExemplarData'] = exemplar_data
if due_date:
ausleihung['DueDate'] = due_date
result = ausleihungen.insert_one(ausleihung) result = ausleihungen.insert_one(ausleihung)
ausleihung_id = result.inserted_id ausleihung_id = result.inserted_id
@@ -270,7 +275,7 @@ def update_ausleihung(id, item_id=None, user_id=None, start=None, end=None, note
return True return True
# UTC Zeitstempel nutzen # UTC Zeitstempel nutzen
update_data['LastUpdated'] = datetime.datetime.now(datetime.timezone.utc) update_data['LastUpdated'] = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
result = ausleihungen.update_one( result = ausleihungen.update_one(
{'_id': doc_id}, {'_id': doc_id},
@@ -300,7 +305,7 @@ def complete_ausleihung(id, end_time=None):
""" """
try: try:
if end_time is None: if end_time is None:
end_time = datetime.datetime.now() end_time = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
@@ -312,7 +317,7 @@ def complete_ausleihung(id, end_time=None):
{'$set': { {'$set': {
'End': end_time, 'End': end_time,
'Status': 'completed', 'Status': 'completed',
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}} }}
) )
@@ -320,7 +325,7 @@ def complete_ausleihung(id, end_time=None):
{'_id': ObjectId(id)}, {'_id': ObjectId(id)},
{'$set': { {'$set': {
'Verfuegbar': True, 'Verfuegbar': True,
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}} }}
) )
@@ -351,7 +356,7 @@ def cancel_ausleihung(id):
{'_id': ObjectId(id)}, {'_id': ObjectId(id)},
{'$set': { {'$set': {
'Status': 'cancelled', 'Status': 'cancelled',
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}} }}
) )
@@ -376,7 +381,7 @@ def remove_ausleihung(id):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
ausleihungen = db['ausleihungen'] ausleihungen = db['ausleihungen']
now = datetime.datetime.now() now = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
result = ausleihungen.update_one( result = ausleihungen.update_one(
{'_id': ObjectId(id), 'Status': {'$ne': 'deleted'}}, {'_id': ObjectId(id), 'Status': {'$ne': 'deleted'}},
{'$set': { {'$set': {
@@ -770,7 +775,7 @@ def activate_ausleihung(id):
{'_id': doc_id, 'Status': 'planned'}, {'_id': doc_id, 'Status': 'planned'},
{'$set': { {'$set': {
'Status': 'active', 'Status': 'active',
'LastUpdated': datetime.datetime.now(datetime.timezone.utc) 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}} }}
) )
return result.modified_count > 0 return result.modified_count > 0
@@ -794,7 +799,7 @@ def reset_item_completely(item_id):
return {'success': False, 'message': 'Item nicht gefunden'} return {'success': False, 'message': 'Item nicht gefunden'}
item_name = item.get('Name', 'Unbekannt') item_name = item.get('Name', 'Unbekannt')
now_utc = datetime.datetime.now(datetime.timezone.utc) now_utc = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
# 1. Bulk update active borrowings # 1. Bulk update active borrowings
update_res = ausleihungen_collection.update_many( update_res = ausleihungen_collection.update_many(
@@ -862,7 +867,7 @@ def mark_booking_active(booking_id, ausleihung_id=None):
doc_id = ObjectId(booking_id) if isinstance(booking_id, str) else booking_id doc_id = ObjectId(booking_id) if isinstance(booking_id, str) else booking_id
update_data = { update_data = {
'Status': 'active', 'Status': 'active',
'LastUpdated': datetime.datetime.now(datetime.timezone.utc) 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
} }
if ausleihung_id: if ausleihung_id:
update_data['AusleihungId'] = ausleihung_id update_data['AusleihungId'] = ausleihung_id
+13 -12
View File
@@ -26,6 +26,7 @@ import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient from Web.modules.database.settings import MongoClient
import Web.modules.inventarsystem.data_protection as dp import Web.modules.inventarsystem.data_protection as dp
import logging import logging
from zoneinfo import ZoneInfo
def is_library_item(item): def is_library_item(item):
@@ -177,8 +178,8 @@ def add_item(name, ort, beschreibung, images=None, filter=None, filter2=None, fi
'SeriesPosition': series_position, 'SeriesPosition': series_position,
'IsGroupedSubItem': is_grouped_sub_item, 'IsGroupedSubItem': is_grouped_sub_item,
'ParentItemId': parent_item_id, 'ParentItemId': parent_item_id,
'Created': datetime.datetime.now(), 'Created': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
} }
result = items.insert_one(item) result = items.insert_one(item)
item_id = result.inserted_id item_id = result.inserted_id
@@ -208,8 +209,8 @@ def remove_item(id):
{'_id': ObjectId(id), 'Deleted': {'$ne': True}}, {'_id': ObjectId(id), 'Deleted': {'$ne': True}},
{'$set': { {'$set': {
'Deleted': True, 'Deleted': True,
'DeletedAt': datetime.datetime.now(), 'DeletedAt': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
'LastUpdated': datetime.datetime.now(), 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
'Verfuegbar': False, 'Verfuegbar': False,
}} }}
) )
@@ -304,7 +305,7 @@ def update_item(id, name, ort, beschreibung, images, verfuegbar, filter1, filter
'is_library': is_lib, 'is_library': is_lib,
'library_category': library_category, 'library_category': library_category,
'Verfuegbar': bool(verfuegbar), 'Verfuegbar': bool(verfuegbar),
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
} }
specific_update = shared_update.copy() specific_update = shared_update.copy()
@@ -346,7 +347,7 @@ def update_item_status(id, verfuegbar, user=None):
update_data = { update_data = {
'Verfuegbar': verfuegbar, 'Verfuegbar': verfuegbar,
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
} }
update_query = {'$set': update_data} update_query = {'$set': update_data}
@@ -387,7 +388,7 @@ def update_item_exemplare_status(id, exemplare_status):
update_data = { update_data = {
'ExemplareStatus': exemplare_status, 'ExemplareStatus': exemplare_status,
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
} }
result = items.update_one( result = items.update_one(
@@ -734,7 +735,7 @@ def unstuck_item(id):
{'$set': { {'$set': {
'Status': 'cancelled', 'Status': 'cancelled',
'CancelledReason': 'unstuck_reset', 'CancelledReason': 'unstuck_reset',
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}} }}
) )
@@ -745,7 +746,7 @@ def unstuck_item(id):
{ {
'$set': { '$set': {
'Verfuegbar': True, 'Verfuegbar': True,
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}, },
'$unset': {'User': ""} '$unset': {'User': ""}
} }
@@ -1075,7 +1076,7 @@ def update_item_next_appointment(item_id, appointment_data):
if appointment_data is None: if appointment_data is None:
update_query = { update_query = {
'$unset': {'NextAppointment': ""}, '$unset': {'NextAppointment': ""},
'$set': {'LastUpdated': datetime.datetime.now()} '$set': {'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))}
} }
else: else:
# Create a copy so we don't mutate the original dictionary passed in # Create a copy so we don't mutate the original dictionary passed in
@@ -1088,7 +1089,7 @@ def update_item_next_appointment(item_id, appointment_data):
update_query = { update_query = {
'$set': { '$set': {
'NextAppointment': data_to_save, 'NextAppointment': data_to_save,
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
} }
} }
@@ -1121,7 +1122,7 @@ def clear_item_next_appointment(item_id):
result = items.update_one( result = items.update_one(
{'_id': ObjectId(item_id)}, {'_id': ObjectId(item_id)},
{'$unset': {'NextAppointment': ""}, '$set': {'LastUpdated': datetime.datetime.now()}} {'$unset': {'NextAppointment': ""}, '$set': {'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))}}
) )
client.close() client.close()
+5 -4
View File
@@ -23,6 +23,7 @@ from Web.modules.database.settings import MongoClient
from bson.objectid import ObjectId from bson.objectid import ObjectId
import datetime import datetime
import ast import ast
from zoneinfo import ZoneInfo
def _get_tenant_db(client): def _get_tenant_db(client):
@@ -99,8 +100,8 @@ def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
'calendar_enabled': bool(calendar_enabled), 'calendar_enabled': bool(calendar_enabled),
'clients_per_slot': clients_p_slot, 'clients_per_slot': clients_p_slot,
'slots_booked': [], # -> [(start_time, (names),(custom1, custom2,...)), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable 'slots_booked': [], # -> [(start_time, (names),(custom1, custom2,...)), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
'Created': datetime.datetime.now(), 'Created': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
} }
result = items.insert_one(item) result = items.insert_one(item)
return result.inserted_id return result.inserted_id
@@ -138,7 +139,7 @@ def update(id, slots_used: list):
update_data = { update_data = {
'slots_booked': dp.encrypt_text(str(slots_used)), 'slots_booked': dp.encrypt_text(str(slots_used)),
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
} }
result = items.update_one( result = items.update_one(
@@ -200,7 +201,7 @@ def remove_slot(id, date_start_time, name):
{ {
'$set': { '$set': {
'slots_booked': dp.encrypt_text(str(updated_slots)), 'slots_booked': dp.encrypt_text(str(updated_slots)),
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
} }
} }
) )
+83 -2
View File
@@ -730,16 +730,97 @@ def get_user_by_student_ident(student_ident):
return user_doc return user_doc
except Exception as e: except Exception as e:
app.logger.error(f"Entschlüsselungsfehler bei ID {user_doc.get('_id')}: {e}") logger.error(f"Entschlüsselungsfehler bei ID {user_doc.get('_id')}: {e}")
continue continue
except Exception as exc: except Exception as exc:
app.logger.error(f"Datenbankfehler in get_user_by_student_ident: {exc}") logger.error(f"Datenbankfehler in get_user_by_student_ident: {exc}")
finally: finally:
client.close() client.close()
return None return None
def get_user_by_student_name(student_name):
"""Return user dict by student name by decrypting all cards and matching."""
if not student_name:
return None
# Normalisiere den Suchbegriff, den wir finden wollen
normalized_target = str(student_name).strip()
if not normalized_target:
return None
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try:
db = _get_tenant_db(client)
all_student_cards = db['student_cards'].find()
for user_doc in all_student_cards:
encrypted_student_name = user_doc.get('SchülerName')
if encrypted_student_name:
try:
decrypted_ident = dp.decrypt_text(encrypted_student_name)
if decrypted_ident and str(decrypted_ident).strip() == normalized_target:
return user_doc
except Exception as e:
logger.error(f"Entschlüsselungsfehler bei ID {user_doc.get('_id')}: {e}")
continue
except Exception as exc:
logger.error(f"Datenbankfehler in get_user_by_student_name: {exc}")
finally:
client.close()
return None
def student_is_blocked(student_id):
"""
Überprüft, ob ein Schüler (Nutzer) im System gesperrt ist (z. B. aufgrund von überfälligen Ausleihen).
Akzeptiert entweder den Benutzernamen oder die Schülerausweis-ID.
Rückgabewert:
True, wenn der Nutzer gesperrt ist.
False, wenn der Nutzer nicht gesperrt ist oder nicht gefunden wurde.
"""
if not student_id:
return False
normalized_card_id = normalize_student_card_id(student_id)
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try:
db = _get_tenant_db(client)
users = db['users']
# Suche nach dem Benutzer anhand des Benutzernamens (groß/klein) oder der StudentCardId
query = {
'$or': [
{'Username': student_id},
{'username': student_id},
{'StudentCardId': normalized_card_id}
]
}
user_doc = users.find_one(query)
if user_doc:
# Lese das Feld 'is_blocked' aus, standardmäßig False, falls es nicht existiert
return bool(user_doc.get('is_blocked', False))
# Wenn kein Nutzer gefunden wurde, gehen wir sicherheitshalber davon aus, dass keine Sperre vorliegt
return False
except Exception as e:
logger.error(f"Datenbankfehler in student_is_blocked: {e}")
# Im Fehlerfall False zurückgeben, um Systemblockaden durch Datenbankfehler zu vermeiden
return False
finally:
client.close()
def make_admin(username): def make_admin(username):
"""Grant administrator privileges to a user.""" """Grant administrator privileges to a user."""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
+9 -3
View File
@@ -17,7 +17,7 @@ def _build_smtp_client():
smtp.starttls() smtp.starttls()
smtp.ehlo() smtp.ehlo()
if cfg.EMAIL_USERNAME: if cfg.EMAIL_USERNAME:
smtp.login(cfg.EMAIL_USERNAME, cfg.EMAIL_PASSWORD or "") smtp.login("no-reply@invario-software.de", "#,EATwIn,68" or "")
return smtp return smtp
@@ -68,14 +68,20 @@ def send(email: list | str, subject: str, note: str, sender: str) -> bool:
msg = MIMEMultipart("alternative") msg = MIMEMultipart("alternative")
msg["Subject"] = str(subject) msg["Subject"] = str(subject)
msg["From"] = f"{sender} <{cfg.EMAIL_USERNAME}>" msg["From"] = f"{sender} <no-reply@invario-software.de>"
msg["To"] = str(recipient) msg["To"] = str(recipient)
msg.attach(MIMEText(text_content, "plain")) msg.attach(MIMEText(text_content, "plain"))
msg.attach(MIMEText(html_content, "html")) msg.attach(MIMEText(html_content, "html"))
#smtp.sendmail(
# from_addr=cfg.EMAIL_USERNAME,
# to_addrs=[recipient],
# msg=msg.as_string()
#)
smtp.sendmail( smtp.sendmail(
from_addr=cfg.EMAIL_USERNAME, from_addr="no-reply@invario-software.de",
to_addrs=[recipient], to_addrs=[recipient],
msg=msg.as_string() msg=msg.as_string()
) )
+5 -4
View File
@@ -10,6 +10,7 @@ from bson import ObjectId
import requests import requests
import hashlib import hashlib
import logging import logging
from zoneinfo import ZoneInfo
import Web.modules.database.settings as cfg import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient from Web.modules.database.settings import MongoClient
@@ -153,7 +154,7 @@ def save_push_subscription(username, subscription_obj):
subs_col.update_one( subs_col.update_one(
{'_id': existing['_id']}, {'_id': existing['_id']},
{'$set': { {'$set': {
'LastUsed': datetime.datetime.now(), 'LastUsed': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
'IsActive': True 'IsActive': True
}} }}
) )
@@ -172,8 +173,8 @@ def save_push_subscription(username, subscription_obj):
'Keys': encrypt_text(keys_str), 'Keys': encrypt_text(keys_str),
'SubscriptionHash': sub_hash, 'SubscriptionHash': sub_hash,
'IsActive': True, 'IsActive': True,
'CreatedAt': datetime.datetime.now(), 'CreatedAt': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
'LastUsed': datetime.datetime.now(), 'LastUsed': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
'UserAgent': subscription_obj.get('userAgent', ''), 'UserAgent': subscription_obj.get('userAgent', ''),
} }
@@ -399,7 +400,7 @@ def cleanup_inactive_subscriptions():
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
subs_col = get_push_subscriptions_collection(db) subs_col = get_push_subscriptions_collection(db)
cutoff_date = datetime.datetime.now() - datetime.timedelta(days=30) cutoff_date = datetime.datetime.now(ZoneInfo("Europe/Berlin")) - datetime.timedelta(days=30)
result = subs_col.delete_many({ result = subs_col.delete_many({
'IsActive': False, 'IsActive': False,
+1
View File
@@ -1373,6 +1373,7 @@
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li> <li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
{% if current_permissions.pages.get('library_loans_admin', False) %} {% if current_permissions.pages.get('library_loans_admin', False) %}
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Alle Ausleihen/Alle Defekten Items</a></li> <li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Alle Ausleihen/Alle Defekten Items</a></li>
<li><a class="dropdown-item" href="{{ url_for('mahnungen_admin') }}">Mahnungen</a></li>
{% endif %} {% endif %}
{% if student_cards_module_enabled %} {% if student_cards_module_enabled %}
{% if current_permissions.actions.get('can_manage_users', False) %} {% if current_permissions.actions.get('can_manage_users', False) %}
+213 -75
View File
@@ -72,9 +72,10 @@
color: #1f2937; color: #1f2937;
} }
/* Angepasst für 4 Filter-Felder statt 3 */
.filter-bar { .filter-bar {
display: grid; display: grid;
grid-template-columns: 1fr 220px 220px; grid-template-columns: 1fr 140px 180px 180px;
gap: 10px; gap: 10px;
margin-bottom: 16px; margin-bottom: 16px;
} }
@@ -121,6 +122,7 @@
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: #64748b; color: #64748b;
background: var(--ui-surface-soft); background: var(--ui-surface-soft);
user-select: none; /* Verhindert Textmarkierung beim Klicken */
} }
.library-table tr:hover td { .library-table tr:hover td {
@@ -149,11 +151,13 @@
.badge-open { background: #fee2e2; color: #991b1b; } .badge-open { background: #fee2e2; color: #991b1b; }
.badge-paid { background: #dcfce7; color: #166534; } .badge-paid { background: #dcfce7; color: #166534; }
.badge-damaged { background: #fee2e2; color: #991b1b; } .badge-damaged { background: #fee2e2; color: #991b1b; }
.badge-class { background: #f1f5f9; color: #475569; border: 1px solid #cbd5e1; } /* Neues Badge für Klasse */
.row-actions { .row-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
align-items: center;
} }
.row-actions form { .row-actions form {
@@ -196,7 +200,7 @@
<div class="library-admin-hero"> <div class="library-admin-hero">
<div> <div>
<h1>Bibliotheks-Ausleihen</h1> <h1>Bibliotheks-Ausleihen</h1>
<p>Nur Bibliotheksmedien. Hier kannst du offene Ausleihen abschließen, Rechnungen bezahlen und defekte Medien direkt zurücksetzen.</p> <p>Nur Bibliotheksmedien. Hier kannst du offene Ausleihen abschließen, Rechnungen bezahlen und defekte Medien verwalten.</p>
</div> </div>
<div class="hero-actions"> <div class="hero-actions">
<a class="btn btn-secondary" href="{{ url_for('library_view') }}">Bibliothek öffnen</a> <a class="btn btn-secondary" href="{{ url_for('library_view') }}">Bibliothek öffnen</a>
@@ -215,21 +219,36 @@
<div class="value">{{ damaged_items|length }}</div> <div class="value">{{ damaged_items|length }}</div>
</div> </div>
<div class="summary-card"> <div class="summary-card">
<span class="label">Direkt reparierbar</span> <span class="label">Direkt bearbeitbar</span>
<div class="value">{{ damaged_items|length }}</div> <div class="value">{{ damaged_items|length }}</div>
</div> </div>
</div> </div>
<div class="filter-bar"> <div class="filter-bar">
<input id="library-search" type="text" placeholder="Nach Element, Benutzer, Ausweis oder Rechnung suchen..."> <input id="library-search" type="text" placeholder="Nach Element, Benutzer, Klasse, Ausweis oder Rechnung suchen...">
<!-- NEU: Klassen-Filter -->
<select id="class-filter">
<option value="all">Alle Klassen</option>
{% set classes = [] %}
{% for e in loan_entries %}
{% if e.klasse and e.klasse not in classes %}
{% set _ = classes.append(e.klasse) %}
{% endif %}
{% endfor %}
{% for c in classes|sort %}
<option value="{{ c|lower }}">{{ c }}</option>
{% endfor %}
</select>
<select id="loan-status-filter"> <select id="loan-status-filter">
<option value="">Alle Ausleihen</option> <option value="">Alle Status</option>
<option value="active">Aktiv</option> <option value="active">Aktiv</option>
<option value="planned">Geplant</option> <option value="planned">Geplant</option>
<option value="completed">Abgeschlossen</option> <option value="completed">Abgeschlossen</option>
</select> </select>
<select id="damage-filter"> <select id="damage-filter">
<option value="all">Alle Einträge</option> <option value="all">Alle Zustände</option>
<option value="damage">Nur defekt</option> <option value="damage">Nur defekt</option>
<option value="clean">Nur ohne Schaden</option> <option value="clean">Nur ohne Schaden</option>
</select> </select>
@@ -241,18 +260,31 @@
<table class="library-table" id="loans-table"> <table class="library-table" id="loans-table">
<thead> <thead>
<tr> <tr>
<th>Status</th> <!-- NEU: onClick Handler zum Sortieren hinzugefügt -->
<th>Element</th> <th style="cursor: pointer;" onclick="sortTable('loans-table', 0)">Status ↕</th>
<th>Benutzer</th> <th style="cursor: pointer;" onclick="sortTable('loans-table', 1)">Element ↕</th>
<th>Zeit</th> <th style="cursor: pointer;" onclick="sortTable('loans-table', 2)">Benutzer ↕</th>
<th>Rechnung</th> <th style="cursor: pointer;" onclick="sortTable('loans-table', 3)">Klasse ↕</th>
<th>Schaden</th> <th style="cursor: pointer;" onclick="sortTable('loans-table', 4)">Zeit ↕</th>
<th style="cursor: pointer;" onclick="sortTable('loans-table', 5)">Rechnung ↕</th>
<th style="cursor: pointer;" onclick="sortTable('loans-table', 6)">Schaden ↕</th>
<th>Aktionen</th> <th>Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<!-- NEU: data-klasse hinzugefügt -->
{% for e in loan_entries %} {% for e in loan_entries %}
<tr class="loan-row" data-borrow-id="{{ e.id }}" data-item-id="{{ e.item_id }}" data-item-name="{{ e.item_name }}" data-item-code="{{ e.item_code }}" data-item-cost="{{ e.item_cost_raw }}" data-user-name="{{ e.user }}" data-search="{{ (e.item_name ~ ' ' ~ e.item_code ~ ' ' ~ e.user ~ ' ' ~ e.invoice_number ~ ' ' ~ e.item_author ~ ' ' ~ e.item_isbn)|lower }}" data-status="{{ e.status }}" data-has-damage="{{ '1' if e.has_damage else '0' }}"> <tr class="loan-row"
data-borrow-id="{{ e.id }}"
data-item-id="{{ e.item_id }}"
data-item-name="{{ e.item_name }}"
data-item-code="{{ e.item_code }}"
data-item-cost="{{ e.item_cost_raw }}"
data-user-name="{{ e.user }}"
data-klasse="{{ (e.klasse|default(''))|lower }}"
data-search="{{ (e.item_name ~ ' ' ~ e.item_code ~ ' ' ~ e.user ~ ' ' ~ (e.klasse|default('')) ~ ' ' ~ e.invoice_number ~ ' ' ~ e.item_author ~ ' ' ~ e.item_isbn)|lower }}"
data-status="{{ e.status }}"
data-has-damage="{{ '1' if e.has_damage else '0' }}">
<td> <td>
{% if e.status == 'active' %} {% if e.status == 'active' %}
<span class="badge-pill badge-active">Aktiv</span> <span class="badge-pill badge-active">Aktiv</span>
@@ -267,24 +299,32 @@
<div class="muted">{{ e.item_author or '—' }}</div> <div class="muted">{{ e.item_author or '—' }}</div>
<div class="mono">{{ e.item_code or '—' }}</div> <div class="mono">{{ e.item_code or '—' }}</div>
<div style="margin-top:6px;"> <div style="margin-top:6px;">
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('library_item_invoices', item_id=e.item_id) }}">Rechnungen</a> <a class="btn btn-outline-secondary btn-sm" href="{{ url_for('library_item_invoices', item_id=e.item_id) }}">Rechnungen / Historie</a>
</div> </div>
</td> </td>
<td> <td>
<div>{{ e.user }}</div> <div><strong>{{ e.user }}</strong></div>
<div class="muted">{{ e.start }}{% if e.end %} bis {{ e.end }}{% endif %}</div> </td>
<!-- NEU: Klasse Spalte -->
<td>
{% if e.klasse %}
<span class="badge-pill badge-class">{{ e.klasse }}</span>
{% else %}
<span class="muted">—</span>
{% endif %}
</td> </td>
<td> <td>
<div>{{ e.period or '—' }}</div> <div>{{ e.period or '—' }}</div>
<div class="muted">{{ e.start }}{% if e.end %} bis {{ e.end }}{% endif %}</div>
{% if e.notes %}<div class="muted">{{ e.notes }}</div>{% endif %} {% if e.notes %}<div class="muted">{{ e.notes }}</div>{% endif %}
</td> </td>
<td> <td>
{% if e.invoice_number %} {% if e.invoice_number %}
<div class="mono">{{ e.invoice_number }}</div> <div class="mono">{{ e.invoice_number }}</div>
<div class="muted">{{ e.invoice_amount }}</div> <div class="muted">{{ e.invoice_amount }}</div>
{% if e.invoice_corrections_count %} <!--{% if e.invoice_corrections_count %}
<div class="muted" style="color:#7c2d12;">{{ e.invoice_corrections_count }} Korrektur(en)</div> <div class="muted" style="color:#7c2d12;">{{ e.invoice_corrections_count }} Korrektur(en)</div>
{% endif %} {% endif %} -->
<div style="margin-top:6px;"> <div style="margin-top:6px;">
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_view_invoice_pdf', borrow_id=e.id) }}" target="_blank" rel="noopener">PDF öffnen</a> <a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_view_invoice_pdf', borrow_id=e.id) }}" target="_blank" rel="noopener">PDF öffnen</a>
</div> </div>
@@ -311,35 +351,29 @@
{% if e.status == 'active' %} {% if e.status == 'active' %}
<button type="button" class="btn btn-outline-danger btn-sm" onclick="openDamageReportPrompt(this)">Schaden melden</button> <button type="button" class="btn btn-outline-danger btn-sm" onclick="openDamageReportPrompt(this)">Schaden melden</button>
{% endif %} {% endif %}
{% if e.invoice_number and ((not e.invoice_paid) or e.has_damage) %}
<form method="post" action="{{ url_for('admin_finalize_invoice_and_repair', borrow_id=e.id) }}" onsubmit="return confirm('Rechnung als bezahlt und Element als repariert markieren?');"> {% if e.invoice_number and not e.invoice_paid %}
<button type="submit" class="btn btn-success btn-sm"> <form method="post" action="{{ url_for('admin_mark_invoice_paid', borrow_id=e.id) }}" onsubmit="return confirm('Rechnung als bezahlt markieren?');">
{% if not e.invoice_paid and e.has_damage %} <button type="submit" class="btn btn-success btn-sm">Als bezahlt markieren</button>
Bezahlt + repariert
{% elif not e.invoice_paid %}
Als bezahlt markieren
{% else %}
Als repariert markieren
{% endif %}
</button>
</form>
{% elif e.has_damage %}
<form method="post" action="{{ url_for('mark_damage_repaired', id=e.item_id) }}" onsubmit="return confirm('Medium als repariert markieren?');">
<button type="submit" class="btn btn-success btn-sm">Als repariert markieren</button>
</form> </form>
{% endif %} {% endif %}
{% if e.invoice_number %} {% if e.has_damage %}
<form method="post" action="{{ url_for('admin_add_invoice_correction', borrow_id=e.id) }}" onsubmit="return confirm('Korrekturbuchung hinzufügen?');"> <!-- Aufruf des Reparatur-Auswahl-Modals -->
<input type="text" name="correction_reason" placeholder="Korrekturgrund" required style="padding:6px; border:1px solid #ddd; border-radius:6px; min-width:140px;"> <button type="button" class="btn btn-warning btn-sm" onclick="openRepairModal('{{ e.item_id }}', '{{ e.item_code }}')">Reparieren / Ersetzen</button>
<input type="text" name="amount_delta" placeholder="Delta optional" style="padding:6px; border:1px solid #ddd; border-radius:6px; width:120px;"> {% endif %}
<!--{% if e.invoice_number %}
<form method="post" action="{{ url_for('admin_add_invoice_correction', borrow_id=e.id) }}" onsubmit="return confirm('Korrekturbuchung hinzufügen?');" style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
<input type="text" name="correction_reason" value="Korrektur zu {{ e.invoice_number }}" placeholder="Korrekturgrund" required style="padding:6px; border:1px solid #ddd; border-radius:6px; min-width:160px; max-width:180px;">
<input type="text" name="amount_delta" placeholder="z.B. -{{ e.invoice_amount }}" style="padding:6px; border:1px solid #ddd; border-radius:6px; width:120px;">
<button type="submit" class="btn btn-outline-danger btn-sm">Korrektur</button> <button type="submit" class="btn btn-outline-danger btn-sm">Korrektur</button>
</form> </form>
{% endif %} {% endif %}-->
{% if e.status in ['active', 'planned'] %} {% if e.status in ['active', 'planned'] %}
<form method="post" action="{{ url_for('admin_reset_borrowing', borrow_id=e.id) }}" onsubmit="return confirm('Ausleihe zurücksetzen?');"> <form method="post" action="{{ url_for('admin_reset_borrowing', borrow_id=e.id) }}" onsubmit="return confirm('Ausleihe zurücksetzen?');">
<button type="submit" class="btn btn-warning btn-sm">Zurücksetzen</button> <button type="submit" class="btn btn-secondary btn-sm">Zurücksetzen</button>
</form> </form>
{% endif %} {% endif %}
</div> </div>
@@ -360,10 +394,11 @@
<table class="library-table" id="damaged-table"> <table class="library-table" id="damaged-table">
<thead> <thead>
<tr> <tr>
<th>Element</th> <!-- NEU: onClick Handler zum Sortieren hinzugefügt -->
<th>Code</th> <th style="cursor: pointer;" onclick="sortTable('damaged-table', 0)">Element ↕</th>
<th>Schaden</th> <th style="cursor: pointer;" onclick="sortTable('damaged-table', 1)">Code ↕</th>
<th>Status</th> <th style="cursor: pointer;" onclick="sortTable('damaged-table', 2)">Schaden ↕</th>
<th style="cursor: pointer;" onclick="sortTable('damaged-table', 3)">Status ↕</th>
<th>Aktion</th> <th>Aktion</th>
</tr> </tr>
</thead> </thead>
@@ -392,9 +427,8 @@
<div class="row-actions" style="margin-bottom:8px;"> <div class="row-actions" style="margin-bottom:8px;">
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('library_item_invoices', item_id=item.id) }}">Rechnungen</a> <a class="btn btn-outline-secondary btn-sm" href="{{ url_for('library_item_invoices', item_id=item.id) }}">Rechnungen</a>
</div> </div>
<form method="post" action="{{ url_for('mark_damage_repaired', id=item.id) }}" onsubmit="return confirm('Dieses Medium als repariert und wieder verfügbar markieren?');"> <!-- Aufruf des Reparatur-Auswahl-Modals -->
<button type="submit" class="btn btn-success btn-sm">Als repariert markieren</button> <button type="button" class="btn btn-warning btn-sm" onclick="openRepairModal('{{ item.id }}', '{{ item.code }}')">Reparieren / Ersetzen</button>
</form>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -407,6 +441,7 @@
</div> </div>
</div> </div>
<!-- Modal: Rechnung für Schaden erstellen -->
<div id="damage-invoice-modal" role="dialog" aria-modal="true" aria-labelledby="modal-title" style="display:none; position:fixed; inset:0; background:rgba(15,23,42,0.72); z-index:9999; padding:20px; overflow:auto;"> <div id="damage-invoice-modal" role="dialog" aria-modal="true" aria-labelledby="modal-title" style="display:none; position:fixed; inset:0; background:rgba(15,23,42,0.72); z-index:9999; padding:20px; overflow:auto;">
<div style="max-width:760px; margin:40px auto; background: var(--ui-surface); border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);"> <div style="max-width:760px; margin:40px auto; background: var(--ui-surface); border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);">
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:18px;"> <div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:18px;">
@@ -464,15 +499,44 @@
</div> </div>
</div> </div>
<!-- Neues Modal: Reparatur-Optionen (Reparieren, Ersetzen mit neuem Code_4, Löschen) -->
<div id="repair-action-modal" role="dialog" aria-modal="true" aria-labelledby="repair-modal-title" style="display:none; position:fixed; inset:0; background:rgba(15,23,42,0.72); z-index:9999; padding:20px; overflow:auto;">
<div style="max-width:540px; margin:60px auto; background: var(--ui-surface); border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);">
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:16px;">
<h2 id="repair-modal-title" style="margin:0; font-size: 1.3rem;">Medium-Bearbeitung nach Reparatur</h2>
<button type="button" class="btn btn-secondary btn-sm" onclick="closeRepairModal()">Schließen</button>
</div>
<p style="color: #666; margin-bottom: 18px; font-size: 0.95rem;">Wähle aus, wie mit diesem beschädigten Medium weiter verfahren werden soll:</p>
<form id="repair-action-form" method="post" action="">
<input type="hidden" name="action" id="repair-action-input" value="repair">
<div style="margin-bottom: 16px; display: none;" id="new-code-container">
<label for="modal-new-code-4" style="display:block; font-weight:700; margin-bottom:6px;">Neuer Code 4 (Inventarnummer / Barcode)</label>
<input type="text" id="modal-new-code-4" name="new_code_4" placeholder="Neuen Code 4 eingeben..." style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px;">
</div>
<div style="display: flex; flex-direction: column; gap: 10px;">
<button type="button" class="btn btn-success" onclick="submitRepairAction('repair')">Nur Reparieren (Wieder verfügbar machen)</button>
<button type="button" class="btn btn-primary" onclick="toggleReplaceMode()">Ersetzen (Neuen Code 4 zuweisen)</button>
<button type="submit" id="submit-replace-btn" class="btn btn-primary" style="display:none;" onclick="document.getElementById('repair-action-input').value='replace'; return confirm('Medium wirklich mit neuem Code aktualisieren?');">Änderung speichern</button>
<button type="submit" class="btn btn-danger" onclick="document.getElementById('repair-action-input').value='delete'; return confirm('Achtung: Soll dieses Medium wirklich gelöscht und der Bestand aktualisiert werden?');">Löschen & Bestand anpassen</button>
</div>
</form>
</div>
</div>
<script> <script>
(function() { (function() {
const searchInput = document.getElementById('library-search'); const searchInput = document.getElementById('library-search');
const classFilter = document.getElementById('class-filter'); // NEU: Klassenfilter hinzugefügt
const statusFilter = document.getElementById('loan-status-filter'); const statusFilter = document.getElementById('loan-status-filter');
const damageFilter = document.getElementById('damage-filter'); const damageFilter = document.getElementById('damage-filter');
const loanRows = Array.from(document.querySelectorAll('.loan-row')); const loanRows = Array.from(document.querySelectorAll('.loan-row'));
const damagedRows = Array.from(document.querySelectorAll('.damaged-row')); const damagedRows = Array.from(document.querySelectorAll('.damaged-row'));
const loansEmpty = document.getElementById('loans-empty'); const loansEmpty = document.getElementById('loans-empty');
const damagedEmpty = document.getElementById('damaged-empty'); const damagedEmpty = document.getElementById('damaged-empty');
const damageInvoiceModal = document.getElementById('damage-invoice-modal'); const damageInvoiceModal = document.getElementById('damage-invoice-modal');
const damageInvoiceForm = document.getElementById('damage-invoice-form'); const damageInvoiceForm = document.getElementById('damage-invoice-form');
const damageInvoiceItem = document.getElementById('damage-invoice-item'); const damageInvoiceItem = document.getElementById('damage-invoice-item');
@@ -482,23 +546,100 @@
const damageInvoiceReason = document.getElementById('damage-invoice-reason'); const damageInvoiceReason = document.getElementById('damage-invoice-reason');
const damageInvoiceReplaceBtn = document.getElementById('damage-invoice-replace-btn'); const damageInvoiceReplaceBtn = document.getElementById('damage-invoice-replace-btn');
// NEU: Globale Sortier-Richtungsobjekte
let sortDirections = {};
// NEU: Sortier-Funktion
window.sortTable = function(tableId, columnIndex) {
const table = document.getElementById(tableId);
const tbody = table.tBodies[0];
const rows = Array.from(tbody.querySelectorAll("tr"));
if (rows.length === 0) return;
const sortKey = tableId + "-" + columnIndex;
if (!(sortKey in sortDirections)) {
sortDirections[sortKey] = true;
} else {
sortDirections[sortKey] = !sortDirections[sortKey];
}
const isAscending = sortDirections[sortKey];
const multiplier = isAscending ? 1 : -1;
// Pfeil im Header aktualisieren
const headers = table.querySelectorAll("th");
headers.forEach(th => {
if(th.innerHTML.includes('↕') || th.innerHTML.includes('▲') || th.innerHTML.includes('▼')) {
th.innerHTML = th.innerHTML.replace(/[↕▲▼]/g, '↕');
}
});
const currentTh = headers[columnIndex];
if (currentTh) {
currentTh.innerHTML = currentTh.innerHTML.replace('↕', isAscending ? '▲' : '▼');
}
// Zeilen sortieren
rows.sort((a, b) => {
const cellA = a.cells[columnIndex].textContent.trim();
const cellB = b.cells[columnIndex].textContent.trim();
return cellA.localeCompare(cellB, 'de', { numeric: true, sensitivity: 'base' }) * multiplier;
});
// Neu einfügen
rows.forEach(row => tbody.appendChild(row));
};
// Funktionen für das Reparatur-Modal
window.openRepairModal = function(itemId, currentCode) {
const modal = document.getElementById('repair-action-modal');
const form = document.getElementById('repair-action-form');
const codeContainer = document.getElementById('new-code-container');
const replaceBtn = document.getElementById('submit-replace-btn');
form.action = `/admin/items/${itemId}/resolve_repair`;
document.getElementById('repair-action-input').value = 'repair';
document.getElementById('modal-new-code-4').value = currentCode || '';
codeContainer.style.display = 'none';
replaceBtn.style.display = 'none';
modal.style.display = 'block';
};
window.closeRepairModal = function() {
document.getElementById('repair-action-modal').style.display = 'none';
};
window.toggleReplaceMode = function() {
const codeContainer = document.getElementById('new-code-container');
const replaceBtn = document.getElementById('submit-replace-btn');
document.getElementById('repair-action-input').value = 'replace';
codeContainer.style.display = 'block';
replaceBtn.style.display = 'block';
document.getElementById('modal-new-code-4').focus();
};
window.submitRepairAction = function(actionType) {
const form = document.getElementById('repair-action-form');
document.getElementById('repair-action-input').value = actionType;
if (confirm('Medium als regulär repariert markieren?')) {
form.submit();
}
};
function openDamageReportPrompt(button) { function openDamageReportPrompt(button) {
const row = button.closest('.loan-row'); const row = button.closest('.loan-row');
if (!row) { if (!row) return;
return;
}
const itemId = row.dataset.itemId || ''; const itemId = row.dataset.itemId || '';
const itemName = row.dataset.itemName || 'Bibliotheksmedium';
const noteInput = prompt('Schadensmeldung für dieses Bibliotheksmedium:\nNotiz zum Schaden (optional):', ''); const noteInput = prompt('Schadensmeldung für dieses Bibliotheksmedium:\nNotiz zum Schaden (optional):', '');
if (noteInput === null) { if (noteInput === null) return;
return;
}
const description = noteInput.trim() || 'Schaden erneut gemeldet'; const description = noteInput.trim() || 'Schaden erneut gemeldet';
// Visuelles Feedback: Button deaktivieren und Text ändern
const originalText = button.textContent; const originalText = button.textContent;
button.disabled = true; button.disabled = true;
button.textContent = 'Speichere...'; button.textContent = 'Speichere...';
@@ -509,7 +650,6 @@
body: JSON.stringify({ description }) body: JSON.stringify({ description })
}) })
.then(async response => { .then(async response => {
// Robustes JSON-Parsing (verhindert Absturz, falls der Server kein JSON zurückgibt)
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (!response.ok || !data.success) { if (!response.ok || !data.success) {
throw new Error(data.message || 'Fehler beim Speichern der Schadensmeldung.'); throw new Error(data.message || 'Fehler beim Speichern der Schadensmeldung.');
@@ -519,18 +659,14 @@
.then(data => { .then(data => {
if (confirm('Schaden gespeichert. Soll direkt eine Rechnung erstellt werden?')) { if (confirm('Schaden gespeichert. Soll direkt eine Rechnung erstellt werden?')) {
openDamageInvoiceModal(row, description); openDamageInvoiceModal(row, description);
// Button wieder zurücksetzen, da die Seite nicht neu geladen wird
button.disabled = false; button.disabled = false;
button.textContent = originalText; button.textContent = originalText;
return; return;
} }
// Bei "Abbrechen" im Confirm -> Neuladen der Tabelle
window.location.reload(); window.location.reload();
}) })
.catch(error => { .catch(error => {
alert(error.message || 'Ein unbekannter Fehler ist aufgetreten.'); alert(error.message || 'Ein unbekannter Fehler ist aufgetreten.');
// Fehlerbehandlung: Button wieder aktiv schalten
button.disabled = false; button.disabled = false;
button.textContent = originalText; button.textContent = originalText;
}); });
@@ -539,17 +675,12 @@
window.openDamageReportPrompt = openDamageReportPrompt; window.openDamageReportPrompt = openDamageReportPrompt;
function openDamageInvoiceModal(row, description) { function openDamageInvoiceModal(row, description) {
if (!damageInvoiceModal || !damageInvoiceForm) { if (!damageInvoiceModal || !damageInvoiceForm) return;
console.error("Modal oder Formular nicht gefunden.");
return;
}
const borrowId = row.dataset.borrowId || ''; const borrowId = row.dataset.borrowId || '';
const itemName = row.dataset.itemName || ''; const itemName = row.dataset.itemName || '';
const borrower = row.dataset.userName || ''; const borrower = row.dataset.userName || '';
const itemCode = row.dataset.itemCode || ''; const itemCode = row.dataset.itemCode || '';
// KORREKTUR: Jetzt greifen wir auf das richtige dataset-Attribut zu
const itemCost = row.dataset.itemCost || ''; const itemCost = row.dataset.itemCost || '';
damageInvoiceForm.action = "{{ url_for('admin_create_invoice', borrow_id='__BORROW_ID__') }}".replace('__BORROW_ID__', borrowId); damageInvoiceForm.action = "{{ url_for('admin_create_invoice', borrow_id='__BORROW_ID__') }}".replace('__BORROW_ID__', borrowId);
@@ -557,20 +688,14 @@
damageInvoiceItem.value = itemName; damageInvoiceItem.value = itemName;
damageInvoiceBorrower.value = borrower; damageInvoiceBorrower.value = borrower;
damageInvoiceCode.value = itemCode; damageInvoiceCode.value = itemCode;
// Feld zunächst leeren, damit der Ersetzen-Button genutzt werden kann
damageInvoiceAmount.value = ''; damageInvoiceAmount.value = '';
// Original-Preis im Button als data-Attribut hinterlegen
if (damageInvoiceReplaceBtn) { if (damageInvoiceReplaceBtn) {
damageInvoiceReplaceBtn.dataset.acquisition_costs = String(itemCost).replace(' EUR', '').trim(); damageInvoiceReplaceBtn.dataset.acquisition_costs = String(itemCost).replace(' EUR', '').trim();
} }
damageInvoiceReason.value = description || `Schaden gemeldet für ${itemName}`; damageInvoiceReason.value = description || `Schaden gemeldet für ${itemName}`;
damageInvoiceModal.style.display = 'block'; damageInvoiceModal.style.display = 'block';
// Accessibility: Fokus ins erste aktivierbare Feld setzen
damageInvoiceAmount.focus(); damageInvoiceAmount.focus();
} }
@@ -580,7 +705,6 @@
} }
} }
// Event-Listener für den Ersetzen-Button
if (damageInvoiceReplaceBtn) { if (damageInvoiceReplaceBtn) {
damageInvoiceReplaceBtn.addEventListener('click', function() { damageInvoiceReplaceBtn.addEventListener('click', function() {
if (this.dataset.acquisition_costs) { if (this.dataset.acquisition_costs) {
@@ -600,20 +724,26 @@
}); });
} }
// NEU: Kombinierte Filter-Funktion (Suche + Status + Schaden + Klasse)
function applyFilters() { function applyFilters() {
const search = (searchInput.value || '').trim().toLowerCase(); const search = (searchInput.value || '').trim().toLowerCase();
const status = statusFilter.value; const status = statusFilter.value;
const damage = damageFilter.value; const damage = damageFilter.value;
const klasse = classFilter.value; // NEU
let visibleLoans = 0; let visibleLoans = 0;
loanRows.forEach(row => { loanRows.forEach(row => {
const haystack = row.dataset.search || ''; const haystack = row.dataset.search || '';
const rowStatus = row.dataset.status || ''; const rowStatus = row.dataset.status || '';
const rowKlasse = row.dataset.klasse || ''; // NEU
const hasDamage = row.dataset.hasDamage === '1'; const hasDamage = row.dataset.hasDamage === '1';
const searchMatch = !search || haystack.includes(search); const searchMatch = !search || haystack.includes(search);
const statusMatch = !status || rowStatus === status; const statusMatch = !status || rowStatus === status;
const classMatch = klasse === 'all' || rowKlasse === klasse; // NEU
const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage); const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage);
const show = searchMatch && statusMatch && damageMatch;
const show = searchMatch && statusMatch && classMatch && damageMatch; // NEU
row.style.display = show ? '' : 'none'; row.style.display = show ? '' : 'none';
if (show) visibleLoans++; if (show) visibleLoans++;
}); });
@@ -624,10 +754,17 @@
const haystack = row.dataset.search || ''; const haystack = row.dataset.search || '';
const rowStatus = row.dataset.status || ''; const rowStatus = row.dataset.status || '';
const hasDamage = row.dataset.hasDamage === '1'; const hasDamage = row.dataset.hasDamage === '1';
const searchMatch = !search || haystack.includes(search); const searchMatch = !search || haystack.includes(search);
const statusMatch = !status || rowStatus === status || status === ''; const statusMatch = !status || rowStatus === status || status === '';
const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage); const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage);
const show = searchMatch && statusMatch && damageMatch;
// Defekte-Medien-Tabelle hat keine verknüpfte "Klasse", deshalb blenden wir sie nur bei Klassen-Filter "all" ein,
// oder wenn gar nicht nach Klasse gefiltert wird, damit sie nicht verschwindet.
// Falls sie bei aktiver Klassensuche komplett verschwinden soll, passe die Bedingung an:
const classMatch = klasse === 'all';
const show = searchMatch && statusMatch && damageMatch && classMatch;
row.style.display = show ? '' : 'none'; row.style.display = show ? '' : 'none';
if (show) visibleDamaged++; if (show) visibleDamaged++;
}); });
@@ -635,6 +772,7 @@
} }
searchInput.addEventListener('input', applyFilters); searchInput.addEventListener('input', applyFilters);
classFilter.addEventListener('change', applyFilters); // NEU
statusFilter.addEventListener('change', applyFilters); statusFilter.addEventListener('change', applyFilters);
damageFilter.addEventListener('change', applyFilters); damageFilter.addEventListener('change', applyFilters);
applyFilters(); applyFilters();
+60 -5
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Rechnungen Element - {{ APP_VERSION }}{% endblock %} {% block title %}Rechnungs- & Schadenshistorie - {{ APP_VERSION }}{% endblock %}
{% block content %} {% block content %}
<style> <style>
@@ -39,6 +39,14 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.section-header {
font-size: 1.25rem;
font-weight: 700;
color: #1e293b;
margin: 36px 0 16px 0;
padding-left: 4px;
}
.invoice-card { .invoice-card {
background: var(--ui-surface); background: var(--ui-surface);
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
@@ -115,7 +123,7 @@
<div class="invoice-history-shell"> <div class="invoice-history-shell">
<div class="invoice-history-head"> <div class="invoice-history-head">
<h1>Rechnungshistorie pro Element</h1> <h1>Rechnungs- & Schadenshistorie pro Element</h1>
<div class="head-meta"> <div class="head-meta">
<span><strong>Element:</strong> {{ item.name or '—' }}</span> <span><strong>Element:</strong> {{ item.name or '—' }}</span>
<span><strong>Code:</strong> {{ item.code or '—' }}</span> <span><strong>Code:</strong> {{ item.code or '—' }}</span>
@@ -124,10 +132,12 @@
</div> </div>
<div class="head-actions"> <div class="head-actions">
<a class="btn btn-outline-secondary" href="{{ url_for('library_loans_admin') }}">Zur Bibliotheks-Ausleihenverwaltung</a> <a class="btn btn-outline-secondary" href="{{ url_for('library_loans_admin') }}">Zur Bibliotheks-Ausleihenverwaltung</a>
<a class="btn btn-secondary" href="{{ url_for('library_admin') }}">Bibliothek öffnen</a> <a class="btn btn-secondary" href="{{ url_for('library_view') }}">Bibliothek öffnen</a>
</div> </div>
</div> </div>
<!-- BEREICH: RECHNUNGEN -->
<h2 class="section-header">Ausgestellte Rechnungen</h2>
<div class="invoice-card"> <div class="invoice-card">
{% if invoices %} {% if invoices %}
<table class="invoice-table"> <table class="invoice-table">
@@ -137,7 +147,7 @@
<th>Betrag</th> <th>Betrag</th>
<th>Ausleihe</th> <th>Ausleihe</th>
<th>Status</th> <th>Status</th>
<th>Schaden</th> <th>Schaden / Grund</th>
<th>Aktion</th> <th>Aktion</th>
</tr> </tr>
</thead> </thead>
@@ -184,5 +194,50 @@
<div class="empty-state">Für dieses Element wurden noch keine Rechnungen gespeichert.</div> <div class="empty-state">Für dieses Element wurden noch keine Rechnungen gespeichert.</div>
{% endif %} {% endif %}
</div> </div>
<!-- BEREICH: SCHÄDEN -->
<h2 class="section-header">Gemeldete Schäden</h2>
<div class="invoice-card">
{% if damages %}
<table class="invoice-table">
<thead>
<tr>
<th>Datum</th>
<th>Quelle</th>
<th>Nutzer / Verursacher</th>
<th>Beschreibung</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for row in damages %}
<tr>
<td>
<div class="muted">{{ row.date }}</div>
</td>
<td>
<div class="mono">{{ row.source }}</div>
{% if row.borrow_id %}
<div class="muted" style="font-size: 0.8rem;">ID: <span title="{{ row.borrow_id }}">{{ row.borrow_id[:6] }}...</span></div>
{% endif %}
</td>
<td>{{ row.user }}</td>
<td>{{ row.description }}</td>
<td>
<!-- Einfache Logik, um bei reparierten Objekten einen grünen Badge zu zeigen -->
{% if row.status|lower in ['repariert', 'erledigt', 'geschlossen', 'bezahlt'] %}
<span class="badge-pill badge-completed">{{ row.status }}</span>
{% else %}
<span class="badge-pill badge-open">{{ row.status }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">Für dieses Element wurden bislang keine Schäden erfasst.</div>
{% endif %}
</div>
</div> </div>
{% endblock %} {% endblock %}
+312
View File
@@ -0,0 +1,312 @@
{% extends "base.html" %}
{% block title %}Mahnungsübersicht{% endblock %}
{% block content %}
<style>
.mahnungen-shell {
max-width: 1180px;
margin: 0 auto;
padding: 20px;
}
.mahnungen-head {
background: linear-gradient(135deg, #ffffff 0%, #f7f9fc 100%);
border: 1px solid #dbe4ee;
border-radius: 16px;
padding: 18px 20px;
margin-bottom: 20px;
box-shadow: 0 10px 26px rgba(15, 23, 42, 0.07);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
}
.mahnungen-head h1 {
margin: 0;
font-size: 1.55rem;
color: #1e293b;
}
.head-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.search-box-wrapper {
margin-bottom: 16px;
}
.search-box {
width: 100%;
max-width: 360px;
padding: 10px 14px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.search-box:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
.mahnungen-card {
background: var(--ui-surface, #ffffff);
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 18px;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
}
.mahnungen-table {
width: 100%;
border-collapse: collapse;
}
.mahnungen-table th,
.mahnungen-table td {
padding: 12px 10px;
border-bottom: 1px solid #edf2f7;
vertical-align: middle;
text-align: left;
}
.mahnungen-table th {
font-size: 0.83rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #64748b;
background: var(--ui-surface-soft, #f8fafc);
}
.mahnungen-table tr:hover td {
background: #fbfdff;
}
.badge-pill {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
line-height: 1;
}
.badge-open { background: #fee2e2; color: #991b1b; }
.badge-warning { background: #fef3c7; color: #b45309; }
.badge-info { background: #e0f2fe; color: #0369a1; }
.badge-paid { background: #dcfce7; color: #166534; }
.mono {
font-family: monospace;
font-size: 0.92rem;
}
.text-danger {
color: #dc2626;
font-weight: 700;
}
.empty-state {
text-align: center;
color: #6b7280;
padding: 36px 20px;
}
@media (max-width: 900px) {
.mahnungen-table {
display: block;
overflow-x: auto;
white-space: nowrap;
}
}
</style>
<div class="container-fluid py-4">
<div class="row mb-4">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-exclamation-triangle-fill me-2"></i>Mahnungs- und Ausleihverwaltung</h5>
<span class="badge bg-light text-dark">Übersicht überfälliger Medien</span>
</div>
<div class="card-body">
{% if overdue_items and overdue_items|length > 0 %}
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th>Schüler / Ausweis</th>
<th>Klasse</th>
<th>Medium</th>
<th>Fälligkeitsdatum</th>
<th>Überfällig seit</th>
<th>Mahnstufe</th>
<th class="text-end">Aktionen</th>
</tr>
</thead>
<tbody>
{% for item in overdue_items %}
<tr>
<td>
<strong>{{ item.schueler_name }}</strong><br>
<small class="text-muted">{{ item.ausweis_id }}</small>
</td>
<td>{{ item.klasse }}</td>
<td>
{{ item.item_name }}<br>
<small class="text-muted">Code: {{ item.item_code or '—' }}</small>
</td>
<td>{{ item.due_date }}</td>
<td>
<span class="badge bg-danger">{{ item.days_overdue }} Tage</span>
</td>
<td>
{% if item.mahnstufe == 2 %}
<span class="badge bg-dark text-danger">Stufe 2 (Gesperrt)</span>
{% elif item.mahnstufe == 1 %}
<span class="badge bg-warning text-dark">Stufe 1</span>
{% else %}
<span class="badge bg-secondary">Stufe 0</span>
{% endif %}
</td>
<td class="text-end">
<div class="btn-group" role="group">
{% if mail_service_enabled %}
<button type="button" class="btn btn-sm btn-outline-primary"
onclick="openEmailModal('{{ item.id }}', '{{ item.schueler_name }}', '{{ item.email }}')">
<i class="bi bi-envelope"></i> E-Mail
</button>
{% 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
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-check-circle-fill text-success fs-1"></i>
<p class="text-muted mt-2">Aktuell gibt es keine überfälligen Mahnungen.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- E-Mail Modal -->
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="emailModalLabel">Mahnungs-E-Mail senden</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Schließen"></button>
</div>
<div class="modal-body">
<form id="emailForm">
<input type="hidden" id="modalLoanId">
<div class="mb-3">
<label for="modalStudentName" class="form-label">Empfänger</label>
<input type="text" class="form-control" id="modalStudentName" readonly>
</div>
<div class="mb-3">
<label for="modalEmail" class="form-label">E-Mail-Adresse</label>
<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>
<textarea class="form-control" id="modalMessage" rows="4" placeholder="Optionaler Text..."></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-primary" onclick="submitEmailMahnung()">E-Mail senden</button>
</div>
</div>
</div>
</div>
<script>
function openEmailModal(loanId, studentName, studentEmail) {
document.getElementById('modalLoanId').value = loanId;
document.getElementById('modalStudentName').value = studentName;
document.getElementById('modalEmail').value = studentEmail || '';
document.getElementById('modalMessage').value = '';
var myModal = new bootstrap.Modal(document.getElementById('emailModal'));
myModal.show();
}
function submitEmailMahnung() {
const loanId = document.getElementById('modalLoanId').value;
const email = document.getElementById('modalEmail').value;
const message = document.getElementById('modalMessage').value;
if (!email) {
alert('Bitte geben Sie eine gültige E-Mail-Adresse ein.');
return;
}
fetch('/mahnungen_send_email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ loan_id: loanId, email: email, message: message })
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('E-Mail erfolgreich gesendet!');
var modalEl = document.getElementById('emailModal');
var modal = bootstrap.Modal.getInstance(modalEl);
modal.hide();
} else {
alert('Fehler: ' + data.message);
}
})
.catch(error => {
alert('Fehler beim Senden der Anfrage.');
console.error(error);
});
}
function resetMahnung(loanId, studentName) {
if (!confirm('Möchten Sie die Mahnung für "' + studentName + '" wirklich zurücksetzen?\n\nDadurch wird die Mahnstufe auf 0 gesetzt, die Frist um 14 Tage verlängert und der Schülerausweis entsperrt.')) {
return;
}
fetch('/mahnungen_reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ loan_id: loanId })
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message);
location.reload(); // Seite neu laden, um die aktualisierte Ansicht zu sehen
} else {
alert('Fehler: ' + data.message);
}
})
.catch(error => {
alert('Fehler beim Senden der Anfrage.');
console.error(error);
});
}
</script>
{% endblock %}
+15 -15
View File
@@ -1531,22 +1531,22 @@
} }
const isbnField = document.getElementById('isbn'); const isbnField = document.getElementById('isbn');
const infoContainer = document.getElementById('book-info-container'); const infoContainer = document.getElementById('book-info-container');
if (!isbnField || !infoContainer) { if (!isbnField || !infoContainer) {
alert('Fehler: Erforderliche Formularelemente nicht gefunden.'); alert('Fehler: Erforderliche Formularelemente nicht gefunden.');
return; return;
} }
const isbn = normalizeIsbnClient(isbnField.value); const isbn = normalizeIsbnClient(isbnField.value);
if (!isbn) { if (!isbn) {
infoContainer.innerHTML = '<div class="error-message">Bitte geben Sie eine ISBN oder einen Barcode ein.</div>'; infoContainer.innerHTML = '<div class="error-message">Bitte geben Sie eine ISBN oder einen Barcode ein.</div>';
return; return;
} }
// Show loading indicator // Show loading indicator
infoContainer.innerHTML = '<div class="loading-spinner">Informationen werden abgerufen...</div>'; infoContainer.innerHTML = '<div class="loading-spinner">Informationen werden abgerufen...</div>';
// Make API request to fetch book data // Make API request to fetch book data
fetch(`/fetch_book_info/${isbn}`) fetch(`/fetch_book_info/${isbn}`)
.then(response => { .then(response => {
@@ -1564,10 +1564,10 @@
infoContainer.innerHTML = `<div class="error-message">${data.error}</div>`; infoContainer.innerHTML = `<div class="error-message">${data.error}</div>`;
return; return;
} }
// Store book data globally for import functionality // Store book data globally for import functionality
currentBookData = data; currentBookData = data;
// Display book information with import button // Display book information with import button
infoContainer.innerHTML = ` infoContainer.innerHTML = `
<div class="book-info"> <div class="book-info">
@@ -1602,23 +1602,23 @@
alert('Keine Buchdaten verfügbar zum Import.'); alert('Keine Buchdaten verfügbar zum Import.');
return; return;
} }
// Get form fields // Get form fields
const nameField = document.getElementById('name'); const nameField = document.getElementById('name');
const descriptionField = document.getElementById('beschreibung'); const descriptionField = document.getElementById('beschreibung');
const priceField = document.getElementById('anschaffungskosten'); // <-- NEU const priceField = document.getElementById('anschaffungskosten'); // <-- NEU
if (!nameField || !descriptionField) { if (!nameField || !descriptionField) {
alert('Fehler: Formularfelder nicht gefunden.'); alert('Fehler: Formularfelder nicht gefunden.');
return; return;
} }
// Generate book title // Generate book title
let bookTitle = currentBookData.title || ''; let bookTitle = currentBookData.title || '';
if (currentBookData.authors && currentBookData.authors !== 'Unknown Author') { if (currentBookData.authors && currentBookData.authors !== 'Unknown Author') {
bookTitle += ` - ${currentBookData.authors}`; bookTitle += ` - ${currentBookData.authors}`;
} }
// Generate description // Generate description
let description = ''; let description = '';
if (currentBookData.description && currentBookData.description !== 'No description available') { if (currentBookData.description && currentBookData.description !== 'No description available') {
@@ -1639,11 +1639,11 @@
description += `\nSeiten: ${currentBookData.pageCount}`; description += `\nSeiten: ${currentBookData.pageCount}`;
} }
} }
// Import the data into form fields // Import the data into form fields
nameField.value = bookTitle; nameField.value = bookTitle;
descriptionField.value = description; descriptionField.value = description;
// Preis in das Formularfeld eintragen // Preis in das Formularfeld eintragen
if (priceField && currentBookData.price !== null && currentBookData.price !== undefined) { if (priceField && currentBookData.price !== null && currentBookData.price !== undefined) {
// Wandelt den Float (z.B. 12.25) in einen String mit Komma (12,25) um // Wandelt den Float (z.B. 12.25) in einen String mit Komma (12,25) um
@@ -1655,14 +1655,14 @@
if (currentBookData.thumbnail) { if (currentBookData.thumbnail) {
downloadBookCover(currentBookData.thumbnail); downloadBookCover(currentBookData.thumbnail);
} }
// Show success message // Show success message
const infoContainer = document.getElementById('book-info-container'); const infoContainer = document.getElementById('book-info-container');
const successMessage = document.createElement('div'); const successMessage = document.createElement('div');
successMessage.className = 'import-success-message'; successMessage.className = 'import-success-message';
successMessage.textContent = 'Buchdaten erfolgreich in das Formular übernommen!'; successMessage.textContent = 'Buchdaten erfolgreich in das Formular übernommen!';
infoContainer.appendChild(successMessage); infoContainer.appendChild(successMessage);
// Remove success message after 3 seconds // Remove success message after 3 seconds
setTimeout(() => { setTimeout(() => {
if (successMessage.parentNode) { if (successMessage.parentNode) {
+52 -8
View File
@@ -53,8 +53,11 @@
<option value="desc">Absteigend</option> <option value="desc">Absteigend</option>
</select> </select>
</div> </div>
<div class="col-md-2"> <div class="col-md-3">
<button class="btn btn-secondary w-100" onclick="resetFilters()">Filter zurücksetzen</button> <div class="d-flex gap-2">
<button class="btn btn-secondary flex-grow-1" onclick="resetFilters()">Zurücksetzen</button>
<button class="btn btn-success flex-grow-1" onclick="exportFilteredUsers()">Exportieren</button>
</div>
</div> </div>
</div> </div>
<div class="mt-2 text-muted small" id="filter-count"></div> <div class="mt-2 text-muted small" id="filter-count"></div>
@@ -87,14 +90,14 @@
Löschen Löschen
</button> </button>
</form> </form>
<button type="button" class="btn btn-primary btn-sm" <button type="button" class="btn btn-primary btn-sm"
data-username="{{ user.username }}" data-username="{{ user.username }}"
data-firstname="{{ user.name if user.name else '' }}" data-firstname="{{ user.name if user.name else '' }}"
data-lastname="{{ user.last_name if user.last_name else '' }}" data-lastname="{{ user.last_name if user.last_name else '' }}"
onclick="openEditUserModal(this)"> onclick="openEditUserModal(this)">
Bearbeiten Bearbeiten
</button> </button>
<button type="button" class="btn btn-warning btn-sm" <button type="button" class="btn btn-warning btn-sm"
onclick="openResetPasswordModal('{{ user.username }}')"> onclick="openResetPasswordModal('{{ user.username }}')">
Passwort zurücksetzen Passwort zurücksetzen
</button> </button>
@@ -313,6 +316,48 @@
applyFilters(); applyFilters();
} }
function exportFilteredUsers() {
var table = document.getElementById('user-table');
var tbody = table.querySelector('tbody');
var rows = Array.from(tbody.querySelectorAll('tr'));
var visibleUsernames = [];
// Sammle alle Benutzernamen, die durch den Filter aktuell NICHT versteckt sind
rows.forEach(function(row) {
if (row.style.display !== 'none') {
var cells = row.querySelectorAll('td');
if (cells.length > 0) {
// Der Benutzername ist in der ersten Spalte (Index 0)
visibleUsernames.push(cells[0].textContent.trim());
}
}
});
if (visibleUsernames.length === 0) {
alert('Keine Benutzer zum Exportieren vorhanden.');
return;
}
// Dynamisches Formular erstellen, um die gefilterten Daten per POST ans Backend zu senden
var form = document.createElement('form');
form.method = 'POST';
form.action = "{{ url_for('export_users_csv') }}"; // Passe dies ggf. an deinen Endpunktnamen an
var input = document.createElement('input');
input.type = 'hidden';
input.name = 'usernames';
input.value = JSON.stringify(visibleUsernames);
form.appendChild(input);
document.body.appendChild(form);
form.submit();
// Aufräumen
document.body.removeChild(form);
}
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
applyFilters(); applyFilters();
document.getElementById('filter-search').addEventListener('input', applyFilters); document.getElementById('filter-search').addEventListener('input', applyFilters);
@@ -332,12 +377,12 @@
var username = button.getAttribute('data-username'); var username = button.getAttribute('data-username');
var name = button.getAttribute('data-firstname'); var name = button.getAttribute('data-firstname');
var lastName = button.getAttribute('data-lastname'); var lastName = button.getAttribute('data-lastname');
document.getElementById('edit-username').value = username; document.getElementById('edit-username').value = username;
document.getElementById('edit-username-display').value = username; document.getElementById('edit-username-display').value = username;
document.getElementById('edit-name').value = name; document.getElementById('edit-name').value = name;
document.getElementById('edit-last-name').value = lastName; document.getElementById('edit-last-name').value = lastName;
var modal = new bootstrap.Modal(document.getElementById('editUserModal')); var modal = new bootstrap.Modal(document.getElementById('editUserModal'));
modal.show(); modal.show();
} }
@@ -345,8 +390,7 @@
function openResetPasswordModal(username) { function openResetPasswordModal(username) {
document.getElementById('reset-username').value = username; document.getElementById('reset-username').value = username;
document.getElementById('username-display').value = username; document.getElementById('username-display').value = username;
// Open modal using Bootstrap
var modal = new bootstrap.Modal(document.getElementById('resetPasswordModal')); var modal = new bootstrap.Modal(document.getElementById('resetPasswordModal'));
modal.show(); modal.show();
} }
+3 -2
View File
@@ -17,6 +17,7 @@ import re
import ipaddress import ipaddress
import Web.modules.database.settings as cfg import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient from Web.modules.database.settings import MongoClient
from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -325,7 +326,7 @@ def get_tenant_trial_status(tenant_id=None, now=None):
"expires_after_days", "ttl_days", or "days". "expires_after_days", "ttl_days", or "days".
""" """
trial_config = get_tenant_trial_config(tenant_id) trial_config = get_tenant_trial_config(tenant_id)
now = now or datetime.datetime.now() now = now or datetime.datetime.now(ZoneInfo("Europe/Berlin"))
enabled = bool(trial_config.get('enabled') or trial_config.get('active')) enabled = bool(trial_config.get('enabled') or trial_config.get('active'))
if not enabled: if not enabled:
@@ -447,7 +448,7 @@ def delete_tenant(tenant_id, *, drop_database=True, remove_from_config=True):
def purge_expired_trial_tenants(now=None): def purge_expired_trial_tenants(now=None):
"""Delete expired trial tenants that opted into auto-delete.""" """Delete expired trial tenants that opted into auto-delete."""
now = now or datetime.datetime.now() now = now or datetime.datetime.now(ZoneInfo("Europe/Berlin"))
purged_tenants = [] purged_tenants = []
for tenant_id in list(TENANT_REGISTRY.keys()): for tenant_id in list(TENANT_REGISTRY.keys()):