app update final
This commit is contained in:
parent
e06a717e4b
commit
b6500bebe1
6 changed files with 406 additions and 30 deletions
|
|
@ -1,7 +1,14 @@
|
|||
# Deployment — SLC-Workshop Companion (App)
|
||||
|
||||
**Auftrag für die Server-Claude / Ops:** Diese App **statisch** ausliefern. Kein
|
||||
Build-Schritt, kein Backend, keine Secrets, keine Datenbank.
|
||||
Build-Schritt, keine Secrets, keine Datenbank.
|
||||
|
||||
> **Neu (v0.10): kleiner Feedback-Endpoint.** Die App sammelt jetzt Workshop-Feedback
|
||||
> (Phasen + Prüfschritte) und **speichert es auf dem Server** zur späteren Auswertung.
|
||||
> Dafür braucht es **einen** schlanken POST-Sammelpunkt (Flat-File, **keine Datenbank**).
|
||||
> Referenz liegt bei: [`feedback.php`](feedback.php). Ist der Endpoint (noch) nicht
|
||||
> aktiv, geht **nichts verloren**: die App hält das Feedback lokal vor (Retry-Queue)
|
||||
> und der Header-Button **„⇩ Feedback"** exportiert es als Backup-Datei.
|
||||
|
||||
## Was das ist
|
||||
- Eine **statische Single-Page-PWA**. Alle Inhalte (Stationen, Quiz, Use-Cases)
|
||||
|
|
@ -11,6 +18,7 @@ Build-Schritt, kein Backend, keine Secrets, keine Datenbank.
|
|||
- `manifest.webmanifest` — PWA-Manifest
|
||||
- `sw.js` — Service Worker (Offline-Cache der App-Shell)
|
||||
- `icon.svg` — App-Icon
|
||||
- `feedback.php` — **optionaler** Feedback-Sammelpunkt (Flat-File, JSON Lines)
|
||||
|
||||
## Ziel
|
||||
Den Ordner `04_Tablet-Quiz/app/` als **statisches Web-Root** über den vorhandenen
|
||||
|
|
@ -47,11 +55,44 @@ slc.example.intern {
|
|||
}
|
||||
```
|
||||
|
||||
## Feedback-Endpoint (empfohlen — für die Auswertung)
|
||||
|
||||
Die App schickt jedes gespeicherte Feedback per `POST` als JSON an den im
|
||||
`<meta name="slc-feedback-endpoint">` hinterlegten Pfad (Default: `feedback.php`,
|
||||
gleiche Domain). Der Service Worker fasst POSTs nicht an — der Offline-Cache stört
|
||||
also nicht.
|
||||
|
||||
**Variante A — PHP (Referenz, einfachste Option).** `feedback.php` liegt im App-Ordner.
|
||||
Voraussetzung: `php-fpm` aktiv und das App-Verzeichnis für PHP freigegeben; das
|
||||
Datenverzeichnis `feedback-data/` muss vom Webserver-User beschreibbar sein.
|
||||
|
||||
```nginx
|
||||
# innerhalb des server{}-Blocks von oben:
|
||||
location ~ \.php$ {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass unix:/run/php/php-fpm.sock; # Pfad anpassen
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
}
|
||||
```
|
||||
Daten landen in `feedback-data/feedback.jsonl` (eine JSON-Zeile pro Feedback) →
|
||||
später für die Auswertung einlesen/zu CSV konvertieren.
|
||||
|
||||
**Variante B — kein PHP verfügbar.** Endpoint auf einen beliebigen JSON-POST-Empfänger
|
||||
zeigen lassen (z. B. ein kleines Node-/Worker-Skript, das den Body an eine Datei
|
||||
anhängt) und im `<meta name="slc-feedback-endpoint">` dessen URL eintragen.
|
||||
|
||||
**Variante C — gar kein Server-Save.** Auch ohne Endpoint funktioniert alles: das
|
||||
Feedback bleibt in der Retry-Queue und kann über den Header-Button **„⇩ Feedback"**
|
||||
als JSON-Datei exportiert werden.
|
||||
|
||||
## Verifikation nach dem Deploy
|
||||
1. URL öffnen → Startbildschirm „SLC Companion · Schritt 1 · Action Card".
|
||||
1. URL öffnen → Startbildschirm „SLC Companion".
|
||||
2. DevTools → Application → **Service Workers**: `sw.js` ist *activated*.
|
||||
3. Flugmodus/Offline → Seite neu laden → App lädt weiterhin (Offline-Cache).
|
||||
4. Einen Durchlauf spielen → „Debrief" → **↓ Markdown / ↓ JSON** lädt eine Datei.
|
||||
4. Einen Major-Durchlauf spielen, an einem Gate Feedback eintragen → **„💾 Feedback
|
||||
speichern"**. Bei aktivem Endpoint erscheint kein Fehler in der Konsole; in
|
||||
`feedback-data/feedback.jsonl` taucht eine neue Zeile auf. Ohne Endpoint: Header
|
||||
**„⇩ Feedback"** lädt die Backup-Datei.
|
||||
|
||||
## Updates
|
||||
- Bei neuem Stand: `git pull`. Wenn sich App-Assets geändert haben, in `sw.js`
|
||||
|
|
|
|||
58
04_Tablet-Quiz/app/feedback.php
Normal file
58
04_Tablet-Quiz/app/feedback.php
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
/**
|
||||
* SLC-Workshop — minimaler Feedback-Sammelpunkt (Referenz-Implementierung).
|
||||
*
|
||||
* Nimmt einen JSON-POST der Companion-App entgegen und hängt ihn als eine Zeile
|
||||
* (JSON Lines) an eine Datei an. Kein Framework, keine Datenbank, keine Secrets.
|
||||
*
|
||||
* Auswertung später: die Datei `feedback-data/feedback.jsonl` einlesen (jede Zeile
|
||||
* ein JSON-Objekt) — z. B. nach JSON/CSV konvertieren.
|
||||
*
|
||||
* Voraussetzung: PHP (php-fpm) ist auf dem Webserver aktiv und dieser Pfad wird
|
||||
* von der App aus erreicht (gleiche Domain). Schreibrechte aufs Datenverzeichnis
|
||||
* sind nötig. Alternativen (Node/Caddy) siehe DEPLOY.md.
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// Nur POST erlaubt (GET nur als simpler Health-Check).
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['ok' => true, 'service' => 'slc-feedback', 'hint' => 'POST JSON to store feedback']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw === false || strlen($raw) === 0 || strlen($raw) > 65536) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'empty or oversized body']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'invalid json']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Serverseitigen Eingangszeitstempel + Roh-Metadaten ergänzen (nicht überschreibend).
|
||||
$data['_received'] = gmdate('c');
|
||||
$data['_ip'] = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : null;
|
||||
|
||||
$dir = __DIR__ . '/feedback-data';
|
||||
$file = $dir . '/feedback.jsonl';
|
||||
if (!is_dir($dir)) { @mkdir($dir, 0775, true); }
|
||||
|
||||
$line = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
|
||||
$fp = @fopen($file, 'ab');
|
||||
if ($fp === false) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['ok' => false, 'error' => 'cannot open data file (Schreibrechte prüfen)']);
|
||||
exit;
|
||||
}
|
||||
flock($fp, LOCK_EX);
|
||||
fwrite($fp, $line);
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
|
||||
echo json_encode(['ok' => true]);
|
||||
|
|
@ -6,6 +6,8 @@
|
|||
<title>SLC-Workshop — Companion-App</title>
|
||||
<meta name="description" content="Begleit-App zum SLC-Workshop-Tabletop: führt durch die Stationen, vermittelndes Quiz und Auflösung. Offline lauffähig." />
|
||||
<meta name="theme-color" content="#161b24" />
|
||||
<!-- Feedback-Sammelpunkt (Server). Bei Bedarf anpassen; Default: gleiche Domain, feedback.php -->
|
||||
<meta name="slc-feedback-endpoint" content="feedback.php" />
|
||||
<link rel="manifest" href="manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="icon.svg" />
|
||||
<link rel="icon" href="icon.svg" type="image/svg+xml" />
|
||||
|
|
@ -176,6 +178,10 @@
|
|||
.sHead{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||
.sHead .phaseChip{font-size:15px;padding:8px 18px;letter-spacing:.8px}
|
||||
.sHead .sId{color:var(--ink);font-size:18px;font-weight:800;font-variant-numeric:tabular-nums}
|
||||
.tiefeBadge{font-size:11px;font-weight:700;padding:3px 9px;border-radius:999px;cursor:help;white-space:nowrap}
|
||||
.tiefeBadge.bearbeitet{background:#e7f5ec;color:#177a44;border:1px solid #b8e0c7}
|
||||
.tiefeBadge.entwurf{background:#f0f1f4;color:#6b7280;border:1px solid #dfe2e8}
|
||||
.sHead{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.sTitle{font-size:22px;font-weight:700;line-height:1.25;margin:10px 0 4px}
|
||||
.caseLine{color:var(--muted);font-size:13px;margin:0 0 22px}
|
||||
.lead{font-size:16px;margin:0 0 16px}
|
||||
|
|
@ -184,6 +190,7 @@
|
|||
.crit{margin:8px 0 0;padding-left:20px;color:var(--muted)}
|
||||
.crit li{margin:4px 0}
|
||||
.critHead{font-size:13px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin:16px 0 8px}
|
||||
.gateZiel{font-size:14px;line-height:1.5;background:var(--soft);border-left:3px solid var(--accent);border-radius:8px;padding:10px 12px;margin:0 0 12px}
|
||||
.gateCrit{display:flex;flex-direction:column;gap:8px;margin:0 0 16px}
|
||||
.critItem{display:flex;align-items:flex-start;gap:10px;text-align:left;padding:11px 14px;border:1px solid var(--line);border-radius:10px;background:var(--soft);color:var(--ink);cursor:pointer;font-size:14px;line-height:1.45;animation:critIn .22s ease}
|
||||
.critItem:hover{border-color:#c9d2dd}
|
||||
|
|
@ -208,6 +215,10 @@
|
|||
.raciLegend .rlHead{font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);font-weight:700;margin-bottom:8px}
|
||||
.raciLegend .rlRow{display:flex;align-items:flex-start;gap:10px;margin:5px 0;font-size:14px;line-height:1.45}
|
||||
.raciLegend .rlRow .raciBadge{flex:none;margin-top:1px}
|
||||
.raciLegend .rlGroup{margin-top:8px;padding-top:6px;border-top:1px dashed var(--line)}
|
||||
.raciLegend .rlGroup:first-of-type{border-top:0;padding-top:0}
|
||||
.raciLegend .rlGroupHead{font-size:11px;font-weight:700;color:var(--muted);margin:2px 0 4px}
|
||||
.raciLegend .rlGroupHead.kern{color:var(--ink)}
|
||||
/* Aufgaben-Kasten (Anweisung hervorgehoben) */
|
||||
.frageBox{background:#f7f9fc;border:1px solid var(--line);border-left:4px solid var(--accent);border-radius:12px;padding:14px 18px;margin:6px 0;font-size:16px;line-height:1.55}
|
||||
.frageBox .frageLabel{font-size:11px;text-transform:uppercase;letter-spacing:.7px;color:var(--muted);font-weight:700;margin-bottom:5px}
|
||||
|
|
@ -288,6 +299,24 @@
|
|||
.slcDonut.clickable .donutSeg{cursor:pointer;transition:opacity .12s}
|
||||
.slcDonut.clickable .donutSeg:hover{opacity:.82}
|
||||
.slcDonut .donutSeg.bad{stroke:var(--bad);stroke-width:4}
|
||||
.slcDonut path.dim{opacity:.32}
|
||||
.slcDonut path.current{stroke:var(--ink);stroke-width:3;filter:drop-shadow(0 1px 4px rgba(0,0,0,.28))}
|
||||
.slcOverview{margin:4px 0 14px;padding:12px 14px;border:1px solid var(--line);border-radius:12px;background:#fbfcfe}
|
||||
.slcOverview .slcOvHead{font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);font-weight:700;text-align:center;margin-bottom:4px}
|
||||
.slcOverview .slcDonut{width:180px;height:180px}
|
||||
/* Feedback-Cards (Phase / Dokument) */
|
||||
.fbCard{margin:14px 0 4px;padding:14px 16px;border:1px solid var(--line);border-radius:12px;background:var(--soft)}
|
||||
.fbCard .fbHead{font-size:15px;font-weight:800;margin-bottom:10px}
|
||||
.fbCard .fbOpt{font-weight:600;color:var(--muted);font-size:12px}
|
||||
.fbCard .fbQ{display:block;font-size:13.5px;font-weight:600;margin:10px 0 5px;line-height:1.4}
|
||||
.fbCard textarea.fbInput{width:100%;box-sizing:border-box;font:inherit;font-size:14px;padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--ink);resize:vertical}
|
||||
.fbCard textarea.fbInput:focus{outline:2px solid var(--accent);outline-offset:0;border-color:var(--accent)}
|
||||
.fbScaleRow{display:flex;gap:8px;margin:2px 0 4px}
|
||||
.fbScale{width:42px;height:42px;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--ink);font-weight:800;font-size:16px;cursor:pointer}
|
||||
.fbScale:hover{border-color:#9fb0c4}
|
||||
.fbScale.sel{background:var(--accent);color:#fff;border-color:var(--accent)}
|
||||
.fbActions{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:12px}
|
||||
.fbSaved{color:#177a44;font-weight:700;font-size:13px}
|
||||
.akteItem.flash{animation:akteFlash 1.6s ease-out}
|
||||
@keyframes akteFlash{0%,55%{background:#fff3bf}100%{background:transparent}}
|
||||
.slcCap{font-size:11px;color:var(--muted);margin-top:4px;text-align:center}
|
||||
|
|
@ -357,13 +386,14 @@
|
|||
<body>
|
||||
<header>
|
||||
<div class="brand">SLC Workshop <b>Companion</b></div>
|
||||
<span class="tag">v0.9</span>
|
||||
<span class="tag">v0.10</span>
|
||||
<div id="cardBadge" class="cardBadge"></div>
|
||||
<div class="spacer"></div>
|
||||
<button class="ghost" id="svcBtn" title="Ausführliche Service-Beschreibungen">ℹ️ Service-Info</button>
|
||||
<button class="ghost" id="akteBtn" title="Service-Akte (gesammelte Artefakte)">📁 Service-Akte</button>
|
||||
<button class="ghost" id="rollenBtn" title="Rollen-Glossar (RACI)">👥 Rollen</button>
|
||||
<button class="ghost" id="stationsBtn" title="Stationsübersicht">☰ Stationen</button>
|
||||
<button class="ghost" id="fbExportBtn" title="Gesammeltes Feedback als Backup-Datei exportieren">⇩ Feedback</button>
|
||||
<button class="ghost" id="resetBtn" title="Neue Action Card / Durchlauf zurücksetzen">Neu starten</button>
|
||||
</header>
|
||||
<div class="progress"><div id="progressBar" style="width:0%"></div></div>
|
||||
|
|
@ -752,13 +782,21 @@ const STATIONEN = [
|
|||
beschreibung:"Entry-Gate der Transition: Die SOR entscheidet zuerst, ob das Vorhaben überhaupt in die Transition darf — und wenn ja, ob die Service-Komponenten neu entwickelt oder nur konfiguriert werden. Erfordert SOR-Approval (Budget- und Ressourcenimplikationen).",
|
||||
umfasst:["Aufwandsschätzung (Build vs. Configure)","Technische Risiken","Budget-Abgleich","ggf. Lieferanten-Einbindung","SOR-Vorlage für Freigabe"],
|
||||
artefakt:"Gate-Entscheidung + SOR-Vorlage",
|
||||
ziel:"Sicherstellen, dass nur Vorhaben in die weitere Entwicklung gehen, die ausreichend geplant sind und für die ein grundsätzliches Commitment bezüglich Ressourcen besteht (Budget, Betrieb & Support, Projektressourcen).",
|
||||
// Schritt A: Freigabe-Entscheidung (Frank-Template). Schritt B: Routing (pfade).
|
||||
freigabe:[
|
||||
["Freigabe","Ausreichend geplant, Ressourcen-Commitment liegt vor → weiter zum Build-/Konfigurations-Routing"],
|
||||
["Freigabe mit Auflagen","Weiter zum Routing; definierte Auflagen müssen nachgearbeitet werden"],
|
||||
["Zurück an Design","Planung oder Commitment unzureichend → zurück in die Design-Phase"],
|
||||
["Ablehnung","Vorhaben wird nicht weiterverfolgt — erfordert SOR-Eskalation"]
|
||||
],
|
||||
pfade:[
|
||||
["Entwicklung","Neuentwicklung/wesentliche Anpassung → weiter zu tr_02"],
|
||||
["Konfiguration","Bestehende Komponenten konfigurieren → springt zu tr_05"]
|
||||
],
|
||||
pruef:[
|
||||
["Design-Vollständigkeit","Ist das Service Design Document vollständig?"],
|
||||
["Budget-Verfügbarkeit","Ist Budget für die Strategie verfügbar?"],
|
||||
["Budget-Verfügbarkeit","Ist Budget für die Implementierung verfügbar?"],
|
||||
["Projektressourcen","Können Ressourcen mobilisiert werden?"],
|
||||
["Betriebs-/Support-Bereitschaft","Grundsätzliches Commitment vorhanden?"]
|
||||
],
|
||||
|
|
@ -851,6 +889,7 @@ const STATIONEN = [
|
|||
{ id:"tr_09", phase:"transition", typ:"gate", gateNr:2,
|
||||
name:"Gate 2: Entry-Prüfung nach Build",
|
||||
beschreibung:"Validierung der Übergabefähigkeit nach Abschluss des Builds. Dies ist eine SO-Einzelentscheidung (keine Gremienentscheidung).",
|
||||
ziel:"Sicherstellen, dass die grundlegenden Voraussetzungen für das Ausrollen eines Service gegeben sind.",
|
||||
umfasst:["Prüfung der Übergabe-Vollständigkeit","Prüfung des Abnahme-Status","Ressourcen-Outlook (Betrieb/Support vorbereitet)","Pilot-Entscheidung (falls erforderlich)","Dokumentation im Transition-Steckbrief"],
|
||||
artefakt:"Gate-2-Entscheidung (Transition-Steckbrief)",
|
||||
pfade:[
|
||||
|
|
@ -899,6 +938,7 @@ const STATIONEN = [
|
|||
{ id:"tr_12", phase:"transition", typ:"gate", gateNr:3,
|
||||
name:"Gate 3: Go-Live-Freigabe",
|
||||
beschreibung:"Portfolio-Freigabe und formale Aktivierung des Services. Exit-Gate der Transition — SOR-Entscheidung nach dem Konsent-Prinzip.",
|
||||
ziel:"Sicherstellen, dass nur Services für den produktiven Betrieb aktiviert werden, die ausreichend geprüft und qualitätsgesichert sind und für die sowohl im Betrieb als auch im Support genügend Ressourcen zur Verfügung stehen.",
|
||||
umfasst:["Prüfung der Portfolio-Konsistenz","Prüfung der Betriebsreife (SO-Bestätigung)","Prüfung der Support-Bereitschaft","Prüfung der SLA/SLO-Vereinbarung","Prüfung der Auflagen-Erfüllung aus Gate 2","Bewertung der Geschäftskritikalität","Formale Aufnahme ins Service-Portfolio"],
|
||||
artefakt:"Gate-3-Entscheidung + Portfolio-Aufnahme",
|
||||
pfade:[
|
||||
|
|
@ -1119,8 +1159,9 @@ const STATIONEN = [
|
|||
RACI + Quiz hier abgeleitet (Franks Entwurf nennt nur die Aktivitaeten). */
|
||||
{ id:"rv_01", phase:"review", typ:"aktivitaet",
|
||||
name:"Durchführen von Service-Reviews",
|
||||
beschreibung:"Den Service systematisch auswerten und die Ergebnisse im Service-Review-Dokument festhalten.",
|
||||
umfasst:["KPIs & Monitoring auswerten","Problems & Incidents auswerten","Kundenfeedback sammeln/einholen","zugrunde liegende Infrastruktur bewerten","Service-Review-Dokument ausfüllen"],
|
||||
beschreibung:"Den Service systematisch auswerten und die Ergebnisse im Service-Review-Dokument festhalten (4 Dimensionen → Handlungsempfehlung).",
|
||||
ziel:"Eine fundierte Entscheidung ermöglichen, ob der Service unverändert weiterbetrieben werden kann oder ob Änderungen erforderlich sind.",
|
||||
umfasst:["KPIs & Monitoring auswerten","Problems & Incidents auswerten","Kundenfeedback sammeln/einholen","zugrunde liegende Infrastruktur bewerten","Bewertung über 4 Dimensionen: Leistungserbringung · Betriebsstabilität · Nutzerzufriedenheit · Zukunftsfähigkeit","Handlungsempfehlung ableiten: Weiterbetrieb · Normal-Change · Major-Change"],
|
||||
artefakt:"Service-Review-Dokument",
|
||||
raci:[["service_owner","A"],["betriebsteam","R"],["service_support_team","C"],["problem_manager","C"]],
|
||||
quiz:[
|
||||
|
|
@ -1221,9 +1262,9 @@ const ARTEFAKTE = {
|
|||
A13:{name:"Wissensdatenbank-Eintrag", phase:"support", live:true,
|
||||
was:"Standardlösungen, Known Errors, Workarounds, FAQ und Anleitungen — das zentrale Arbeitsmittel für 1st und 2nd Level Support.",
|
||||
warum:"Es beschleunigt den Support, sichert konsistente Antworten und entlastet die höheren Support-Level."},
|
||||
A14:{name:"Service-Review-Bericht", phase:"review",
|
||||
was:"Das „Service Performance & Improvement Review“: Bewertung über 4 Dimensionen (Leistung, Stabilität, Nutzerzufriedenheit, Zukunftsfähigkeit) per Ampel und eine Handlungsempfehlung (CONTINUE / IMPROVEMENT / REDESIGN / RETIRE).",
|
||||
warum:"Es liefert die strukturierte Grundlage für die SOR-Entscheidung über die Zukunft des Service."},
|
||||
A14:{name:"Service-Review-Dokument", phase:"review",
|
||||
was:"Verantwortlich: Service-Owner. Bewertung über 4 Dimensionen mit Leitfrage — Leistungserbringung (liefert der Service den erwarteten Nutzen?), Betriebsstabilität (läuft er störungsarm und beherrschbar?), Nutzerzufriedenheit (wie bewerten die Nutzer den Service?) und Zukunftsfähigkeit (ist er mittelfristig tragfähig?). Daraus eine Handlungsempfehlung: Weiterbetrieb (ggf. mit Monitoring-Fokus) · Änderung als Normal-Change · Änderung als Major-Change.",
|
||||
warum:"Es ermöglicht eine fundierte Entscheidung, ob der Service unverändert weiterbetrieben werden kann oder ob Änderungen erforderlich sind — die Grundlage für die SOR-Bewertung."},
|
||||
A15:{name:"DPM-Rücklauf", phase:"review",
|
||||
was:"Die Übergabe an den Demand-Lifecycle — Variante A: Neuer Demand (Redesign/Erweiterung) oder Variante B: Retirement-Plan / Decommissioning-Auftrag (Stilllegung).",
|
||||
warum:"Es schließt den Lebenszyklus: größere Änderungen oder das Ende eines Service laufen kontrolliert über den Demand-Lifecycle, nicht „nebenbei“."}
|
||||
|
|
@ -1246,6 +1287,20 @@ const FOLDS_INTO = {
|
|||
op_02:"A6", op_05:"A9", op_07:"A11",
|
||||
sp_03:"A10", sp_04:"A10", sp_05:"A10", sp_06:"A10", sp_08:"A10", sp_10:"A11"
|
||||
};
|
||||
/* Bearbeitungstiefe (Frank): "bearbeitet" = eigene Arbeitsergebnisse/Templates,
|
||||
alles andere "entwurf" (konzeptioneller Best-Practice-Entwurf). Fuer Spieler sichtbar. */
|
||||
const BEARBEITET = new Set(["ds_01","tr_01","tr_09","tr_12","rv_01"]);
|
||||
function stationTiefe(id){ return BEARBEITET.has(id) ? "bearbeitet" : "entwurf"; }
|
||||
function tiefeBadge(id){
|
||||
const t = stationTiefe(id);
|
||||
return t==="bearbeitet"
|
||||
? `<span class="tiefeBadge bearbeitet" title="Eigene Arbeitsergebnisse/Templates liegen vor — hier ist detailliertes Feedback besonders wertvoll.">● Bearbeitet</span>`
|
||||
: `<span class="tiefeBadge entwurf" title="Konzeptioneller Entwurf nach Best Practice — noch keine substantiellen eigenen Arbeitsergebnisse.">○ Entwurf</span>`;
|
||||
}
|
||||
/* Dokumente mit eigenem Detail-Feedback (Frank): Gate 1/2/3 + Service Review. */
|
||||
const FEEDBACK_DOCS = {
|
||||
tr_01:"Gate 1", tr_09:"Gate 2", tr_12:"Gate 3", rv_01:"Service Review"
|
||||
};
|
||||
|
||||
function addArtefakt(a){ if(a){ S.akte = S.akte || {}; S.akte[a] = true; } }
|
||||
// Beim Start nach Einstiegspunkt vorbefuellen: alles, was VOR der Einstiegs-Station
|
||||
|
|
@ -1270,7 +1325,8 @@ function defaultState(){
|
|||
freigabeDone:false, freigabeWrong:null,
|
||||
entryDone:false, entryWrong:null,
|
||||
bonusReveal:false, bonusDone:{}, servicesDone:{}, akteFlash:null, svcModalSel:null,
|
||||
index:0, stage:"discuss", quizIndex:0, gateDeciderDone:false, gateCrit:0,
|
||||
index:0, stage:"discuss", quizIndex:0, gateDeciderDone:false, gateCrit:0, gate1Approval:null,
|
||||
feedback:{}, feedbackSaved:{}, feedbackQueue:[],
|
||||
actStep:0, actReveal:false, actDone:false, arteWrong:null,
|
||||
picks:{}, done:{}, akte:{},
|
||||
loopback:null, revisit:{}, endReason:null, endGate:null };
|
||||
|
|
@ -1462,6 +1518,7 @@ function renderMainIntro(){
|
|||
<p style="margin:0 0 8px">${card.text}</p>
|
||||
<p style="margin:0;color:var(--muted)">Als <b>Major Change</b> wird der Service <b>neu eingeführt</b> und durchläuft den <b>kompletten Lifecycle ab Design</b>. Die Freigaben fallen unterwegs an den <b>Gates</b> (SOR bzw. Service Owner) an — hier müsst ihr nichts einordnen. Das Einordnen (Change-Art · Freigabe · Einstieg) kommt danach bei den <b>Bonus-Varianten</b>.</p></div>
|
||||
<div class="recBox"><h4>Service-Beschreibung</h4>${serviceDetailHtml(S.service)}</div>
|
||||
${slcOverview("design","Start — ihr durchlauft den vollen Lifecycle ab <b>Design</b>")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
|
|
@ -1580,15 +1637,17 @@ function renderFreigabe(){
|
|||
}
|
||||
}
|
||||
|
||||
/* SLC-Orientierungs-Donut (5 Phasen, Farben = Phasenfarben der App) */
|
||||
function phaseDonut(wrongPh, clickable){
|
||||
/* SLC-Orientierungs-Donut (5 Phasen, Farben = Phasenfarben der App).
|
||||
currentPh: hebt die aktuelle/nächste Phase hervor, dimmt die übrigen. */
|
||||
function phaseDonut(wrongPh, clickable, currentPh){
|
||||
const order=["design","transition","operation","support","review"];
|
||||
const cx=100,cy=100,R=92,r=46,seg=72,start=-90-seg/2;
|
||||
const P=(a,rad)=>[ (cx+rad*Math.cos(a*Math.PI/180)).toFixed(1), (cy+rad*Math.sin(a*Math.PI/180)).toFixed(1) ];
|
||||
let segs="", labels="";
|
||||
order.forEach((ph,i)=>{
|
||||
const a0=start+i*seg, a1=a0+seg, o0=P(a0,R), o1=P(a1,R), i1=P(a1,r), i0=P(a0,r);
|
||||
const cls = (clickable?"donutSeg":"") + (ph===wrongPh?" bad":"");
|
||||
const cls = (clickable?"donutSeg":"") + (ph===wrongPh?" bad":"")
|
||||
+ (currentPh ? (ph===currentPh?" current":" dim") : "");
|
||||
segs+=`<path class="${cls}" data-ph="${ph}" d="M${o0[0]} ${o0[1]} A${R} ${R} 0 0 1 ${o1[0]} ${o1[1]} L${i1[0]} ${i1[1]} A${r} ${r} 0 0 0 ${i0[0]} ${i0[1]} Z" fill="var(--${ph})"/>`;
|
||||
const mid=a0+seg/2, L=P(mid,(R+r)/2);
|
||||
labels+=`<text x="${L[0]}" y="${(+L[1]+3).toFixed(1)}" text-anchor="middle" font-size="9.5" font-weight="700" fill="#fff">${PHASEN[ph].label}</text>`;
|
||||
|
|
@ -1597,6 +1656,15 @@ function phaseDonut(wrongPh, clickable){
|
|||
labels+=`<text x="100" y="108" text-anchor="middle" font-size="9" font-weight="700" fill="var(--muted)">Lifecycle</text>`;
|
||||
return `<svg viewBox="0 0 200 200" class="slcDonut${clickable?' clickable':''}" role="img" aria-label="Service-Lifecycle: Design, Transition, Operation und Support (parallel), Review">${segs}<g pointer-events="none">${labels}</g></svg>`;
|
||||
}
|
||||
/* Gesamtbild-Einblendung für den Major-Walk (Frank: Start, Phasenübergänge, Schluss).
|
||||
phase = hervorzuhebende Phase (oder null am Schluss). Nur im Major-Modus genutzt. */
|
||||
function slcOverview(phase, caption){
|
||||
const cap = caption || (phase ? `Ihr seid in der Phase <b style="color:var(--${phase})">${PHASEN[phase].label}</b>` : `Alle Phasen durchlaufen`);
|
||||
return `<div class="slcOverview">
|
||||
<div class="slcOvHead">Service-Lifecycle — Gesamtbild</div>
|
||||
<div class="slcOrient">${phaseDonut(null, false, phase)}<div class="slcCap">${cap}</div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ---------- Aufgabe 3: Einstieg finden (Phase anklicken) ---------------- */
|
||||
function renderEntry(){
|
||||
|
|
@ -1752,7 +1820,7 @@ function enterStation(idx){
|
|||
}
|
||||
S.index = idx;
|
||||
S.stage = STATIONEN[idx].typ==="gate" ? "gate" : "act";
|
||||
S.gatePick = null; S.quizIndex = 0; S.gateDeciderDone = false; S.gateCrit = 0;
|
||||
S.gatePick = null; S.quizIndex = 0; S.gateDeciderDone = false; S.gateCrit = 0; S.gate1Approval = null;
|
||||
S.actStep = 0; S.actReveal = false; S.actDone = false; S.arteWrong = null;
|
||||
S.akteFlash = null; document.body.classList.remove("akteOpen");
|
||||
}
|
||||
|
|
@ -1784,16 +1852,21 @@ function raciTable(st){
|
|||
const rows = st.raci.map(([r,c])=>`<tr><td>${roleLabel(r)}</td><td><span class="raciBadge raci-${c}">${c}</span></td></tr>`).join("");
|
||||
return `<table class="raci"><thead><tr><th>Rolle</th><th>RACI</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||||
}
|
||||
/* RACI-Legende (deutsch) — wird bei der RACI-Frage immer mit angezeigt. */
|
||||
/* RACI-Legende (deutsch) — wird bei der RACI-Frage immer mit angezeigt.
|
||||
Frank: R + A sind die Pflicht (durchdenken!), C + I ergänzend (nice-to-have). */
|
||||
function raciLegendHtml(){
|
||||
const items = [
|
||||
const kern = [
|
||||
["R","Responsible","verantwortlich für die Durchführung — erledigt die Aufgabe operativ"],
|
||||
["A","Accountable","rechenschaftspflichtig — trägt die Ergebnisverantwortung (genau eine Rolle)"],
|
||||
["A","Accountable","rechenschaftspflichtig — trägt die Ergebnisverantwortung (genau eine Rolle)"]
|
||||
];
|
||||
const erg = [
|
||||
["C","Consulted","konsultiert — wird vorab um Rat/Beitrag gefragt"],
|
||||
["I","Informed","informiert — wird über das Ergebnis in Kenntnis gesetzt"]
|
||||
];
|
||||
const row = ([l,en,de])=>`<div class="rlRow"><span class="raciBadge raci-${l}">${l}</span><span><b>${en}</b> — ${de}</span></div>`;
|
||||
return `<div class="raciLegend"><div class="rlHead">Wofür RACI steht</div>` +
|
||||
items.map(([l,en,de])=>`<div class="rlRow"><span class="raciBadge raci-${l}">${l}</span><span><b>${en}</b> — ${de}</span></div>`).join("") +
|
||||
`<div class="rlGroup"><div class="rlGroupHead kern">★ Pflicht — unbedingt durchdenken</div>${kern.map(row).join("")}</div>` +
|
||||
`<div class="rlGroup"><div class="rlGroupHead">ergänzend — nice-to-have</div>${erg.map(row).join("")}</div>` +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
|
|
@ -1821,7 +1894,7 @@ function renderRun(){
|
|||
<div class="classifyTop">
|
||||
${cardBig}
|
||||
<div class="classifyMain">
|
||||
<div class="sHead">${chip}<span class="sId">${st.id}</span></div>
|
||||
<div class="sHead">${chip}<span class="sId">${st.id}</span>${tiefeBadge(st.id)}</div>
|
||||
<h2 class="sTitle" style="margin-top:8px">${st.name}</h2>
|
||||
${stepBody}
|
||||
</div>
|
||||
|
|
@ -1841,7 +1914,7 @@ function activitySteps(st){
|
|||
frage:`Welche Rollen setzen diese Aktivität <b>operativ</b> um — wer sorgt für die <b>Umsetzung</b> (Responsible)? Stellt deren Figuren auf die <b>Mulden des Station-Pucks</b>.`,
|
||||
auf:`<h4 class="aufH">Operativ verantwortlich (R)</h4><div class="roleChips">${(st.raci.filter(([r,c])=>c.includes("R")).map(([r,c])=>`<span class="roleChip">${roleLabel(r)}${c==="A/R"?" (zugleich A)":""}</span>`).join("")) || '<span class="roleChip">— (keine eigene R-Rolle)</span>'}</div><p class="muted" style="margin:10px 0 0;font-size:13px">Das sind die „Macher" der Aktivität. <b>Wer</b> dafür geradesteht (A) sowie beratend (C) bzw. informiert (I) ist, klärt Schritt 3.</p>` },
|
||||
{ label:"RACI",
|
||||
frage:`Ergänzt nun die <b>vollständige RACI</b>: Wer ist <b>A</b>ccountable (trägt die Verantwortung), wer <b>C</b>onsulted, wer <b>I</b>nformed — zusätzlich zu den Responsible aus Schritt 2? Sortiert die Figuren ins <b>Aktiv-Feld (R·A·C·I)</b>.`,
|
||||
frage:`Vervollständigt die RACI. <b>Wichtig zuerst:</b> Wer ist <b>A</b>ccountable (trägt die Ergebnisverantwortung — genau eine Rolle)? Zusammen mit den Responsible aus Schritt 2 sind <b>R + A die Pflicht</b>. <b>C</b>onsulted und <b>I</b>nformed danach ergänzen (nice-to-have). Sortiert die Figuren ins <b>Aktiv-Feld (R·A·C·I)</b>.`,
|
||||
legend: raciLegendHtml(),
|
||||
auf:`<h4 class="aufH">RACI (vollständig)</h4>${raciTable(st)}` },
|
||||
{ label:"Artefakt", artefakt:true,
|
||||
|
|
@ -1878,6 +1951,9 @@ function renderActivity(st){
|
|||
<p style="margin:6px 0">Gut gemacht! Ihr habt <b>${st.id} — ${st.name}</b> durchgespielt.</p>
|
||||
${phaseLine}
|
||||
</div>
|
||||
${phaseEnd && S.mode==="main" ? slcOverview(next.phase, `Weiter in der Phase <b style="color:${PHASEN[next.phase].color}">${PHASEN[next.phase].label}</b>`) : ``}
|
||||
${FEEDBACK_DOCS[st.id] ? docFeedbackBlock(st.id) : ``}
|
||||
${phaseEnd ? phaseFeedbackBlock(st.phase) : ``}
|
||||
<div class="actions">
|
||||
<button class="ghost" id="actBack">← zurück</button>
|
||||
<div class="spacer"></div>
|
||||
|
|
@ -1975,6 +2051,7 @@ function renderGate(st){
|
|||
const reqLine = req.length === 0 ? `` : blocked
|
||||
? `<div class="gateReq bad">🔒 Gate öffnet nicht — es fehlt in der Akte: ${missing.map(a=>a+" "+ARTEFAKTE[a].name).join(" · ")}. Erzeugt diese Artefakte zuerst.</div>`
|
||||
: `<div class="gateReq ok">✓ Alle geforderten Artefakte liegen in der Akte (${req.join(" · ")}).</div>`;
|
||||
const zielLine = st.ziel ? `<div class="gateZiel"><b>Ziel:</b> ${st.ziel}</div>` : ``;
|
||||
|
||||
// Prüf-Kriterien als nacheinander erscheinende Checkboxen (nur Anzeige, nicht blockierend)
|
||||
const pruef = st.pruef || [];
|
||||
|
|
@ -1994,8 +2071,34 @@ function renderGate(st){
|
|||
<div class="gateCrit">${critItems}</div>
|
||||
${allChecked?`<p class="muted" style="margin:0 0 16px">Alle Kriterien geprüft. Geforderte Artefakte in der Akte (siehe oben)? Pflicht-Figuren am Gate-Puck?</p>`:``}` : ``;
|
||||
|
||||
// Zweistufiges Gate (Gate 1): erst Freigabe-Entscheidung, dann Build-/Konfig-Routing.
|
||||
if(st.freigabe){
|
||||
if(S.gate1Approval==null){
|
||||
const fopts = st.freigabe.map(([n,d],i)=>
|
||||
`<button class="choice freigabeOpt" data-fi="${i}" ${blocked?"disabled":""}><b>${n}</b><br><span style="color:var(--muted);font-weight:400">${d}</span></button>`).join("");
|
||||
return `
|
||||
${revisitNote}
|
||||
${zielLine}
|
||||
<p class="lead"><b>Entscheidet:</b> ${roleLabel(keeper)}</p>
|
||||
${reqLine}
|
||||
${critWrap}
|
||||
<div class="critHead">Freigabe-Entscheidung</div>
|
||||
<div class="choiceGrid">${fopts}</div>`;
|
||||
}
|
||||
// Freigabe erteilt (0/1) → Routing-Schritt
|
||||
const fa = st.freigabe[S.gate1Approval];
|
||||
return `
|
||||
${revisitNote}
|
||||
<div class="hint ok">✓ Freigabe-Entscheidung: <b>${fa[0]}</b></div>
|
||||
<div class="critHead">Build-/Konfigurations-Routing</div>
|
||||
<p class="muted" style="margin:0 0 10px">Da freigegeben: Werden die Service-Komponenten <b>neu entwickelt</b> oder bestehende nur <b>konfiguriert</b>?</p>
|
||||
<div class="choiceGrid">${opts}</div>
|
||||
<div class="actions"><button class="ghost" id="gateApprovalBack">← andere Freigabe-Entscheidung</button><div class="spacer"></div></div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
${revisitNote}
|
||||
${zielLine}
|
||||
<p class="lead"><b>Entscheidet:</b> ${roleLabel(keeper)}</p>
|
||||
${reqLine}
|
||||
${critWrap}
|
||||
|
|
@ -2023,6 +2126,8 @@ function renderGateDone(st){
|
|||
<p style="margin:4px 0">Weiter geht's mit der Phase <b style="color:${nextColor}">${PHASEN[target.phase].label}</b>.</p>
|
||||
</div>`;
|
||||
}
|
||||
const overview = (phaseEnd && S.mode==="main")
|
||||
? slcOverview(target.phase, `Weiter in der Phase <b style="color:${PHASEN[target.phase].color}">${PHASEN[target.phase].label}</b>`) : ``;
|
||||
return `
|
||||
<div class="step reveal">
|
||||
<div class="stepHead"><span class="n">⛩</span> Entscheidung getroffen</div>
|
||||
|
|
@ -2030,6 +2135,9 @@ function renderGateDone(st){
|
|||
${sorNote}
|
||||
${feedback}
|
||||
</div>
|
||||
${overview}
|
||||
${FEEDBACK_DOCS[st.id] ? docFeedbackBlock(st.id) : ``}
|
||||
${phaseEnd ? phaseFeedbackBlock(st.phase) : ``}
|
||||
<div class="actions">
|
||||
<button class="ghost" id="gateBack">← andere Entscheidung</button>
|
||||
<div class="spacer"></div>
|
||||
|
|
@ -2037,6 +2145,117 @@ function renderGateDone(st){
|
|||
</div>`;
|
||||
}
|
||||
|
||||
/* ====================== FEEDBACK (Frank: Phase + Dokument, Server-Save) ======================
|
||||
Erfasst zwei Arten von Feedback und speichert sie auf dem Server (POST an
|
||||
FEEDBACK_ENDPOINT) — mit Retry-Queue (offline-fest) und localStorage-Archiv als
|
||||
Backup für den manuellen Export. Die Erfassung blockt den Spielfluss nie (optional). */
|
||||
const FEEDBACK_ENDPOINT = ((document.querySelector('meta[name="slc-feedback-endpoint"]')||{}).content) || "feedback.php";
|
||||
const FB_QUEUE = "slc-fb-queue", FB_ARCHIVE = "slc-fb-archive", FB_SESSION = "slc-fb-session";
|
||||
function fbReadArr(k){ try{ return JSON.parse(localStorage.getItem(k)||"[]"); }catch(e){ return []; } }
|
||||
function fbWriteArr(k,a){ localStorage.setItem(k, JSON.stringify(a)); }
|
||||
function escapeHtml(s){ return (s==null?"":""+s).replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c])); }
|
||||
function feedbackSessionId(){
|
||||
let id = localStorage.getItem(FB_SESSION);
|
||||
if(!id){ id = "s-"+Math.random().toString(36).slice(2,10)+"-"+Date.now().toString(36); localStorage.setItem(FB_SESSION, id); }
|
||||
return id;
|
||||
}
|
||||
function fbCtx(){
|
||||
return { service: S.service!=null ? USE_CASES[S.service].service : null,
|
||||
serviceIdx: S.service,
|
||||
change: S.change!=null ? CHANGE_TYPES[S.change] : null,
|
||||
mode: S.mode };
|
||||
}
|
||||
|
||||
/* Phasen-Feedback (2 Freitexte, am Phasenende) */
|
||||
function phaseFeedbackBlock(phase){
|
||||
const key = "phase:"+phase;
|
||||
const fb = (S.feedback && S.feedback[key]) || {};
|
||||
const saved = S.feedbackSaved && S.feedbackSaved[key];
|
||||
return `<div class="fbCard" data-fbkey="${key}" data-fbtype="phase" data-target="${phase}">
|
||||
<div class="fbHead">📝 Feedback zur Phase <b>${PHASEN[phase].label}</b> <span class="fbOpt">· optional</span></div>
|
||||
<label class="fbQ">Welche Elemente oder Aktivitäten dieser Phase sind besonders relevant oder hilfreich?</label>
|
||||
<textarea class="fbInput" data-f="relevant" rows="2" placeholder="Freitext …">${escapeHtml(fb.relevant)}</textarea>
|
||||
<label class="fbQ">Welche Elemente oder Aktivitäten erscheinen überflüssig? Warum?</label>
|
||||
<textarea class="fbInput" data-f="ueberfluessig" rows="2" placeholder="Freitext …">${escapeHtml(fb.ueberfluessig)}</textarea>
|
||||
<div class="fbActions">${saved?`<span class="fbSaved">✓ gespeichert</span>`:`<span></span>`}<button class="ghost fbSaveBtn" type="button">💾 Feedback speichern</button></div>
|
||||
</div>`;
|
||||
}
|
||||
/* Dokument-Feedback (Skala 1–5 + 2 Freitexte) für Gate 1/2/3 + Service Review */
|
||||
function docFeedbackBlock(sid){
|
||||
const key = "doc:"+sid;
|
||||
const fb = (S.feedback && S.feedback[key]) || {};
|
||||
const saved = S.feedbackSaved && S.feedbackSaved[key];
|
||||
const label = FEEDBACK_DOCS[sid] || sid;
|
||||
const scale = [1,2,3,4,5].map(n=>`<button type="button" class="fbScale ${(+fb.skala===n)?'sel':''}" data-n="${n}">${n}</button>`).join("");
|
||||
return `<div class="fbCard" data-fbkey="${key}" data-fbtype="doc" data-target="${sid}">
|
||||
<div class="fbHead">📝 Feedback zum Prüfschritt <b>„${label}"</b> <span class="fbOpt">· optional</span></div>
|
||||
<label class="fbQ">Wie relevant oder hilfreich ist dieser Prüfschritt? <span class="muted">(1 = gar nicht … 5 = sehr)</span></label>
|
||||
<div class="fbScaleRow">${scale}</div>
|
||||
<label class="fbQ">Fehlen noch Themen oder Punkte, die auch geprüft werden sollten? Wenn ja, welche?</label>
|
||||
<textarea class="fbInput" data-f="fehlt" rows="2" placeholder="Freitext …">${escapeHtml(fb.fehlt)}</textarea>
|
||||
<label class="fbQ">Weiteres Feedback zu diesem Prüfschritt</label>
|
||||
<textarea class="fbInput" data-f="weiteres" rows="2" placeholder="Freitext …">${escapeHtml(fb.weiteres)}</textarea>
|
||||
<div class="fbActions">${saved?`<span class="fbSaved">✓ gespeichert</span>`:`<span></span>`}<button class="ghost fbSaveBtn" type="button">💾 Feedback speichern</button></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* Ein Feedback-Card aus dem DOM lesen; nur committen, wenn Inhalt vorhanden. */
|
||||
function commitFeedbackCard(card){
|
||||
const key = card.dataset.fbkey;
|
||||
const rec = (S.feedback && S.feedback[key]) || {};
|
||||
card.querySelectorAll('.fbInput[data-f]').forEach(t=>{ rec[t.dataset.f] = t.value; });
|
||||
const sel = card.querySelector('.fbScale.sel'); if(sel) rec.skala = +sel.dataset.n;
|
||||
S.feedback = S.feedback || {}; S.feedback[key] = rec;
|
||||
const hasContent = (rec.relevant||rec.ueberfluessig||rec.fehlt||rec.weiteres||rec.skala);
|
||||
if(!hasContent) return false;
|
||||
S.feedbackSaved = S.feedbackSaved || {}; S.feedbackSaved[key] = true;
|
||||
const record = Object.assign({
|
||||
id: key+"@"+feedbackSessionId(),
|
||||
key, type: card.dataset.fbtype, target: card.dataset.target,
|
||||
ts: new Date().toISOString(), session: feedbackSessionId()
|
||||
}, fbCtx(), rec);
|
||||
// Archiv + Queue (außerhalb des Spielstands, übersteht „Neu starten")
|
||||
const arch = fbReadArr(FB_ARCHIVE).filter(r=>r.id!==record.id); arch.push(record); fbWriteArr(FB_ARCHIVE, arch);
|
||||
const q = fbReadArr(FB_QUEUE).filter(r=>r.id!==record.id); q.push(record); fbWriteArr(FB_QUEUE, q);
|
||||
return true;
|
||||
}
|
||||
/* Alle aktuell sichtbaren Feedback-Cards committen (z. B. beim Weiterblättern). */
|
||||
function captureFeedbackInputs(){
|
||||
let any=false;
|
||||
document.querySelectorAll('.fbCard').forEach(card=>{ if(commitFeedbackCard(card)) any=true; });
|
||||
if(any){ save(); flushFeedbackQueue(); }
|
||||
return any;
|
||||
}
|
||||
/* Offene Server-Submissions abarbeiten (Retry). Fehlt der Endpoint, bleibt alles in der Queue. */
|
||||
function flushFeedbackQueue(){
|
||||
const q = fbReadArr(FB_QUEUE);
|
||||
if(!q.length || !navigator.onLine) return;
|
||||
q.slice().forEach(record=>{
|
||||
fetch(FEEDBACK_ENDPOINT, { method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(record) })
|
||||
.then(r=>{ if(r.ok){ fbWriteArr(FB_QUEUE, fbReadArr(FB_QUEUE).filter(x=>x.id!==record.id)); } })
|
||||
.catch(()=>{ /* offline / kein Endpoint → bleibt für späteren Retry in der Queue */ });
|
||||
});
|
||||
}
|
||||
/* Manueller Export (Backup) — lädt das gesammelte Feedback als JSON herunter. */
|
||||
function exportFeedback(){
|
||||
const data = fbReadArr(FB_ARCHIVE);
|
||||
if(!data.length){ alert("Noch kein Feedback erfasst."); return; }
|
||||
const pending = fbReadArr(FB_QUEUE).length;
|
||||
const blob = new Blob([JSON.stringify({exported:new Date().toISOString(), pendingUpload:pending, items:data}, null, 2)], {type:"application/json"});
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = "slc-feedback-"+new Date().toISOString().slice(0,10)+".json";
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
}
|
||||
function wireFeedback(){
|
||||
document.querySelectorAll('.fbCard .fbScale[data-n]').forEach(btn=>{
|
||||
btn.onclick = ()=>{ btn.parentNode.querySelectorAll('.fbScale').forEach(x=>x.classList.remove('sel')); btn.classList.add('sel'); };
|
||||
});
|
||||
document.querySelectorAll('.fbCard .fbSaveBtn').forEach(btn=>{
|
||||
btn.onclick = ()=>{ const card = btn.closest('.fbCard'); commitFeedbackCard(card); save(); flushFeedbackQueue(); draw(); };
|
||||
});
|
||||
}
|
||||
|
||||
/* ====================== WIRING ====================== */
|
||||
function wire(st){
|
||||
const b = id => $("#"+id);
|
||||
|
|
@ -2055,12 +2274,13 @@ function wire(st){
|
|||
};
|
||||
});
|
||||
if(b("actBack")) b("actBack").onclick = ()=>{
|
||||
captureFeedbackInputs();
|
||||
if(S.actDone){ S.actDone=false; }
|
||||
else if(S.actReveal){ S.actReveal=false; }
|
||||
else if((S.actStep||0)>0){ S.actStep--; S.actReveal=true; }
|
||||
save(); draw(); };
|
||||
if(b("nextStation")) b("nextStation").onclick = ()=>{ S.done[st.id]=true; enterStation(S.index+1); save(); draw(); };
|
||||
if(b("finish")) b("finish").onclick = ()=>{ S.done[st.id]=true; S.view="end"; S.endReason="done"; save(); draw(); };
|
||||
if(b("nextStation")) b("nextStation").onclick = ()=>{ captureFeedbackInputs(); S.done[st.id]=true; enterStation(S.index+1); save(); draw(); };
|
||||
if(b("finish")) b("finish").onclick = ()=>{ captureFeedbackInputs(); S.done[st.id]=true; S.view="end"; S.endReason="done"; save(); draw(); };
|
||||
// Gate
|
||||
if(b("gateDeciderNext")) b("gateDeciderNext").onclick = ()=>{ S.gateDeciderDone=true; save(); draw(); };
|
||||
$("#panel").querySelectorAll(".critItem[data-ci]").forEach(el=>{
|
||||
|
|
@ -2071,8 +2291,23 @@ function wire(st){
|
|||
$("#panel").querySelectorAll(".choiceGrid .choice[data-i]").forEach(el=>{
|
||||
el.onclick = ()=>{ S.gatePick=+el.dataset.i; S.stage="gateDone"; save(); draw(); };
|
||||
});
|
||||
if(b("gateBack")) b("gateBack").onclick = ()=>{ S.stage="gate"; save(); draw(); };
|
||||
if(b("gateNext")) b("gateNext").onclick = ()=>{ gateGoto(st, S.gatePick||0); };
|
||||
// Gate 1 — Freigabe-Entscheidung (Frank-Template): 0/1 → Routing, 2 → zurück an Design, 3 → Ablehnung.
|
||||
$("#panel").querySelectorAll(".freigabeOpt[data-fi]").forEach(el=>{
|
||||
el.onclick = ()=>{ const fi=+el.dataset.fi;
|
||||
if(fi<=1){ S.gate1Approval=fi; save(); draw(); return; }
|
||||
S.done[st.id]=true;
|
||||
if(fi===2){ // Zurück an Design — echte Rückschleife, Gate wird danach erneut vorgelegt
|
||||
S.loopback = { gateId: st.id, gateNr: st.gateNr, untilIdx: S.index };
|
||||
enterStation(0);
|
||||
} else { // Ablehnung
|
||||
S.view="end"; S.endReason="rejected"; S.endGate=st.id;
|
||||
}
|
||||
save(); draw(); };
|
||||
});
|
||||
if(b("gateApprovalBack")) b("gateApprovalBack").onclick = ()=>{ S.gate1Approval=null; save(); draw(); };
|
||||
if(b("gateBack")) b("gateBack").onclick = ()=>{ captureFeedbackInputs(); S.stage="gate"; save(); draw(); };
|
||||
if(b("gateNext")) b("gateNext").onclick = ()=>{ captureFeedbackInputs(); gateGoto(st, S.gatePick||0); };
|
||||
wireFeedback();
|
||||
}
|
||||
|
||||
/* ====================== ABSCHLUSS-SCREEN ====================== */
|
||||
|
|
@ -2088,20 +2323,28 @@ function renderEnd(){
|
|||
: `<div class="recBox">
|
||||
<h4>Service-Lifecycle durchlaufen</h4>
|
||||
<p style="margin:0;color:var(--muted)">Ihr habt den Change von der Einordnung bis zur Umsetzung begleitet. Im Workshop schließt hier das gemeinsame <b>Debriefing am Tisch</b> an (Reflexion der Stationen, offene Fragen).</p></div>`;
|
||||
const ending = (!rejected && S.mode==="main")
|
||||
? slcOverview(null, `Alle Phasen durchlaufen — der Lifecycle ist komplett`) + phaseFeedbackBlock("review")
|
||||
: ``;
|
||||
$("#panel").innerHTML = `
|
||||
<div class="setupHead">Abschluss</div>
|
||||
<h2 class="setupTitle">${icon} ${head}</h2>
|
||||
<div class="hint ${rejected?"bad":"ok"}">${USE_CASES[S.service].service} · ${CHANGE_TYPES[S.change]}</div>
|
||||
${box}
|
||||
${ending}
|
||||
<div class="actions">
|
||||
<div class="spacer"></div>
|
||||
<button class="primary" id="toBonus">Weiter → Varianten dieses Service →</button>
|
||||
</div>`;
|
||||
$("#toBonus").onclick = ()=>{ S.view="bonusPick"; save(); draw(); };
|
||||
wireFeedback();
|
||||
$("#toBonus").onclick = ()=>{ captureFeedbackInputs(); S.view="bonusPick"; save(); draw(); };
|
||||
}
|
||||
|
||||
/* ====================== INIT ====================== */
|
||||
(function init(){
|
||||
flushFeedbackQueue(); // offene Feedback-Submissions nachreichen
|
||||
window.addEventListener("online", flushFeedbackQueue); // bei Reconnect erneut versuchen
|
||||
$("#fbExportBtn").onclick = exportFeedback;
|
||||
$("#resetBtn").onclick = ()=>{ if(confirm("Neue Action Card ziehen und Durchlauf zurücksetzen?")){ S=defaultState(); save(); draw(); } };
|
||||
const closeOverlays = ()=> document.body.classList.remove("navOpen","akteOpen","rollenOpen");
|
||||
$("#stationsBtn").onclick = ()=>{ const o=document.body.classList.contains("navOpen"); closeOverlays(); if(!o) document.body.classList.add("navOpen"); };
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/* Service Worker — SLC-Workshop Companion (App-Shell, offline-first) */
|
||||
const CACHE = "slc-companion-v31";
|
||||
const CACHE = "slc-companion-v32";
|
||||
const SHELL = ["./", "index.html", "manifest.webmanifest", "icon.svg"];
|
||||
// Action-Card-Grafiken (cards/s<service>-c<change>.png) fuer Offline vorab cachen (alle 24).
|
||||
const CARDS = [];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue