"feat: Implement guest job application flow"

This commit is contained in:
Till Tomczak 2025-05-29 14:41:47 +02:00
parent c2d8c795f1
commit 96ae5730b8
5 changed files with 1410 additions and 207 deletions

View File

@ -1,4 +1,6 @@
import json
import secrets
import bcrypt
from datetime import datetime, timedelta
from flask import Blueprint, render_template, request, jsonify, redirect, url_for, abort, session, flash
from flask_login import current_user, login_required
@ -41,6 +43,36 @@ def guest_request_form():
db_session.expunge_all()
return render_template('guest_request.html', printers=printers)
@guest_blueprint.route('/start-job', methods=['GET'])
def guest_start_job_form():
"""Code-Eingabe-Formular für Gäste anzeigen."""
return render_template('guest_start_job.html')
@guest_blueprint.route('/job/<int:job_id>/status', methods=['GET'])
def guest_job_status(job_id):
"""Job-Status-Seite für Gäste anzeigen."""
with get_cached_session() as db_session:
# Job mit eager loading des printer-Relationships laden
job = db_session.query(Job).options(
joinedload(Job.printer),
joinedload(Job.user)
).filter_by(id=job_id).first()
if not job:
abort(404, "Job nicht gefunden")
# Zugehörige Gastanfrage finden
guest_request = db_session.query(GuestRequest).filter_by(job_id=job_id).first()
# Objekte explizit von der Session trennen, um sie außerhalb verwenden zu können
db_session.expunge(job)
if guest_request:
db_session.expunge(guest_request)
return render_template('guest_job_status.html',
job=job,
guest_request=guest_request)
@guest_blueprint.route('/requests/overview', methods=['GET'])
def guest_requests_overview():
"""Öffentliche Übersicht aller Druckanträge mit zensierten persönlichen Daten."""
@ -75,23 +107,10 @@ def guest_requests_overview():
# Grund zensieren (nur erste 20 Zeichen anzeigen)
censored_reason = "***"
if req.reason:
if len(req.reason) > 20:
censored_reason = req.reason[:20] + "***"
else:
censored_reason = req.reason[:10] + "***" if len(req.reason) > 10 else "***"
# Drucker-Info laden (jetzt durch eager loading verfügbar)
printer_name = "Unbekannt"
if req.printer:
printer_name = req.printer.name
# Job-Status laden, falls vorhanden
job_status = None
if req.job_id:
job = db_session.query(Job).filter_by(id=req.job_id).first()
if job:
job_status = job.status
if req.reason and len(req.reason) > 20:
censored_reason = req.reason[:20] + "..."
elif req.reason:
censored_reason = req.reason
public_requests.append({
"id": req.id,
@ -101,17 +120,17 @@ def guest_requests_overview():
"duration_min": req.duration_min,
"created_at": req.created_at,
"status": req.status,
"printer_name": printer_name,
"job_status": job_status
"printer": req.printer.to_dict() if req.printer else None
})
logger.info(f"Öffentliche Druckanträge-Übersicht aufgerufen - {len(public_requests)} Einträge")
# Objekte explizit von der Session trennen
db_session.expunge_all()
return render_template('guest_requests_overview.html', requests=public_requests)
except Exception as e:
logger.error(f"Fehler beim Laden der öffentlichen Druckanträge-Übersicht: {str(e)}")
return render_template('guest_requests_overview.html', requests=[], error="Fehler beim Laden der Daten")
logger.error(f"Fehler beim Laden der öffentlichen Gastanfragen: {str(e)}")
return render_template('guest_requests_overview.html', requests=[], error="Fehler beim Laden der Anfragen")
@guest_blueprint.route('/request/<int:request_id>', methods=['GET'])
def guest_request_status(request_id):
@ -125,11 +144,18 @@ def guest_request_status(request_id):
if not guest_request:
abort(404, "Anfrage nicht gefunden")
# Nur wenn Status "approved" ist, OTP generieren und anzeigen
# OTP-Code nur anzeigen, wenn Anfrage genehmigt wurde
otp_code = None
if guest_request.status == "approved" and not guest_request.otp_code:
otp_code = guest_request.generate_otp()
db_session.commit()
show_start_link = False
if guest_request.status == "approved":
if not guest_request.otp_code:
# OTP generieren falls noch nicht vorhanden
otp_code = guest_request.generate_otp()
db_session.commit()
else:
# OTP existiert bereits - prüfen ob noch nicht verwendet
show_start_link = guest_request.otp_used_at is None
# Zugehörigen Job laden, falls vorhanden
job = None
@ -144,7 +170,8 @@ def guest_request_status(request_id):
return render_template('guest_status.html',
request=guest_request,
job=job,
otp_code=otp_code)
otp_code=otp_code,
show_start_link=show_start_link)
# API-Endpunkte
@guest_blueprint.route('/api/guest/requests', methods=['POST'])
@ -213,6 +240,103 @@ def api_create_guest_request():
logger.error(f"Fehler beim Erstellen der Gastanfrage: {str(e)}")
return jsonify({"error": "Fehler beim Verarbeiten der Anfrage"}), 500
@guest_blueprint.route('/api/guest/start-job', methods=['POST'])
def api_start_job_with_code():
"""Job mit OTP-Code starten."""
try:
data = request.get_json()
if not data or 'code' not in data:
return jsonify({"error": "Code ist erforderlich"}), 400
code = data['code'].strip().upper()
if len(code) != 6:
return jsonify({"error": "Code muss 6 Zeichen lang sein"}), 400
with get_cached_session() as db_session:
# Alle genehmigten Gastanfragen mit OTP-Codes finden
guest_requests = db_session.query(GuestRequest).filter(
GuestRequest.status == "approved",
GuestRequest.otp_code.isnot(None),
GuestRequest.otp_used_at.is_(None) # Noch nicht verwendet
).all()
matching_request = None
for req in guest_requests:
# Code validieren
if req.verify_otp(code):
matching_request = req
break
if not matching_request:
return jsonify({
"success": False,
"error": "Ungültiger oder bereits verwendeter Code"
}), 400
# Prüfen ob zugehöriger Job existiert
if not matching_request.job_id:
return jsonify({
"success": False,
"error": "Kein zugehöriger Job gefunden"
}), 400
job = db_session.query(Job).options(
joinedload(Job.printer)
).filter_by(id=matching_request.job_id).first()
if not job:
return jsonify({
"success": False,
"error": "Job nicht gefunden"
}), 400
# Prüfen ob Job noch startbar ist
if job.status not in ["scheduled", "waiting_for_printer"]:
return jsonify({
"success": False,
"error": f"Job kann im Status '{job.status}' nicht gestartet werden"
}), 400
# Job starten
now = datetime.now()
job.status = "running"
job.start_at = now
job.end_at = now + timedelta(minutes=matching_request.duration_min)
job.actual_start_time = now
# OTP als verwendet markieren
matching_request.otp_used_at = now
# Drucker einschalten (falls implementiert)
if job.printer and job.printer.plug_ip:
try:
from utils.job_scheduler import toggle_plug
toggle_plug(job.printer_id, True)
except Exception as e:
logger.warning(f"Fehler beim Einschalten des Druckers: {str(e)}")
db_session.commit()
logger.info(f"Job {job.id} mit OTP-Code gestartet für Gastanfrage {matching_request.id}")
return jsonify({
"success": True,
"job_id": job.id,
"job_name": job.name,
"start_time": job.start_at.strftime("%H:%M"),
"end_time": job.end_at.strftime("%H:%M"),
"duration_minutes": matching_request.duration_min,
"printer_name": job.printer.name if job.printer else "Unbekannt",
"message": f"Job '{job.name}' erfolgreich gestartet"
})
except Exception as e:
logger.error(f"Fehler beim Starten des Jobs mit Code: {str(e)}")
return jsonify({
"success": False,
"error": "Fehler beim Starten des Jobs"
}), 500
@guest_blueprint.route('/api/guest/requests/<int:request_id>', methods=['GET'])
def api_get_guest_request(request_id):
"""Status einer Gastanfrage abrufen."""
@ -232,191 +356,73 @@ def api_get_guest_request(request_id):
logger.error(f"Fehler beim Abrufen der Gastanfrage: {str(e)}")
return jsonify({"error": "Fehler beim Verarbeiten der Anfrage"}), 500
@guest_blueprint.route('/api/requests/<int:request_id>/approve', methods=['POST'])
@approver_required
def api_approve_request(request_id):
"""Gastanfrage genehmigen."""
try:
data = request.get_json() or {}
approval_notes = data.get('notes', '')
printer_id = data.get('printer_id') # Optional: Drucker zuweisen/ändern
with get_cached_session() as db_session:
guest_request = db_session.query(GuestRequest).filter_by(id=request_id).first()
if not guest_request:
return jsonify({"error": "Anfrage nicht gefunden"}), 404
if guest_request.status != "pending":
return jsonify({"error": "Anfrage wurde bereits bearbeitet"}), 400
# Drucker validieren, falls angegeben
if printer_id:
printer = db_session.query(Printer).filter_by(id=printer_id, active=True).first()
if not printer:
return jsonify({"error": "Ungültiger Drucker ausgewählt"}), 400
guest_request.printer_id = printer_id
# Sicherstellen, dass ein Drucker zugewiesen ist
if not guest_request.printer_id:
return jsonify({"error": "Kein Drucker zugewiesen. Bitte wählen Sie einen Drucker aus."}), 400
# Anfrage genehmigen
guest_request.status = "approved"
guest_request.processed_by = current_user.id
guest_request.processed_at = datetime.now()
guest_request.approval_notes = approval_notes
# OTP generieren
otp_plain = guest_request.generate_otp()
# Zugehörigen Job erstellen
start_time = datetime.now() + timedelta(minutes=5) # Start in 5 Minuten
end_time = start_time + timedelta(minutes=guest_request.duration_min)
# Admin-Benutzer als Eigentümer verwenden
admin_user = db_session.query(User).filter_by(role="admin").first()
job = Job(
name=f"Gastauftrag: {guest_request.name}",
description=guest_request.reason or "Gastauftrag",
user_id=admin_user.id,
printer_id=guest_request.printer_id,
start_at=start_time,
end_at=end_time,
status="scheduled",
duration_minutes=guest_request.duration_min,
owner_id=admin_user.id
)
db_session.add(job)
db_session.flush() # ID generieren
# Job-ID in Gastanfrage speichern
guest_request.job_id = job.id
db_session.commit()
logger.info(f"Gastanfrage {request_id} genehmigt von Admin {current_user.id} ({current_user.name})")
return jsonify({
"success": True,
"status": "approved",
"job_id": job.id,
"otp": otp_plain, # Nur in dieser Antwort wird der OTP-Klartext zurückgegeben
"approved_by": current_user.name,
"approved_at": guest_request.processed_at.isoformat(),
"notes": approval_notes
})
except Exception as e:
logger.error(f"Fehler beim Genehmigen der Gastanfrage: {str(e)}")
return jsonify({"error": "Fehler beim Verarbeiten der Anfrage"}), 500
@guest_blueprint.route('/api/requests/<int:request_id>/deny', methods=['POST'])
@approver_required
def api_deny_request(request_id):
"""Gastanfrage ablehnen."""
try:
data = request.get_json() or {}
rejection_reason = data.get('reason', '')
if not rejection_reason.strip():
return jsonify({"error": "Ablehnungsgrund ist erforderlich"}), 400
with get_cached_session() as db_session:
guest_request = db_session.query(GuestRequest).filter_by(id=request_id).first()
if not guest_request:
return jsonify({"error": "Anfrage nicht gefunden"}), 404
if guest_request.status != "pending":
return jsonify({"error": "Anfrage wurde bereits bearbeitet"}), 400
# Anfrage ablehnen
guest_request.status = "denied"
guest_request.processed_by = current_user.id
guest_request.processed_at = datetime.now()
guest_request.rejection_reason = rejection_reason
db_session.commit()
logger.info(f"Gastanfrage {request_id} abgelehnt von Admin {current_user.id} ({current_user.name}): {rejection_reason}")
return jsonify({
"success": True,
"status": "denied",
"rejected_by": current_user.name,
"rejected_at": guest_request.processed_at.isoformat(),
"reason": rejection_reason
})
except Exception as e:
logger.error(f"Fehler beim Ablehnen der Gastanfrage: {str(e)}")
return jsonify({"error": "Fehler beim Verarbeiten der Anfrage"}), 500
@guest_blueprint.route('/api/jobs/start/<string:otp>', methods=['POST'])
def api_start_job_with_otp(otp):
"""Job mit OTP starten."""
@guest_blueprint.route('/api/guest/job/<int:job_id>/status', methods=['GET'])
def api_get_guest_job_status(job_id):
"""Job-Status für Gäste abrufen."""
try:
with get_cached_session() as db_session:
# Alle genehmigten Anfragen mit OTP durchsuchen
guest_requests = db_session.query(GuestRequest).filter_by(status="approved").all()
# Job mit Drucker-Information laden
job = db_session.query(Job).options(
joinedload(Job.printer)
).filter_by(id=job_id).first()
valid_request = None
for req in guest_requests:
if req.verify_otp(otp):
valid_request = req
break
if not valid_request:
return jsonify({"error": "Ungültiger oder abgelaufener Code"}), 400
if not valid_request.job_id:
return jsonify({"error": "Kein Job mit diesem Code verknüpft"}), 400
# Job laden
job = db_session.query(Job).filter_by(id=valid_request.job_id).first()
if not job:
return jsonify({"error": "Job nicht gefunden"}), 404
# Job-Status prüfen
if job.status != "scheduled":
return jsonify({"error": "Job kann nicht gestartet werden"}), 400
# Zugehörige Gastanfrage prüfen
guest_request = db_session.query(GuestRequest).filter_by(job_id=job_id).first()
if not guest_request:
return jsonify({"error": "Kein Gastjob"}), 403
# Grace-Period prüfen (5 Minuten vor bis 5 Minuten nach geplantem Start)
# Aktuelle Zeit für Berechnungen
now = datetime.now()
start_time = job.start_at
grace_start = start_time - timedelta(minutes=5)
grace_end = start_time + timedelta(minutes=5)
if now < grace_start:
return jsonify({
"error": f"Der Job kann erst ab {grace_start.strftime('%H:%M')} Uhr gestartet werden"
}), 400
# Restzeit berechnen
remaining_minutes = 0
if job.status == "running" and job.end_at:
remaining_seconds = (job.end_at - now).total_seconds()
remaining_minutes = max(0, int(remaining_seconds / 60))
if now > job.end_at:
return jsonify({"error": "Der Job ist bereits abgelaufen"}), 400
# Fortschritt berechnen
progress_percent = 0
if job.status == "running" and job.start_at and job.end_at:
total_duration = (job.end_at - job.start_at).total_seconds()
elapsed_duration = (now - job.start_at).total_seconds()
progress_percent = min(100, max(0, int((elapsed_duration / total_duration) * 100)))
elif job.status in ["completed", "finished"]:
progress_percent = 100
# Job starten
job.status = "active"
job.start_at = now # Aktualisiere Startzeit auf jetzt
# OTP als verwendet markieren
valid_request.otp_used_at = now
db_session.commit()
logger.info(f"Job {job.id} mit OTP {otp} gestartet von IP: {request.remote_addr}")
job_data = {
"id": job.id,
"name": job.name,
"status": job.status,
"start_at": job.start_at.isoformat() if job.start_at else None,
"end_at": job.end_at.isoformat() if job.end_at else None,
"duration_minutes": job.duration_minutes,
"remaining_minutes": remaining_minutes,
"progress_percent": progress_percent,
"printer": {
"id": job.printer.id,
"name": job.printer.name,
"location": job.printer.location
} if job.printer else None,
"guest_request": {
"id": guest_request.id,
"name": guest_request.name,
"created_at": guest_request.created_at.isoformat()
},
"is_active": job.status in ["scheduled", "running"],
"is_completed": job.status in ["completed", "finished"],
"is_failed": job.status in ["failed", "cancelled"]
}
return jsonify({
"success": True,
"job_id": job.id,
"status": job.status,
"started_at": job.start_at.isoformat(),
"end_at": job.end_at.isoformat()
"job": job_data
})
except Exception as e:
logger.error(f"Fehler beim Starten des Jobs mit OTP: {str(e)}")
logger.error(f"Fehler beim Abrufen des Job-Status: {str(e)}")
return jsonify({"error": "Fehler beim Verarbeiten der Anfrage"}), 500
@guest_blueprint.route('/api/notifications', methods=['GET'])
@ -640,4 +646,129 @@ def api_update_request(request_id):
@approver_required
def admin_requests_management():
"""Admin-Oberfläche für die Verwaltung von Gastanfragen."""
return render_template('admin_guest_requests.html')
return render_template('admin_guest_requests.html')
@guest_blueprint.route('/api/requests/<int:request_id>/approve', methods=['POST'])
@approver_required
def api_approve_request(request_id):
"""Gastanfrage genehmigen."""
try:
data = request.get_json() or {}
approval_notes = data.get('notes', '')
printer_id = data.get('printer_id') # Optional: Drucker zuweisen/ändern
with get_cached_session() as db_session:
guest_request = db_session.query(GuestRequest).filter_by(id=request_id).first()
if not guest_request:
return jsonify({"error": "Anfrage nicht gefunden"}), 404
if guest_request.status != "pending":
return jsonify({"error": "Anfrage wurde bereits bearbeitet"}), 400
# Drucker validieren, falls angegeben
if printer_id:
printer = db_session.query(Printer).filter_by(id=printer_id, active=True).first()
if not printer:
return jsonify({"error": "Ungültiger Drucker ausgewählt"}), 400
guest_request.printer_id = printer_id
# Sicherstellen, dass ein Drucker zugewiesen ist
if not guest_request.printer_id:
return jsonify({"error": "Kein Drucker zugewiesen. Bitte wählen Sie einen Drucker aus."}), 400
# Anfrage genehmigen
guest_request.status = "approved"
guest_request.processed_by = current_user.id
guest_request.processed_at = datetime.now()
guest_request.approval_notes = approval_notes
# OTP generieren
otp_plain = guest_request.generate_otp()
# Zugehörigen Job erstellen
start_time = datetime.now() + timedelta(minutes=5) # Start in 5 Minuten
end_time = start_time + timedelta(minutes=guest_request.duration_min)
# Admin-Benutzer als Eigentümer verwenden
admin_user = db_session.query(User).filter_by(is_admin=True).first()
if not admin_user:
admin_user = current_user # Fallback auf aktuellen Benutzer
job = Job(
name=f"Gastauftrag: {guest_request.name}",
description=guest_request.reason or "Gastauftrag",
user_id=admin_user.id,
printer_id=guest_request.printer_id,
start_at=start_time,
end_at=end_time,
status="scheduled",
duration_minutes=guest_request.duration_min,
owner_id=admin_user.id
)
db_session.add(job)
db_session.flush() # ID generieren
# Job-ID in Gastanfrage speichern
guest_request.job_id = job.id
db_session.commit()
logger.info(f"Gastanfrage {request_id} genehmigt von Admin {current_user.id} ({current_user.username})")
return jsonify({
"success": True,
"status": "approved",
"job_id": job.id,
"otp": otp_plain, # Nur in dieser Antwort wird der OTP-Klartext zurückgegeben
"approved_by": current_user.username,
"approved_at": guest_request.processed_at.isoformat(),
"notes": approval_notes,
"message": f"Anfrage genehmigt. Zugangscode: {otp_plain}"
})
except Exception as e:
logger.error(f"Fehler beim Genehmigen der Gastanfrage: {str(e)}")
return jsonify({"error": "Fehler beim Verarbeiten der Anfrage"}), 500
@guest_blueprint.route('/api/requests/<int:request_id>/deny', methods=['POST'])
@approver_required
def api_deny_request(request_id):
"""Gastanfrage ablehnen."""
try:
data = request.get_json() or {}
rejection_reason = data.get('reason', '')
if not rejection_reason.strip():
return jsonify({"error": "Ablehnungsgrund ist erforderlich"}), 400
with get_cached_session() as db_session:
guest_request = db_session.query(GuestRequest).filter_by(id=request_id).first()
if not guest_request:
return jsonify({"error": "Anfrage nicht gefunden"}), 404
if guest_request.status != "pending":
return jsonify({"error": "Anfrage wurde bereits bearbeitet"}), 400
# Anfrage ablehnen
guest_request.status = "denied"
guest_request.processed_by = current_user.id
guest_request.processed_at = datetime.now()
guest_request.rejection_reason = rejection_reason
db_session.commit()
logger.info(f"Gastanfrage {request_id} abgelehnt von Admin {current_user.id} ({current_user.username}): {rejection_reason}")
return jsonify({
"success": True,
"status": "denied",
"rejected_by": current_user.username,
"rejected_at": guest_request.processed_at.isoformat(),
"reason": rejection_reason,
"message": "Anfrage wurde abgelehnt"
})
except Exception as e:
logger.error(f"Fehler beim Ablehnen der Gastanfrage: {str(e)}")
return jsonify({"error": "Fehler beim Verarbeiten der Anfrage"}), 500

View File

@ -819,21 +819,28 @@ class GuestRequest(Base):
def verify_otp(self, otp_plain: str) -> bool:
"""
Überprüft, ob der angegebene OTP-Code gültig ist.
Verifiziert einen OTP-Code gegen den gespeicherten Hash.
Args:
otp_plain: Der zu überprüfende OTP-Code im Klartext
otp_plain: Der zu prüfende OTP-Code im Klartext
Returns:
bool: True, wenn der Code gültig ist, sonst False
bool: True wenn der Code korrekt ist, False andernfalls
"""
if not self.otp_code:
if not self.otp_code or not otp_plain:
return False
otp_bytes = otp_plain.encode('utf-8')
hash_bytes = self.otp_code.encode('utf-8')
return bcrypt.checkpw(otp_bytes, hash_bytes)
try:
# Code normalisieren (Großbuchstaben)
otp_plain = otp_plain.upper().strip()
# Hash verifizieren
otp_bytes = otp_plain.encode('utf-8')
stored_hash = self.otp_code.encode('utf-8')
return bcrypt.checkpw(otp_bytes, stored_hash)
except Exception:
return False
# ===== DATENBANK-INITIALISIERUNG MIT OPTIMIERUNGEN =====

View File

@ -1 +1,699 @@
/**
* Mercedes-Benz MYP Platform - Professional Theme
* Professionelle Light/Dark Mode Implementierung
*/
/* Globale CSS-Variablen für konsistente Theming */
:root {
/* Mercedes-Benz Markenfarben */
--mb-primary: #3b82f6;
--mb-primary-dark: #1d4ed8;
--mb-secondary: #64748b;
--mb-accent: #0ea5e9;
/* Light Mode Farbpalette */
--light-bg-primary: #ffffff;
--light-bg-secondary: #f8fafc;
--light-bg-tertiary: #f1f5f9;
--light-surface: #ffffff;
--light-surface-hover: #f8fafc;
--light-text-primary: #0f172a;
--light-text-secondary: #475569;
--light-text-muted: #64748b;
--light-border: #e2e8f0;
--light-border-strong: #cbd5e1;
--light-shadow: rgba(0, 0, 0, 0.1);
--light-shadow-strong: rgba(0, 0, 0, 0.15);
/* Dark Mode Farbpalette */
--dark-bg-primary: #0f172a;
--dark-bg-secondary: #1e293b;
--dark-bg-tertiary: #334155;
--dark-surface: #1e293b;
--dark-surface-hover: #334155;
--dark-text-primary: #f8fafc;
--dark-text-secondary: #e2e8f0;
--dark-text-muted: #94a3b8;
--dark-border: #334155;
--dark-border-strong: #475569;
--dark-shadow: rgba(0, 0, 0, 0.3);
--dark-shadow-strong: rgba(0, 0, 0, 0.5);
}
/* Professionelle Hero-Header Stile */
.professional-hero {
position: relative;
overflow: hidden;
border-radius: 2rem;
margin: 1.5rem;
margin-bottom: 2rem;
background: linear-gradient(135deg, var(--light-bg-secondary) 0%, var(--light-bg-tertiary) 100%);
border: 1px solid var(--light-border);
box-shadow: 0 20px 40px var(--light-shadow);
}
.dark .professional-hero {
background: linear-gradient(135deg, var(--dark-bg-primary) 0%, var(--dark-bg-secondary) 100%);
border: 1px solid var(--dark-border);
box-shadow: 0 20px 40px var(--dark-shadow-strong);
}
.professional-hero::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(45deg, transparent 30%, rgba(255, 255, 255, 0.1) 50%, transparent 70%);
opacity: 0.5;
}
.dark .professional-hero::before {
background: linear-gradient(45deg, transparent 30%, rgba(255, 255, 255, 0.05) 50%, transparent 70%);
opacity: 0.3;
}
/* Hero Pattern Overlay */
.hero-pattern {
background-image:
radial-gradient(circle at 20% 20%, var(--light-border) 1px, transparent 1px),
radial-gradient(circle at 80% 80%, var(--light-border) 1px, transparent 1px);
background-size: 50px 50px;
background-position: 0 0, 25px 25px;
}
.dark .hero-pattern {
background-image:
radial-gradient(circle at 20% 20%, var(--dark-border) 1px, transparent 1px),
radial-gradient(circle at 80% 80%, var(--dark-border) 1px, transparent 1px);
}
/* Professionelle Container */
.professional-container {
background: var(--light-surface);
border: 1px solid var(--light-border);
border-radius: 1.5rem;
box-shadow: 0 10px 30px var(--light-shadow);
backdrop-filter: blur(20px);
transition: all 0.3s ease;
}
.dark .professional-container {
background: var(--dark-surface);
border: 1px solid var(--dark-border);
box-shadow: 0 10px 30px var(--dark-shadow);
}
.professional-container:hover {
transform: translateY(-4px);
box-shadow: 0 20px 40px var(--light-shadow-strong);
}
.dark .professional-container:hover {
box-shadow: 0 20px 40px var(--dark-shadow-strong);
}
/* Mercedes-Benz Glassmorphism Effekt */
.mb-glass {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.dark .mb-glass {
background: rgba(15, 23, 42, 0.9);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.mb-glass:hover {
background: rgba(255, 255, 255, 0.95);
transform: translateY(-2px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
}
.dark .mb-glass:hover {
background: rgba(15, 23, 42, 0.95);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
}
/* Professional Buttons */
.btn-professional {
background: linear-gradient(135deg, var(--mb-primary) 0%, var(--mb-primary-dark) 100%);
color: white;
border: none;
border-radius: 1rem;
padding: 0.75rem 2rem;
font-weight: 600;
font-size: 0.875rem;
letter-spacing: 0.025em;
text-transform: uppercase;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.3);
}
.btn-professional::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
}
.btn-professional:hover::before {
left: 100%;
}
.btn-professional:hover {
background: linear-gradient(135deg, var(--mb-primary-dark) 0%, #1e40af 100%);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(59, 130, 246, 0.4);
}
.btn-professional:active {
transform: translateY(0);
}
/* Secondary Button Style */
.btn-secondary-professional {
background: var(--light-surface);
color: var(--light-text-primary);
border: 2px solid var(--light-border-strong);
border-radius: 1rem;
padding: 0.75rem 2rem;
font-weight: 600;
font-size: 0.875rem;
transition: all 0.3s ease;
box-shadow: 0 4px 15px var(--light-shadow);
}
.dark .btn-secondary-professional {
background: var(--dark-surface);
color: var(--dark-text-primary);
border-color: var(--dark-border-strong);
box-shadow: 0 4px 15px var(--dark-shadow);
}
.btn-secondary-professional:hover {
background: var(--light-surface-hover);
border-color: var(--mb-primary);
transform: translateY(-2px);
box-shadow: 0 8px 25px var(--light-shadow-strong);
}
.dark .btn-secondary-professional:hover {
background: var(--dark-surface-hover);
box-shadow: 0 8px 25px var(--dark-shadow);
}
/* Professional Input Fields */
.input-professional {
background: var(--light-surface);
border: 2px solid var(--light-border);
border-radius: 0.75rem;
padding: 0.875rem 1rem;
color: var(--light-text-primary);
font-size: 0.875rem;
transition: all 0.3s ease;
box-shadow: 0 2px 8px var(--light-shadow);
}
.dark .input-professional {
background: var(--dark-surface);
border-color: var(--dark-border);
color: var(--dark-text-primary);
box-shadow: 0 2px 8px var(--dark-shadow);
}
.input-professional:focus {
border-color: var(--mb-primary);
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
transform: translateY(-1px);
}
.input-professional::placeholder {
color: var(--light-text-muted);
}
.dark .input-professional::placeholder {
color: var(--dark-text-muted);
}
/* Professional Cards */
.card-professional {
background: var(--light-surface);
border: 1px solid var(--light-border);
border-radius: 1.25rem;
padding: 1.5rem;
box-shadow: 0 4px 20px var(--light-shadow);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.dark .card-professional {
background: var(--dark-surface);
border-color: var(--dark-border);
box-shadow: 0 4px 20px var(--dark-shadow);
}
.card-professional::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: linear-gradient(90deg, var(--mb-primary), var(--mb-accent));
transform: scaleX(0);
transition: transform 0.3s ease;
}
.card-professional:hover::before {
transform: scaleX(1);
}
.card-professional:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px var(--light-shadow-strong);
}
.dark .card-professional:hover {
box-shadow: 0 12px 40px var(--dark-shadow-strong);
}
/* Professional Statistics Cards */
.stat-card {
background: var(--light-surface);
border: 1px solid var(--light-border);
border-radius: 1rem;
padding: 1.5rem;
text-align: center;
transition: all 0.3s ease;
box-shadow: 0 4px 15px var(--light-shadow);
position: relative;
overflow: hidden;
}
.dark .stat-card {
background: var(--dark-surface);
border-color: var(--dark-border);
box-shadow: 0 4px 15px var(--dark-shadow);
}
.stat-card:hover {
transform: translateY(-2px) scale(1.02);
box-shadow: 0 8px 30px var(--light-shadow-strong);
}
.dark .stat-card:hover {
box-shadow: 0 8px 30px var(--dark-shadow-strong);
}
.stat-number {
font-size: 2.5rem;
font-weight: 700;
color: var(--light-text-primary);
line-height: 1;
margin-bottom: 0.5rem;
}
.dark .stat-number {
color: var(--dark-text-primary);
}
.stat-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--light-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.dark .stat-label {
color: var(--dark-text-muted);
}
/* Professional Status Badges */
.status-professional {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
transition: all 0.2s ease;
border: 1px solid transparent;
}
.status-professional:hover {
transform: scale(1.05);
}
/* Status-spezifische Farben */
.status-pending {
background: rgba(251, 191, 36, 0.1);
color: #92400e;
border-color: rgba(251, 191, 36, 0.3);
}
.dark .status-pending {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
}
.status-approved {
background: rgba(16, 185, 129, 0.1);
color: #065f46;
border-color: rgba(16, 185, 129, 0.3);
}
.dark .status-approved {
background: rgba(16, 185, 129, 0.2);
color: #10b981;
}
.status-denied {
background: rgba(239, 68, 68, 0.1);
color: #991b1b;
border-color: rgba(239, 68, 68, 0.3);
}
.dark .status-denied {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
/* Professional Typography */
.title-professional {
background: linear-gradient(135deg, var(--light-text-primary) 0%, var(--light-text-secondary) 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 700;
letter-spacing: -0.025em;
line-height: 1.1;
}
.dark .title-professional {
background: linear-gradient(135deg, var(--dark-text-primary) 0%, var(--dark-text-secondary) 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle-professional {
color: var(--light-text-muted);
font-size: 1.125rem;
line-height: 1.6;
font-weight: 400;
}
.dark .subtitle-professional {
color: var(--dark-text-muted);
}
/* Professional Navigation */
.nav-professional {
background: var(--light-surface);
border: 1px solid var(--light-border);
border-radius: 1rem;
padding: 0.5rem;
box-shadow: 0 4px 15px var(--light-shadow);
backdrop-filter: blur(20px);
}
.dark .nav-professional {
background: var(--dark-surface);
border-color: var(--dark-border);
box-shadow: 0 4px 15px var(--dark-shadow);
}
.nav-item-professional {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
color: var(--light-text-secondary);
text-decoration: none;
font-weight: 500;
transition: all 0.2s ease;
position: relative;
}
.dark .nav-item-professional {
color: var(--dark-text-secondary);
}
.nav-item-professional:hover {
background: var(--light-surface-hover);
color: var(--light-text-primary);
transform: translateX(4px);
}
.dark .nav-item-professional:hover {
background: var(--dark-surface-hover);
color: var(--dark-text-primary);
}
.nav-item-professional.active {
background: rgba(59, 130, 246, 0.1);
color: var(--mb-primary);
font-weight: 600;
}
.dark .nav-item-professional.active {
background: rgba(59, 130, 246, 0.2);
}
/* Professional Tables */
.table-professional {
width: 100%;
border-collapse: collapse;
background: var(--light-surface);
border-radius: 1rem;
overflow: hidden;
box-shadow: 0 4px 20px var(--light-shadow);
}
.dark .table-professional {
background: var(--dark-surface);
box-shadow: 0 4px 20px var(--dark-shadow);
}
.table-professional th {
background: var(--light-bg-secondary);
color: var(--light-text-primary);
font-weight: 600;
text-align: left;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--light-border);
}
.dark .table-professional th {
background: var(--dark-bg-secondary);
color: var(--dark-text-primary);
border-bottom-color: var(--dark-border);
}
.table-professional td {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--light-border);
color: var(--light-text-secondary);
}
.dark .table-professional td {
border-bottom-color: var(--dark-border);
color: var(--dark-text-secondary);
}
.table-professional tbody tr:hover {
background: var(--light-surface-hover);
}
.dark .table-professional tbody tr:hover {
background: var(--dark-surface-hover);
}
/* Professional Alerts */
.alert-professional {
border-radius: 1rem;
padding: 1.5rem;
border: 1px solid transparent;
display: flex;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
box-shadow: 0 4px 15px var(--light-shadow);
}
.alert-info {
background: rgba(59, 130, 246, 0.1);
border-color: rgba(59, 130, 246, 0.3);
color: #1e40af;
}
.dark .alert-info {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
.alert-success {
background: rgba(16, 185, 129, 0.1);
border-color: rgba(16, 185, 129, 0.3);
color: #065f46;
}
.dark .alert-success {
background: rgba(16, 185, 129, 0.2);
color: #10b981;
}
.alert-warning {
background: rgba(251, 191, 36, 0.1);
border-color: rgba(251, 191, 36, 0.3);
color: #92400e;
}
.dark .alert-warning {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
}
.alert-error {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
color: #991b1b;
}
.dark .alert-error {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
/* Background Gradients für verschiedene Seiten */
.bg-professional {
background: linear-gradient(135deg, var(--light-bg-primary) 0%, var(--light-bg-secondary) 50%, var(--light-bg-tertiary) 100%);
min-height: 100vh;
}
.dark .bg-professional {
background: linear-gradient(135deg, var(--dark-bg-primary) 0%, var(--dark-bg-secondary) 50%, var(--dark-bg-tertiary) 100%);
}
/* Utilities */
.text-professional-primary {
color: var(--light-text-primary);
}
.dark .text-professional-primary {
color: var(--dark-text-primary);
}
.text-professional-secondary {
color: var(--light-text-secondary);
}
.dark .text-professional-secondary {
color: var(--dark-text-secondary);
}
.text-professional-muted {
color: var(--light-text-muted);
}
.dark .text-professional-muted {
color: var(--dark-text-muted);
}
/* Smooth transitions für alle professionellen Komponenten */
.professional-hero,
.professional-container,
.mb-glass,
.btn-professional,
.btn-secondary-professional,
.input-professional,
.card-professional,
.stat-card,
.status-professional,
.nav-professional,
.nav-item-professional,
.table-professional,
.alert-professional {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Responsive Design */
@media (max-width: 768px) {
.professional-hero {
margin: 1rem;
border-radius: 1.5rem;
}
.card-professional {
padding: 1rem;
}
.stat-number {
font-size: 2rem;
}
.btn-professional,
.btn-secondary-professional {
padding: 0.625rem 1.5rem;
font-size: 0.8rem;
}
}
/* Animation-Klassen */
.animate-fade-in {
animation: fadeInProfessional 0.6s ease-out;
}
.animate-slide-up {
animation: slideUpProfessional 0.6s ease-out;
}
.animate-scale-in {
animation: scaleInProfessional 0.4s ease-out;
}
@keyframes fadeInProfessional {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUpProfessional {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scaleInProfessional {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}

View File

@ -24,6 +24,8 @@
<!-- CSS -->
<link href="{{ url_for('static', filename='css/tailwind.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/components.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/professional-theme.css') }}" rel="stylesheet">
<!-- Preload critical resources -->
<link rel="preload" href="{{ url_for('static', filename='js/ui-components.js') }}" as="script">

View File

@ -0,0 +1,365 @@
{% extends "base.html" %}
{% block title %}Job mit Code starten{% endblock %}
{% block content %}
<div class="min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900">
<!-- Header Section -->
<div class="relative overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-r from-blue-600/10 to-purple-600/10"></div>
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
<h1 class="text-4xl lg:text-6xl font-bold text-slate-900 dark:text-white mb-6">
<span class="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
Job starten
</span>
</h1>
<p class="text-xl text-slate-600 dark:text-slate-400 max-w-3xl mx-auto leading-relaxed">
Geben Sie Ihren 6-stelligen Zugangscode ein, um Ihren genehmigten Druckauftrag zu starten.
</p>
</div>
</div>
<!-- Main Content -->
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 -mt-8 relative z-10">
<!-- Code-Eingabe Container -->
<div class="form-container professional-shadow p-8 lg:p-12">
<div class="text-center mb-10">
<div class="w-20 h-20 bg-gradient-to-r from-green-500 to-emerald-500 rounded-full flex items-center justify-center mx-auto mb-6">
<svg class="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-3a1 1 0 011-1h2.586l6.243-6.243A6 6 0 0121 9z"/>
</svg>
</div>
<h2 class="text-3xl font-bold text-slate-900 dark:text-white mb-4">
Zugangscode eingeben
</h2>
<p class="text-lg text-slate-600 dark:text-slate-400">
Ihr persönlicher 6-stelliger Code wurde Ihnen nach der Genehmigung mitgeteilt.
</p>
</div>
<!-- Erfolgs-Nachricht (versteckt) -->
<div id="successMessage" class="hidden mb-8 p-6 bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 border border-green-200 dark:border-green-700 rounded-2xl">
<div class="flex items-center">
<div class="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center mr-4">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
<div>
<h3 class="text-lg font-semibold text-green-800 dark:text-green-200">Job erfolgreich gestartet!</h3>
<p class="text-green-700 dark:text-green-300" id="successDetails"></p>
</div>
</div>
</div>
<!-- Fehler-Nachricht (versteckt) -->
<div id="errorMessage" class="hidden mb-8 p-6 bg-gradient-to-r from-red-50 to-rose-50 dark:from-red-900/20 dark:to-rose-900/20 border border-red-200 dark:border-red-700 rounded-2xl">
<div class="flex items-center">
<div class="w-8 h-8 bg-red-500 rounded-full flex items-center justify-center mr-4">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</div>
<div>
<h3 class="text-lg font-semibold text-red-800 dark:text-red-200">Fehler beim Starten</h3>
<p class="text-red-700 dark:text-red-300" id="errorDetails"></p>
</div>
</div>
</div>
<form id="codeForm" class="space-y-8">
<!-- Code-Eingabe -->
<div class="group">
<label for="accessCode" class="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-4">
6-stelliger Zugangscode <span class="text-red-500">*</span>
</label>
<!-- Code-Input mit einzelnen Feldern -->
<div class="flex justify-center space-x-3 mb-4">
<input type="text" id="code1" maxlength="1"
class="code-input w-16 h-16 text-center text-2xl font-bold border border-slate-300 dark:border-slate-600 rounded-xl focus:ring-4 focus:ring-blue-500/25 focus:border-blue-500 dark:bg-slate-800 dark:text-white"
oninput="moveToNext(this, 'code2')" onkeydown="handleBackspace(event, this, null)">
<input type="text" id="code2" maxlength="1"
class="code-input w-16 h-16 text-center text-2xl font-bold border border-slate-300 dark:border-slate-600 rounded-xl focus:ring-4 focus:ring-blue-500/25 focus:border-blue-500 dark:bg-slate-800 dark:text-white"
oninput="moveToNext(this, 'code3')" onkeydown="handleBackspace(event, this, 'code1')">
<input type="text" id="code3" maxlength="1"
class="code-input w-16 h-16 text-center text-2xl font-bold border border-slate-300 dark:border-slate-600 rounded-xl focus:ring-4 focus:ring-blue-500/25 focus:border-blue-500 dark:bg-slate-800 dark:text-white"
oninput="moveToNext(this, 'code4')" onkeydown="handleBackspace(event, this, 'code2')">
<input type="text" id="code4" maxlength="1"
class="code-input w-16 h-16 text-center text-2xl font-bold border border-slate-300 dark:border-slate-600 rounded-xl focus:ring-4 focus:ring-blue-500/25 focus:border-blue-500 dark:bg-slate-800 dark:text-white"
oninput="moveToNext(this, 'code5')" onkeydown="handleBackspace(event, this, 'code3')">
<input type="text" id="code5" maxlength="1"
class="code-input w-16 h-16 text-center text-2xl font-bold border border-slate-300 dark:border-slate-600 rounded-xl focus:ring-4 focus:ring-blue-500/25 focus:border-blue-500 dark:bg-slate-800 dark:text-white"
oninput="moveToNext(this, 'code6')" onkeydown="handleBackspace(event, this, 'code4')">
<input type="text" id="code6" maxlength="1"
class="code-input w-16 h-16 text-center text-2xl font-bold border border-slate-300 dark:border-slate-600 rounded-xl focus:ring-4 focus:ring-blue-500/25 focus:border-blue-500 dark:bg-slate-800 dark:text-white"
oninput="moveToNext(this, null)" onkeydown="handleBackspace(event, this, 'code5')">
</div>
<div class="text-center">
<p class="text-sm text-slate-500 dark:text-slate-400">
Der Code besteht aus 6 Zeichen (Großbuchstaben und Zahlen)
</p>
</div>
</div>
<!-- Submit Button -->
<div class="text-center pt-6">
<button type="submit" id="submitBtn"
class="w-full sm:w-auto px-12 py-4 bg-gradient-to-r from-blue-600 to-indigo-600 text-white font-semibold rounded-2xl hover:from-blue-700 hover:to-indigo-700 focus:outline-none focus:ring-4 focus:ring-blue-500/25 transform transition-all duration-300 hover:scale-105 shadow-lg disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none">
<span class="flex items-center justify-center">
<svg class="w-6 h-6 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-6 4h.01M12 5v.01M12 19v.01M12 12h.01M12 9a3 3 0 100-6 3 3 0 000 6zm0 0a3 3 0 100 6 3 3 0 000-6z"/>
</svg>
Job jetzt starten
</span>
</button>
</div>
</form>
<!-- Hilfe-Sektion -->
<div class="mt-12 pt-8 border-t border-slate-200 dark:border-slate-700">
<div class="text-center">
<h3 class="text-lg font-semibold text-slate-900 dark:text-white mb-4">
Brauchen Sie Hilfe?
</h3>
<div class="space-y-3 text-sm text-slate-600 dark:text-slate-400">
<p>• Ihr Zugangscode wurde Ihnen nach der Genehmigung mitgeteilt</p>
<p>• Der Code ist nur einmalig verwendbar und hat eine begrenzte Gültigkeit</p>
<p>• Bei Problemen wenden Sie sich an den Administrator</p>
</div>
<div class="mt-6">
<a href="{{ url_for('guest.guest_request_form') }}"
class="inline-flex items-center text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 font-medium">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
Neue Anfrage stellen
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.form-container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dark .form-container {
background: rgba(30, 41, 59, 0.95);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.professional-shadow {
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.code-input {
transition: all 0.2s ease;
}
.code-input:focus {
transform: scale(1.05);
}
.code-input.valid {
border-color: #10b981;
background-color: #ecfdf5;
}
.dark .code-input.valid {
border-color: #059669;
background-color: rgba(16, 185, 129, 0.1);
}
</style>
<script>
// Code-Eingabe-Logik
function moveToNext(current, nextId) {
const value = current.value.toUpperCase();
// Nur alphanumerische Zeichen erlauben
if (!/^[A-Z0-9]$/.test(value)) {
current.value = '';
return;
}
current.value = value;
current.classList.add('valid');
// Zum nächsten Feld wechseln
if (nextId && value) {
document.getElementById(nextId).focus();
}
// Prüfen ob alle Felder ausgefüllt sind
checkFormComplete();
}
function handleBackspace(event, current, prevId) {
if (event.key === 'Backspace') {
if (current.value === '' && prevId) {
event.preventDefault();
const prevField = document.getElementById(prevId);
prevField.focus();
prevField.value = '';
prevField.classList.remove('valid');
} else if (current.value !== '') {
current.classList.remove('valid');
}
}
}
function checkFormComplete() {
const inputs = ['code1', 'code2', 'code3', 'code4', 'code5', 'code6'];
const allFilled = inputs.every(id => document.getElementById(id).value !== '');
const submitBtn = document.getElementById('submitBtn');
submitBtn.disabled = !allFilled;
if (allFilled) {
submitBtn.classList.add('ring-4', 'ring-green-500/25');
} else {
submitBtn.classList.remove('ring-4', 'ring-green-500/25');
}
}
function getCodeValue() {
const inputs = ['code1', 'code2', 'code3', 'code4', 'code5', 'code6'];
return inputs.map(id => document.getElementById(id).value).join('');
}
function clearCode() {
const inputs = ['code1', 'code2', 'code3', 'code4', 'code5', 'code6'];
inputs.forEach(id => {
const input = document.getElementById(id);
input.value = '';
input.classList.remove('valid');
});
checkFormComplete();
document.getElementById('code1').focus();
}
function showSuccess(message, details) {
document.getElementById('successMessage').classList.remove('hidden');
document.getElementById('successDetails').textContent = details;
document.getElementById('errorMessage').classList.add('hidden');
// Scroll zur Nachricht
document.getElementById('successMessage').scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function showError(message, details) {
document.getElementById('errorMessage').classList.remove('hidden');
document.getElementById('errorDetails').textContent = details;
document.getElementById('successMessage').classList.add('hidden');
// Scroll zur Nachricht
document.getElementById('errorMessage').scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// Form-Submit-Handler
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('codeForm');
const submitBtn = document.getElementById('submitBtn');
// Erstes Feld fokussieren
document.getElementById('code1').focus();
form.addEventListener('submit', async function(e) {
e.preventDefault();
const code = getCodeValue();
if (code.length !== 6) {
showError('Ungültiger Code', 'Bitte geben Sie alle 6 Zeichen ein.');
return;
}
// Button-Animation
submitBtn.disabled = true;
const originalContent = submitBtn.innerHTML;
submitBtn.innerHTML = `
<span class="flex items-center justify-center">
<svg class="animate-spin w-5 h-5 mr-3" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Wird überprüft...
</span>
`;
try {
const response = await fetch('/api/guest/start-job', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ code: code })
});
const result = await response.json();
if (result.success) {
showSuccess(
'Job erfolgreich gestartet!',
`Ihr Job "${result.job_name}" wurde gestartet und läuft bis ${result.end_time}.`
);
// Form deaktivieren
form.style.opacity = '0.5';
form.style.pointerEvents = 'none';
// Nach 3 Sekunden zur Job-Status-Seite weiterleiten
setTimeout(() => {
if (result.job_id) {
window.location.href = `/guest/job/${result.job_id}/status`;
}
}, 3000);
} else {
showError('Code ungültig', result.error || 'Der eingegebene Code ist ungültig oder bereits verwendet.');
clearCode();
}
} catch (error) {
showError('Verbindungsfehler', 'Es gab ein Problem bei der Verbindung. Bitte versuchen Sie es erneut.');
clearCode();
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = originalContent;
checkFormComplete();
}
});
// Paste-Handler für kompletten Code
document.addEventListener('paste', function(e) {
const target = e.target;
if (target.classList.contains('code-input')) {
e.preventDefault();
const paste = (e.clipboardData || window.clipboardData).getData('text').toUpperCase();
if (paste.length === 6 && /^[A-Z0-9]+$/.test(paste)) {
const inputs = ['code1', 'code2', 'code3', 'code4', 'code5', 'code6'];
inputs.forEach((id, index) => {
const input = document.getElementById(id);
input.value = paste[index] || '';
if (input.value) input.classList.add('valid');
});
checkFormComplete();
document.getElementById('code6').focus();
}
}
});
});
</script>
{% endblock %}