feat: Zentrale per Durchwahl selbst besetzen

Das Busylight im Empfangsbereich wird wechselnd besetzt. Mitarbeiter
koennen sich jetzt selbst darauf setzen, ohne Admin-Passwort.

- Neue Seite /zentrale (Durchwahl eingeben) und /zentrale/{durchwahl}
  als Direktlink fuer QR-Codes. Der Direktlink zeigt nur an und aendert
  nichts, damit Link-Vorschau und Prefetch niemanden versetzen.
- Neue Tabelle home_assignments merkt das eigene Tuerschild, solange der
  Mitarbeiter in der Zentrale sitzt. Beim Verlassen wird es
  wiederhergestellt, das Zentral-Segment wird freigegeben.
- Der Vorgaenger bleibt ohne Zuordnung - er ist nicht da. Sein gemerkter
  Heimatplatz bleibt erhalten, er kann jederzeit zurueckkehren.
- Ist der Heimatplatz inzwischen belegt, wird die Rueckkehr abgelehnt
  und nichts geaendert.
- Eine ausdrueckliche Admin-Zuordnung verwirft den gemerkten Heimatplatz.
  Ein reines Loeschen der Zuordnung nicht, sonst kaeme niemand mehr von
  der Zentrale zurueck.
- Farbe wird sofort ans Geraet geschickt (scheduler.push_user_light).
  Das Polling allein reicht nicht: _process_user sendet nur bei einer
  Statusaenderung, und beim Platzwechsel bleibt der Status gleich.
- Neue Settings selfservice_device_id und selfservice_segment,
  einstellbar unter Einstellungen.
- Jeder Wechsel landet im Protokoll (zentrale / zentrale_frei).

Die Durchwahl ist eine Kennung, kein Geheimnis - im README dokumentiert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 08:12:42 +00:00
co-authored by Claude Opus 5
parent 65314f2338
commit 086b21dc2b
6 changed files with 554 additions and 3 deletions
+65 -1
View File
@@ -14,6 +14,7 @@ Jedes WLED-Geraet verfuegt ueber zwei Segmente (oben/unten), die jeweils einem B
- **Telefon-Webhook** fuer Telefonanlage (callstart/callend auf Port 8081)
- **Modulare Zeiterfassung** per CSV oder REST API - kompatibel mit jeder Zeiterfassung
- **Webbasierte Azure-Authentifizierung** direkt im Browser (kein Terminal noetig)
- **Zentrale (Self-Service)** - wechselnde Besetzung des Empfangs-Busylights per Durchwahl, ohne Admin-Passwort
- **Einstellungen** fuer Azure, Polling-Intervall und Zeiterfassung
- **Protokoll** der letzten Statusaenderungen
@@ -174,6 +175,59 @@ GET/POST http://SERVER:8081/{durchwahl}/callend - Anruf beendet (WLED -> zu
- Waehrend eines aktiven Telefonats ueberschreibt das Teams-Polling die Farbe nicht
- Die Telefon-Farbe (`phone_active`) ist unter **Farb-Regeln** konfigurierbar
## Zentrale (Self-Service)
Ein Busylight im Empfangsbereich wird wechselnd besetzt. Damit die Mitarbeiter das
selbst umstellen koennen, gibt es eine eigene Seite, die **ohne Admin-Passwort**
erreichbar ist und die Durchwahl als Kennung nutzt.
### Einrichtung
1. Unter **Benutzer** die **Durchwahl** pro Mitarbeiter eintragen (dieselbe wie fuer den Telefon-Webhook)
2. Unter **Einstellungen > Zentrale (Self-Service)** das Busylight des Empfangs und das Segment auswaehlen
3. Die Seite `http://SERVER:8080/zentrale` an die Mitarbeiter geben
### Bedienung
```
http://SERVER:8080/zentrale - Durchwahl eingeben und Zentrale uebernehmen
http://SERVER:8080/zentrale/{durchwahl} - Direktlink pro Person, z.B. als QR-Code
```
Der Direktlink zeigt nur den Stand an und aendert nichts - das Umstellen passiert
erst per Knopfdruck.
### Verhalten
- **Zentrale uebernehmen**: Der Mitarbeiter wird auf das Segment der Zentrale
gesetzt. Das eigene Tuerschild gibt er dabei ab, weil ein Mitarbeiter nur einer
Zuordnung gleichzeitig zugewiesen sein kann. Der alte Platz wird gemerkt und das
Segment ausgeschaltet.
- **Zurueck auf mein Tuerschild**: Der gemerkte Platz wird wiederhergestellt und die
Zentrale freigegeben (Farbe `abwesend`, Standard: aus).
- **Vorgaenger**: Wer die Zentrale abgibt, bleibt zunaechst ohne Zuordnung - er ist
ja nicht da. Sein gemerkter Heimatplatz bleibt erhalten, er kann jederzeit
zurueckkehren.
- **Belegter Heimatplatz**: Ist das eigene Tuerschild inzwischen jemand anderem
zugeordnet, wird die Rueckkehr abgelehnt und nichts geaendert. Dann muss der
Administrator die Zuordnung aufloesen.
- **Sofortiges Umschalten**: Die Farbe wird direkt ans Geraet geschickt. Das Polling
allein wuerde nicht reichen, weil es nur bei einer Statusaenderung sendet und der
Status des Mitarbeiters beim Platzwechsel unveraendert ist.
- **Protokoll**: Jeder Wechsel landet unter **Protokoll** (`zentrale` beim Uebernehmen,
`zentrale_frei` beim Freigeben).
- **Vorrang des Administrators**: Wird ein Mitarbeiter unter **Zuordnungen** neu
zugeordnet, verfaellt sein gemerkter Heimatplatz - die ausdrueckliche Zuordnung
gilt. Wird seine Zuordnung dort nur geloescht, bleibt der gemerkte Platz erhalten,
damit er von der Zentrale zurueckkehren kann.
### Sicherheit
Die Durchwahl ist eine Kennung, kein Geheimnis. Wer Port 8080 erreicht, kann jeden
Kollegen auf die Zentrale setzen. Fuer den internen Gebrauch ist das gewollt - die
Aenderung ist sofort sichtbar, im Protokoll nachvollziehbar und mit einem Klick
zurueckzunehmen. Die Seite gehoert deshalb nicht ins Internet.
## Starten
### Manuell (zum Testen)
@@ -238,7 +292,7 @@ busylight/
├── docs/ # Dokumentation
├── static/
│ └── style.css # CSS Styles
└── templates/ # Jinja2 HTML-Templates
└── templates/ # Jinja2 HTML-Templates (u.a. zentrale.html)
```
## Ports und Firewall
@@ -274,5 +328,15 @@ busylight/
- Unter Tuerschilder den Status pruefen ("Alle jetzt pruefen")
- Netzwerkverbindung pruefen: `curl http://HOSTNAME/json/info`
### Zentrale-Seite sagt "noch nicht eingerichtet"
Unter **Einstellungen > Zentrale (Self-Service)** ist kein Busylight ausgewaehlt.
Ohne diese Einstellung weiss die Anwendung nicht, welches Geraet der Empfang ist.
### Durchwahl wird auf der Zentrale-Seite nicht erkannt
Die Durchwahl muss unter **Benutzer** exakt so eingetragen sein wie eingegeben, und
der Benutzer muss **aktiv** sein. Inaktive Benutzer werden bewusst abgewiesen.
### Nach git pull aendert sich nichts
- Service neustarten: `sudo systemctl restart busylight`
+257 -1
View File
@@ -469,6 +469,10 @@ async def create_assignment(
"INSERT OR REPLACE INTO assignments (user_azure_id, wled_device_id, segment) VALUES (?, ?, ?)",
(user_azure_id, wled_device_id, segment),
)
# Eine ausdrueckliche Admin-Zuordnung setzt den fuer die Zentrale gemerkten
# Heimatplatz ausser Kraft, sonst wuerde die Rueckkehr auf einen veralteten
# Platz zeigen.
conn.execute("DELETE FROM home_assignments WHERE user_azure_id=?", (user_azure_id,))
conn.commit()
conn.close()
return RedirectResponse("/assignments", status_code=303)
@@ -477,12 +481,258 @@ async def create_assignment(
@app.post("/api/assignments/{assignment_id}/delete")
async def delete_assignment(assignment_id: int):
conn = database.get_db()
# Der fuer die Zentrale gemerkte Heimatplatz bleibt hier absichtlich stehen:
# wer von der Zentrale entfernt wird, soll noch zurueckkehren koennen.
conn.execute("DELETE FROM assignments WHERE id=?", (assignment_id,))
conn.commit()
conn.close()
return RedirectResponse("/assignments", status_code=303)
# ── Zentrale (Self-Service per Durchwahl) ──────────────────────
def _selfservice_target(conn):
"""Konfiguriertes Zentral-Geraet + Segment, oder None wenn nicht eingerichtet."""
raw_id = database.get_setting("selfservice_device_id", "")
if not raw_id:
return None
try:
device_id = int(raw_id)
except (TypeError, ValueError):
return None
dev = conn.execute(
"SELECT id, hostname, name FROM wled_devices WHERE id = ?", (device_id,)
).fetchone()
if not dev:
return None
try:
segment = int(database.get_setting("selfservice_segment", "0"))
except (TypeError, ValueError):
segment = 0
return {
"id": dev["id"],
"hostname": dev["hostname"],
"name": dev["name"] or dev["hostname"],
"segment": segment,
}
def _user_by_extension(extension, conn):
"""Aktiven Benutzer anhand der Telefon-Durchwahl finden."""
ext = (extension or "").strip()
if not ext:
return None
return conn.execute("""
SELECT azure_id, display_name, phone_extension
FROM users
WHERE active = 1 AND TRIM(phone_extension) = ?
""", (ext,)).fetchone()
def _zentrale_view(request, conn, target, extension="", user=None, error=None, message=None):
"""Kontext fuer die Self-Service-Seite zusammenbauen."""
occupant = None
if target:
occupant = conn.execute("""
SELECT u.display_name
FROM assignments a JOIN users u ON u.azure_id = a.user_azure_id
WHERE a.wled_device_id = ? AND a.segment = ?
""", (target["id"], target["segment"])).fetchone()
home = None
is_here = False
if user:
home = conn.execute("""
SELECT h.segment, d.name, d.hostname
FROM home_assignments h JOIN wled_devices d ON d.id = h.wled_device_id
WHERE h.user_azure_id = ?
""", (user["azure_id"],)).fetchone()
if target:
is_here = conn.execute("""
SELECT 1 FROM assignments
WHERE user_azure_id = ? AND wled_device_id = ? AND segment = ?
""", (user["azure_id"], target["id"], target["segment"])).fetchone() is not None
return templates.TemplateResponse(request, "zentrale.html", {
"page": "zentrale",
"target": target,
"occupant": occupant["display_name"] if occupant else None,
"extension": extension,
"user": user,
"home": home,
"is_here": is_here,
"error": error,
"message": message,
})
@app.get("/zentrale", response_class=HTMLResponse)
async def zentrale_page(request: Request):
"""Self-Service-Seite fuer den Empfangsbereich. Absichtlich ohne Admin-Login."""
conn = database.get_db()
try:
return _zentrale_view(request, conn, _selfservice_target(conn))
finally:
conn.close()
@app.get("/zentrale/{extension}", response_class=HTMLResponse)
async def zentrale_page_for_extension(request: Request, extension: str):
"""Direktlink pro Mitarbeiter (z.B. als QR-Code). Zeigt nur an, aendert nichts."""
conn = database.get_db()
try:
target = _selfservice_target(conn)
user = _user_by_extension(extension, conn)
error = None if user else f"Durchwahl {extension} ist keinem aktiven Benutzer zugeordnet."
return _zentrale_view(request, conn, target, extension=extension, user=user, error=error)
finally:
conn.close()
@app.post("/api/zentrale/uebernehmen", response_class=HTMLResponse)
async def zentrale_takeover(request: Request, extension: str = Form(...)):
"""Mitarbeiter setzt sich selbst auf das Busylight der Zentrale."""
conn = database.get_db()
try:
target = _selfservice_target(conn)
if not target:
return _zentrale_view(request, conn, target, extension=extension,
error="Die Zentrale ist noch nicht eingerichtet. Bitte den Administrator informieren.")
user = _user_by_extension(extension, conn)
if not user:
return _zentrale_view(request, conn, target, extension=extension,
error=f"Durchwahl {extension} ist keinem aktiven Benutzer zugeordnet.")
# Sitzt der Benutzer schon dort? Dann nur das Licht nachziehen.
already = conn.execute("""
SELECT 1 FROM assignments
WHERE user_azure_id = ? AND wled_device_id = ? AND segment = ?
""", (user["azure_id"], target["id"], target["segment"])).fetchone()
if already:
scheduler.push_user_light(user["azure_id"], user["display_name"],
target["hostname"], target["segment"], conn)
return _zentrale_view(request, conn, target, extension=extension, user=user,
message=f"{user['display_name']} ist bereits auf der Zentrale eingetragen.")
# Bisheriger Platz des Benutzers wird als Heimat-Tuerschild gemerkt
own = conn.execute("""
SELECT a.wled_device_id, a.segment, d.hostname, d.name
FROM assignments a JOIN wled_devices d ON d.id = a.wled_device_id
WHERE a.user_azure_id = ?
""", (user["azure_id"],)).fetchone()
predecessor = conn.execute("""
SELECT u.display_name
FROM assignments a JOIN users u ON u.azure_id = a.user_azure_id
WHERE a.wled_device_id = ? AND a.segment = ?
""", (target["id"], target["segment"])).fetchone()
if own:
conn.execute("""
INSERT OR REPLACE INTO home_assignments (user_azure_id, wled_device_id, segment)
VALUES (?, ?, ?)
""", (user["azure_id"], own["wled_device_id"], own["segment"]))
# Zentral-Segment freimachen, eigene Zuordnung loesen, neu eintragen.
# Beide DELETEs sind noetig: UNIQUE(wled_device_id, segment) und UNIQUE(user_azure_id).
conn.execute("DELETE FROM assignments WHERE wled_device_id = ? AND segment = ?",
(target["id"], target["segment"]))
conn.execute("DELETE FROM assignments WHERE user_azure_id = ?", (user["azure_id"],))
conn.execute("""
INSERT INTO assignments (user_azure_id, wled_device_id, segment) VALUES (?, ?, ?)
""", (user["azure_id"], target["id"], target["segment"]))
conn.commit()
# Licht sofort setzen - das Polling wuerde bei unveraendertem Status nichts senden
scheduler.push_user_light(user["azure_id"], user["display_name"],
target["hostname"], target["segment"], conn)
if own:
scheduler.clear_segment(own["hostname"], own["segment"], conn)
logger.info("Zentrale uebernommen von %s (Durchwahl %s), Vorgaenger: %s",
user["display_name"], extension,
predecessor["display_name"] if predecessor else "-")
msg = f"{user['display_name']} sitzt jetzt auf der Zentrale ({target['name']})."
if own:
msg += f" Das eigene Tuerschild ({own['name'] or own['hostname']}) ist bis zur Rueckkehr ausgeschaltet."
return _zentrale_view(request, conn, target, extension=extension, user=user, message=msg)
finally:
conn.close()
@app.post("/api/zentrale/verlassen", response_class=HTMLResponse)
async def zentrale_release(request: Request, extension: str = Form(...)):
"""Mitarbeiter verlaesst die Zentrale und geht auf sein Heimat-Tuerschild zurueck."""
conn = database.get_db()
try:
target = _selfservice_target(conn)
user = _user_by_extension(extension, conn)
if not user:
return _zentrale_view(request, conn, target, extension=extension,
error=f"Durchwahl {extension} ist keinem aktiven Benutzer zugeordnet.")
was_here = bool(target) and conn.execute("""
SELECT 1 FROM assignments
WHERE user_azure_id = ? AND wled_device_id = ? AND segment = ?
""", (user["azure_id"], target["id"], target["segment"])).fetchone() is not None
home = conn.execute("""
SELECT h.wled_device_id, h.segment, d.hostname, d.name
FROM home_assignments h JOIN wled_devices d ON d.id = h.wled_device_id
WHERE h.user_azure_id = ?
""", (user["azure_id"],)).fetchone()
if not home:
if not was_here:
return _zentrale_view(request, conn, target, extension=extension, user=user,
error="Es ist kein Heimat-Tuerschild gespeichert und du sitzt nicht auf der Zentrale.")
# Nur von der Zentrale abmelden, es gibt keinen Platz zum Zurueckgehen
conn.execute("DELETE FROM assignments WHERE user_azure_id = ?", (user["azure_id"],))
conn.commit()
scheduler.clear_segment(target["hostname"], target["segment"], conn)
logger.info("Zentrale verlassen von %s (kein Heimat-Tuerschild)", user["display_name"])
return _zentrale_view(request, conn, target, extension=extension, user=user,
message=f"{user['display_name']} ist von der Zentrale abgemeldet. Ein eigenes Tuerschild war nicht gespeichert.")
# Ist der Heimatplatz inzwischen von jemand anderem belegt?
blocker = conn.execute("""
SELECT u.display_name
FROM assignments a JOIN users u ON u.azure_id = a.user_azure_id
WHERE a.wled_device_id = ? AND a.segment = ? AND a.user_azure_id != ?
""", (home["wled_device_id"], home["segment"], user["azure_id"])).fetchone()
if blocker:
return _zentrale_view(request, conn, target, extension=extension, user=user,
error=(f"Das eigene Tuerschild ({home['name'] or home['hostname']}, Segment {home['segment']}) "
f"ist inzwischen {blocker['display_name']} zugeordnet. "
"Bitte den Administrator ansprechen - es wurde nichts geaendert."))
conn.execute("DELETE FROM assignments WHERE user_azure_id = ?", (user["azure_id"],))
conn.execute("""
INSERT INTO assignments (user_azure_id, wled_device_id, segment) VALUES (?, ?, ?)
""", (user["azure_id"], home["wled_device_id"], home["segment"]))
conn.execute("DELETE FROM home_assignments WHERE user_azure_id = ?", (user["azure_id"],))
conn.commit()
scheduler.push_user_light(user["azure_id"], user["display_name"],
home["hostname"], home["segment"], conn)
if was_here:
scheduler.clear_segment(target["hostname"], target["segment"], conn)
logger.info("Zentrale verlassen von %s, zurueck auf %s Segment %s",
user["display_name"], home["hostname"], home["segment"])
return _zentrale_view(request, conn, target, extension=extension, user=user,
message=(f"{user['display_name']} ist zurueck auf dem eigenen Tuerschild "
f"({home['name'] or home['hostname']}, Segment {home['segment']})."))
finally:
conn.close()
# ── Color Rules ────────────────────────────────────────────────────────────
@app.get("/colors", response_class=HTMLResponse)
@@ -548,7 +798,12 @@ async def settings_page(request: Request):
redir = _require_login(request)
if redir: return redir
settings = database.get_all_settings()
return templates.TemplateResponse(*_ctx(request, "settings", settings=settings))
conn = database.get_db()
devices = conn.execute(
"SELECT id, hostname, name, segment_count FROM wled_devices ORDER BY name"
).fetchall()
conn.close()
return templates.TemplateResponse(*_ctx(request, "settings", settings=settings, devices=devices))
@app.post("/api/settings")
@@ -559,6 +814,7 @@ async def update_settings(request: Request):
"azure_auth_tenant", "azure_scopes", "poll_interval",
"attendance_csv_path", "attendance_status_anwesend", "attendance_status_abwesend",
"attendance_status_homeoffice",
"selfservice_device_id", "selfservice_segment",
]
for key in keys:
value = form.get(key, "")
+10
View File
@@ -49,6 +49,14 @@ def init_db():
UNIQUE(user_azure_id)
);
-- Heimat-Tuerschild: gemerkte Zuordnung, waehrend der Nutzer in der Zentrale sitzt
CREATE TABLE IF NOT EXISTS home_assignments (
user_azure_id TEXT PRIMARY KEY REFERENCES users(azure_id) ON DELETE CASCADE,
wled_device_id INTEGER NOT NULL REFERENCES wled_devices(id) ON DELETE CASCADE,
segment INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS color_rules (
status TEXT PRIMARY KEY,
r INTEGER NOT NULL DEFAULT 0,
@@ -140,6 +148,8 @@ def init_db():
"attendance_status_anwesend": "~~1~~",
"attendance_status_abwesend": "~~0~~",
"attendance_status_homeoffice": "~~2~~",
"selfservice_device_id": "",
"selfservice_segment": "0",
}
for key, value in defaults.items():
conn.execute(
+61
View File
@@ -72,6 +72,67 @@ def get_color_for_status(status, conn):
return {"r": 200, "g": 200, "b": 200, "effect": ""}
def current_color_for_user(azure_id, conn):
"""Farbe fuer den zuletzt gespeicherten Status eines Users.
Prioritaet wie im Polling und im Telefon-Webhook: Telefon > Teams > Zeiterfassung.
Wer laut Zeiterfassung nicht anwesend/homeoffice ist, bekommt die abwesend-Farbe.
"""
row = conn.execute("""
SELECT last_attendance_status, last_teams_status,
COALESCE(phone_active, 0) as phone_active
FROM state WHERE user_azure_id = ?
""", (azure_id,)).fetchone()
attendance = (row["last_attendance_status"] or "") if row else ""
teams = (row["last_teams_status"] or "") if row else ""
phone_active = row["phone_active"] if row else 0
if attendance not in ("anwesend", "homeoffice"):
return get_color_for_status("abwesend", conn)
if phone_active:
return get_color_for_status("phone_active", conn)
if attendance == "homeoffice":
return get_color_for_status("homeoffice", conn)
if teams:
return get_color_for_status(teams, conn)
return get_color_for_status("available", conn)
def push_user_light(azure_id, display_name, hostname, segment, conn, reason="zentrale"):
"""Aktuelle Farbe eines Users sofort an WLED senden.
Wird beim Platzwechsel gebraucht: _process_user schickt nur bei einer
Statusaenderung etwas ans Geraet, nach einem Wechsel ist der Status aber
unveraendert. Ohne diesen Push wuerde das Segment die Farbe des Vorgaengers
behalten, bis sich zufaellig etwas aendert.
"""
color = current_color_for_user(azure_id, conn)
url = build_wled_url(
hostname, color["r"], color["g"], color["b"],
segment=segment, effect=color.get("effect", ""),
)
success, result_text = call_wled(url)
log_activity(azure_id, display_name, reason, "-", url, result_text)
if not success:
logger.warning("WLED Fehler beim Platzwechsel fuer %s: %s", display_name, result_text)
return success, result_text
def clear_segment(hostname, segment, conn, reason="zentrale_frei"):
"""Segment auf die abwesend-Farbe setzen (Standard ist 0,0,0 = aus)."""
color = get_color_for_status("abwesend", conn)
url = build_wled_url(
hostname, color["r"], color["g"], color["b"],
segment=segment, effect=color.get("effect", ""),
)
success, result_text = call_wled(url)
log_activity(None, "-", reason, "-", url, result_text)
if not success:
logger.warning("WLED Fehler beim Freigeben von %s Segment %s: %s", hostname, segment, result_text)
return success, result_text
def poll_status():
"""Main polling function - called periodically by APScheduler."""
global graph
+36 -1
View File
@@ -127,7 +127,7 @@
</div>
</div>
<div class="card">
<div class="card mb-4">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-file-earmark-spreadsheet"></i> Zeiterfassung (CSV)</h6>
</div>
@@ -167,6 +167,41 @@
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-headset"></i> Zentrale (Self-Service)</h6>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Busylight der Zentrale</label>
<select name="selfservice_device_id" class="form-select">
<option value="">-- nicht eingerichtet --</option>
{% for d in devices %}
<option value="{{ d.id }}" {% if settings.selfservice_device_id == d.id|string %}selected{% endif %}>
{{ d.name or d.hostname }} ({{ d.hostname }})
</option>
{% endfor %}
</select>
<small class="text-muted">Das Busylight im Empfangsbereich, das wechselnd besetzt wird.</small>
</div>
<div class="mb-3">
<label class="form-label">Segment</label>
<input type="number" name="selfservice_segment" class="form-control"
min="0" step="1" value="{{ settings.selfservice_segment or '0' }}">
<small class="text-muted">0 = unten, 1 = oben</small>
</div>
<div class="border rounded p-3 bg-light">
<small class="text-muted">
<strong>Self-Service-Seite</strong><br>
Mitarbeiter melden sich per Durchwahl an, ohne Admin-Passwort:<br>
<code>/zentrale</code><br>
Direktlink bzw. QR-Code pro Person:<br>
<code>/zentrale/{durchwahl}</code>
</small>
</div>
</div>
</div>
</div>
</div>
+125
View File
@@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zentrale - Busylight</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container">
<div class="row justify-content-center mt-4 mb-5">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-body p-4">
<div class="text-center mb-4">
<img src="/static/logo.png" alt="cynfo" style="height: 40px;" class="mb-2">
<h5 class="mb-0">Zentrale besetzen</h5>
</div>
{% if message %}
<div class="alert alert-success py-2">
<small><i class="bi bi-check-circle"></i> {{ message }}</small>
</div>
{% endif %}
{% if error %}
<div class="alert alert-danger py-2">
<small><i class="bi bi-exclamation-triangle"></i> {{ error }}</small>
</div>
{% endif %}
{% if not target %}
<div class="alert alert-warning py-2 mb-0">
<small>
<i class="bi bi-gear"></i>
Die Zentrale ist noch nicht eingerichtet. Unter
<strong>Einstellungen &gt; Zentrale</strong> muss ein Busylight
und ein Segment ausgewaehlt werden.
</small>
</div>
{% else %}
<div class="border rounded p-3 mb-4 bg-white">
<div class="d-flex justify-content-between align-items-center">
<div>
<small class="text-muted d-block">Busylight der Zentrale</small>
<strong>{{ target.name }}</strong>
<small class="text-muted">(Segment {{ target.segment }})</small>
</div>
</div>
<hr class="my-2">
<small class="text-muted d-block">Aktuell besetzt von</small>
{% if occupant %}
<strong class="text-success"><i class="bi bi-person-fill"></i> {{ occupant }}</strong>
{% else %}
<span class="text-muted"><i class="bi bi-person-dash"></i> niemand</span>
{% endif %}
</div>
{% if user %}
<div class="text-center mb-3">
<small class="text-muted d-block">Durchwahl {{ user.phone_extension }}</small>
<strong>{{ user.display_name }}</strong>
</div>
{% if is_here %}
<div class="alert alert-info py-2">
<small><i class="bi bi-info-circle"></i> Du sitzt derzeit auf der Zentrale.</small>
</div>
{% endif %}
<form method="post" action="/api/zentrale/uebernehmen" class="mb-2">
<input type="hidden" name="extension" value="{{ user.phone_extension }}">
<button type="submit" class="btn btn-primary w-100" {% if is_here %}disabled{% endif %}>
<i class="bi bi-box-arrow-in-right"></i> Zentrale uebernehmen
</button>
</form>
<form method="post" action="/api/zentrale/verlassen">
<input type="hidden" name="extension" value="{{ user.phone_extension }}">
<button type="submit" class="btn btn-outline-secondary w-100">
<i class="bi bi-box-arrow-left"></i> Zurueck auf mein Tuerschild
</button>
</form>
{% if home %}
<div class="text-center mt-3">
<small class="text-muted">
Gemerktes Tuerschild: {{ home.name or home.hostname }} (Segment {{ home.segment }})
</small>
</div>
{% endif %}
<div class="text-center mt-3">
<a href="/zentrale" class="text-muted small">Andere Durchwahl</a>
</div>
{% else %}
<form method="post" action="/api/zentrale/uebernehmen">
<div class="mb-3">
<label class="form-label">Eigene Telefon-Durchwahl</label>
<input type="text" name="extension" class="form-control form-control-lg text-center"
inputmode="numeric" autocomplete="off" autofocus required
value="{{ extension }}" placeholder="z.B. 123">
<small class="text-muted">
Damit wird die eigene Person auf das Busylight der Zentrale gesetzt.
</small>
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-box-arrow-in-right"></i> Zentrale uebernehmen
</button>
</form>
{% endif %}
{% endif %}
<div class="text-center mt-4">
<small class="text-secondary" style="font-size:0.65rem">&copy; cynfo GmbH</small>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>