Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5afcaa5e19 | |||
| 3aa44b64b8 | |||
| 86d8f19313 | |||
| e74798f252 | |||
| 31c3d700a8 | |||
| a3c114e4b5 | |||
| 170a2a53ab |
+1
-1
@@ -32,4 +32,4 @@ RUN if [ "$NUITKA_BUILD" = "1" ]; then \
|
||||
WORKDIR /app/Web
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "60", "--graceful-timeout", "20", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||
|
||||
+2
-1
@@ -6,9 +6,10 @@ The latest version will allways be supported the rest are old version that are n
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 0.2.17 | ✅ |
|
||||
| 3.2.x | :white_check_mark: |
|
||||
| 3.1.x | :x: |
|
||||
| 3.0.x | :white_check_mark: |
|
||||
| 3.0.x | :x: |
|
||||
| 2.6.x | :x: |
|
||||
| 2.4.x | :x: |
|
||||
| 1.8.x | :x: |
|
||||
|
||||
+460
-7
@@ -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
|
||||
@@ -2282,7 +2450,7 @@ def library_admin():
|
||||
@app.route('/student_cards_admin', methods=['GET', 'POST'])
|
||||
def student_cards_admin():
|
||||
"""
|
||||
Admin page for managing student library cards (Schülerausweise).
|
||||
Admin page for managing student library cards (Bibliotheksausweis).
|
||||
Only accessible by admins and only when the student cards module is enabled.
|
||||
"""
|
||||
if 'username' not in session:
|
||||
@@ -2410,7 +2578,7 @@ def student_cards_admin():
|
||||
@app.route('/student_cards_print', methods=['GET'])
|
||||
def student_cards_print():
|
||||
"""
|
||||
Generate a printable template for all student library cards (Schülerausweise).
|
||||
Generate a printable template for all student library cards (Bibliotheksausweis).
|
||||
Only accessible by admins and only when the student cards module is enabled.
|
||||
"""
|
||||
if 'username' not in session:
|
||||
@@ -2967,14 +3135,12 @@ def get_items():
|
||||
offset = 0
|
||||
limit = None
|
||||
|
||||
# Light mode: minimal fields for faster initial load (auto-enabled on offset=0 unless explicitly disabled)
|
||||
# Can be manually controlled via ?light_mode=true/false parameter
|
||||
if light_mode_param in ('1', 'true', 'yes', 'on'):
|
||||
light_mode = True
|
||||
elif light_mode_param in ('0', 'false', 'no', 'off'):
|
||||
light_mode = False
|
||||
else:
|
||||
light_mode = (offset == 0) # Auto-enable light mode for first page
|
||||
light_mode = (offset == 0)
|
||||
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
db = client[MONGODB_DB]
|
||||
@@ -2987,7 +3153,6 @@ def get_items():
|
||||
|
||||
total_count = items_col.count_documents(base_query)
|
||||
|
||||
# Light projection: essentials only for fast initial render
|
||||
light_projection = {
|
||||
'Name': 1,
|
||||
'Code_4': 1,
|
||||
@@ -4678,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']
|
||||
@@ -7575,6 +7753,281 @@ 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('/notifications/unread_status', methods=['GET'])
|
||||
def notifications_unread_status():
|
||||
"""Return unread notification count and latest unread message metadata."""
|
||||
if 'username' not in session:
|
||||
return jsonify({'ok': False, 'error': 'not_authenticated'}), 401
|
||||
|
||||
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]
|
||||
|
||||
visibility_query = {
|
||||
'$or': [
|
||||
{'Audience': 'user', 'TargetUser': username},
|
||||
]
|
||||
}
|
||||
if is_admin_user:
|
||||
visibility_query['$or'].append({'Audience': 'admin'})
|
||||
|
||||
unread_query = {
|
||||
'$and': [
|
||||
visibility_query,
|
||||
{'ReadBy': {'$ne': username}},
|
||||
]
|
||||
}
|
||||
|
||||
unread_count = db['notifications'].count_documents(unread_query)
|
||||
latest_unread = db['notifications'].find_one(
|
||||
unread_query,
|
||||
{
|
||||
'Title': 1,
|
||||
'Message': 1,
|
||||
'CreatedAt': 1,
|
||||
'Type': 1,
|
||||
'Severity': 1,
|
||||
},
|
||||
sort=[('CreatedAt', -1)]
|
||||
)
|
||||
|
||||
latest_payload = None
|
||||
if latest_unread:
|
||||
latest_payload = {
|
||||
'title': latest_unread.get('Title', 'Benachrichtigung'),
|
||||
'message': latest_unread.get('Message', ''),
|
||||
'created_at': latest_unread.get('CreatedAt').isoformat() if isinstance(latest_unread.get('CreatedAt'), datetime.datetime) else '',
|
||||
'type': latest_unread.get('Type', ''),
|
||||
'severity': latest_unread.get('Severity', 'info'),
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'ok': True,
|
||||
'unread_count': unread_count,
|
||||
'latest_unread': latest_payload,
|
||||
})
|
||||
except Exception as exc:
|
||||
app.logger.warning(f"Could not fetch unread notification status for {username}: {exc}")
|
||||
return jsonify({'ok': False, 'error': 'status_fetch_failed'}), 500
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
|
||||
@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 %}
|
||||
+188
-5
@@ -418,6 +418,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>
|
||||
@@ -435,11 +436,14 @@
|
||||
<div class="d-flex">
|
||||
{% if 'username' in session %}
|
||||
<span class="navbar-text text-light me-3">{{ session['username'] }}</span>
|
||||
<div class="dropdown me-2">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" id="invUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<div class="dropdown me-2 user-menu-wrap">
|
||||
<button class="btn btn-secondary dropdown-toggle user-menu-btn" type="button" id="invUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false" data-notification-button="true">
|
||||
👤
|
||||
<span class="user-notification-dot {% if unread_notification_count and unread_notification_count > 0 %}visible{% endif %}" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invUserMenuDropdown">
|
||||
<li><a class="dropdown-item" href="{{ url_for('notifications_view') }}">Benachrichtigungen</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
|
||||
@@ -482,8 +486,9 @@
|
||||
{% 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') }}">Schülerausweise</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li>
|
||||
{% endif %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><h6 class="dropdown-header">System</h6></li>
|
||||
@@ -500,11 +505,14 @@
|
||||
<div class="d-flex">
|
||||
{% if 'username' in session %}
|
||||
<span class="navbar-text text-light me-3">{{ session['username'] }}</span>
|
||||
<div class="dropdown me-2">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" id="libUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<div class="dropdown me-2 user-menu-wrap">
|
||||
<button class="btn btn-secondary dropdown-toggle user-menu-btn" type="button" id="libUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false" data-notification-button="true">
|
||||
👤
|
||||
<span class="user-notification-dot {% if unread_notification_count and unread_notification_count > 0 %}visible{% endif %}" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libUserMenuDropdown">
|
||||
<li><a class="dropdown-item" href="{{ url_for('notifications_view') }}">Benachrichtigungen</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
|
||||
@@ -597,6 +605,73 @@
|
||||
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;
|
||||
}
|
||||
|
||||
.user-menu-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-menu-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-notification-dot {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 10px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #2563eb;
|
||||
border: 2px solid #ffffff;
|
||||
box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.35);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-notification-dot.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.notification-toast {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 18px;
|
||||
z-index: 2300;
|
||||
max-width: 360px;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
box-shadow: 0 18px 35px rgba(2, 6, 23, 0.4);
|
||||
padding: 12px 14px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.35;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.notification-toast strong {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.notification-toast.show {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
<div id="cookie-banner" role="dialog" aria-live="polite" aria-label="Cookie-Hinweis">
|
||||
<div class="cb-inner">
|
||||
@@ -624,6 +699,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="notification-toast" class="notification-toast" role="status" aria-live="polite">
|
||||
<strong id="notification-toast-title">Neue Benachrichtigung</strong>
|
||||
<span id="notification-toast-message"></span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
function getCookie(name){
|
||||
@@ -657,8 +737,111 @@
|
||||
const username = {{ (session['username'] if 'username' in session else '')|tojson }};
|
||||
const isTutorialPage = window.location.pathname === {{ url_for('tutorial_page')|tojson }};
|
||||
const isLoginPage = window.location.pathname === {{ url_for('login')|tojson }};
|
||||
const notificationsPagePath = {{ url_for('notifications_view')|tojson }};
|
||||
const onboardingKey = username ? ('inventarsystem_tutorial_prompt_v1_' + username) : null;
|
||||
const onboardingOverlay = document.getElementById('onboarding-overlay');
|
||||
const notificationButtons = Array.from(document.querySelectorAll('[data-notification-button="true"]'));
|
||||
const notificationToast = document.getElementById('notification-toast');
|
||||
const notificationToastTitle = document.getElementById('notification-toast-title');
|
||||
const notificationToastMessage = document.getElementById('notification-toast-message');
|
||||
let lastUnreadCount = Number({{ unread_notification_count|default(0)|int }});
|
||||
|
||||
function updateNotificationDots(unreadCount) {
|
||||
const hasUnread = Number(unreadCount) > 0;
|
||||
notificationButtons.forEach(function(btn) {
|
||||
const dot = btn.querySelector('.user-notification-dot');
|
||||
if (!dot) {
|
||||
return;
|
||||
}
|
||||
if (hasUnread) {
|
||||
dot.classList.add('visible');
|
||||
} else {
|
||||
dot.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showInAppNotification(title, message) {
|
||||
if (!notificationToast || !notificationToastTitle || !notificationToastMessage) {
|
||||
return;
|
||||
}
|
||||
notificationToastTitle.textContent = title || 'Neue Benachrichtigung';
|
||||
notificationToastMessage.textContent = message || '';
|
||||
notificationToast.classList.add('show');
|
||||
window.setTimeout(function() {
|
||||
notificationToast.classList.remove('show');
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function showBrowserNotification(title, message) {
|
||||
if (!('Notification' in window)) {
|
||||
showInAppNotification(title, message);
|
||||
return;
|
||||
}
|
||||
if (Notification.permission === 'granted') {
|
||||
new Notification(title || 'Neue Benachrichtigung', {
|
||||
body: message || '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (Notification.permission === 'default') {
|
||||
Notification.requestPermission().then(function(permission) {
|
||||
if (permission === 'granted') {
|
||||
new Notification(title || 'Neue Benachrichtigung', {
|
||||
body: message || '',
|
||||
});
|
||||
} else {
|
||||
showInAppNotification(title, message);
|
||||
}
|
||||
}).catch(function() {
|
||||
showInAppNotification(title, message);
|
||||
});
|
||||
return;
|
||||
}
|
||||
showInAppNotification(title, message);
|
||||
}
|
||||
|
||||
function pollNotificationStatus() {
|
||||
if (!username) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/notifications/unread_status', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(function(response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('status request failed');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (!data || !data.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unreadCount = Number(data.unread_count || 0);
|
||||
updateNotificationDots(unreadCount);
|
||||
|
||||
if (unreadCount > lastUnreadCount && window.location.pathname !== notificationsPagePath) {
|
||||
const latest = data.latest_unread || {};
|
||||
showBrowserNotification(latest.title || 'Neue Nachricht', latest.message || 'Es gibt neue Benachrichtigungen.');
|
||||
}
|
||||
|
||||
lastUnreadCount = unreadCount;
|
||||
})
|
||||
.catch(function() {
|
||||
// Silent fail; polling retries automatically.
|
||||
});
|
||||
}
|
||||
|
||||
updateNotificationDots(lastUnreadCount);
|
||||
if (username) {
|
||||
window.setInterval(pollNotificationStatus, 30000);
|
||||
}
|
||||
|
||||
function showOnboarding(){
|
||||
if (onboardingOverlay) {
|
||||
|
||||
@@ -415,13 +415,17 @@
|
||||
}
|
||||
|
||||
.bulk-delete-row {
|
||||
display: flex;
|
||||
display: none;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bulk-delete-mode-active .bulk-delete-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.bulk-delete-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -3056,6 +3060,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
bulkDeleteDrawerOpen = Boolean(open);
|
||||
const drawer = document.getElementById('bulk-delete-drawer');
|
||||
const toggle = document.getElementById('bulk-delete-drawer-toggle');
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
if (drawer) {
|
||||
drawer.classList.toggle('open', bulkDeleteDrawerOpen);
|
||||
drawer.setAttribute('aria-hidden', bulkDeleteDrawerOpen ? 'false' : 'true');
|
||||
@@ -3065,6 +3070,10 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
toggle.innerHTML = bulkDeleteDrawerOpen ? '<span class="drawer-icon">✕</span>' : '<span class="drawer-icon">⚙</span>';
|
||||
toggle.title = bulkDeleteDrawerOpen ? 'Massenlöschung schließen' : 'Massenlöschung öffnen';
|
||||
}
|
||||
// Toggle checkbox visibility: show checkboxes only when bulk delete mode is active
|
||||
if (itemsContainer) {
|
||||
itemsContainer.classList.toggle('bulk-delete-mode-active', bulkDeleteDrawerOpen);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBulkDeleteDrawer() {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
{% 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>
|
||||
{% if n.type == 'damage_reported' %}
|
||||
<div style="margin-top:8px;">
|
||||
<a class="btn btn-sm btn-outline-danger" href="{{ url_for('admin_damaged_items') }}">Zu Defekte-Items Verwaltung</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<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 %}
|
||||
@@ -124,12 +124,12 @@
|
||||
<div class="container">
|
||||
<div class="icon">📇</div>
|
||||
<h1>Schülerausweis-Download</h1>
|
||||
<p>Generieren Sie eine PDF mit Barcodes aller Schülerausweise zum direkten Drucken</p>
|
||||
<p>Generieren Sie eine PDF mit Barcodes aller Bibliotheksausweise zum direkten Drucken</p>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>📌 Was wird heruntergeladen?</strong>
|
||||
<p>Eine druckfertige PDF mit:</p>
|
||||
<p>✓ Alle Schülerausweise</p>
|
||||
<p>✓ Alle Bibliotheksausweise</p>
|
||||
<p>✓ Scanbare CODE128 Barcodes</p>
|
||||
<p>✓ Optimiert für A4-Druck (2 Karten pro Seite)</p>
|
||||
<p>✓ Professionelle Qualität</p>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Schülerausweise - Inventarsystem{% endblock %}
|
||||
{% block title %}Bibliotheksausweise - Inventarsystem{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
@@ -229,7 +229,7 @@
|
||||
<div class="container">
|
||||
<div class="student-card-header">
|
||||
<div>
|
||||
<h1>📚 Schülerausweise (Bibliotek)</h1>
|
||||
<h1>📚 Bibliotheksausweise (Bibliotek)</h1>
|
||||
</div>
|
||||
<div class="export-buttons">
|
||||
<a href="{{ url_for('student_card_barcode_download') }}" class="btn-print" style="background: #28a745;">📥 Alle Ausweise (PDF)</a>
|
||||
@@ -342,7 +342,7 @@
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>Keine Schülerausweise registriert.</p>
|
||||
<p>Keine Bibliotheksausweise registriert.</p>
|
||||
<p>Fügen Sie ein neues Ausweis oben hinzu.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user