"Refactor global refresh functions and templates for better performance (feat)"
This commit is contained in:
parent
e338c5835e
commit
a473c06658
@ -5102,4 +5102,548 @@ def refresh_dashboard():
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': 'Fehler beim Aktualisieren der Dashboard-Daten'
|
'error': 'Fehler beim Aktualisieren der Dashboard-Daten'
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
# ===== ADMIN GASTAUFTRÄGE API-ENDPUNKTE =====
|
||||||
|
|
||||||
|
@app.route('/api/admin/guest-requests', methods=['GET'])
|
||||||
|
@admin_required
|
||||||
|
def get_admin_guest_requests():
|
||||||
|
"""Gibt alle Gastaufträge für Admin-Verwaltung zurück"""
|
||||||
|
try:
|
||||||
|
db_session = get_db_session()
|
||||||
|
|
||||||
|
# Parameter auslesen
|
||||||
|
status = request.args.get('status', 'all')
|
||||||
|
limit = int(request.args.get('limit', 50))
|
||||||
|
offset = int(request.args.get('offset', 0))
|
||||||
|
search = request.args.get('search', '')
|
||||||
|
|
||||||
|
# Basis-Query
|
||||||
|
query = db_session.query(GuestRequest)
|
||||||
|
|
||||||
|
# Status-Filter
|
||||||
|
if status != 'all':
|
||||||
|
query = query.filter(GuestRequest.status == status)
|
||||||
|
|
||||||
|
# Suchfilter
|
||||||
|
if search:
|
||||||
|
search_term = f"%{search}%"
|
||||||
|
query = query.filter(
|
||||||
|
(GuestRequest.name.ilike(search_term)) |
|
||||||
|
(GuestRequest.email.ilike(search_term)) |
|
||||||
|
(GuestRequest.file_name.ilike(search_term)) |
|
||||||
|
(GuestRequest.reason.ilike(search_term))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Gesamtanzahl vor Pagination
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
# Sortierung und Pagination
|
||||||
|
requests = query.order_by(GuestRequest.created_at.desc()).offset(offset).limit(limit).all()
|
||||||
|
|
||||||
|
# Statistiken berechnen
|
||||||
|
stats = {
|
||||||
|
'total': db_session.query(GuestRequest).count(),
|
||||||
|
'pending': db_session.query(GuestRequest).filter(GuestRequest.status == 'pending').count(),
|
||||||
|
'approved': db_session.query(GuestRequest).filter(GuestRequest.status == 'approved').count(),
|
||||||
|
'rejected': db_session.query(GuestRequest).filter(GuestRequest.status == 'rejected').count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Requests zu Dictionary konvertieren
|
||||||
|
requests_data = []
|
||||||
|
for req in requests:
|
||||||
|
# Priorität berechnen
|
||||||
|
now = datetime.now()
|
||||||
|
hours_old = (now - req.created_at).total_seconds() / 3600 if req.created_at else 0
|
||||||
|
is_urgent = hours_old > 24 and req.status == 'pending'
|
||||||
|
|
||||||
|
request_data = {
|
||||||
|
'id': req.id,
|
||||||
|
'name': req.name,
|
||||||
|
'email': req.email,
|
||||||
|
'file_name': req.file_name,
|
||||||
|
'file_path': req.file_path,
|
||||||
|
'duration_minutes': req.duration_minutes,
|
||||||
|
'copies': req.copies,
|
||||||
|
'reason': req.reason,
|
||||||
|
'status': req.status,
|
||||||
|
'created_at': req.created_at.isoformat() if req.created_at else None,
|
||||||
|
'updated_at': req.updated_at.isoformat() if req.updated_at else None,
|
||||||
|
'approved_at': req.approved_at.isoformat() if req.approved_at else None,
|
||||||
|
'rejected_at': req.rejected_at.isoformat() if req.rejected_at else None,
|
||||||
|
'approval_notes': req.approval_notes,
|
||||||
|
'rejection_reason': req.rejection_reason,
|
||||||
|
'is_urgent': is_urgent,
|
||||||
|
'hours_old': round(hours_old, 1)
|
||||||
|
}
|
||||||
|
requests_data.append(request_data)
|
||||||
|
|
||||||
|
db_session.close()
|
||||||
|
|
||||||
|
app_logger.info(f"Admin-Gastaufträge geladen: {len(requests_data)} von {total} (Status: {status})")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'requests': requests_data,
|
||||||
|
'stats': stats,
|
||||||
|
'total': total,
|
||||||
|
'offset': offset,
|
||||||
|
'limit': limit,
|
||||||
|
'has_more': offset + limit < total
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.error(f"Fehler beim Laden der Admin-Gastaufträge: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': f'Fehler beim Laden der Gastaufträge: {str(e)}'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@app.route('/api/guest-requests/<int:request_id>/approve', methods=['POST'])
|
||||||
|
@admin_required
|
||||||
|
def approve_guest_request(request_id):
|
||||||
|
"""Genehmigt einen Gastauftrag"""
|
||||||
|
try:
|
||||||
|
db_session = get_db_session()
|
||||||
|
|
||||||
|
guest_request = db_session.query(GuestRequest).filter(GuestRequest.id == request_id).first()
|
||||||
|
|
||||||
|
if not guest_request:
|
||||||
|
db_session.close()
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': 'Gastauftrag nicht gefunden'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
if guest_request.status != 'pending':
|
||||||
|
db_session.close()
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': f'Gastauftrag kann im Status "{guest_request.status}" nicht genehmigt werden'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Daten aus Request Body
|
||||||
|
data = request.get_json() or {}
|
||||||
|
notes = data.get('notes', '')
|
||||||
|
printer_id = data.get('printer_id')
|
||||||
|
|
||||||
|
# Status aktualisieren
|
||||||
|
guest_request.status = 'approved'
|
||||||
|
guest_request.approved_at = datetime.now()
|
||||||
|
guest_request.approved_by = current_user.id
|
||||||
|
guest_request.approval_notes = notes
|
||||||
|
guest_request.updated_at = datetime.now()
|
||||||
|
|
||||||
|
# Falls Drucker zugewiesen werden soll
|
||||||
|
if printer_id:
|
||||||
|
printer = db_session.query(Printer).filter(Printer.id == printer_id).first()
|
||||||
|
if printer:
|
||||||
|
guest_request.assigned_printer_id = printer_id
|
||||||
|
|
||||||
|
# OTP-Code generieren für den Gast
|
||||||
|
import secrets
|
||||||
|
otp_code = ''.join([str(secrets.randbelow(10)) for _ in range(6)])
|
||||||
|
guest_request.otp_code = otp_code
|
||||||
|
guest_request.otp_expires_at = datetime.now() + timedelta(hours=24)
|
||||||
|
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# Benachrichtigung an den Gast senden (falls E-Mail verfügbar)
|
||||||
|
if guest_request.email:
|
||||||
|
try:
|
||||||
|
# Hier würde normalerweise eine E-Mail gesendet werden
|
||||||
|
app_logger.info(f"E-Mail-Benachrichtigung würde an {guest_request.email} gesendet (OTP: {otp_code})")
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.warning(f"Fehler beim Senden der E-Mail-Benachrichtigung: {str(e)}")
|
||||||
|
|
||||||
|
db_session.close()
|
||||||
|
|
||||||
|
app_logger.info(f"Gastauftrag {request_id} von Admin {current_user.id} genehmigt (OTP: {otp_code})")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Gastauftrag erfolgreich genehmigt',
|
||||||
|
'otp_code': otp_code,
|
||||||
|
'expires_at': (datetime.now() + timedelta(hours=24)).isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.error(f"Fehler beim Genehmigen des Gastauftrags {request_id}: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': f'Fehler beim Genehmigen: {str(e)}'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@app.route('/api/guest-requests/<int:request_id>/reject', methods=['POST'])
|
||||||
|
@admin_required
|
||||||
|
def reject_guest_request(request_id):
|
||||||
|
"""Lehnt einen Gastauftrag ab"""
|
||||||
|
try:
|
||||||
|
db_session = get_db_session()
|
||||||
|
|
||||||
|
guest_request = db_session.query(GuestRequest).filter(GuestRequest.id == request_id).first()
|
||||||
|
|
||||||
|
if not guest_request:
|
||||||
|
db_session.close()
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': 'Gastauftrag nicht gefunden'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
if guest_request.status != 'pending':
|
||||||
|
db_session.close()
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': f'Gastauftrag kann im Status "{guest_request.status}" nicht abgelehnt werden'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Daten aus Request Body
|
||||||
|
data = request.get_json() or {}
|
||||||
|
reason = data.get('reason', '').strip()
|
||||||
|
|
||||||
|
if not reason:
|
||||||
|
db_session.close()
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': 'Ablehnungsgrund ist erforderlich'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Status aktualisieren
|
||||||
|
guest_request.status = 'rejected'
|
||||||
|
guest_request.rejected_at = datetime.now()
|
||||||
|
guest_request.rejected_by = current_user.id
|
||||||
|
guest_request.rejection_reason = reason
|
||||||
|
guest_request.updated_at = datetime.now()
|
||||||
|
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# Benachrichtigung an den Gast senden (falls E-Mail verfügbar)
|
||||||
|
if guest_request.email:
|
||||||
|
try:
|
||||||
|
# Hier würde normalerweise eine E-Mail gesendet werden
|
||||||
|
app_logger.info(f"Ablehnungs-E-Mail würde an {guest_request.email} gesendet (Grund: {reason})")
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.warning(f"Fehler beim Senden der Ablehnungs-E-Mail: {str(e)}")
|
||||||
|
|
||||||
|
db_session.close()
|
||||||
|
|
||||||
|
app_logger.info(f"Gastauftrag {request_id} von Admin {current_user.id} abgelehnt (Grund: {reason})")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Gastauftrag erfolgreich abgelehnt'
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.error(f"Fehler beim Ablehnen des Gastauftrags {request_id}: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': f'Fehler beim Ablehnen: {str(e)}'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@app.route('/api/guest-requests/<int:request_id>', methods=['DELETE'])
|
||||||
|
@admin_required
|
||||||
|
def delete_guest_request(request_id):
|
||||||
|
"""Löscht einen Gastauftrag"""
|
||||||
|
try:
|
||||||
|
db_session = get_db_session()
|
||||||
|
|
||||||
|
guest_request = db_session.query(GuestRequest).filter(GuestRequest.id == request_id).first()
|
||||||
|
|
||||||
|
if not guest_request:
|
||||||
|
db_session.close()
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': 'Gastauftrag nicht gefunden'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
# Datei löschen falls vorhanden
|
||||||
|
if guest_request.file_path and os.path.exists(guest_request.file_path):
|
||||||
|
try:
|
||||||
|
os.remove(guest_request.file_path)
|
||||||
|
app_logger.info(f"Datei {guest_request.file_path} für Gastauftrag {request_id} gelöscht")
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.warning(f"Fehler beim Löschen der Datei: {str(e)}")
|
||||||
|
|
||||||
|
# Gastauftrag aus Datenbank löschen
|
||||||
|
request_name = guest_request.name
|
||||||
|
db_session.delete(guest_request)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.close()
|
||||||
|
|
||||||
|
app_logger.info(f"Gastauftrag {request_id} ({request_name}) von Admin {current_user.id} gelöscht")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Gastauftrag erfolgreich gelöscht'
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.error(f"Fehler beim Löschen des Gastauftrags {request_id}: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': f'Fehler beim Löschen: {str(e)}'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@app.route('/api/guest-requests/<int:request_id>', methods=['GET'])
|
||||||
|
@admin_required
|
||||||
|
def get_guest_request_detail(request_id):
|
||||||
|
"""Gibt Details eines spezifischen Gastauftrags zurück"""
|
||||||
|
try:
|
||||||
|
db_session = get_db_session()
|
||||||
|
|
||||||
|
guest_request = db_session.query(GuestRequest).filter(GuestRequest.id == request_id).first()
|
||||||
|
|
||||||
|
if not guest_request:
|
||||||
|
db_session.close()
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': 'Gastauftrag nicht gefunden'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
# Detaildaten zusammenstellen
|
||||||
|
request_data = {
|
||||||
|
'id': guest_request.id,
|
||||||
|
'name': guest_request.name,
|
||||||
|
'email': guest_request.email,
|
||||||
|
'file_name': guest_request.file_name,
|
||||||
|
'file_path': guest_request.file_path,
|
||||||
|
'file_size': None,
|
||||||
|
'duration_minutes': guest_request.duration_minutes,
|
||||||
|
'copies': guest_request.copies,
|
||||||
|
'reason': guest_request.reason,
|
||||||
|
'status': guest_request.status,
|
||||||
|
'created_at': guest_request.created_at.isoformat() if guest_request.created_at else None,
|
||||||
|
'updated_at': guest_request.updated_at.isoformat() if guest_request.updated_at else None,
|
||||||
|
'approved_at': guest_request.approved_at.isoformat() if guest_request.approved_at else None,
|
||||||
|
'rejected_at': guest_request.rejected_at.isoformat() if guest_request.rejected_at else None,
|
||||||
|
'approval_notes': guest_request.approval_notes,
|
||||||
|
'rejection_reason': guest_request.rejection_reason,
|
||||||
|
'otp_code': guest_request.otp_code,
|
||||||
|
'otp_expires_at': guest_request.otp_expires_at.isoformat() if guest_request.otp_expires_at else None,
|
||||||
|
'author_ip': guest_request.author_ip
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dateigröße ermitteln
|
||||||
|
if guest_request.file_path and os.path.exists(guest_request.file_path):
|
||||||
|
try:
|
||||||
|
file_size = os.path.getsize(guest_request.file_path)
|
||||||
|
request_data['file_size'] = file_size
|
||||||
|
request_data['file_size_mb'] = round(file_size / (1024 * 1024), 2)
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.warning(f"Fehler beim Ermitteln der Dateigröße: {str(e)}")
|
||||||
|
|
||||||
|
# Bearbeiter-Informationen hinzufügen
|
||||||
|
if guest_request.approved_by:
|
||||||
|
approved_by_user = db_session.query(User).filter(User.id == guest_request.approved_by).first()
|
||||||
|
if approved_by_user:
|
||||||
|
request_data['approved_by_name'] = approved_by_user.name or approved_by_user.username
|
||||||
|
|
||||||
|
if guest_request.rejected_by:
|
||||||
|
rejected_by_user = db_session.query(User).filter(User.id == guest_request.rejected_by).first()
|
||||||
|
if rejected_by_user:
|
||||||
|
request_data['rejected_by_name'] = rejected_by_user.name or rejected_by_user.username
|
||||||
|
|
||||||
|
# Zugewiesener Drucker
|
||||||
|
if hasattr(guest_request, 'assigned_printer_id') and guest_request.assigned_printer_id:
|
||||||
|
assigned_printer = db_session.query(Printer).filter(Printer.id == guest_request.assigned_printer_id).first()
|
||||||
|
if assigned_printer:
|
||||||
|
request_data['assigned_printer'] = {
|
||||||
|
'id': assigned_printer.id,
|
||||||
|
'name': assigned_printer.name,
|
||||||
|
'location': assigned_printer.location,
|
||||||
|
'status': assigned_printer.status
|
||||||
|
}
|
||||||
|
|
||||||
|
db_session.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'request': request_data
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.error(f"Fehler beim Abrufen der Gastauftrag-Details {request_id}: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': f'Fehler beim Abrufen der Details: {str(e)}'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@app.route('/api/admin/guest-requests/stats', methods=['GET'])
|
||||||
|
@admin_required
|
||||||
|
def get_guest_requests_stats():
|
||||||
|
"""Gibt detaillierte Statistiken zu Gastaufträgen zurück"""
|
||||||
|
try:
|
||||||
|
db_session = get_db_session()
|
||||||
|
|
||||||
|
# Basis-Statistiken
|
||||||
|
total = db_session.query(GuestRequest).count()
|
||||||
|
pending = db_session.query(GuestRequest).filter(GuestRequest.status == 'pending').count()
|
||||||
|
approved = db_session.query(GuestRequest).filter(GuestRequest.status == 'approved').count()
|
||||||
|
rejected = db_session.query(GuestRequest).filter(GuestRequest.status == 'rejected').count()
|
||||||
|
|
||||||
|
# Zeitbasierte Statistiken
|
||||||
|
today = datetime.now().date()
|
||||||
|
week_ago = datetime.now() - timedelta(days=7)
|
||||||
|
month_ago = datetime.now() - timedelta(days=30)
|
||||||
|
|
||||||
|
today_requests = db_session.query(GuestRequest).filter(
|
||||||
|
func.date(GuestRequest.created_at) == today
|
||||||
|
).count()
|
||||||
|
|
||||||
|
week_requests = db_session.query(GuestRequest).filter(
|
||||||
|
GuestRequest.created_at >= week_ago
|
||||||
|
).count()
|
||||||
|
|
||||||
|
month_requests = db_session.query(GuestRequest).filter(
|
||||||
|
GuestRequest.created_at >= month_ago
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Dringende Requests (älter als 24h und pending)
|
||||||
|
urgent_cutoff = datetime.now() - timedelta(hours=24)
|
||||||
|
urgent_requests = db_session.query(GuestRequest).filter(
|
||||||
|
GuestRequest.status == 'pending',
|
||||||
|
GuestRequest.created_at < urgent_cutoff
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Durchschnittliche Bearbeitungszeit
|
||||||
|
avg_processing_time = None
|
||||||
|
try:
|
||||||
|
processed_requests = db_session.query(GuestRequest).filter(
|
||||||
|
GuestRequest.status.in_(['approved', 'rejected']),
|
||||||
|
GuestRequest.updated_at.isnot(None)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if processed_requests:
|
||||||
|
total_time = sum([
|
||||||
|
(req.updated_at - req.created_at).total_seconds()
|
||||||
|
for req in processed_requests
|
||||||
|
if req.updated_at and req.created_at
|
||||||
|
])
|
||||||
|
avg_processing_time = round(total_time / len(processed_requests) / 3600, 2) # Stunden
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.warning(f"Fehler beim Berechnen der durchschnittlichen Bearbeitungszeit: {str(e)}")
|
||||||
|
|
||||||
|
# Erfolgsrate
|
||||||
|
success_rate = 0
|
||||||
|
if approved + rejected > 0:
|
||||||
|
success_rate = round((approved / (approved + rejected)) * 100, 1)
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'total': total,
|
||||||
|
'pending': pending,
|
||||||
|
'approved': approved,
|
||||||
|
'rejected': rejected,
|
||||||
|
'urgent': urgent_requests,
|
||||||
|
'today': today_requests,
|
||||||
|
'week': week_requests,
|
||||||
|
'month': month_requests,
|
||||||
|
'success_rate': success_rate,
|
||||||
|
'avg_processing_time_hours': avg_processing_time,
|
||||||
|
'completion_rate': round(((approved + rejected) / total * 100), 1) if total > 0 else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
db_session.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'stats': stats,
|
||||||
|
'generated_at': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.error(f"Fehler beim Abrufen der Gastauftrag-Statistiken: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': f'Fehler beim Abrufen der Statistiken: {str(e)}'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@app.route('/api/admin/guest-requests/export', methods=['GET'])
|
||||||
|
@admin_required
|
||||||
|
def export_guest_requests():
|
||||||
|
"""Exportiert Gastaufträge als CSV"""
|
||||||
|
try:
|
||||||
|
db_session = get_db_session()
|
||||||
|
|
||||||
|
# Filter-Parameter
|
||||||
|
status = request.args.get('status', 'all')
|
||||||
|
start_date = request.args.get('start_date')
|
||||||
|
end_date = request.args.get('end_date')
|
||||||
|
|
||||||
|
# Query aufbauen
|
||||||
|
query = db_session.query(GuestRequest)
|
||||||
|
|
||||||
|
if status != 'all':
|
||||||
|
query = query.filter(GuestRequest.status == status)
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
try:
|
||||||
|
start_dt = datetime.fromisoformat(start_date)
|
||||||
|
query = query.filter(GuestRequest.created_at >= start_dt)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
try:
|
||||||
|
end_dt = datetime.fromisoformat(end_date)
|
||||||
|
query = query.filter(GuestRequest.created_at <= end_dt)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
requests = query.order_by(GuestRequest.created_at.desc()).all()
|
||||||
|
|
||||||
|
# CSV-Daten erstellen
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
|
||||||
|
# Header
|
||||||
|
writer.writerow([
|
||||||
|
'ID', 'Name', 'E-Mail', 'Datei', 'Status', 'Erstellt am',
|
||||||
|
'Dauer (Min)', 'Kopien', 'Begründung', 'Genehmigt am',
|
||||||
|
'Abgelehnt am', 'Bearbeitungsnotizen', 'Ablehnungsgrund'
|
||||||
|
])
|
||||||
|
|
||||||
|
# Daten
|
||||||
|
for req in requests:
|
||||||
|
writer.writerow([
|
||||||
|
req.id,
|
||||||
|
req.name or '',
|
||||||
|
req.email or '',
|
||||||
|
req.file_name or '',
|
||||||
|
req.status,
|
||||||
|
req.created_at.strftime('%Y-%m-%d %H:%M:%S') if req.created_at else '',
|
||||||
|
req.duration_minutes or '',
|
||||||
|
req.copies or '',
|
||||||
|
req.reason or '',
|
||||||
|
req.approved_at.strftime('%Y-%m-%d %H:%M:%S') if req.approved_at else '',
|
||||||
|
req.rejected_at.strftime('%Y-%m-%d %H:%M:%S') if req.rejected_at else '',
|
||||||
|
req.approval_notes or '',
|
||||||
|
req.rejection_reason or ''
|
||||||
|
])
|
||||||
|
|
||||||
|
db_session.close()
|
||||||
|
|
||||||
|
# Response erstellen
|
||||||
|
output_value = output.getvalue()
|
||||||
|
output.close()
|
||||||
|
|
||||||
|
response = make_response(output_value)
|
||||||
|
response.headers["Content-Disposition"] = f"attachment; filename=gastauftraege_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||||
|
response.headers["Content-Type"] = "text/csv; charset=utf-8"
|
||||||
|
|
||||||
|
app_logger.info(f"Gastaufträge-Export erstellt: {len(requests)} Einträge")
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.error(f"Fehler beim Exportieren der Gastaufträge: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': f'Fehler beim Export: {str(e)}'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
# ===== ENDE ADMIN GASTAUFTRÄGE API-ENDPUNKTE =====
|
376
backend/app/static/js/global-refresh-functions.js
Normal file
376
backend/app/static/js/global-refresh-functions.js
Normal file
@ -0,0 +1,376 @@
|
|||||||
|
/**
|
||||||
|
* MYP Platform - Globale Refresh-Funktionen
|
||||||
|
* Sammelt alle Refresh-Funktionen für verschiedene Seiten und Komponenten
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard-Refresh-Funktion
|
||||||
|
*/
|
||||||
|
window.refreshDashboard = async function() {
|
||||||
|
const refreshButton = document.getElementById('refreshDashboard');
|
||||||
|
if (refreshButton) {
|
||||||
|
// Button-State ändern
|
||||||
|
refreshButton.disabled = true;
|
||||||
|
const icon = refreshButton.querySelector('svg');
|
||||||
|
if (icon) {
|
||||||
|
icon.classList.add('animate-spin');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Dashboard-Statistiken aktualisieren
|
||||||
|
const response = await fetch('/api/dashboard/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': getCSRFToken()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Statistiken im DOM aktualisieren
|
||||||
|
updateDashboardStats(data.stats);
|
||||||
|
|
||||||
|
// Benachrichtigung anzeigen
|
||||||
|
showToast('✅ Dashboard erfolgreich aktualisiert', 'success');
|
||||||
|
|
||||||
|
// Seite neu laden für vollständige Aktualisierung
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
showToast('❌ Fehler beim Aktualisieren des Dashboards', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Dashboard-Refresh Fehler:', error);
|
||||||
|
showToast('❌ Netzwerkfehler beim Dashboard-Refresh', 'error');
|
||||||
|
} finally {
|
||||||
|
// Button-State zurücksetzen
|
||||||
|
if (refreshButton) {
|
||||||
|
refreshButton.disabled = false;
|
||||||
|
const icon = refreshButton.querySelector('svg');
|
||||||
|
if (icon) {
|
||||||
|
icon.classList.remove('animate-spin');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jobs-Refresh-Funktion
|
||||||
|
*/
|
||||||
|
window.refreshJobs = async function() {
|
||||||
|
const refreshButton = document.getElementById('refresh-button');
|
||||||
|
if (refreshButton) {
|
||||||
|
refreshButton.disabled = true;
|
||||||
|
const icon = refreshButton.querySelector('svg');
|
||||||
|
if (icon) {
|
||||||
|
icon.classList.add('animate-spin');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Jobs-Daten neu laden
|
||||||
|
if (typeof jobManager !== 'undefined' && jobManager.loadJobs) {
|
||||||
|
await jobManager.loadJobs();
|
||||||
|
} else {
|
||||||
|
// Fallback: Seite neu laden
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('✅ Druckaufträge erfolgreich aktualisiert', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Jobs-Refresh Fehler:', error);
|
||||||
|
showToast('❌ Fehler beim Aktualisieren der Jobs', 'error');
|
||||||
|
} finally {
|
||||||
|
if (refreshButton) {
|
||||||
|
refreshButton.disabled = false;
|
||||||
|
const icon = refreshButton.querySelector('svg');
|
||||||
|
if (icon) {
|
||||||
|
icon.classList.remove('animate-spin');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendar-Refresh-Funktion
|
||||||
|
*/
|
||||||
|
window.refreshCalendar = async function() {
|
||||||
|
const refreshButton = document.getElementById('refresh-button');
|
||||||
|
if (refreshButton) {
|
||||||
|
refreshButton.disabled = true;
|
||||||
|
const icon = refreshButton.querySelector('svg');
|
||||||
|
if (icon) {
|
||||||
|
icon.classList.add('animate-spin');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// FullCalendar neu laden falls verfügbar
|
||||||
|
if (typeof calendar !== 'undefined' && calendar.refetchEvents) {
|
||||||
|
calendar.refetchEvents();
|
||||||
|
showToast('✅ Kalender erfolgreich aktualisiert', 'success');
|
||||||
|
} else {
|
||||||
|
// Fallback: Seite neu laden
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Calendar-Refresh Fehler:', error);
|
||||||
|
showToast('❌ Fehler beim Aktualisieren des Kalenders', 'error');
|
||||||
|
} finally {
|
||||||
|
if (refreshButton) {
|
||||||
|
refreshButton.disabled = false;
|
||||||
|
const icon = refreshButton.querySelector('svg');
|
||||||
|
if (icon) {
|
||||||
|
icon.classList.remove('animate-spin');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drucker-Refresh-Funktion
|
||||||
|
*/
|
||||||
|
window.refreshPrinters = async function() {
|
||||||
|
const refreshButton = document.getElementById('refresh-button');
|
||||||
|
if (refreshButton) {
|
||||||
|
refreshButton.disabled = true;
|
||||||
|
const icon = refreshButton.querySelector('svg');
|
||||||
|
if (icon) {
|
||||||
|
icon.classList.add('animate-spin');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Drucker-Manager verwenden falls verfügbar
|
||||||
|
if (typeof printerManager !== 'undefined' && printerManager.loadPrinters) {
|
||||||
|
await printerManager.loadPrinters();
|
||||||
|
} else {
|
||||||
|
// Fallback: API-Aufruf für Drucker-Update
|
||||||
|
const response = await fetch('/api/printers/status/live', {
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': getCSRFToken()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Seite neu laden für vollständige Aktualisierung
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
throw new Error('Drucker-Status konnte nicht abgerufen werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('✅ Drucker erfolgreich aktualisiert', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Printer-Refresh Fehler:', error);
|
||||||
|
showToast('❌ Fehler beim Aktualisieren der Drucker', 'error');
|
||||||
|
} finally {
|
||||||
|
if (refreshButton) {
|
||||||
|
refreshButton.disabled = false;
|
||||||
|
const icon = refreshButton.querySelector('svg');
|
||||||
|
if (icon) {
|
||||||
|
icon.classList.remove('animate-spin');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard-Statistiken im DOM aktualisieren
|
||||||
|
*/
|
||||||
|
function updateDashboardStats(stats) {
|
||||||
|
// Aktive Jobs
|
||||||
|
const activeJobsEl = document.querySelector('[data-stat="active-jobs"]');
|
||||||
|
if (activeJobsEl) {
|
||||||
|
activeJobsEl.textContent = stats.active_jobs || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verfügbare Drucker
|
||||||
|
const availablePrintersEl = document.querySelector('[data-stat="available-printers"]');
|
||||||
|
if (availablePrintersEl) {
|
||||||
|
availablePrintersEl.textContent = stats.available_printers || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gesamte Jobs
|
||||||
|
const totalJobsEl = document.querySelector('[data-stat="total-jobs"]');
|
||||||
|
if (totalJobsEl) {
|
||||||
|
totalJobsEl.textContent = stats.total_jobs || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erfolgsrate
|
||||||
|
const successRateEl = document.querySelector('[data-stat="success-rate"]');
|
||||||
|
if (successRateEl) {
|
||||||
|
successRateEl.textContent = (stats.success_rate || 0) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📊 Dashboard-Statistiken aktualisiert:', stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSRF-Token abrufen
|
||||||
|
*/
|
||||||
|
function getCSRFToken() {
|
||||||
|
const token = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
return token ? token.getAttribute('content') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toast-Benachrichtigung anzeigen
|
||||||
|
*/
|
||||||
|
function showToast(message, type = 'info') {
|
||||||
|
// Prüfen ob der OptimizationManager verfügbar ist und dessen Toast-Funktion verwenden
|
||||||
|
if (typeof optimizationManager !== 'undefined' && optimizationManager.showToast) {
|
||||||
|
optimizationManager.showToast(message, type);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Einfache Console-Ausgabe
|
||||||
|
const emoji = {
|
||||||
|
success: '✅',
|
||||||
|
error: '❌',
|
||||||
|
warning: '⚠️',
|
||||||
|
info: 'ℹ️'
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`${emoji[type] || 'ℹ️'} ${message}`);
|
||||||
|
|
||||||
|
// Versuche eine Alert-Benachrichtigung zu erstellen
|
||||||
|
try {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `fixed top-4 right-4 z-50 p-4 rounded-lg shadow-lg transition-all duration-300 transform translate-x-full`;
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
success: 'bg-green-500 text-white',
|
||||||
|
error: 'bg-red-500 text-white',
|
||||||
|
warning: 'bg-yellow-500 text-black',
|
||||||
|
info: 'bg-blue-500 text-white'
|
||||||
|
};
|
||||||
|
|
||||||
|
toast.className += ` ${colors[type]}`;
|
||||||
|
toast.textContent = message;
|
||||||
|
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
// Animation einblenden
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('translate-x-full');
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// Nach 3 Sekunden automatisch entfernen
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.add('translate-x-full');
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 300);
|
||||||
|
}, 3000);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Toast-Erstellung fehlgeschlagen:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Universelle Refresh-Funktion basierend auf aktueller Seite
|
||||||
|
*/
|
||||||
|
window.universalRefresh = function() {
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
|
||||||
|
if (currentPath.includes('/dashboard')) {
|
||||||
|
refreshDashboard();
|
||||||
|
} else if (currentPath.includes('/jobs')) {
|
||||||
|
refreshJobs();
|
||||||
|
} else if (currentPath.includes('/calendar') || currentPath.includes('/schichtplan')) {
|
||||||
|
refreshCalendar();
|
||||||
|
} else if (currentPath.includes('/printers') || currentPath.includes('/drucker')) {
|
||||||
|
refreshPrinters();
|
||||||
|
} else {
|
||||||
|
// Fallback: Seite neu laden
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-Refresh-Funktionalität
|
||||||
|
*/
|
||||||
|
class AutoRefreshManager {
|
||||||
|
constructor() {
|
||||||
|
this.isEnabled = false;
|
||||||
|
this.interval = null;
|
||||||
|
this.intervalTime = 30000; // 30 Sekunden
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.isEnabled) return;
|
||||||
|
|
||||||
|
this.isEnabled = true;
|
||||||
|
this.interval = setInterval(() => {
|
||||||
|
// Nur refresh wenn Seite sichtbar ist
|
||||||
|
if (!document.hidden) {
|
||||||
|
universalRefresh();
|
||||||
|
}
|
||||||
|
}, this.intervalTime);
|
||||||
|
|
||||||
|
console.log('🔄 Auto-Refresh aktiviert (alle 30 Sekunden)');
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (!this.isEnabled) return;
|
||||||
|
|
||||||
|
this.isEnabled = false;
|
||||||
|
if (this.interval) {
|
||||||
|
clearInterval(this.interval);
|
||||||
|
this.interval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('⏸️ Auto-Refresh deaktiviert');
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
if (this.isEnabled) {
|
||||||
|
this.stop();
|
||||||
|
} else {
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Globaler Auto-Refresh-Manager
|
||||||
|
window.autoRefreshManager = new AutoRefreshManager();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyboard-Shortcuts
|
||||||
|
*/
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
// F5 oder Ctrl+R abfangen und eigene Refresh-Funktion verwenden
|
||||||
|
if (e.key === 'F5' || (e.ctrlKey && e.key === 'r')) {
|
||||||
|
e.preventDefault();
|
||||||
|
universalRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Shift+R für Auto-Refresh-Toggle
|
||||||
|
if (e.ctrlKey && e.shiftKey && e.key === 'R') {
|
||||||
|
e.preventDefault();
|
||||||
|
autoRefreshManager.toggle();
|
||||||
|
showToast(
|
||||||
|
autoRefreshManager.isEnabled ?
|
||||||
|
'🔄 Auto-Refresh aktiviert' :
|
||||||
|
'⏸️ Auto-Refresh deaktiviert',
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visibility API für Auto-Refresh bei Tab-Wechsel
|
||||||
|
*/
|
||||||
|
document.addEventListener('visibilitychange', function() {
|
||||||
|
if (!document.hidden && autoRefreshManager.isEnabled) {
|
||||||
|
// Verzögertes Refresh wenn Tab wieder sichtbar wird
|
||||||
|
setTimeout(universalRefresh, 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('🔄 Globale Refresh-Funktionen geladen');
|
@ -33,6 +33,7 @@
|
|||||||
<!-- Preload critical resources -->
|
<!-- Preload critical resources -->
|
||||||
<link rel="preload" href="{{ url_for('static', filename='js/ui-components.js') }}" as="script">
|
<link rel="preload" href="{{ url_for('static', filename='js/ui-components.js') }}" as="script">
|
||||||
<link rel="preload" href="{{ url_for('static', filename='js/offline-app.js') }}" as="script">
|
<link rel="preload" href="{{ url_for('static', filename='js/offline-app.js') }}" as="script">
|
||||||
|
<link rel="preload" href="{{ url_for('static', filename='js/optimization-features.js') }}" as="script">
|
||||||
|
|
||||||
<!-- Additional CSS -->
|
<!-- Additional CSS -->
|
||||||
{% block extra_css %}{% endblock %}
|
{% block extra_css %}{% endblock %}
|
||||||
@ -577,6 +578,8 @@
|
|||||||
<!-- JavaScript -->
|
<!-- JavaScript -->
|
||||||
<script src="{{ url_for('static', filename='js/ui-components.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/ui-components.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/dark-mode-fix.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/dark-mode-fix.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/optimization-features.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/global-refresh-functions.js') }}"></script>
|
||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
<script src="{{ url_for('static', filename='js/notifications.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/notifications.js') }}"></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -325,11 +325,27 @@
|
|||||||
<span>Exportieren</span>
|
<span>Exportieren</span>
|
||||||
</button>
|
</button>
|
||||||
<button onclick="toggleAutoOptimization()" id="auto-opt-toggle"
|
<button onclick="toggleAutoOptimization()" id="auto-opt-toggle"
|
||||||
class="btn-secondary flex items-center gap-2">
|
class="btn-secondary flex items-center gap-2 relative group">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Auto-Optimierung</span>
|
<span>Auto-Optimierung</span>
|
||||||
|
|
||||||
|
<!-- Info-Tooltip für Auto-Optimierung -->
|
||||||
|
<div class="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 w-80 bg-slate-900 dark:bg-slate-700 text-white text-xs rounded-lg p-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none z-50 shadow-lg">
|
||||||
|
<div class="font-semibold mb-2">🚀 Intelligente Auto-Optimierung</div>
|
||||||
|
<ul class="space-y-1 text-left">
|
||||||
|
<li>• <strong>Round Robin:</strong> Gleichmäßige Druckerverteilung</li>
|
||||||
|
<li>• <strong>Load Balancing:</strong> Auslastungsoptimierung</li>
|
||||||
|
<li>• <strong>Prioritätsbasiert:</strong> Wichtige Jobs zuerst</li>
|
||||||
|
<li>• <strong>Rüstzeiten:</strong> Minimierung der Umrüstzeiten</li>
|
||||||
|
<li>• <strong>Entfernungen:</strong> Berücksichtigung der Druckerstandorte</li>
|
||||||
|
</ul>
|
||||||
|
<div class="mt-2 text-xs text-slate-300">
|
||||||
|
Tastenkürzel: <kbd class="bg-slate-800 px-1 rounded">Ctrl+Alt+O</kbd>
|
||||||
|
</div>
|
||||||
|
<div class="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-slate-900 dark:border-t-slate-700"></div>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -397,11 +397,27 @@
|
|||||||
<span>Aktualisieren</span>
|
<span>Aktualisieren</span>
|
||||||
</button>
|
</button>
|
||||||
<button onclick="toggleBatchMode()" id="batch-toggle"
|
<button onclick="toggleBatchMode()" id="batch-toggle"
|
||||||
class="btn-secondary flex items-center gap-2">
|
class="btn-secondary flex items-center gap-2 relative group">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Mehrfachauswahl</span>
|
<span>Mehrfachauswahl</span>
|
||||||
|
|
||||||
|
<!-- Info-Tooltip für Batch-Planung -->
|
||||||
|
<div class="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 w-80 bg-slate-900 dark:bg-slate-700 text-white text-xs rounded-lg p-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none z-50 shadow-lg">
|
||||||
|
<div class="font-semibold mb-2">📦 Intelligente Batch-Planung</div>
|
||||||
|
<ul class="space-y-1 text-left">
|
||||||
|
<li>• <strong>Mehrfachauswahl:</strong> Markieren Sie mehrere Jobs gleichzeitig</li>
|
||||||
|
<li>• <strong>Batch-Operationen:</strong> Starten, pausieren oder abbrechen</li>
|
||||||
|
<li>• <strong>Prioritäten setzen:</strong> Hoch/Normal für ausgewählte Jobs</li>
|
||||||
|
<li>• <strong>Gruppenlöschung:</strong> Mehrere abgeschlossene Jobs löschen</li>
|
||||||
|
<li>• <strong>Optimierte Planung:</strong> Intelligente Reihenfolge-Anpassung</li>
|
||||||
|
</ul>
|
||||||
|
<div class="mt-2 text-xs text-slate-300">
|
||||||
|
Tastenkürzel: <kbd class="bg-slate-800 px-1 rounded">Ctrl+Alt+B</kbd>
|
||||||
|
</div>
|
||||||
|
<div class="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-slate-900 dark:border-t-slate-700"></div>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<a href="{{ url_for('new_job_page') }}"
|
<a href="{{ url_for('new_job_page') }}"
|
||||||
class="btn-primary flex items-center gap-2">
|
class="btn-primary flex items-center gap-2">
|
||||||
|
Loading…
x
Reference in New Issue
Block a user