Implement notification system with user and admin views, including unread count and reminder functionality for damaged items
This commit is contained in:
+388
-1
@@ -436,17 +436,183 @@ def _prepare_invoice_pdf_payload(invoice_data, borrow_doc=None, item_doc=None):
|
||||
'amount_text': amount_text or '-',
|
||||
}
|
||||
|
||||
|
||||
def _create_notification(db, *, audience, notif_type, title, message, target_user=None, reference=None, unique_key=None, severity='info'):
|
||||
"""Create a notification entry with optional deduplication via unique_key."""
|
||||
notifications_col = db['notifications']
|
||||
|
||||
if unique_key:
|
||||
existing = notifications_col.find_one({'UniqueKey': unique_key}, {'_id': 1})
|
||||
if existing:
|
||||
return False
|
||||
|
||||
now = datetime.datetime.now()
|
||||
payload = {
|
||||
'Audience': audience,
|
||||
'Type': notif_type,
|
||||
'Title': title,
|
||||
'Message': message,
|
||||
'TargetUser': target_user,
|
||||
'Reference': reference or {},
|
||||
'UniqueKey': unique_key,
|
||||
'Severity': severity,
|
||||
'ReadBy': [],
|
||||
'CreatedAt': now,
|
||||
'UpdatedAt': now,
|
||||
}
|
||||
notifications_col.insert_one(payload)
|
||||
return True
|
||||
|
||||
|
||||
def _get_notifications_for_user(db, username, is_admin=False, limit=150):
|
||||
"""Fetch notifications visible to the current user, newest first."""
|
||||
query = {
|
||||
'$or': [
|
||||
{'Audience': 'user', 'TargetUser': username},
|
||||
]
|
||||
}
|
||||
if is_admin:
|
||||
query['$or'].append({'Audience': 'admin'})
|
||||
|
||||
cursor = db['notifications'].find(query).sort('CreatedAt', -1).limit(limit)
|
||||
return list(cursor)
|
||||
|
||||
|
||||
def _get_unread_notification_count(db, username, is_admin=False):
|
||||
"""Count unread notifications for navbar badge rendering."""
|
||||
query = {
|
||||
'$and': [
|
||||
{
|
||||
'$or': [
|
||||
{'Audience': 'user', 'TargetUser': username},
|
||||
]
|
||||
},
|
||||
{'ReadBy': {'$ne': username}},
|
||||
]
|
||||
}
|
||||
if is_admin:
|
||||
query['$and'][0]['$or'].append({'Audience': 'admin'})
|
||||
|
||||
return db['notifications'].count_documents(query)
|
||||
|
||||
|
||||
def _build_reminder_message(item_name, start_dt=None, end_dt=None):
|
||||
"""Build a concise reminder text for borrowed items."""
|
||||
start_text = start_dt.strftime('%d.%m.%Y %H:%M') if isinstance(start_dt, datetime.datetime) else '-'
|
||||
end_text = end_dt.strftime('%d.%m.%Y %H:%M') if isinstance(end_dt, datetime.datetime) else '-'
|
||||
return (
|
||||
f"Bitte denke an die Rueckgabe von '{item_name}'. "
|
||||
f"Ausleihe seit: {start_text}. "
|
||||
f"Geplantes Ende: {end_text}."
|
||||
)
|
||||
|
||||
|
||||
def create_return_reminders():
|
||||
"""Create one-time reminders for day-1 and planned-end events."""
|
||||
now = datetime.datetime.now()
|
||||
one_day_ago = now - datetime.timedelta(days=1)
|
||||
|
||||
client = None
|
||||
try:
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
db = client[MONGODB_DB]
|
||||
ausleihungen_col = db['ausleihungen']
|
||||
items_col = db['items']
|
||||
|
||||
candidates = list(ausleihungen_col.find(
|
||||
{
|
||||
'Status': {'$in': ['active', 'planned']},
|
||||
'User': {'$exists': True, '$ne': ''},
|
||||
'$or': [
|
||||
{'Start': {'$lte': one_day_ago}},
|
||||
{'End': {'$lte': now}},
|
||||
]
|
||||
},
|
||||
{'User': 1, 'Item': 1, 'Status': 1, 'Start': 1, 'End': 1}
|
||||
))
|
||||
|
||||
item_ids = []
|
||||
for entry in candidates:
|
||||
item_id = entry.get('Item')
|
||||
if item_id:
|
||||
item_ids.append(item_id)
|
||||
|
||||
item_docs = {}
|
||||
for raw_id in item_ids:
|
||||
try:
|
||||
doc = items_col.find_one({'_id': ObjectId(raw_id)}, {'Name': 1, 'Code_4': 1})
|
||||
if doc:
|
||||
item_docs[raw_id] = doc
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for borrow_doc in candidates:
|
||||
borrow_id = str(borrow_doc.get('_id'))
|
||||
username = str(borrow_doc.get('User', '')).strip()
|
||||
if not borrow_id or not username:
|
||||
continue
|
||||
|
||||
item_id = str(borrow_doc.get('Item', '')).strip()
|
||||
item_doc = item_docs.get(item_id, {})
|
||||
item_name = item_doc.get('Name') or f'Item {item_id}'
|
||||
|
||||
start_dt = borrow_doc.get('Start')
|
||||
end_dt = borrow_doc.get('End')
|
||||
|
||||
if isinstance(start_dt, datetime.datetime) and start_dt <= one_day_ago:
|
||||
_create_notification(
|
||||
db,
|
||||
audience='user',
|
||||
notif_type='return_day_1',
|
||||
title='Erinnerung: Rueckgabe nach 1 Tag',
|
||||
message=_build_reminder_message(item_name, start_dt=start_dt, end_dt=end_dt),
|
||||
target_user=username,
|
||||
reference={'borrow_id': borrow_id, 'item_id': item_id, 'event': 'day_1'},
|
||||
unique_key=f'reminder:day1:{borrow_id}',
|
||||
severity='warning',
|
||||
)
|
||||
|
||||
if isinstance(end_dt, datetime.datetime) and end_dt <= now:
|
||||
_create_notification(
|
||||
db,
|
||||
audience='user',
|
||||
notif_type='return_after_end',
|
||||
title='Erinnerung: Geplante Ausleihe ist beendet',
|
||||
message=_build_reminder_message(item_name, start_dt=start_dt, end_dt=end_dt),
|
||||
target_user=username,
|
||||
reference={'borrow_id': borrow_id, 'item_id': item_id, 'event': 'after_end'},
|
||||
unique_key=f'reminder:end:{borrow_id}',
|
||||
severity='warning',
|
||||
)
|
||||
except Exception as exc:
|
||||
app.logger.warning(f"Reminder creation failed: {exc}")
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
@app.context_processor
|
||||
def inject_version():
|
||||
"""Inject global template variables."""
|
||||
is_admin = False
|
||||
asset_version = _get_asset_version()
|
||||
unread_notification_count = 0
|
||||
if 'username' in session:
|
||||
try:
|
||||
is_admin = us.check_admin(session['username'])
|
||||
except Exception:
|
||||
is_admin = False
|
||||
|
||||
client = None
|
||||
try:
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
db = client[MONGODB_DB]
|
||||
unread_notification_count = _get_unread_notification_count(db, session['username'], is_admin=is_admin)
|
||||
except Exception:
|
||||
unread_notification_count = 0
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
current_module = _get_current_module(request.path)
|
||||
|
||||
return {
|
||||
@@ -456,7 +622,8 @@ def inject_version():
|
||||
'school_periods': SCHOOL_PERIODS,
|
||||
'library_module_enabled': cfg.LIBRARY_MODULE_ENABLED,
|
||||
'student_cards_module_enabled': cfg.STUDENT_CARDS_MODULE_ENABLED,
|
||||
'is_admin': is_admin
|
||||
'is_admin': is_admin,
|
||||
'unread_notification_count': unread_notification_count,
|
||||
}
|
||||
|
||||
# Create necessary directories at startup
|
||||
@@ -639,6 +806,7 @@ scheduler = BackgroundScheduler()
|
||||
if cfg.SCHEDULER_ENABLED:
|
||||
scheduler.add_job(func=create_daily_backup, trigger="interval", hours=cfg.BACKUP_INTERVAL_HOURS)
|
||||
scheduler.add_job(func=update_appointment_statuses, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN)
|
||||
scheduler.add_job(func=create_return_reminders, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN)
|
||||
scheduler.start()
|
||||
|
||||
# Register shutdown handler to stop scheduler when app is terminated
|
||||
@@ -4675,6 +4843,19 @@ def report_damage(id):
|
||||
updated_item = items_col.find_one({'_id': ObjectId(id)}, {'DamageReports': 1})
|
||||
damage_count = len(updated_item.get('DamageReports', [])) if updated_item else 0
|
||||
|
||||
_create_notification(
|
||||
db,
|
||||
audience='admin',
|
||||
notif_type='damage_reported',
|
||||
title='Defekt gemeldet',
|
||||
message=(
|
||||
f"Fuer das Item '{item_doc.get('Name', id)}' wurde ein Defekt gemeldet. "
|
||||
f"Meldung von {session.get('username', '-')}: {description}"
|
||||
),
|
||||
reference={'item_id': id, 'damage_count': damage_count},
|
||||
severity='danger',
|
||||
)
|
||||
|
||||
# Best-effort system log entry for auditability
|
||||
try:
|
||||
logs_collection = db['system_logs']
|
||||
@@ -7572,6 +7753,212 @@ def my_borrowed_items():
|
||||
user_is_admin=user_is_admin
|
||||
)
|
||||
|
||||
|
||||
@app.route('/notifications')
|
||||
def notifications_view():
|
||||
"""Notification center for users and admins."""
|
||||
if 'username' not in session:
|
||||
flash('Bitte melden Sie sich an, um Benachrichtigungen zu sehen.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
username = session['username']
|
||||
is_admin_user = False
|
||||
try:
|
||||
is_admin_user = us.check_admin(username)
|
||||
except Exception:
|
||||
is_admin_user = False
|
||||
|
||||
client = None
|
||||
try:
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
db = client[MONGODB_DB]
|
||||
notifications = _get_notifications_for_user(db, username, is_admin=is_admin_user, limit=200)
|
||||
|
||||
user_notifications = []
|
||||
admin_notifications = []
|
||||
for notif in notifications:
|
||||
is_read = username in (notif.get('ReadBy') or [])
|
||||
row = {
|
||||
'id': str(notif.get('_id')),
|
||||
'title': notif.get('Title', 'Benachrichtigung'),
|
||||
'message': notif.get('Message', ''),
|
||||
'severity': notif.get('Severity', 'info'),
|
||||
'type': notif.get('Type', ''),
|
||||
'created_at': notif.get('CreatedAt'),
|
||||
'is_read': is_read,
|
||||
'reference': notif.get('Reference', {}) or {},
|
||||
}
|
||||
if notif.get('Audience') == 'admin':
|
||||
admin_notifications.append(row)
|
||||
else:
|
||||
user_notifications.append(row)
|
||||
|
||||
return render_template(
|
||||
'notifications.html',
|
||||
user_notifications=user_notifications,
|
||||
admin_notifications=admin_notifications,
|
||||
is_admin_user=is_admin_user,
|
||||
library_module_enabled=cfg.LIBRARY_MODULE_ENABLED,
|
||||
student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED,
|
||||
)
|
||||
except Exception as exc:
|
||||
app.logger.error(f"Error loading notifications: {exc}")
|
||||
flash('Fehler beim Laden der Benachrichtigungen.', 'error')
|
||||
return redirect(url_for('home'))
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
|
||||
@app.route('/notifications/mark_read/<notification_id>', methods=['POST'])
|
||||
def mark_notification_read(notification_id):
|
||||
"""Mark a single notification as read for the current user."""
|
||||
if 'username' not in session:
|
||||
return redirect(url_for('login'))
|
||||
|
||||
username = session['username']
|
||||
client = None
|
||||
try:
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
db = client[MONGODB_DB]
|
||||
db['notifications'].update_one(
|
||||
{'_id': ObjectId(notification_id)},
|
||||
{
|
||||
'$addToSet': {'ReadBy': username},
|
||||
'$set': {'UpdatedAt': datetime.datetime.now()}
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
app.logger.warning(f"Could not mark notification as read {notification_id}: {exc}")
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
return redirect(url_for('notifications_view'))
|
||||
|
||||
|
||||
@app.route('/notifications/mark_all_read', methods=['POST'])
|
||||
def mark_all_notifications_read():
|
||||
"""Mark all visible notifications as read for the current user."""
|
||||
if 'username' not in session:
|
||||
return redirect(url_for('login'))
|
||||
|
||||
username = session['username']
|
||||
is_admin_user = False
|
||||
try:
|
||||
is_admin_user = us.check_admin(username)
|
||||
except Exception:
|
||||
is_admin_user = False
|
||||
|
||||
query = {
|
||||
'$or': [
|
||||
{'Audience': 'user', 'TargetUser': username},
|
||||
]
|
||||
}
|
||||
if is_admin_user:
|
||||
query['$or'].append({'Audience': 'admin'})
|
||||
|
||||
client = None
|
||||
try:
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
db = client[MONGODB_DB]
|
||||
db['notifications'].update_many(
|
||||
query,
|
||||
{
|
||||
'$addToSet': {'ReadBy': username},
|
||||
'$set': {'UpdatedAt': datetime.datetime.now()}
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
app.logger.warning(f"Could not mark all notifications as read for {username}: {exc}")
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
return redirect(url_for('notifications_view'))
|
||||
|
||||
|
||||
@app.route('/admin/damaged_items')
|
||||
def admin_damaged_items():
|
||||
"""Dedicated admin management window for damaged items."""
|
||||
if 'username' not in session or not us.check_admin(session['username']):
|
||||
flash('Administratorrechte erforderlich.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
client = None
|
||||
try:
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
db = client[MONGODB_DB]
|
||||
items_col = db['items']
|
||||
ausleihungen_col = db['ausleihungen']
|
||||
|
||||
items = list(items_col.find(
|
||||
{
|
||||
'Deleted': {'$ne': True},
|
||||
'$or': [
|
||||
{'HasDamage': True},
|
||||
{'Condition': 'destroyed'},
|
||||
{'DamageReports.0': {'$exists': True}},
|
||||
]
|
||||
},
|
||||
{
|
||||
'Name': 1,
|
||||
'Code_4': 1,
|
||||
'ItemType': 1,
|
||||
'Author': 1,
|
||||
'ISBN': 1,
|
||||
'Condition': 1,
|
||||
'DamageReports': 1,
|
||||
'DamageRepairs': 1,
|
||||
'Verfuegbar': 1,
|
||||
'User': 1,
|
||||
'LastUpdated': 1,
|
||||
}
|
||||
).sort('LastUpdated', -1))
|
||||
|
||||
damaged_rows = []
|
||||
for item_doc in items:
|
||||
item_id = str(item_doc.get('_id'))
|
||||
active_borrow = ausleihungen_col.find_one(
|
||||
{'Item': item_id, 'Status': {'$in': ['active', 'planned']}},
|
||||
{'_id': 1, 'User': 1, 'Status': 1, 'End': 1}
|
||||
)
|
||||
reports = item_doc.get('DamageReports', []) or []
|
||||
latest_report = reports[0] if reports else {}
|
||||
|
||||
damaged_rows.append({
|
||||
'id': item_id,
|
||||
'name': item_doc.get('Name', ''),
|
||||
'code': item_doc.get('Code_4', ''),
|
||||
'item_type': item_doc.get('ItemType', ''),
|
||||
'author': item_doc.get('Author', ''),
|
||||
'isbn': item_doc.get('ISBN', ''),
|
||||
'condition': item_doc.get('Condition', ''),
|
||||
'available': bool(item_doc.get('Verfuegbar', False)),
|
||||
'borrow_user': item_doc.get('User', ''),
|
||||
'damage_count': len(reports),
|
||||
'damage_reports': reports,
|
||||
'latest_damage_description': latest_report.get('description', ''),
|
||||
'latest_damage_by': latest_report.get('reported_by', ''),
|
||||
'latest_damage_at': latest_report.get('reported_at'),
|
||||
'active_borrow': active_borrow,
|
||||
'last_updated': item_doc.get('LastUpdated'),
|
||||
})
|
||||
|
||||
return render_template(
|
||||
'admin_damaged_items.html',
|
||||
damaged_items=damaged_rows,
|
||||
library_module_enabled=cfg.LIBRARY_MODULE_ENABLED,
|
||||
student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED,
|
||||
)
|
||||
except Exception as exc:
|
||||
app.logger.error(f"Error loading damaged-items admin view: {exc}")
|
||||
flash('Fehler beim Laden der Defekte-Items-Verwaltung.', 'error')
|
||||
return redirect(url_for('home_admin'))
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
@app.route('/favicon.ico')
|
||||
def favicon():
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Defekte Items verwalten{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="max-width: 1250px; margin: 18px auto 32px;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap; margin-bottom:16px;">
|
||||
<div>
|
||||
<h1 style="margin:0;">Defekte Items</h1>
|
||||
<p style="margin:6px 0 0; color:#64748b;">Eigenes Verwaltungsfenster fuer gemeldete Defekte mit schneller Reparatur-Funktion.</p>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
||||
<a class="btn btn-outline-secondary" href="{{ url_for('admin_borrowings') }}">Ausleihen</a>
|
||||
{% if library_module_enabled %}
|
||||
<a class="btn btn-outline-secondary" href="{{ url_for('library_loans_admin') }}">Bibliothek</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:14px; margin-bottom:12px;">
|
||||
<input id="damage-search" type="text" placeholder="Suche nach Name, Code, Typ, Benutzer oder Schaden..." style="width:100%; padding:10px 12px; border:1px solid #d1d5db; border-radius:10px;">
|
||||
</div>
|
||||
|
||||
<div style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:0; overflow:hidden;">
|
||||
<table id="damage-table" style="width:100%; border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr style="background:#f8fafc;">
|
||||
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Item</th>
|
||||
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Status</th>
|
||||
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Letzte Meldung</th>
|
||||
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Ausleihe</th>
|
||||
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in damaged_items %}
|
||||
<tr class="damage-row" data-search="{{ (item.name ~ ' ' ~ item.code ~ ' ' ~ item.item_type ~ ' ' ~ item.borrow_user ~ ' ' ~ item.latest_damage_description)|lower }}">
|
||||
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
|
||||
<div style="font-weight:700;">{{ item.name }}</div>
|
||||
<div style="font-size:0.86rem; color:#64748b;">{{ item.code or '-' }} | {{ item.item_type or '-' }}</div>
|
||||
{% if item.author %}<div style="font-size:0.82rem; color:#64748b;">{{ item.author }}</div>{% endif %}
|
||||
{% if item.isbn %}<div style="font-size:0.82rem; color:#64748b;">ISBN: {{ item.isbn }}</div>{% endif %}
|
||||
</td>
|
||||
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
|
||||
<div style="display:inline-block; padding:3px 9px; border-radius:999px; background:#fee2e2; color:#991b1b; font-size:0.75rem; font-weight:700;">Defekt</div>
|
||||
<div style="font-size:0.84rem; color:#475569; margin-top:6px;">Meldungen: {{ item.damage_count }}</div>
|
||||
<div style="font-size:0.84rem; color:#475569;">Condition: {{ item.condition or '-' }}</div>
|
||||
<div style="font-size:0.84rem; color:#475569;">Verfuegbar: {{ 'Ja' if item.available else 'Nein' }}</div>
|
||||
</td>
|
||||
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
|
||||
<div style="font-size:0.9rem; color:#1f2937;">{{ item.latest_damage_description or '-' }}</div>
|
||||
<div style="font-size:0.82rem; color:#64748b; margin-top:4px;">
|
||||
Gemeldet von {{ item.latest_damage_by or '-' }}
|
||||
{% if item.latest_damage_at %} am {{ item.latest_damage_at.strftime('%d.%m.%Y %H:%M') }}{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
|
||||
{% if item.active_borrow %}
|
||||
<div style="display:inline-block; padding:3px 9px; border-radius:999px; background:#dbeafe; color:#1d4ed8; font-size:0.75rem; font-weight:700;">Aktiv/Geplant</div>
|
||||
<div style="font-size:0.84rem; color:#475569; margin-top:6px;">User: {{ item.active_borrow.User or item.borrow_user or '-' }}</div>
|
||||
{% if item.active_borrow.End %}
|
||||
<div style="font-size:0.84rem; color:#475569;">Ende: {{ item.active_borrow.End.strftime('%d.%m.%Y %H:%M') }}</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div style="display:inline-block; padding:3px 9px; border-radius:999px; background:#dcfce7; color:#166534; font-size:0.75rem; font-weight:700;">Keine offene Ausleihe</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
|
||||
<button class="btn btn-success btn-sm" onclick="repairDamage('{{ item.id }}', this)">Als repariert markieren</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if not damaged_items %}
|
||||
<div style="padding:24px; color:#64748b; text-align:center;">Keine defekten Items vorhanden.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const searchInput = document.getElementById('damage-search');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', function() {
|
||||
const q = (searchInput.value || '').toLowerCase();
|
||||
document.querySelectorAll('.damage-row').forEach(function(row) {
|
||||
const text = row.getAttribute('data-search') || '';
|
||||
row.style.display = text.includes(q) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
function repairDamage(itemId, button) {
|
||||
if (!itemId) return;
|
||||
if (!confirm('Alle offenen Defektmeldungen fuer dieses Item als repariert markieren?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
button.disabled = true;
|
||||
fetch('/mark_damage_repaired/' + itemId, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || 'Reparatur fehlgeschlagen');
|
||||
}
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function(err) {
|
||||
button.disabled = false;
|
||||
alert(err.message || 'Fehler beim Markieren als repariert.');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -410,6 +410,14 @@
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown">
|
||||
{% if 'username' in session %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('notifications_view') }}">
|
||||
Benachrichtigungen
|
||||
{% if unread_notification_count and unread_notification_count > 0 %}
|
||||
<span class="notif-badge">{{ unread_notification_count }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
{% endif %}
|
||||
@@ -418,6 +426,7 @@
|
||||
<li><a class="dropdown-item" href="{{ url_for('manage_filters') }}">Filter verwalten</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('manage_locations') }}">Orte verwalten</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin_borrowings') }}">Ausleihen</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin_audit_dashboard') }}">Audit Dashboard</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('logs') }}">Logs</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
@@ -476,12 +485,21 @@
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown">
|
||||
{% if 'username' in session %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('notifications_view') }}">
|
||||
Benachrichtigungen
|
||||
{% if unread_notification_count and unread_notification_count > 0 %}
|
||||
<span class="notif-badge">{{ unread_notification_count }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
{% endif %}
|
||||
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
|
||||
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li>
|
||||
{% if student_cards_module_enabled %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li>
|
||||
{% endif %}
|
||||
@@ -597,6 +615,22 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.notif-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
padding: 0 6px;
|
||||
margin-left: 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
<div id="cookie-banner" role="dialog" aria-live="polite" aria-label="Cookie-Hinweis">
|
||||
<div class="cb-inner">
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Benachrichtigungen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="max-width: 1080px; margin: 18px auto 32px;">
|
||||
<div style="display:flex; justify-content:space-between; gap:12px; align-items:center; margin-bottom:16px; flex-wrap:wrap;">
|
||||
<div>
|
||||
<h1 style="margin:0;">Benachrichtigungen</h1>
|
||||
<p style="margin:6px 0 0; color:#64748b;">Rueckgabe-Erinnerungen und wichtige Hinweise fuer Benutzer und Verwaltung.</p>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('mark_all_notifications_read') }}">
|
||||
<button class="btn btn-outline-secondary" type="submit">Alle als gelesen markieren</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns:1fr; gap:14px;">
|
||||
<section style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:16px;">
|
||||
<h2 style="margin:0 0 12px; font-size:1.15rem;">Meine Benachrichtigungen</h2>
|
||||
{% if user_notifications %}
|
||||
<div style="display:grid; gap:10px;">
|
||||
{% for n in user_notifications %}
|
||||
<article style="border:1px solid #e5e7eb; border-left:5px solid {% if n.severity == 'danger' %}#dc2626{% elif n.severity == 'warning' %}#d97706{% else %}#2563eb{% endif %}; border-radius:10px; padding:12px 12px 10px; background:{% if n.is_read %}#f8fafc{% else %}#ffffff{% endif %};">
|
||||
<div style="display:flex; justify-content:space-between; gap:10px; align-items:flex-start; flex-wrap:wrap;">
|
||||
<div>
|
||||
<div style="font-weight:800; color:#0f172a;">{{ n.title }}</div>
|
||||
<div style="font-size:0.9rem; color:#475569; margin-top:2px;">{{ n.message }}</div>
|
||||
<div style="font-size:0.78rem; color:#64748b; margin-top:6px;">
|
||||
{% if n.created_at %}{{ n.created_at.strftime('%d.%m.%Y %H:%M') }}{% else %}-{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if not n.is_read %}
|
||||
<form method="post" action="{{ url_for('mark_notification_read', notification_id=n.id) }}">
|
||||
<button class="btn btn-sm btn-primary" type="submit">Als gelesen</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span style="font-size:0.8rem; color:#16a34a; font-weight:700;">Gelesen</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color:#64748b; margin:0;">Keine Benachrichtigungen vorhanden.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if is_admin_user %}
|
||||
<section style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:16px;">
|
||||
<h2 style="margin:0 0 12px; font-size:1.15rem;">Admin-Benachrichtigungen</h2>
|
||||
{% if admin_notifications %}
|
||||
<div style="display:grid; gap:10px;">
|
||||
{% for n in admin_notifications %}
|
||||
<article style="border:1px solid #e5e7eb; border-left:5px solid {% if n.severity == 'danger' %}#dc2626{% elif n.severity == 'warning' %}#d97706{% else %}#2563eb{% endif %}; border-radius:10px; padding:12px 12px 10px; background:{% if n.is_read %}#f8fafc{% else %}#ffffff{% endif %};">
|
||||
<div style="display:flex; justify-content:space-between; gap:10px; align-items:flex-start; flex-wrap:wrap;">
|
||||
<div>
|
||||
<div style="font-weight:800; color:#0f172a;">{{ n.title }}</div>
|
||||
<div style="font-size:0.9rem; color:#475569; margin-top:2px;">{{ n.message }}</div>
|
||||
<div style="font-size:0.78rem; color:#64748b; margin-top:6px;">
|
||||
{% if n.created_at %}{{ n.created_at.strftime('%d.%m.%Y %H:%M') }}{% else %}-{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if not n.is_read %}
|
||||
<form method="post" action="{{ url_for('mark_notification_read', notification_id=n.id) }}">
|
||||
<button class="btn btn-sm btn-primary" type="submit">Als gelesen</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span style="font-size:0.8rem; color:#16a34a; font-weight:700;">Gelesen</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color:#64748b; margin:0;">Keine Admin-Benachrichtigungen vorhanden.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user