Files
geograsim/App/teacher.html
T
Adminator 33fb423967 Atlas: Live-View-Feature + Submit-Bug-Fixes + Admin-Styleguide
- Neuer API-Endpoint /api/live (heartbeat / spectator) + DB-Tabelle
  live_sessions + class_modules.paused-Spalte
- Plattform-Live-Client (assets/js/live-client.js): Heartbeat,
  Pause-Overlay, Spectator-Mode bei ?view=teacher
- ggs_inject_live() in _scripts.php als Helper, in alle 11 Sim-Wrapper
  integriert
- Lehrer-Cockpit: tabellarische Klassen-Live-Ansicht pro Sim,
  Pause-Toggle, blinkender Tab-Indikator wenn aktive Sessions
- Submit-Bug erschlagen: progress.php hat jetzt submit_assessment-
  und reflection-Action; saves.php akzeptiert beide Key-Konventionen;
  alle Direkt-Pfad-API-Files mit require_once-Bootstrap
- Avatar-Default: zufaelliger DALL-E-Avatar fuer neue Schueler:innen
- Admin-Styleguide-Seite mit Bildstil, Farb-Tokens, Layout, iPad-Pattern
- Klima 2D + 3D: GGS_LIVE_STATE-Hook, Klima 2D zusaetzlich skipResume
  fuer Klassenaufgaben-Reset
- Test-Lehrer Jakob + 3 Schueler:innen lokal + auf Live angelegt
- Rundbriefe an alle Sim-Instanzen + Submit-Briefe an sonnensystem,
  busfahrt, energiemanager + Glossar-Anfrage zu Grundriess
- Status-Uebergabe in _inbox/zentrale/_status.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 01:20:53 +02:00

1833 lines
96 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoGraSim — Dashboard für Lehrpersonen</title>
<link rel="icon" type="image/png" href="favicon-96x96.png" sizes="96x96"><link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="stylesheet" href="assets/fonts/inter.css">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',sans-serif;background:#f7f6f3;color:#1a1a1a;min-height:100vh}
/* Topbar */
.topbar{background:#fff;padding:.5rem 1rem;display:flex;align-items:center;gap:.6rem;border-bottom:1px solid rgba(0,0,0,.06);position:sticky;top:0;z-index:10;flex-wrap:wrap}
.topbar .logo{height:28px}
.topbar .spacer{flex:1}
.topbar .user{font-size:.7rem;color:#8a8a8a}
.btn-sm{padding:.25rem .55rem;border:1px solid rgba(0,0,0,.08);border-radius:6px;background:#fff;font-size:.65rem;font-weight:600;cursor:pointer;color:#4a4a4a;text-decoration:none;display:inline-flex;align-items:center;gap:.2rem}
.btn-sm:hover{background:#dae8ec;color:#4a7c8a}
/* Klassen-Bar */
.class-bar{background:#fff;padding:.4rem 1rem;border-bottom:1px solid rgba(0,0,0,.04);display:flex;align-items:center;gap:.6rem;flex-wrap:wrap}
.class-bar label{font-size:.68rem;font-weight:600;color:#8a8a8a}
.class-bar select{padding:.3rem .6rem;border:1.5px solid rgba(0,0,0,.1);border-radius:6px;font-size:.78rem;font-family:inherit;font-weight:600;color:#4a7c8a;background:#fff;cursor:pointer}
.class-bar .code-box{background:#dae8ec;border-radius:5px;padding:.2rem .5rem;font-family:'Courier New',monospace;font-weight:800;font-size:.75rem;color:#4a7c8a;letter-spacing:.06em;cursor:pointer}
.class-bar .code-box:hover{background:#c8dce2}
/* Tabs */
.tabs{display:flex;gap:0;background:#fff;border-bottom:2px solid rgba(0,0,0,.04);padding:0 1rem}
.tab{padding:.6rem 1rem;font-size:.78rem;font-weight:600;color:#8a8a8a;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-2px;transition:all .15s}
.tab:hover{color:#4a7c8a}
.tab.on{color:#4a7c8a;border-bottom-color:#4a7c8a}
/* Live-Tab-Indikator: idle = grau, aktiv = grün und blinkt */
.live-dot{display:inline-block;width:.55em;height:.55em;border-radius:50%;background:#c0c0c0;margin-right:.25em;vertical-align:middle;transition:background .2s}
#tab-btn-live.live-active .live-dot{background:#5a8a5e;box-shadow:0 0 0 0 rgba(90,138,94,.7);animation:livePulse 1.4s infinite}
.live-count{display:none;margin-left:.3em;font-size:.62rem;background:#5a8a5e;color:#fff;border-radius:999px;padding:.05rem .4rem;font-weight:700;vertical-align:middle}
#tab-btn-live.live-active .live-count{display:inline-block}
@keyframes livePulse{
0% {box-shadow:0 0 0 0 rgba(90,138,94,.55)}
70% {box-shadow:0 0 0 7px rgba(90,138,94,0)}
100% {box-shadow:0 0 0 0 rgba(90,138,94,0)}
}
/* Content */
.wrap{max-width:1100px;margin:0 auto;padding:1rem}
.card{background:#fff;border-radius:10px;padding:.8rem;box-shadow:0 1px 4px rgba(0,0,0,.03);border:1px solid rgba(0,0,0,.04);margin-bottom:.8rem}
.card-h{font-size:.6rem;font-weight:700;color:#4a7c8a;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.4rem}
.btn{padding:.4rem .8rem;border:none;border-radius:6px;background:#4a7c8a;color:#fff;font-size:.72rem;font-weight:700;cursor:pointer}
.btn:hover{background:#3a6470}
.btn-outline{background:transparent;color:#4a7c8a;border:1.5px solid #4a7c8a}
.btn-danger{background:#c07a6b}
/* Stats */
.stat-row{display:flex;gap:.5rem;margin-bottom:.5rem}
.stat{flex:1;text-align:center;background:#f7f6f3;border-radius:8px;padding:.5rem}
.stat .n{font-size:1.2rem;font-weight:900;color:#4a7c8a}
.stat .l{font-size:.55rem;color:#8a8a8a}
/* Module Cards */
.mod-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem;margin-bottom:1rem}
.mod-card{background:#fff;border:1.5px solid rgba(0,0,0,.06);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;transition:all .2s;position:relative}
.mod-card.coming{opacity:.6}
.mod-card.coming .mc-img{filter:grayscale(.5)}
.mc-img{height:210px;background:linear-gradient(135deg,#dae8ec,#c8dce2);position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;font-size:2.5rem}
.mc-img img{width:100%;height:100%;object-fit:cover}
.mc-mode-badge{position:absolute;top:8px;right:8px;background:rgba(255,255,255,.95);border-radius:8px;padding:.3rem .55rem;font-size:.78rem;font-weight:700;cursor:pointer;border:1.5px solid rgba(0,0,0,.08);transition:all .15s;display:inline-flex;align-items:center;gap:.25rem}
.mc-mode-badge:hover:not(.disabled){border-color:#4a7c8a;background:#fff}
.mc-mode-badge.locked{color:#8a8a8a}
.mc-mode-badge.free{color:#5a8a5e;background:#dceadd}
.mc-mode-badge.teacher_started{color:#4a7c8a;background:#dae8ec}
.mc-mode-badge.disabled{cursor:not-allowed;opacity:.5}
.mc-coming-overlay{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(255,255,255,.92);border-radius:8px;padding:.35rem .7rem;font-size:.78rem;font-weight:700;color:#a37800}
.mc-body{padding:.7rem .85rem .85rem;flex:1;display:flex;flex-direction:column}
.mc-name{font-size:.92rem;font-weight:700;line-height:1.2;margin-bottom:.25rem}
.mc-desc{font-size:.72rem;color:#4a4a4a;line-height:1.4;margin-bottom:.55rem;flex:1}
.mc-actions{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:auto}
.mc-btn{display:inline-flex;align-items:center;gap:.25rem;padding:.42rem .65rem;border-radius:7px;font-size:.7rem;font-weight:600;text-decoration:none;border:1.5px solid rgba(0,0,0,.08);background:#f7f6f3;color:#4a4a4a;transition:all .12s;min-height:32px;white-space:nowrap}
.mc-btn:hover{background:#fff;border-color:rgba(74,124,138,.4);color:#1a1a1a}
.mc-btn.primary{background:#e8833a;color:#fff;border-color:#e8833a;flex:1;justify-content:center}
.mc-btn.primary:hover{background:#d06f2a;color:#fff}
/* Matrix */
.matrix-wrap{overflow-x:auto}
.matrix{border-collapse:collapse;font-size:.68rem;width:100%}
.matrix th{background:#f7f6f3;padding:.3rem .25rem;font-weight:600;font-size:.6rem;color:#8a8a8a;text-align:center;position:sticky;top:0;z-index:1}
.matrix th.student-col{text-align:left;min-width:120px}
.matrix th .mod-emoji{font-size:1rem;display:block}
.matrix th .mod-short{font-size:.45rem;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:55px}
.matrix td{padding:.2rem .25rem;text-align:center;border-bottom:1px solid rgba(0,0,0,.02)}
.matrix td.student-cell{text-align:left;font-weight:600;white-space:nowrap}
.m-cell{width:30px;height:28px;border-radius:5px;border:1.5px solid rgba(0,0,0,.06);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;font-size:.75rem;transition:all .12s;background:#fff}
.m-cell:hover{border-color:#4a7c8a;transform:scale(1.15)}
.m-cell.locked{background:#f7f6f3;color:#bbb}
.m-cell.free{background:#dceadd;color:#5a8a5e}
.m-cell.teacher_started{background:#dae8ec;color:#4a7c8a}
/* Schüler-Tabelle */
table.students{width:100%;border-collapse:collapse;font-size:.72rem}
table.students th{background:#f7f6f3;padding:.35rem .4rem;text-align:left;font-weight:600;font-size:.62rem;color:#8a8a8a}
table.students td{padding:.35rem .4rem;border-bottom:1px solid rgba(0,0,0,.03)}
.toolbar{display:flex;gap:.3rem;margin-bottom:.5rem;flex-wrap:wrap}
/* Modals */
.modal{position:fixed;inset:0;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;z-index:100}
.modal-card{background:#fff;border-radius:12px;padding:1.2rem;max-width:420px;width:90%;max-height:85vh;overflow-y:auto}
.modal-card h3{font-size:.9rem;font-weight:700;margin-bottom:.5rem}
.modal-card label{display:block;font-size:.68rem;font-weight:600;margin-top:.5rem;color:#4a4a4a}
.modal-card input,.modal-card textarea{width:100%;padding:.4rem .6rem;border:1.5px solid rgba(0,0,0,.1);border-radius:6px;font-size:.78rem;margin-top:.15rem;font-family:inherit}
.hidden{display:none}
/* === Ergebnisse-Tab: kompakte Modulcards (4 in einer Reihe) === */
.result-mod-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:.6rem;margin-bottom:.4rem}
.result-mod-card{background:#fff;border:1.5px solid rgba(0,0,0,.06);border-radius:10px;overflow:hidden;cursor:pointer;transition:all .18s;display:flex;flex-direction:column}
.result-mod-card:hover{border-color:#4a7c8a;transform:translateY(-2px);box-shadow:0 6px 16px rgba(74,124,138,.1)}
.rm-img{height:90px;background:#dae8ec;overflow:hidden;display:flex;align-items:center;justify-content:center}
.rm-img img{width:100%;height:100%;object-fit:cover}
.rm-body{padding:.45rem .55rem .55rem}
.rm-name{font-size:.78rem;font-weight:700;line-height:1.15;margin-bottom:.15rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.rm-stats{font-size:.65rem;color:#6a6a6a}
@media(max-width:700px){.result-mod-grid{grid-template-columns:repeat(2,1fr)}}
/* === Ergebnisse-Tab === */
.result-matrix{border-collapse:collapse;width:100%;font-size:.7rem}
.result-matrix th{background:#f7f6f3;padding:.45rem .35rem;font-weight:700;cursor:pointer;text-align:center;border-bottom:1.5px solid rgba(0,0,0,.1);user-select:none;white-space:nowrap}
.result-matrix th:hover{background:#eee9dd}
.result-matrix th.r-stu{text-align:left;min-width:140px}
.result-matrix td{padding:.4rem .3rem;text-align:center;border-bottom:1px solid rgba(0,0,0,.04)}
.result-matrix td.r-stu{text-align:left;font-weight:600;white-space:nowrap}
.result-matrix td.r-cell{padding:.3rem;border-radius:0}
.result-matrix td.r-cell .rs{display:block;font-weight:700;color:#1a1a1a}
.result-matrix td.r-cell .rp{display:block;font-size:.6rem;color:#6a6a6a;margin-top:.05rem}
.result-matrix td.r-empty{color:#cbc6b8}
.result-matrix td.r-total{background:#dae8ec;font-weight:700}
.result-matrix td.r-total .rp{display:block;font-size:.6rem;color:#4a7c8a;font-weight:500}
.result-matrix .sort-i{font-size:.6rem;color:#bbb;margin-left:.2rem}
.result-matrix .sort-i.active{color:#4a7c8a}
.activity-chart{margin:.5rem 0}
.activity-svg{width:100%;height:auto;display:block}
.activity-legend{display:flex;flex-wrap:wrap;gap:.6rem;font-size:.66rem;color:#4a4a4a;margin-top:.4rem}
.activity-legend .lg-item{display:inline-flex;align-items:center;gap:.25rem}
.activity-legend .lg-dot{width:10px;height:10px;border-radius:2px;display:inline-block}
.activity-legend b{color:#1f4b37;font-weight:700}
@media(max-width:700px){.mod-cards{grid-template-columns:1fr}.stat-row{flex-wrap:wrap}}
</style>
</head>
<body>
<!-- Topbar -->
<div class="topbar">
<a href="teacher" aria-label="Dashboard"><img src="assets/img/geograsim_t.svg" alt="GeoGraSim" class="logo"></a>
<span class="spacer"></span>
<span class="user" id="user-info"></span>
<a href="profil.html" class="btn-sm">Profil</a>
<button class="btn-sm" onclick="logout()">Abmelden</button>
</div>
<!-- Klassen-Bar -->
<div class="class-bar">
<label>Klasse:</label>
<select id="class-select" onchange="onClassChange()"></select>
<span class="code-box" id="class-code" onclick="showCode()" title="Einladungscode kopieren"></span>
<button class="btn-sm" onclick="showModal('modal-class')">+ Neue Klasse</button>
<button class="btn-sm" onclick="openTickets()">🎫 Tickets</button>
</div>
<!-- Tabs -->
<div class="tabs" id="tab-bar">
<div class="tab on" onclick="switchTab('overview')">Übersicht</div>
<div class="tab" onclick="switchTab('modules')">Modulfreigabe</div>
<div class="tab" onclick="switchTab('students')">Klasse</div>
<div class="tab" id="tab-btn-live" onclick="switchTab('live')"><span class="live-dot"></span> Live<span class="live-count" id="live-count"></span></div>
<div class="tab" onclick="switchTab('results')">Ergebnisse</div>
<div class="tab" onclick="switchTab('licenses')">Lizenzen</div>
<div class="tab" onclick="switchTab('classes')">Klassen</div>
</div>
<!-- Content -->
<div class="wrap">
<div id="tab-overview"></div>
<div id="tab-modules" class="hidden"></div>
<div id="tab-students" class="hidden"></div>
<div id="tab-live" class="hidden"></div>
<div id="tab-results" class="hidden"></div>
<div id="tab-licenses" class="hidden"></div>
<div id="tab-classes" class="hidden"></div>
</div>
<!-- Modal: Klasse erstellen -->
<div class="modal hidden" id="modal-class">
<div class="modal-card">
<h3>Neue Klasse</h3>
<label>Klassenname</label>
<input type="text" id="mc-name" placeholder="z.B. 1A">
<label>Schuljahr</label>
<input type="text" id="mc-year" placeholder="z.B. 2025/26">
<div style="display:flex;gap:.4rem;margin-top:.8rem">
<button class="btn" onclick="saveClass()">Speichern</button>
<button class="btn btn-outline" onclick="hideModal('modal-class')">Abbrechen</button>
</div>
</div>
</div>
<!-- Modal: Schüler erstellen -->
<div class="modal hidden" id="modal-student">
<div class="modal-card">
<h3>Schüler*in erstellen</h3>
<label>Benutzername *</label>
<input type="text" id="ms-user" placeholder="z.B. max01">
<label>Passwort *</label>
<input type="text" id="ms-pass" placeholder="mind. 4 Zeichen">
<label>Anzeigename (optional)</label>
<input type="text" id="ms-name" placeholder="z.B. Max M.">
<label>Vorname (optional)</label>
<input type="text" id="ms-first">
<label>Nachname (optional)</label>
<input type="text" id="ms-last">
<label>E-Mail (optional)</label>
<input type="email" id="ms-email">
<label style="margin-top:.6rem"><input type="checkbox" id="ms-license" checked> Lizenz automatisch zuweisen</label>
<div style="display:flex;gap:.4rem;margin-top:.8rem">
<button class="btn" onclick="saveStudent()">Erstellen</button>
<button class="btn btn-outline" onclick="hideModal('modal-student')">Abbrechen</button>
</div>
</div>
</div>
<!-- Modal: Batch -->
<div class="modal hidden" id="modal-batch">
<div class="modal-card">
<h3>Mehrere Schüler*innen erstellen</h3>
<label>Prefix</label>
<input type="text" id="mb-prefix" value="schueler">
<label>Anzahl</label>
<input type="number" id="mb-count" value="25" min="1" max="50">
<div style="display:flex;gap:.4rem;margin-top:.8rem">
<button class="btn" onclick="batchCreate()">Erstellen</button>
<button class="btn btn-outline" onclick="hideModal('modal-batch')">Abbrechen</button>
</div>
<div id="batch-result" style="margin-top:.5rem;font-size:.65rem;max-height:200px;overflow-y:auto"></div>
</div>
</div>
<!-- Modal: Bulk-PIN-Reset Ergebnis -->
<div class="modal hidden" id="modal-pins">
<div class="modal-card" style="max-width:560px">
<div id="pins-result"></div>
</div>
</div>
<!-- Modal: Modul-Detail (Ergebnisse-Tab) -->
<div class="modal hidden" id="modal-mod-detail">
<div class="modal-card" style="max-width:920px;width:96%">
<div style="display:flex;justify-content:flex-end;margin-bottom:.4rem">
<button class="btn btn-outline" style="font-size:.7rem;padding:.25rem .6rem" onclick="hideModal('modal-mod-detail')">Schließen</button>
</div>
<div id="mod-detail-body"></div>
</div>
</div>
<!-- Modal: CSV-Import -->
<div class="modal hidden" id="modal-csv">
<div class="modal-card" style="max-width:560px">
<h3>Schüler:innen aus CSV importieren</h3>
<p style="font-size:.7rem;color:#4a4a4a;margin-bottom:.4rem">
Eine Zeile pro Schüler:in. Pflicht: <b>username</b>. Optional: <b>display_name</b>, <b>password</b>, <b>first_name</b>, <b>last_name</b>, <b>email</b>, <b>easy</b> (0/1).
Wenn kein Passwort angegeben ist, wird ein 4-stelliges generiert.
</p>
<details style="margin-bottom:.4rem;font-size:.65rem">
<summary style="cursor:pointer;color:#4a7c8a;font-weight:600">Beispiel-Format anzeigen</summary>
<pre style="background:#f7f6f3;padding:.5rem;border-radius:6px;margin-top:.3rem;white-space:pre-wrap;font-family:'Courier New',monospace">username,display_name,password,first_name,last_name,email,easy
ben01,Ben,,Benjamin,Müller,,
anna01,Anna,kuh3,Anna,Schmid,,1
schueler01,Max M.,,,,,</pre>
</details>
<label>CSV-Datei (UTF-8, mit Header-Zeile)</label>
<input type="file" id="csv-file" accept=".csv,.txt" style="margin-bottom:.5rem">
<label>… oder direkt einfügen</label>
<textarea id="csv-text" rows="6" style="width:100%;font-family:'Courier New',monospace;font-size:.7rem;padding:.4rem"></textarea>
<label style="margin-top:.6rem"><input type="checkbox" id="csv-license" checked> Lizenz automatisch zuweisen</label>
<div style="display:flex;gap:.4rem;margin-top:.8rem">
<button class="btn" onclick="importCsv()">Importieren</button>
<button class="btn btn-outline" onclick="hideModal('modal-csv')">Abbrechen</button>
</div>
<div id="csv-result" style="margin-top:.5rem;font-size:.65rem;max-height:240px;overflow-y:auto"></div>
</div>
</div>
<!-- Modal: Schüler bearbeiten -->
<div class="modal hidden" id="modal-edit">
<div class="modal-card">
<h3>Schüler*in bearbeiten</h3>
<input type="hidden" id="me-id">
<label>Benutzername</label>
<input type="text" id="me-user" disabled style="background:#f7f6f3;color:#8a8a8a">
<label>Anzeigename</label>
<input type="text" id="me-name">
<label>Neues Passwort (leer = unverändert)</label>
<input type="text" id="me-pass" placeholder="mind. 4 Zeichen">
<p style="font-size:.62rem;color:#8a8a8a;margin-top:.3rem">Oder neue 4-stellige PIN automatisch generieren:</p>
<button class="btn btn-outline" style="font-size:.7rem" onclick="resetPinFromModal()">🔄 PIN neu vergeben</button>
<div style="display:flex;gap:.4rem;margin-top:.8rem">
<button class="btn" onclick="updateStudent()">Speichern</button>
<button class="btn btn-outline" onclick="hideModal('modal-edit')">Abbrechen</button>
</div>
</div>
</div>
<!-- Modal: Live-Detailansicht (Spectator) -->
<div class="modal hidden" id="modal-spectate">
<div class="modal-card" style="max-width:500px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:.6rem">
<h3 style="margin:0;font-size:.95rem" id="spec-title">Live-Ansicht</h3>
<button class="btn btn-outline" onclick="closeSpectator()" style="font-size:.7rem;padding:.3rem .7rem">Schließen</button>
</div>
<div id="spec-body" style="min-height:120px"></div>
</div>
</div>
<!-- Modal: Klassen-Simulation starten -->
<div class="modal hidden" id="modal-startsim">
<div class="modal-card">
<h3>🚀 Klassen-Simulation starten</h3>
<p style="font-size:.7rem;color:#8a8a8a;margin-bottom:.5rem">Wählen Sie ein Modul und Level. Die Aufgabe erscheint bei allen Schüler*innen an oberster Stelle.</p>
<label>Modul</label>
<select id="ss-module" style="width:100%;padding:.4rem .6rem;border:1.5px solid rgba(0,0,0,.1);border-radius:6px;font-size:.78rem;font-family:inherit"></select>
<label>Level</label>
<select id="ss-level" style="width:100%;padding:.4rem .6rem;border:1.5px solid rgba(0,0,0,.1);border-radius:6px;font-size:.78rem;font-family:inherit">
<option value="1">Level 1 — Einsteiger</option>
<option value="2">Level 2 — Fortgeschritten</option>
<option value="3">Level 3 — Profi</option>
</select>
<label>Endet am (optional, leer = ohne Zeitlimit)</label>
<input type="datetime-local" id="ss-ends">
<label style="display:flex;align-items:center;gap:.4rem;margin-top:.5rem;cursor:pointer">
<input type="checkbox" id="ss-paused"> <span style="font-size:.74rem">⏸ Tempo-Sperre — Schüler:in startet pausiert, bis Lehrperson freigibt</span>
</label>
<div style="display:flex;gap:.4rem;margin-top:.8rem">
<button class="btn" onclick="startSimulation()">🚀 Starten</button>
<button class="btn btn-outline" onclick="hideModal('modal-startsim')">Abbrechen</button>
</div>
</div>
</div>
<!-- Modal: Lizenzen einlösen -->
<div class="modal hidden" id="modal-license">
<div class="modal-card">
<h3>🔑 Lizenzen einlösen</h3>
<p style="font-size:.7rem;color:#8a8a8a;margin-bottom:.5rem">Ein oder mehrere Codes (einer pro Zeile):</p>
<textarea id="ml-codes" rows="4" style="font-family:'Courier New',monospace" placeholder="ABCD-EFGH-JKLM-2026"></textarea>
<div style="display:flex;gap:.4rem;margin-top:.5rem">
<button class="btn" onclick="redeemLicenses()">Einlösen</button>
<button class="btn btn-outline" onclick="hideModal('modal-license')">Abbrechen</button>
</div>
<div id="license-result" style="margin-top:.4rem;font-size:.65rem"></div>
</div>
</div>
<script>
var BASE = (window.__GGS__?.baseUrl) || '';
if (!BASE) { var p = location.pathname, i = p.indexOf('/geograsim'); if (i !== -1) { var rest = p.substring(i + '/geograsim'.length); BASE = rest.indexOf('/App/') === 0 ? p.substring(0, i) + '/geograsim/App' : p.substring(0, i) + '/geograsim'; } }
var allClasses = [], currentClassId = null, currentClass = null;
var currentTab = 'overview';
async function api(endpoint, data) {
var opts = data ? {method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',body:JSON.stringify(data)} : {credentials:'same-origin'};
return (await fetch(BASE + '/api/' + endpoint, opts)).json();
}
function esc(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
function showModal(id) { document.getElementById(id).classList.remove('hidden'); }
function hideModal(id) { document.getElementById(id).classList.add('hidden'); }
function logout() { api('auth', {action:'logout'}).then(function() { location.href = 'login.html'; }); }
// === INIT ===
api('auth', {action:'status'}).then(function(r) {
if (r.role !== 'teacher') { location.href = 'login.html'; return; }
document.getElementById('user-info').textContent = r.user?.display_name || 'Lehrperson';
loadClasses();
});
// === TABS ===
function switchTab(tab) {
currentTab = tab;
var tabs = ['overview','modules','students','live','results','licenses','classes'];
document.querySelectorAll('#tab-bar .tab').forEach(function(t, i) { t.classList.toggle('on', tabs[i] === tab); });
tabs.forEach(function(t) { document.getElementById('tab-' + t).classList.toggle('hidden', t !== tab); });
// Live-Tab darf seinen Polling-Timer beim Verlassen aufräumen
if (tab !== 'live' && _liveTimer) { clearInterval(_liveTimer); _liveTimer = null; }
// "classes"-Tab funktioniert ohne aktive Klasse
if (tab === 'classes') { loadClassesAdmin(); return; }
if (currentClassId) loadTab(tab);
}
function loadTab(tab) {
if (tab === 'overview') loadOverview();
if (tab === 'modules') loadModuleMatrix();
if (tab === 'students') loadStudents();
if (tab === 'live') loadLive();
if (tab === 'results') loadResults();
if (tab === 'licenses') loadLicenses();
if (tab === 'classes') loadClassesAdmin();
}
// === LIVE-VIEW ===
var _liveTimer = null;
async function loadLive() {
await renderLive();
if (_liveTimer) clearInterval(_liveTimer);
_liveTimer = setInterval(renderLive, 4000);
}
// Pro Sim eine Auswahl an "Vergleichs-Spalten" (Reihenfolge ist UX-Reihenfolge).
// Felder, die nicht in dieser Liste stehen, werden in einer Klapp-Sektion gezeigt.
// Sim-Instanzen sollten ihren GGS_LIVE_STATE so füllen, dass diese Keys vorhanden sind.
var LIVE_PRIMARY_FIELDS = {
klima: [
{k:'level', label:'Stufe'},
{k:'tick', label:'Tick'},
{k:'year', label:'Jahr'},
{k:'budget', label:'Budget', unit:'Mio €'},
{k:'co2', label:'CO₂', unit:'ppm'},
{k:'temp', label:'Temp', unit:'°C'},
{k:'floodedPct', label:'Geflutet', unit:'%'},
{k:'population', label:'Bev.'},
{k:'measures', label:'Maßn.'},
{k:'speed', label:'Speed', unit:'×'},
],
// Default-Schema: zeigt einfach den state, soweit Felder benannt sind.
_default: null,
};
function _liveFmt(v, unit) {
if (v === undefined || v === null || v === '') return '—';
if (typeof v === 'object') return JSON.stringify(v).slice(0, 30);
if (typeof v === 'boolean') return v ? '✓' : '';
return v + (unit ? ' <span style="color:#8a8a8a;font-weight:400">'+unit+'</span>' : '');
}
async function renderLive() {
var sessions = await api('live?class_id=' + currentClassId);
if (sessions.error) { document.getElementById('tab-live').innerHTML = '<p style="color:#c07a6b">'+esc(sessions.error)+'</p>'; return; }
var host = document.getElementById('tab-live');
var html = '<div class="card"><div class="card-h">Live · Klassen-Vergleich</div>';
html += '<p style="font-size:.7rem;color:#8a8a8a;margin-bottom:.6rem">Aktualisiert sich automatisch alle 4 Sekunden. Aktiv = letztes Lebenszeichen ≤ 30 s. Klick auf eine Zeile öffnet die Detailansicht.</p>';
if (!sessions.length) {
html += '<p style="font-size:.78rem;color:#8a8a8a">Gerade niemand online — sobald eine Schülerin oder ein Schüler eine Simulation startet, taucht sie hier auf.</p>';
host.innerHTML = html + '</div>';
return;
}
// Nach Modul gruppieren
var byModule = {};
sessions.forEach(function(ls) {
if (!byModule[ls.module_id]) byModule[ls.module_id] = { meta: ls, rows: [] };
byModule[ls.module_id].rows.push(ls);
});
Object.keys(byModule).forEach(function(modId) {
var grp = byModule[modId];
var meta = grp.meta;
// Spalten-Schema: bevorzugt LIVE_PRIMARY_FIELDS, sonst Vereinigung aller state-Keys.
var fields = LIVE_PRIMARY_FIELDS[modId];
if (!fields) {
var keySet = {};
grp.rows.forEach(function(r) {
var s = r.state || {};
Object.keys(s).forEach(function(k) { keySet[k] = true; });
});
fields = Object.keys(keySet).map(function(k) { return {k:k, label:k}; });
}
var anyState = grp.rows.some(function(r) { return r.state && Object.keys(r.state).length > 0; });
html += '<div style="margin-bottom:1.2rem">';
html += '<div style="display:flex;align-items:center;gap:.4rem;margin:.5rem 0 .35rem;font-size:.85rem;font-weight:700;color:#1f4b37">'
+ '<span style="font-size:1.1rem">'+(meta.moduleIcon||'📚')+'</span>'
+ '<span>'+esc(meta.moduleName||modId)+'</span>'
+ '<span style="font-size:.62rem;font-weight:500;color:#8a8a8a">· '+grp.rows.length+' aktiv</span>'
+ '</div>';
if (!anyState) {
html += '<p style="font-size:.7rem;color:#c07a6b;background:#fdf3ee;border-left:3px solid #e8833a;padding:.4rem .6rem;border-radius:5px;margin-bottom:.4rem">⚠ Diese Simulation meldet noch keine Live-Werte. Sim-Instanz muss <code>window.GGS_LIVE_STATE</code> implementieren — Atlas hat sie informiert.</p>';
}
// Tabelle
html += '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:.74rem">';
html += '<thead><tr style="background:#f7f6f3;border-bottom:2px solid rgba(0,0,0,.08)">';
html += '<th style="text-align:left;padding:.4rem .5rem;white-space:nowrap">Schüler:in</th>';
html += '<th style="text-align:left;padding:.4rem .5rem;white-space:nowrap;color:#8a8a8a;font-weight:500">spielt seit</th>';
fields.forEach(function(f) {
html += '<th style="text-align:right;padding:.4rem .5rem;white-space:nowrap">'+esc(f.label)+(f.unit?' <span style="color:#8a8a8a;font-weight:400;font-size:.66rem">'+f.unit+'</span>':'')+'</th>';
});
html += '<th style="width:1.2em"></th>';
html += '</tr></thead><tbody>';
grp.rows.forEach(function(ls) {
var avatarHtml = (typeof ls.emoji === 'string' && ls.emoji.indexOf('avatar:') === 0)
? '<img src="assets/img/avatars/avatar-'+ls.emoji.substring(7)+'.png" alt="" style="width:1.6em;height:1.6em;border-radius:50%;object-fit:cover;vertical-align:middle">'
: '<span style="font-size:1.1rem">'+(ls.emoji||'🧑‍🎓')+'</span>';
var since = ls.started_at ? Math.max(0, Math.round((Date.now() - new Date(ls.started_at).getTime())/1000)) : 0;
var sinceStr = since < 60 ? since+' s' : Math.floor(since/60)+' min';
var lastSeen = ls.last_seen ? Math.max(0, Math.round((Date.now() - new Date(ls.last_seen).getTime())/1000)) : 0;
var dotColor = lastSeen < 8 ? '#5a8a5e' : lastSeen < 20 ? '#e8833a' : '#c07a6b';
var s = ls.state || {};
html += '<tr style="border-bottom:1px solid rgba(0,0,0,.04);cursor:pointer" onclick="openLiveSpectator('+ls.student_id+',\''+ls.module_id+'\')" onmouseover="this.style.background=\'#f7f6f3\'" onmouseout="this.style.background=\'\'">';
html += '<td style="padding:.4rem .5rem;white-space:nowrap"><span style="display:inline-flex;align-items:center;gap:.4rem">'+avatarHtml+'<strong>'+esc(ls.displayName)+'</strong></span></td>';
html += '<td style="padding:.4rem .5rem;white-space:nowrap;color:#8a8a8a">'+sinceStr+'</td>';
fields.forEach(function(f) {
html += '<td style="text-align:right;padding:.4rem .5rem;font-variant-numeric:tabular-nums">'+_liveFmt(s[f.k], f.unit)+'</td>';
});
html += '<td style="text-align:center"><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:'+dotColor+'" title="Letztes Lebenszeichen vor '+lastSeen+' s"></span></td>';
html += '</tr>';
});
html += '</tbody></table></div></div>';
});
html += '</div>';
host.innerHTML = html;
}
// Inline-Spectator-Modal: pollt /api/live?student_id=X alle 3s und rendert
// den State-Snapshot, den die Sim via window.GGS_LIVE_STATE meldet.
var _spectateTimer = null;
async function openLiveSpectator(studentId, moduleId) {
showModal('modal-spectate');
await renderSpectator(studentId);
if (_spectateTimer) clearInterval(_spectateTimer);
_spectateTimer = setInterval(function() { renderSpectator(studentId); }, 3000);
}
function closeSpectator() {
if (_spectateTimer) { clearInterval(_spectateTimer); _spectateTimer = null; }
hideModal('modal-spectate');
}
async function renderSpectator(studentId) {
var data = await api('live?student_id=' + studentId);
if (data.error) { document.getElementById('spec-body').innerHTML = '<p style="color:#c07a6b">'+esc(data.error)+'</p>'; return; }
var host = document.getElementById('spec-body');
if (!data.active) {
host.innerHTML = '<p style="color:#8a8a8a;font-size:.78rem">Schüler:in ist gerade nicht aktiv (letztes Lebenszeichen vor mehr als 30 Sekunden).</p>';
document.getElementById('spec-title').textContent = (data.displayName || data.username || 'Schüler:in') + ' · offline';
return;
}
var avatarHtml = (typeof data.emoji === 'string' && data.emoji.indexOf('avatar:') === 0)
? '<img src="assets/img/avatars/avatar-'+data.emoji.substring(7)+'.png" alt="" style="width:2.4em;height:2.4em;border-radius:50%;object-fit:cover">'
: '<span style="font-size:1.8rem">'+(data.emoji||'🧑‍🎓')+'</span>';
var since = data.started_at ? Math.max(0, Math.round((Date.now() - new Date(data.started_at).getTime())/1000)) : 0;
var sinceStr = since < 60 ? since+' s' : Math.floor(since/60)+' min '+(since%60)+' s';
document.getElementById('spec-title').textContent = (data.displayName || data.username) + ' · ' + data.module_id;
var html = '<div style="display:flex;align-items:center;gap:.7rem;margin-bottom:.8rem">'+avatarHtml+'<div><strong style="font-size:.92rem">'+esc(data.displayName||data.username)+'</strong><div style="font-size:.7rem;color:#8a8a8a">spielt seit '+sinceStr+'</div></div></div>';
var s = data.state || {};
if (Object.keys(s).length === 0) {
html += '<p style="font-size:.74rem;color:#8a8a8a">Diese Sim sendet noch keinen Live-State (Sim-Hersteller muss <code>window.GGS_LIVE_STATE</code> implementieren).</p>';
} else {
html += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:.4rem .8rem;font-size:.78rem">';
function row(label, val, unit) {
if (val === undefined || val === null) return '';
return '<div style="color:#8a8a8a">'+label+'</div><div style="font-weight:700">'+val+(unit?'<span style="color:#8a8a8a;font-weight:400"> '+unit+'</span>':'')+'</div>';
}
html += row('Tick', s.tick);
html += row('Jahr', s.year);
html += row('Stufe', s.level);
html += row('Phase', s.phase);
html += row('Budget', s.budget, 'Mio €');
html += row('Bevölkerung',s.population);
html += row('CO₂', s.co2, 'ppm');
html += row('Temperatur', s.temp, '°C');
html += row('Geflutet', s.floodedPct, '%');
html += row('Speed', s.speed, '×');
html += row('Maßnahmen', s.measures);
html += row('Stufe gewonnen', s.levelWon ? 'ja' : 'nein');
html += '</div>';
}
html += '<div style="margin-top:.8rem;font-size:.62rem;color:#8a8a8a">Aktualisiert alle 3 Sekunden · letztes Lebenszeichen: '+(data.last_seen?new Date(data.last_seen).toLocaleTimeString('de-AT'):'?')+'</div>';
host.innerHTML = html;
}
// === KLASSEN-VERWALTUNG (Tab) ===
async function loadClassesAdmin() {
var classes = await api('classes');
if (classes.error) classes = [];
var html = '<div class="card"><div class="card-h">Meine Klassen verwalten</div>';
html += '<div class="toolbar"><button class="btn" onclick="newClassFromAdmin()">+ Neue Klasse anlegen</button></div>';
if (!classes.length) {
html += '<p style="font-size:.72rem;color:#8a8a8a">Noch keine Klassen.</p>';
} else {
html += '<table class="students"><tr><th>Name</th><th>Schuljahr</th><th>Klassencode</th><th>Schüler:innen</th><th>Erstellt</th><th></th></tr>';
classes.forEach(function(c) {
var n = parseInt(c.student_count || 0);
var canDelete = n === 0;
var created = c.created_at ? new Date(c.created_at).toLocaleDateString('de-AT') : '—';
html += '<tr>'
+ '<td><strong>'+esc(c.name)+'</strong></td>'
+ '<td>'+esc(c.school_year || '')+'</td>'
+ '<td><span style="font-family:monospace;font-weight:700">'+esc(c.join_code)+'</span></td>'
+ '<td>'+(n > 0 ? '<strong>'+n+'</strong>' : '<span style="color:#8a8a8a">leer</span>')+'</td>'
+ '<td>'+created+'</td>'
+ '<td>'
+ '<button class="btn-sm" onclick="editClassAdmin('+c.id+',\''+esc(c.name).replace(/\\\'/g,"\\\\'")+'\',\''+esc(c.school_year||'').replace(/\\\'/g,"\\\\'")+'\')">Umbenennen</button> '
+ (canDelete
? '<button class="btn-sm" style="color:#c07a6b" onclick="deleteClassAdmin('+c.id+',\''+esc(c.name).replace(/\\\'/g,"\\\\'")+'\')">Löschen</button>'
: '<button class="btn-sm" disabled title="Klasse hat noch Schüler:innen — erst dort löschen" style="opacity:.4;cursor:not-allowed">Löschen</button>')
+ '</td></tr>';
});
html += '</table>';
}
html += '</div>';
document.getElementById('tab-classes').innerHTML = html;
}
async function newClassFromAdmin() {
var name = prompt('Name der neuen Klasse:');
if (!name) return;
var year = prompt('Schuljahr (z.B. 2026/27, optional):') || '';
var r = await api('classes', {action:'create', name:name, schoolYear:year});
if (r.error) { alert(r.error); return; }
await loadClasses();
loadClassesAdmin();
}
async function editClassAdmin(id, name, year) {
var newName = prompt('Neuer Klassenname:', name);
if (!newName || newName === name) {
var newYear = prompt('Schuljahr:', year || '');
if (newYear === null) return;
var r = await api('classes', {action:'update', classId:id, name:name, schoolYear:newYear});
if (r.error) { alert(r.error); return; }
} else {
var newYear2 = prompt('Schuljahr:', year || '');
if (newYear2 === null) newYear2 = year || '';
var r2 = await api('classes', {action:'update', classId:id, name:newName, schoolYear:newYear2});
if (r2.error) { alert(r2.error); return; }
}
await loadClasses();
loadClassesAdmin();
}
async function deleteClassAdmin(id, name) {
if (!confirm('Klasse „' + name + '" wirklich löschen? Das geht nur, wenn keine Schüler:innen mehr drin sind.')) return;
var r = await api('classes', {action:'delete', classId:id});
if (r.error) { alert(r.error); return; }
await loadClasses();
loadClassesAdmin();
}
// === KLASSEN-DROPDOWN ===
async function loadClasses() {
var classes = await api('classes');
if (classes.error) classes = [];
allClasses = classes;
var sel = document.getElementById('class-select');
sel.innerHTML = '';
if (classes.length === 0) {
sel.innerHTML = '<option value="">Keine Klassen</option>';
document.getElementById('class-code').textContent = '—';
return;
}
classes.forEach(function(c) {
var opt = document.createElement('option');
opt.value = c.id; opt.textContent = c.name + ' (' + c.school_year + ')';
sel.appendChild(opt);
});
if (!currentClassId) currentClassId = classes[0].id;
sel.value = currentClassId;
currentClass = classes.find(function(c) { return c.id === currentClassId; });
document.getElementById('class-code').textContent = currentClass?.join_code || '—';
loadTab(currentTab);
startLiveStatusPoll();
}
function onClassChange() {
currentClassId = parseInt(document.getElementById('class-select').value);
currentClass = allClasses.find(function(c) { return c.id === currentClassId; });
document.getElementById('class-code').textContent = currentClass?.join_code || '—';
loadTab(currentTab);
startLiveStatusPoll();
}
async function saveClass() {
var name = document.getElementById('mc-name').value.trim();
var year = document.getElementById('mc-year').value.trim();
if (!name) return;
var r = await api('classes', {action:'create', name:name, schoolYear:year});
hideModal('modal-class');
if (r.id) currentClassId = r.id;
loadClasses();
}
function showCode() {
var code = currentClass?.join_code;
if (!code) return;
navigator.clipboard.writeText(code).then(function() {
var el = document.getElementById('class-code');
el.textContent = 'Kopiert!';
setTimeout(function() { el.textContent = code; }, 1200);
});
}
function openTickets() { if (currentClassId) window.open(BASE + '/api/tickets?class_id=' + currentClassId, '_blank'); }
// === LIVE-TAB-INDIKATOR (klassen-weite Hintergrund-Pollung) ===
// Unabhängig vom Live-Tab-Polling — pulsiert den Live-Tab-Punkt nur, wenn
// gerade jemand aktiv ist (≥ 1 Session mit Heartbeat ≤ 30 s).
var _liveStatusTimer = null;
async function pollLiveStatus() {
if (!currentClassId) return;
try {
var sessions = await api('live?class_id=' + currentClassId);
var n = Array.isArray(sessions) ? sessions.length : 0;
var btn = document.getElementById('tab-btn-live');
var cnt = document.getElementById('live-count');
if (btn) btn.classList.toggle('live-active', n > 0);
if (cnt) cnt.textContent = n > 0 ? n : '';
} catch (_) { /* still */ }
}
function startLiveStatusPoll() {
pollLiveStatus();
if (_liveStatusTimer) clearInterval(_liveStatusTimer);
_liveStatusTimer = setInterval(pollLiveStatus, 10000);
}
// === ÜBERSICHT ===
// Lehrer-Übersicht: Stat-Bar + aktive Aufgaben + Top-Performer + Aktivitäts-Sparkline + Modul-Cards.
// Drei parallele API-Calls (matrix + activity-week + assignments) — alle existing.
async function loadOverview() {
var host = document.getElementById('tab-overview');
host.innerHTML = '<p style="font-size:.78rem;color:#8a8a8a;padding:1rem">Lade…</p>';
var [matrix, activity, assignments, students] = await Promise.all([
api('results?class_id=' + currentClassId + '&view=matrix'),
api('results?class_id=' + currentClassId + '&view=activity&range=week'),
api('assignments?class_id=' + currentClassId),
api('students?class_id=' + currentClassId),
]);
if (matrix.error) { host.innerHTML = '<p style="color:#c07a6b">'+esc(matrix.error)+'</p>'; return; }
if (activity.error) activity = {series:[], buckets:[]};
if (assignments.error) assignments = [];
if (students.error) students = [];
// === Top 3 Performer aus Matrix-Cells ableiten ===
var topRuns = [];
matrix.students.forEach(function(s) {
Object.keys(matrix.cells[s.id] || {}).forEach(function(mid) {
var c = matrix.cells[s.id][mid];
if (c && c.plays > 0) {
topRuns.push({
studentId: s.id, displayName: s.displayName, emoji: s.emoji,
moduleId: mid, score: c.score, stars: c.stars, plays: c.plays,
});
}
});
});
topRuns.sort(function(a, b) { return b.score - a.score; });
var top = topRuns.slice(0, 3);
// Modul-Lookup für Bilder/Farben
var modById = {};
matrix.modules.forEach(function(m) { modById[m.id] = m; });
// Total-Stats
var totalPlays = 0, totalScore = 0, activeStudents = 0;
matrix.students.forEach(function(s) {
var t = matrix.totals[s.id] || {plays:0, score:0};
totalPlays += t.plays; totalScore += t.score;
if (t.plays > 0) activeStudents++;
});
var html = '';
// === Sektion A: Stat-Bar ===
html += '<div class="card"><div class="stat-row">';
html += '<div class="stat"><div class="n">'+students.length+'</div><div class="l">Schüler:innen</div></div>';
html += '<div class="stat"><div class="n">'+activeStudents+'</div><div class="l">Aktiv</div></div>';
html += '<div class="stat"><div class="n">'+totalPlays+'</div><div class="l">Durchgänge</div></div>';
html += '<div class="stat"><div class="n">'+totalScore.toFixed(0)+'</div><div class="l">Σ Score</div></div>';
html += '<div class="stat"><div class="n" style="font-family:monospace">'+(currentClass?.join_code||'—')+'</div><div class="l">Klassencode</div></div>';
html += '</div></div>';
// === Sektion B: Aktive Klassen-Simulationen ===
var active = (assignments || []).filter(function(a){return a.active;});
if (active.length > 0) {
html += '<div class="card" style="border-color:#e8833a;border-width:2px"><div class="card-h" style="color:#e8833a">📋 Aktive Klassen-Aufgaben</div>';
active.forEach(function(a) {
var mod = modById[a.module_id] || {icon:'📋', name:a.module_id};
var compl = (matrix.cells && Object.values(matrix.cells).filter(function(c){return c[a.module_id]?.plays > 0;}).length) || 0;
var pct = students.length > 0 ? Math.round(compl / students.length * 100) : 0;
var due = a.ends_at ? ' · bis ' + new Date(a.ends_at).toLocaleDateString('de-AT',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : '';
html += '<div style="display:flex;align-items:center;gap:.5rem;margin:.4rem 0">'
+ '<span style="font-size:1.5rem">'+mod.icon+'</span>'
+ '<div style="flex:1">'
+ '<strong style="font-size:.82rem">'+esc(mod.name)+'</strong>'
+ '<span style="font-size:.65rem;color:#8a8a8a"> · Level '+a.level+due+'</span>'
+ '<div style="background:#f0eeea;border-radius:4px;height:6px;margin-top:.25rem;overflow:hidden"><div style="background:#e8833a;height:100%;width:'+pct+'%;transition:width .3s"></div></div>'
+ '<div style="font-size:.62rem;color:#8a8a8a;margin-top:.15rem">'+compl+'/'+students.length+' fertig</div>'
+ '</div></div>';
});
html += '</div>';
}
// === Sektion C: Top-Performer (3 Karten) ===
if (top.length > 0) {
html += '<div class="card"><div class="card-h">🏆 Top-Performer</div>';
html += '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:.6rem">';
var medals = ['🥇','🥈','🥉'];
top.forEach(function(r, i) {
var mod = modById[r.moduleId] || {icon:'📚', name:r.moduleId};
var avatar = (typeof r.emoji === 'string' && r.emoji.indexOf('avatar:') === 0)
? '<img src="assets/img/avatars/avatar-'+r.emoji.substring(7)+'.png" alt="" style="width:32px;height:32px;border-radius:50%;object-fit:cover;vertical-align:middle">'
: '<span style="font-size:1.6rem">'+r.emoji+'</span>';
html += '<div style="background:#f7f6f3;border-radius:10px;padding:.6rem .8rem;border-left:4px solid '+(i===0?'#e8c547':i===1?'#c0c0c0':'#cd7f32')+'">'
+ '<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.3rem">'
+ '<span style="font-size:1.6rem">'+medals[i]+'</span>'
+ avatar
+ '<strong style="font-size:.85rem">'+esc(r.displayName)+'</strong>'
+ '</div>'
+ '<div style="font-size:.72rem;color:#4a4a4a">'+mod.icon+' '+esc(mod.name)+'</div>'
+ '<div style="font-size:.66rem;color:#8a8a8a;margin-top:.15rem">'+r.stars+'★ · '+r.plays+'×</div>'
+ '<button class="btn-sm" style="margin-top:.4rem;font-size:.62rem;padding:.2rem .5rem" onclick="openModuleDetail(\''+r.moduleId+'\')">Modul ansehen</button>'
+ '</div>';
});
html += '</div></div>';
}
// === Sektion D: Aktivitäts-Sparkline (kompakt, Woche) ===
if (activity.series && activity.series.length > 0) {
var maxStack = 0;
for (var i = 0; i < activity.buckets.length; i++) {
var sum = activity.series.reduce(function(acc, s){ return acc + (s.values[i]||0); }, 0);
if (sum > maxStack) maxStack = sum;
}
if (maxStack < 1) maxStack = 1;
var W = 600, H = 90, padL = 22, padR = 6, padT = 8, padB = 18;
var chartW = W - padL - padR, chartH = H - padT - padB;
var step = chartW / activity.buckets.length;
var barW = step * 0.7;
var svg = '<svg viewBox="0 0 '+W+' '+H+'" style="width:100%;height:auto;display:block">';
activity.buckets.forEach(function(label, idx) {
var x = padL + idx * step + (step - barW)/2;
var yCursor = padT + chartH;
activity.series.forEach(function(s) {
var v = s.values[idx] || 0;
if (!v) return;
var h = (v / maxStack) * chartH;
yCursor -= h;
svg += '<rect x="'+x+'" y="'+yCursor+'" width="'+barW+'" height="'+h+'" fill="'+s.color+'"><title>'+s.icon+' '+esc(s.name)+': '+v+'</title></rect>';
});
svg += '<text x="'+(x+barW/2)+'" y="'+(H-3)+'" font-size="9" text-anchor="middle" fill="#4a4a4a">'+esc(label)+'</text>';
});
svg += '</svg>';
html += '<div class="card"><div class="card-h">📊 Diese Woche — Durchgänge nach Tag</div>'+svg+'</div>';
}
// === Sektion E: Modul-Performance-Cards (kompakt, alle aktiven Module) ===
html += '<div class="card"><div class="card-h">Module — Stand der Klasse</div>';
html += '<div class="result-mod-grid">';
matrix.modules.forEach(function(mod) {
var totalPlaysM = 0, totalStars = 0, withPlays = 0;
matrix.students.forEach(function(s) {
var c = matrix.cells[s.id]?.[mod.id];
if (c && c.plays > 0) { totalPlaysM += c.plays; totalStars += c.stars; withPlays++; }
});
var avgStars = withPlays ? (totalStars / withPlays).toFixed(1) : '';
var imgSrc = mod.card_image ? 'assets/img/' + mod.card_image : null;
html += '<div class="result-mod-card" onclick="openModuleDetail(\''+mod.id+'\')">'
+ '<div class="rm-img">' + (imgSrc ? '<img src="'+imgSrc+'" alt="">' : '<span style="font-size:2.2rem">'+(mod.icon||'📚')+'</span>') + '</div>'
+ '<div class="rm-body">'
+ '<div class="rm-name">'+(mod.icon||'')+' '+esc(mod.name)+'</div>'
+ '<div class="rm-stats">⭐ '+avgStars+' · '+totalPlaysM+'× · '+withPlays+'/'+matrix.students.length+'</div>'
+ '</div></div>';
});
html += '</div></div>';
host.innerHTML = html;
}
// === MODULFREIGABE (Matrix) ===
async function loadModuleMatrix() {
var data = await api('modules?class_id=' + currentClassId + '&matrix=1');
if (data.error) { document.getElementById('tab-modules').innerHTML = '<p style="color:#c07a6b">'+esc(data.error)+'</p>'; return; }
var mods = data.modules, students = data.students;
// Aktive Aufgaben laden
var assignments = await api('assignments?class_id=' + currentClassId);
if (assignments.error) assignments = [];
var activeAssign = assignments.filter(function(a) { return a.active; });
// Aktive Aufgaben anzeigen
var html = '';
if (activeAssign.length > 0) {
html += '<div class="card" style="border-color:#e8833a;border-width:2px"><div class="card-h" style="color:#e8833a">Aktive Klassen-Simulation</div>';
activeAssign.forEach(function(a) {
var mod = mods.find(function(m){return m.id===a.module_id;});
var name = mod ? mod.icon + ' ' + mod.name : a.module_id;
var end = a.ends_at ? ' · bis ' + new Date(a.ends_at).toLocaleDateString('de-AT',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : ' · ohne Zeitlimit';
html += '<div style="display:flex;align-items:center;gap:.6rem;padding:.3rem 0">';
html += '<span style="font-size:1.2rem">' + (mod?.icon||'📋') + '</span>';
html += '<div style="flex:1"><strong style="font-size:.78rem">' + esc(mod?.name||a.module_id) + '</strong>';
html += '<span style="font-size:.65rem;color:#8a8a8a"> · Level ' + a.level + end + '</span></div>';
html += '<button class="btn" style="background:#c07a6b;font-size:.6rem;padding:.2rem .5rem" onclick="stopAssignment('+a.id+')">Beenden</button>';
html += '</div>';
});
html += '</div>';
}
// Start-Button
html += '<div style="margin-bottom:.6rem"><button class="btn" onclick="showStartSim()" style="font-size:.75rem">🚀 Klassen-Simulation starten</button></div>';
// Vorbereitung: spielbare vs. geplante Module trennen (vor Matrix nutzen)
var modsActive = mods.filter(function(m){ return m.status !== 'geplant' && m.status !== 'archiv'; });
var modsComing = mods.filter(function(m){ return m.status === 'geplant' || m.status === 'archiv'; });
// Matrix oben: Individuelle Freigabe — nur spielbare Module
html += '<div class="card"><div class="card-h">Individuelle Freigabe</div>';
if (students.length === 0) {
html += '<p style="font-size:.72rem;color:#8a8a8a">Noch keine Schüler*innen in dieser Klasse.</p>';
} else {
html += '<div class="matrix-wrap"><table class="matrix">';
html += '<tr><th class="student-col">Schüler*in</th>';
modsActive.forEach(function(m) {
var hint = (m.classMode === 'free' ? 'alle ausschalten' : 'alle einschalten');
html += '<th class="mod-th-clickable" onclick="toggleClassForModule(\''+m.id+'\',\''+m.classMode+'\')" title="Klick: '+hint+'" style="cursor:pointer">'
+ '<span class="mod-emoji">'+m.icon+'</span><span class="mod-short">'+esc(m.name)+'</span></th>';
});
html += '</tr>';
students.forEach(function(s) {
var avatarHtml = (typeof s.emoji === 'string' && s.emoji.indexOf('avatar:') === 0)
? '<img src="assets/img/avatars/avatar-'+s.emoji.substring(7)+'.png" alt="" style="width:1.4em;height:1.4em;border-radius:50%;object-fit:cover;vertical-align:middle">'
: '<span style="font-size:.9rem">'+s.emoji+'</span>';
html += '<tr><td class="student-cell">'+avatarHtml+' '+esc(s.displayName)+'</td>';
modsActive.forEach(function(m) {
var mode = s.modules[m.id] || 'locked';
var icon = mode === 'locked' ? '🔒' : mode === 'free' ? '🟢' : '▶';
html += '<td><span class="m-cell '+mode+'" onclick="cycleStudentMode('+s.id+',\''+m.id+'\',\''+mode+'\')">'+icon+'</span></td>';
});
html += '</tr>';
});
html += '</table></div>';
}
html += '</div>';
// Modul-Cards: spielbar zuerst, "in Vorbereitung" am Ende.
// Pro Card: Bild + Beschreibung + Modus-Toggle + 4 Aktionen.
html += '<div class="card"><div class="card-h">Module — Klassen-Default</div>';
function _renderModRow(m) {
var coming = (m.status === 'geplant' || m.status === 'archiv');
var isAssignment = m.classMode === 'teacher_started';
var modeIcon = coming ? '🚧' : (m.classMode === 'locked' ? '🔒' : m.classMode === 'free' ? '🟢' : (m.paused ? '⏸' : '▶'));
var modeText = coming ? 'In Vorbereitung'
: (m.classMode === 'locked' ? 'Gesperrt'
: m.classMode === 'free' ? 'Frei'
: ('Auftrag · L' + (m.currentLevel || 1) + (m.paused ? ' · pausiert' : '')));
var modeBadgeCls = coming ? 'disabled' : (m.classMode || 'locked');
var modeClick = coming ? '' : 'onclick="cycleClassMode(\''+m.id+'\',\''+m.classMode+'\')"';
var cardImg = m.card_image ? '<img src="assets/img/'+m.card_image+'" alt="">' : '<span>'+(m.icon||'📚')+'</span>';
var overlay = coming ? '<div class="mc-coming-overlay">🚧 '+modeText+'</div>' : '';
var modeBadge = '<span class="mc-mode-badge '+modeBadgeCls+'" '+modeClick+' title="'+modeText+'">'+modeIcon+' '+modeText+'</span>';
// Pause-Toggle nur sichtbar wenn Klassen-Auftrag aktiv
var pauseBtn = isAssignment
? '<button class="btn-sm" style="position:absolute;top:8px;left:8px;background:rgba(255,255,255,.95);border:1.5px solid rgba(0,0,0,.08);font-size:.66rem;padding:.18rem .42rem" onclick="event.stopPropagation();toggleAssignmentPause(\''+m.id+'\','+(m.paused?'true':'false')+')" title="'+(m.paused?'Auftrag fortsetzen':'Auftrag pausieren')+'">'+(m.paused?'▶ Weiter':'⏸ Pause')+'</button>'
: '';
var s = '<div class="mod-card '+(coming?'coming':'')+'">';
s += '<div class="mc-img" style="position:relative">'+cardImg+overlay+pauseBtn+modeBadge+'</div>';
s += '<div class="mc-body">';
s += '<div class="mc-name">'+(m.icon||'📚')+' '+esc(m.name)+'</div>';
s += '<div class="mc-desc">'+esc(m.desc||'')+'</div>';
s += '<div class="mc-actions">';
if (!coming) {
s += '<button class="mc-btn primary" onclick="cycleClassMode(\''+m.id+'\',\''+m.classMode+'\')" title="Modus wechseln">🚀 Klassen-Mode</button>';
}
s += '<a href="modul-'+m.id+'" class="mc-btn" target="_blank" rel="noopener" title="Detail-Seite">📖 Infos</a>';
s += '<a href="glossar?module='+m.id+'" class="mc-btn" target="_blank" rel="noopener" title="Glossar gefiltert">📚 Glossar</a>';
s += '<a href="lehrplan?module='+m.id+'" class="mc-btn" target="_blank" rel="noopener" title="Lehrplan-Bezug">📋 Lehrplan</a>';
s += '</div></div></div>';
return s;
}
html += '<div class="mod-cards">';
modsActive.forEach(function(m){ html += _renderModRow(m); });
html += '</div>';
if (modsComing.length) {
html += '<div style="font-size:.74rem;font-weight:700;color:#8a8a8a;text-transform:uppercase;letter-spacing:.06em;padding:1rem .2rem .5rem;border-top:1px solid rgba(0,0,0,.06);margin-top:.5rem">In Vorbereitung</div>';
html += '<div class="mod-cards">';
modsComing.forEach(function(m){ html += _renderModRow(m); });
html += '</div>';
}
html += '</div>';
document.getElementById('tab-modules').innerHTML = html;
}
// === KLASSEN-SIMULATION ===
// ALL_MODS wird zur Laufzeit aus /api/modules?class_id=... geholt — kein Hardcode.
var ALL_MODS = [];
async function loadAllMods() {
if (ALL_MODS.length || !currentClassId) return ALL_MODS;
var r = await api('modules?class_id=' + currentClassId);
if (Array.isArray(r)) ALL_MODS = r.filter(function(m){return m.status!=='archiv';});
return ALL_MODS;
}
async function showStartSim(preselectedModuleId) {
await loadAllMods();
var sel = document.getElementById('ss-module');
sel.innerHTML = '';
ALL_MODS.forEach(function(m) { var o=document.createElement('option'); o.value=m.id; o.textContent=(m.icon||'')+' '+m.name; sel.appendChild(o); });
if (preselectedModuleId) sel.value = preselectedModuleId;
document.getElementById('ss-ends').value = '';
document.getElementById('ss-paused').checked = false;
document.getElementById('ss-level').value = '1';
showModal('modal-startsim');
}
async function startSimulation() {
var moduleId = document.getElementById('ss-module').value;
var level = parseInt(document.getElementById('ss-level').value);
var endsAt = document.getElementById('ss-ends').value || null;
var paused = document.getElementById('ss-paused').checked;
// Sowohl class_modules (Modus + Level + Pause) als auch class_assignments
// (Auftragshistorie) setzen, damit Schüler-Cockpit + saves.php-Filter +
// Live-View den gleichen Auftragszustand sehen.
var rm = await api('modules', {action:'set_mode', classId:currentClassId, moduleId:moduleId, mode:'teacher_started', currentLevel:level, dueDate:endsAt, paused:paused});
if (rm && rm.error) { alert(rm.error); return; }
var r = await api('assignments', {action:'create', classId:currentClassId, moduleId:moduleId, level:level, endsAt:endsAt});
if (r.error) { alert(r.error); return; }
hideModal('modal-startsim');
loadModuleMatrix();
}
async function stopAssignment(id) {
if (!confirm('Simulation beenden?')) return;
await api('assignments', {action:'stop', assignmentId:id});
loadModuleMatrix();
}
function cycleClassMode(moduleId, current) {
var next = current === 'locked' ? 'free' : current === 'free' ? 'teacher_started' : 'locked';
// Wechsel auf teacher_started → Auftrags-Modal öffnen (Level + Pause + Zeitlimit
// bewusst gesetzt, statt blindem Toggle).
if (next === 'teacher_started') { showStartSim(moduleId); return; }
api('modules', {action:'set_mode', classId:currentClassId, moduleId:moduleId, mode:next}).then(loadModuleMatrix);
}
async function toggleAssignmentPause(moduleId, paused) {
await api('modules', {action: paused ? 'resume' : 'pause', classId:currentClassId, moduleId:moduleId});
loadModuleMatrix();
}
// Klick auf Modul-Spalte in Matrix: toggelt alle Schueler dieser Klasse fuer
// das Modul. Setzt Klassen-Default auf locked oder free und loescht alle
// individuellen Overrides — Schueler erben den Default.
async function toggleClassForModule(moduleId, classMode) {
var next = classMode === 'free' ? 'locked' : 'free';
await api('modules', {action:'set_mode', classId:currentClassId, moduleId:moduleId, mode:next});
await api('modules', {action:'reset_overrides', classId:currentClassId, moduleId:moduleId});
loadModuleMatrix();
}
function cycleStudentMode(studentId, moduleId, current) {
var next = current === 'locked' ? 'free' : current === 'free' ? 'teacher_started' : 'locked';
api('modules', {action:'set_student_mode', classId:currentClassId, studentId:studentId, moduleId:moduleId, mode:next}).then(loadModuleMatrix);
}
// === SCHÜLER ===
async function loadStudents() {
var students = await api('students?class_id=' + currentClassId);
if (students.error) students = [];
// Lizenzen laden für Anzeige
var licData = await api('licenses?class_id=' + currentClassId);
var assignedMap = {};
if (licData.assigned) licData.assigned.forEach(function(l) { assignedMap[l.student_id] = l.code; });
// Count without license
var withoutLicense = students.filter(function(s) { return !assignedMap[s.id]; }).length;
var html = '<div class="card"><div class="card-h">Klasse — ' + esc(currentClass?.name || '') + '</div>';
html += '<div class="toolbar">';
html += '<button class="btn" onclick="showModal(\'modal-student\')">+ Einzeln erstellen</button>';
html += '<button class="btn btn-outline" onclick="showModal(\'modal-batch\')">+ Mehrere erstellen</button>';
html += '<button class="btn btn-outline" onclick="showModal(\'modal-csv\')">📄 CSV importieren</button>';
html += '</div>';
// Bulk-Toolbar (hidden until selection)
html += '<div id="bulk-bar" class="hidden" style="display:none;align-items:center;gap:.4rem;padding:.4rem .6rem;background:#dae8ec;border-radius:6px;margin-bottom:.5rem;font-size:.7rem">';
html += '<strong id="bulk-count">0</strong> ausgewählt';
html += '<button class="btn" style="font-size:.65rem;padding:.25rem .6rem;margin-left:.5rem" onclick="bulkAssignLicense()">🔑 Lizenz zuweisen</button>';
html += '<button class="btn btn-outline" style="font-size:.65rem;padding:.25rem .6rem" onclick="bulkSelectNone()">Auswahl aufheben</button>';
html += '</div>';
if (students.length > 0) {
html += '<table class="students"><tr><th style="width:28px"><input type="checkbox" onchange="bulkToggleAll(this)"></th><th></th><th>Benutzer</th><th>Name</th><th title="PIN anzeigen — klick auf Schlüssel">🔑 PIN</th><th>Lizenz</th><th title="Leichte Sprache">📖</th><th>Letzter Login</th><th>Ø</th><th></th></tr>';
students.forEach(function(s) {
var avg = s.total_score?.avg_score ? Math.round(parseFloat(s.total_score.avg_score)) : '—';
var last = s.last_login ? new Date(s.last_login).toLocaleDateString('de-AT') : '—';
var emoji = s.emoji_avatar || '🧑‍🎓';
if (typeof emoji === 'string' && emoji.indexOf('avatar:') === 0) {
emoji = '<img src="assets/img/avatars/avatar-' + emoji.substring(7) + '.png" alt="" style="width:1.4em;height:1.4em;border-radius:50%;object-fit:cover;vertical-align:middle">';
}
var name = s.display_name || s.username;
if (s.first_name || s.last_name) name = [s.first_name, s.last_name].filter(Boolean).join(' ');
var lic = assignedMap[s.id];
var licHtml = lic ? '<span style="font-family:monospace;font-size:.6rem;color:#5a8a5e">'+lic+'</span>' : '<span style="font-size:.6rem;color:#c07a6b">—</span>';
var easyChecked = s.easy_language ? ' checked' : '';
var pinHtml;
if (s.password_plaintext) {
pinHtml = '<span class="pin-cell" id="pin-'+s.id+'">'
+ '<button class="btn-sm" onclick="togglePin('+s.id+')" title="PIN anzeigen/verbergen">🔑</button>'
+ '<span class="pin-val" data-pin="'+esc(s.password_plaintext)+'" style="display:none;font-family:monospace;font-weight:700;margin-left:.3rem">'+esc(s.password_plaintext)+'</span></span>';
} else {
pinHtml = '<span style="font-size:.6rem;color:#8a8a8a" title="Passwort wurde von der/dem Schüler:in selbst gesetzt — Lehrkraft kann zurücksetzen">selbst gesetzt</span>';
}
html += '<tr>';
html += '<td><input type="checkbox" class="bulk-cb" value="'+s.id+'" onchange="bulkUpdate()"'+(lic?' disabled title="Hat bereits Lizenz"':'')+'></td>';
html += '<td style="font-size:1.1rem;text-align:center">'+emoji+'</td>';
html += '<td><strong>'+esc(s.username)+'</strong></td>';
html += '<td>'+esc(name)+'</td>';
html += '<td>'+pinHtml+'</td>';
html += '<td>'+licHtml+'</td>';
html += '<td style="text-align:center"><input type="checkbox"'+easyChecked+' onchange="toggleEasyLanguage('+s.id+', this.checked)" title="Leichte Sprache für diese*n Schüler*in aktivieren"></td>';
html += '<td>'+last+'</td><td>'+avg+'</td>';
html += '<td>'
+ '<button class="btn-sm" onclick="editStudent('+s.id+',\''+esc(s.username).replace(/'/g,"\\'")+'\',\''+esc(name).replace(/'/g,"\\'")+'\')">Bearbeiten</button> '
+ '<button class="btn-sm" onclick="resetPin('+s.id+',\''+esc(s.username).replace(/'/g,"\\'")+'\')" title="Neue PIN vergeben">🔄 PIN</button> '
+ '<button class="btn-sm" style="color:#c07a6b" onclick="deleteStudent('+s.id+')">Löschen</button></td></tr>';
});
html += '</table>';
html += '<div style="margin-top:.6rem;display:flex;gap:.4rem;flex-wrap:wrap">';
html += '<button class="btn btn-outline" style="font-size:.65rem" onclick="window.open(BASE+\'/api/tickets?class_id=\'+currentClassId, \'_blank\')">🎫 Tickets drucken (alle)</button>';
html += '<button class="btn btn-outline" style="font-size:.65rem" onclick="exportStudentsCsv()">📥 CSV exportieren</button>';
html += '<button class="btn btn-outline" style="font-size:.65rem;color:#c07a6b" onclick="resetSelectedPins()">🔄 PIN für Auswahl neu vergeben</button>';
html += '</div>';
if (withoutLicense > 0) {
html += '<div style="margin-top:.5rem"><button class="btn btn-outline" style="font-size:.65rem" onclick="bulkSelectWithout()">Alle ohne Lizenz auswählen ('+withoutLicense+')</button></div>';
}
} else {
html += '<p style="font-size:.72rem;color:#8a8a8a">Noch keine Schüler*innen in dieser Klasse.</p>';
}
html += '</div>';
document.getElementById('tab-students').innerHTML = html;
}
async function saveStudent() {
var user = document.getElementById('ms-user').value.trim();
var pass = document.getElementById('ms-pass').value;
if (!user || !pass) { alert('Benutzername und Passwort sind Pflichtfelder.'); return; }
var r = await api('students', {
action:'create', classId:currentClassId, username:user, password:pass,
displayName: document.getElementById('ms-name').value.trim(),
firstName: document.getElementById('ms-first').value.trim(),
lastName: document.getElementById('ms-last').value.trim(),
email: document.getElementById('ms-email').value.trim()
});
if (r.error) { alert(r.error); return; }
if (document.getElementById('ms-license').checked && r.id) {
await api('licenses', {action:'auto_assign', studentId:r.id}).catch(function(){});
}
hideModal('modal-student');
['ms-user','ms-pass','ms-name','ms-first','ms-last','ms-email'].forEach(function(id) { document.getElementById(id).value = ''; });
loadStudents(); loadLicenses();
}
// CSV-Import: parse Header + Zeilen, sende rows-Array an Backend
async function importCsv() {
var resultDiv = document.getElementById('csv-result');
resultDiv.innerHTML = '';
var text = document.getElementById('csv-text').value;
var file = document.getElementById('csv-file').files[0];
if (file && !text) {
text = await file.text();
}
if (!text || !text.trim()) { resultDiv.innerHTML = '<span style="color:#c00">Bitte CSV-Datei wählen oder Text einfügen.</span>'; return; }
// Einfacher CSV-Parser: Komma- oder Strichpunkt-getrennt.
// Excel-Hint "sep=;" am Anfang wird übersprungen.
var lines = text.split(/\r?\n/).filter(function(l){return l.trim();});
if (lines[0] && /^sep=.$/i.test(lines[0].trim())) lines.shift();
if (lines.length < 2) { resultDiv.innerHTML = '<span style="color:#c00">Mindestens Header + 1 Datenzeile.</span>'; return; }
var sep = lines[0].indexOf(';') > -1 && lines[0].indexOf(',') === -1 ? ';' : ',';
var headers = lines[0].split(sep).map(function(h){return h.trim().toLowerCase();});
var rows = [];
for (var i = 1; i < lines.length; i++) {
var cols = lines[i].split(sep);
var row = {};
headers.forEach(function(h, idx) { row[h] = (cols[idx] || '').trim(); });
rows.push(row);
}
var r = await api('students', {action:'create_csv', classId:currentClassId, rows:rows});
if (r.error) { resultDiv.innerHTML = '<span style="color:#c00">' + r.error + '</span>'; return; }
var created = r.created || [];
var skipped = r.skipped || [];
var html = '<p style="color:#5a8a5e;font-weight:600">✓ ' + created.length + ' Schüler:innen angelegt'
+ (skipped.length ? ', ' + skipped.length + ' übersprungen' : '') + '</p>';
if (created.length) {
html += '<details open><summary style="font-weight:600;cursor:pointer">Zugangsdaten (zum Drucken)</summary>';
html += '<table style="font-size:.65rem;border-collapse:collapse;width:100%"><tr><th>Username</th><th>Passwort</th><th>Anzeigename</th></tr>';
created.forEach(function(s) {
html += '<tr><td>' + esc(s.username) + '</td><td><b>' + esc(s.password) + '</b></td><td>' + esc(s.displayName) + '</td></tr>';
});
html += '</table></details>';
}
if (skipped.length) {
html += '<details><summary style="cursor:pointer;color:#a37800">' + skipped.length + ' übersprungene Zeilen</summary><ul>';
skipped.forEach(function(s) { html += '<li>' + (s.row.username || '?') + ': ' + s.reason + '</li>'; });
html += '</ul></details>';
}
resultDiv.innerHTML = html;
// Lizenzen ggf. zuweisen + Liste neu laden
if (document.getElementById('csv-license').checked && created.length) {
for (var j = 0; j < created.length; j++) {
try { await api('licenses', {action:'assign', studentId:created[j].id}); } catch(e) {}
}
}
loadStudents();
}
// Reset-PIN aus dem Bearbeiten-Modal: holt neue PIN, schreibt sie ins Feld
async function resetPinFromModal() {
var id = parseInt(document.getElementById('me-id').value);
if (!id) return;
if (!confirm('Neue PIN vergeben? Die alte PIN funktioniert dann nicht mehr.')) return;
var r = await api('students', {action:'reset_password', studentId:id});
if (r.error) { alert(r.error); return; }
document.getElementById('me-pass').value = r.password;
alert('Neue PIN: ' + r.password + '\n(Sie ist bereits gespeichert.)');
loadStudents();
}
// CSV-Export der Schüler-Liste (mit PIN, falls vorhanden)
async function exportStudentsCsv() {
var students = await api('students?class_id=' + currentClassId);
if (students.error) { alert('Fehler beim Laden: ' + students.error); return; }
if (!Array.isArray(students) || !students.length) { alert('Keine Schüler:innen.'); return; }
function csvCell(v) {
if (v === null || v === undefined) return '';
var s = String(v);
// Excel mag Anführungszeichen rund um Felder mit Sonderzeichen
if (/[";\n\r]/.test(s)) s = '"' + s.replace(/"/g, '""') + '"';
return s;
}
var SEP = ';'; // deutsches Excel erwartet Semikolon (Komma = Dezimaltrenner)
var header = ['username','display_name','first_name','last_name','email','easy','pin'];
var lines = [
'sep=' + SEP, // Excel-Hint: Trennzeichen explizit
header.join(SEP)
];
students.forEach(function(s) {
var row = [
csvCell(s.username),
csvCell(s.display_name || ''),
csvCell(s.first_name || ''),
csvCell(s.last_name || ''),
csvCell(s.email || ''),
s.easy_language ? '1' : '0',
csvCell(s.password_plaintext || '')
];
lines.push(row.join(SEP));
});
// BOM voran für UTF-8-Erkennung in Excel + Windows-Zeilenenden
var csv = '' + lines.join('\r\n') + '\r\n';
var blob = new Blob([csv], {type: 'text/csv;charset=utf-8'});
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
var fname = 'schueler_' + (currentClass?.name || 'klasse').replace(/[^a-z0-9_-]/gi,'_') + '_' + new Date().toISOString().slice(0,10) + '.csv';
a.href = url; a.download = fname;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// PIN anzeigen/verbergen pro Schüler-Zeile
function togglePin(studentId) {
var box = document.getElementById('pin-' + studentId);
if (!box) return;
var v = box.querySelector('.pin-val');
v.style.display = v.style.display === 'none' ? 'inline' : 'none';
}
// Einzel-PIN-Reset → bestätigen, dann neue PIN setzen + im Modal anzeigen
async function resetPin(studentId, username) {
if (!confirm('Neue PIN für „' + username + '" vergeben? Die alte PIN funktioniert dann nicht mehr.')) return;
var r = await api('students', {action:'reset_password', studentId:studentId});
if (r.error) { alert(r.error); return; }
alert('Neue PIN für ' + username + ': ' + r.password);
loadStudents();
}
// Bulk-PIN-Reset für die per Checkbox ausgewählten Schüler:innen
async function resetSelectedPins() {
var ids = Array.prototype.slice.call(document.querySelectorAll('.bulk-cb:checked')).map(function(c){return parseInt(c.value);});
if (!ids.length) { alert('Bitte zuerst Schüler:innen mit den Checkboxen auswählen.'); return; }
if (!confirm('Neue PINs für ' + ids.length + ' Schüler:innen vergeben? Die alten PINs funktionieren dann nicht mehr.')) return;
var r = await api('students', {action:'reset_class_passwords', classId:currentClassId, studentIds:ids});
if (r.error) { alert(r.error); return; }
// Liste in Druck-Modal zeigen
var html = '<h3>Neue PINs — bitte drucken oder notieren</h3>';
html += '<table style="font-size:.7rem;width:100%;border-collapse:collapse;margin-top:.5rem">';
html += '<tr><th style="text-align:left;border-bottom:1px solid #ddd;padding:.3rem">Benutzer</th><th style="text-align:left;border-bottom:1px solid #ddd;padding:.3rem">Anzeigename</th><th style="text-align:left;border-bottom:1px solid #ddd;padding:.3rem">Neue PIN</th></tr>';
r.reset.forEach(function(s) {
html += '<tr><td style="padding:.3rem">'+esc(s.username)+'</td><td style="padding:.3rem">'+esc(s.displayName)+'</td><td style="padding:.3rem;font-family:monospace;font-weight:700">'+esc(s.password)+'</td></tr>';
});
html += '</table>';
html += '<div style="margin-top:.8rem;display:flex;gap:.4rem">';
html += '<button class="btn" onclick="window.open(BASE+\'/api/tickets?class_id=\'+currentClassId,\'_blank\')">🎫 Tickets-PDF drucken</button>';
html += '<button class="btn btn-outline" onclick="hideModal(\'modal-pins\')">Schließen</button>';
html += '</div>';
document.getElementById('pins-result').innerHTML = html;
showModal('modal-pins');
loadStudents();
}
async function batchCreate() {
var prefix = document.getElementById('mb-prefix').value.trim() || 'schueler';
var count = parseInt(document.getElementById('mb-count').value) || 25;
var r = await api('students', {action:'create_batch', classId:currentClassId, prefix:prefix, count:count});
if (r.error) { alert(r.error); return; }
var html = '<strong>Erstellt!</strong><br><table style="font-size:.6rem;width:100%"><tr><th>Benutzer</th><th>Passwort</th></tr>';
r.created.forEach(function(s) { html += '<tr><td>'+s.username+'</td><td><code style="background:#dae8ec;padding:1px 4px;border-radius:3px">'+s.password+'</code></td></tr>'; });
html += '</table><br><button class="btn" onclick="printBatchTickets()">🎫 Tickets drucken</button>';
document.getElementById('batch-result').innerHTML = html;
window._lastBatch = r.created;
loadStudents();
}
function printBatchTickets() {
if (!window._lastBatch) return;
var cls = currentClass, w = window.open('','_blank');
var html = '<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Zugangskarten</title><style>@page{size:A4;margin:10mm}*{margin:0;padding:0;box-sizing:border-box}body{font-family:Inter,system-ui,sans-serif}.tickets{display:grid;grid-template-columns:repeat(3,1fr);gap:8mm;padding:5mm}.ticket{border:2px solid #4a7c8a;border-radius:10px;padding:10px;page-break-inside:avoid}.ticket-top{display:flex;align-items:center;gap:8px;margin-bottom:6px;border-bottom:1px dashed #dae8ec;padding-bottom:6px}.ticket-emoji{font-size:1.8rem}.ticket-brand{font-size:.6rem;font-weight:800;color:#4a7c8a}.ticket-row{display:flex;gap:4px;margin-bottom:4px;font-size:.7rem}.ticket-label{color:#8a8a8a;font-weight:500;min-width:80px}.ticket-val{font-weight:700;font-family:monospace}.ticket-url{font-size:.55rem;color:#4a7c8a;text-align:center;margin-top:6px;font-weight:600}.no-print{text-align:center;padding:1rem}.no-print button{padding:.5rem 1.5rem;background:#4a7c8a;color:#fff;border:none;border-radius:8px;font-weight:700;cursor:pointer}@media print{.no-print{display:none}}</style></head><body><div class="no-print"><button onclick="window.print()">Drucken / PDF</button></div><div class="tickets">';
window._lastBatch.forEach(function(s) {
html += '<div class="ticket"><div class="ticket-top"><span class="ticket-emoji">🧑‍🎓</span><div><div class="ticket-brand">GeoGraSim</div><div style="font-size:.55rem;color:#8a8a8a">Klasse '+(cls?.name||'')+'</div></div></div>';
html += '<div class="ticket-row"><span class="ticket-label">Klassencode:</span><span class="ticket-val">'+(cls?.join_code||'')+'</span></div>';
html += '<div class="ticket-row"><span class="ticket-label">Benutzer:</span><span class="ticket-val">'+s.username+'</span></div>';
html += '<div class="ticket-row"><span class="ticket-label">Passwort:</span><span class="ticket-val">'+s.password+'</span></div>';
html += '<div class="ticket-url">geograsim.at</div></div>';
});
html += '</div></body></html>';
w.document.write(html); w.document.close();
}
function editStudent(id, username, displayName) {
document.getElementById('me-id').value = id;
document.getElementById('me-user').value = username;
document.getElementById('me-name').value = displayName;
document.getElementById('me-pass').value = '';
showModal('modal-edit');
}
async function updateStudent() {
var id = parseInt(document.getElementById('me-id').value);
var name = document.getElementById('me-name').value.trim();
var pass = document.getElementById('me-pass').value;
var data = {action:'update', studentId:id};
if (name) data.displayName = name;
if (pass) data.newPassword = pass;
var r = await api('students', data);
if (r.error) { alert(r.error); return; }
hideModal('modal-edit');
loadStudents();
}
async function deleteStudent(id) {
if (!confirm('Schüler*in wirklich löschen?')) return;
await api('students', {action:'delete', studentId:id});
loadStudents();
}
async function toggleEasyLanguage(studentId, enabled) {
await api('students', {action:'update', studentId:studentId, easyLanguage: enabled});
// Kein Reload nötig — Checkbox-Zustand wird direkt am Element reflektiert
}
// === ERGEBNISSE ===
// === ERGEBNISSE — drei Sektionen: Matrix, Aktivitäts-Heatmap, Modul-Drill-Down ===
// Performance: zwei parallele API-Calls pro Tab-Wechsel, clientseitige Sortierung,
// Modul-Detail lazy bei Klick.
var _resultsState = {
matrix: null, // {students, modules, cells, totals}
activity: null, // {range, buckets, series}
range: 'week',
sortKey: 'name', // für Matrix
sortDir: 'asc',
};
async function loadResults() {
var host = document.getElementById('tab-results');
host.innerHTML = '<p style="font-size:.78rem;color:#8a8a8a;padding:1rem">Lade Ergebnisse…</p>';
// Zwei parallele API-Calls: Matrix + Activity (Default-Range = Woche)
var [matrix, activity] = await Promise.all([
api('results?class_id=' + currentClassId + '&view=matrix'),
api('results?class_id=' + currentClassId + '&view=activity&range=' + _resultsState.range),
]);
if (matrix.error) { host.innerHTML = '<p style="color:#c07a6b">'+esc(matrix.error)+'</p>'; return; }
if (activity.error) { host.innerHTML = '<p style="color:#c07a6b">'+esc(activity.error)+'</p>'; return; }
_resultsState.matrix = matrix;
_resultsState.activity = activity;
renderResults();
}
function renderResults() {
var host = document.getElementById('tab-results');
host.innerHTML = ''
+ '<div id="res-matrix"></div>'
+ '<div id="res-activity"></div>'
+ '<div id="res-modules"></div>';
renderMatrix();
renderActivity();
renderModuleCards();
}
// ---------- Sektion 1: Matrix ----------
function renderMatrix() {
var m = _resultsState.matrix;
if (!m) return;
var key = _resultsState.sortKey;
var dir = _resultsState.sortDir;
// Score je Schüler je Modul holen + sortieren
function rowKey(stu) {
if (key === 'name') return (stu.displayName || '').toLowerCase();
if (key === 'total') return -(m.totals[stu.id]?.score || 0);
return -(m.cells[stu.id]?.[key]?.score || 0); // Modul-Score (negativ = absteigend)
}
var students = m.students.slice().sort(function(a, b) {
var ka = rowKey(a), kb = rowKey(b);
var cmp = ka < kb ? -1 : ka > kb ? 1 : 0;
return dir === 'asc' ? cmp : -cmp;
});
function arrow(thisKey) {
if (key !== thisKey) return '<span class="sort-i">↕</span>';
return '<span class="sort-i active">' + (dir === 'asc' ? '↑' : '↓') + '</span>';
}
function cellColor(stars) {
if (!stars) return '#f4f1e8';
if (stars <= 2) return '#fff1c8';
if (stars <= 4) return '#dceadd';
return '#a3c167';
}
var html = '<div class="card"><div class="card-h">Klassen-Leistung pro Modul</div>';
html += '<p style="font-size:.66rem;color:#8a8a8a;margin-bottom:.5rem">Pro Zelle: <b>★ Sterne</b> &nbsp;(<b>n×</b> Durchgänge). Score = ★ × √Durchgänge. Klick auf Spalten-Kopf sortiert.</p>';
html += '<div class="matrix-wrap"><table class="result-matrix">';
html += '<thead><tr><th class="r-stu" onclick="sortMatrix(\'name\')">Schüler:in '+arrow('name')+'</th>';
m.modules.forEach(function(mod) {
html += '<th class="r-mod" onclick="sortMatrix(\''+mod.id+'\')" title="'+esc(mod.name)+'">'+mod.icon+' '+arrow(mod.id)+'</th>';
});
html += '<th class="r-total" onclick="sortMatrix(\'total\')">Gesamt '+arrow('total')+'</th>';
html += '</tr></thead><tbody>';
students.forEach(function(s) {
var avatarHtml = (typeof s.emoji === 'string' && s.emoji.indexOf('avatar:') === 0)
? '<img src="assets/img/avatars/avatar-'+s.emoji.substring(7)+'.png" alt="" style="width:1.2em;height:1.2em;border-radius:50%;object-fit:cover;vertical-align:middle">'
: (s.emoji || '🧑‍🎓');
html += '<tr><td class="r-stu">'+avatarHtml+' '+esc(s.displayName)+'</td>';
m.modules.forEach(function(mod) {
var c = m.cells[s.id]?.[mod.id];
if (c && c.plays > 0) {
html += '<td class="r-cell" style="background:'+cellColor(c.stars)+'"><span class="rs">'+c.stars+'★</span><span class="rp">'+c.plays+'×</span></td>';
} else {
html += '<td class="r-cell r-empty">·</td>';
}
});
var t = m.totals[s.id] || {score:0, plays:0};
html += '<td class="r-total"><b>'+t.score.toFixed(1)+'</b><span class="rp">'+t.plays+'×</span></td>';
html += '</tr>';
});
html += '</tbody></table></div></div>';
document.getElementById('res-matrix').innerHTML = html;
}
function sortMatrix(key) {
if (_resultsState.sortKey === key) {
_resultsState.sortDir = _resultsState.sortDir === 'asc' ? 'desc' : 'asc';
} else {
_resultsState.sortKey = key;
_resultsState.sortDir = (key === 'name') ? 'asc' : 'desc';
}
renderMatrix();
}
// ---------- Sektion 2: Aktivitäts-Heatmap (SVG-Stacked-Bar) ----------
function renderActivity() {
var a = _resultsState.activity;
var range = _resultsState.range;
var rangeBtns = ['day','week','month','year'].map(function(r) {
var label = {day:'Tag',week:'Woche',month:'Monat',year:'Jahr'}[r];
var cls = r === range ? 'btn' : 'btn btn-outline';
return '<button class="'+cls+'" style="font-size:.7rem;padding:.3rem .7rem" onclick="changeActivityRange(\''+r+'\')">'+label+'</button>';
}).join('');
var html = '<div class="card"><div class="card-h">Fleißmessung — Durchgänge im Zeitverlauf</div>';
html += '<div style="display:flex;gap:.4rem;margin-bottom:.6rem">'+rangeBtns+'</div>';
if (!a || !a.series || a.series.length === 0) {
html += '<p style="font-size:.72rem;color:#8a8a8a">Keine Aktivität in dieser Periode.</p>';
html += '</div>';
document.getElementById('res-activity').innerHTML = html;
return;
}
// Max-Stack-Höhe für Y-Skala
var maxStack = 0;
for (var i = 0; i < a.buckets.length; i++) {
var sum = a.series.reduce(function(acc, s) { return acc + (s.values[i] || 0); }, 0);
if (sum > maxStack) maxStack = sum;
}
if (maxStack < 1) maxStack = 1;
var W = 720, H = 240, padL = 36, padR = 14, padT = 14, padB = 30;
var chartW = W - padL - padR;
var chartH = H - padT - padB;
var barWidth = chartW / a.buckets.length * 0.78;
var step = chartW / a.buckets.length;
var svg = '<svg class="activity-svg" viewBox="0 0 '+W+' '+H+'" preserveAspectRatio="none">';
// Y-Grid + Labels (4 Schritte)
for (var g = 0; g <= 4; g++) {
var y = padT + chartH * (1 - g/4);
var v = Math.round(maxStack * g / 4);
svg += '<line x1="'+padL+'" y1="'+y+'" x2="'+(W-padR)+'" y2="'+y+'" stroke="rgba(0,0,0,0.06)"/>';
svg += '<text x="'+(padL-6)+'" y="'+(y+3)+'" font-size="9" text-anchor="end" fill="#8a8a8a">'+v+'</text>';
}
// Stacked Bars
a.buckets.forEach(function(label, idx) {
var x = padL + idx * step + (step - barWidth) / 2;
var yCursor = padT + chartH;
a.series.forEach(function(s) {
var v = s.values[idx] || 0;
if (!v) return;
var h = (v / maxStack) * chartH;
yCursor -= h;
svg += '<rect x="'+x+'" y="'+yCursor+'" width="'+barWidth+'" height="'+h+'" fill="'+s.color+'"><title>'+s.icon+' '+esc(s.name)+': '+v+'</title></rect>';
});
// X-Label
svg += '<text x="'+(x+barWidth/2)+'" y="'+(H-padB+14)+'" font-size="10" text-anchor="middle" fill="#4a4a4a">'+esc(label)+'</text>';
});
svg += '</svg>';
// Legende
var legend = '<div class="activity-legend">';
a.series.forEach(function(s) {
var total = s.values.reduce(function(x,y){return x+y;}, 0);
legend += '<span class="lg-item"><span class="lg-dot" style="background:'+s.color+'"></span>'+s.icon+' '+esc(s.name)+' <b>'+total+'</b></span>';
});
legend += '</div>';
html += '<div class="activity-chart">' + svg + '</div>' + legend + '</div>';
document.getElementById('res-activity').innerHTML = html;
}
async function changeActivityRange(r) {
_resultsState.range = r;
document.getElementById('res-activity').innerHTML = '<div class="card"><p style="padding:1rem;font-size:.7rem;color:#8a8a8a">Lade…</p></div>';
var a = await api('results?class_id=' + currentClassId + '&view=activity&range=' + r);
if (a.error) { document.getElementById('res-activity').innerHTML = '<p style="color:#c07a6b">'+esc(a.error)+'</p>'; return; }
_resultsState.activity = a;
renderActivity();
}
// ---------- Sektion 3: Modul-Cards (kompakt, 4 in einer Reihe) mit Drill-Down ----------
function renderModuleCards() {
var m = _resultsState.matrix;
if (!m) return;
var html = '<div class="card"><div class="card-h">Modul-Detail (Klick öffnet Schüler-Vergleich)</div>';
html += '<div class="result-mod-grid">';
m.modules.forEach(function(mod) {
// Aggregat über alle Schüler
var totalPlays = 0, totalStars = 0, withPlays = 0;
m.students.forEach(function(s) {
var c = m.cells[s.id]?.[mod.id];
if (c && c.plays > 0) { totalPlays += c.plays; totalStars += c.stars; withPlays++; }
});
var avgStars = withPlays ? (totalStars / withPlays).toFixed(1) : '';
var imgSrc = mod.card_image ? 'assets/img/' + mod.card_image : null;
html += '<div class="result-mod-card" onclick="openModuleDetail(\''+mod.id+'\')">';
html += '<div class="rm-img">';
html += imgSrc ? '<img src="'+imgSrc+'" alt="">' : '<span style="font-size:2.2rem">'+(mod.icon||'📚')+'</span>';
html += '</div>';
html += '<div class="rm-body">';
html += '<div class="rm-name">'+(mod.icon||'')+' '+esc(mod.name)+'</div>';
html += '<div class="rm-stats">⭐ '+avgStars+' · '+totalPlays+'×</div>';
html += '</div></div>';
});
html += '</div></div>';
document.getElementById('res-modules').innerHTML = html;
}
// Modul-Detail-Modal
async function openModuleDetail(moduleId) {
showModal('modal-mod-detail');
document.getElementById('mod-detail-body').innerHTML = '<p style="font-size:.7rem;color:#8a8a8a">Lade…</p>';
var d = await api('results?class_id=' + currentClassId + '&view=module&module_id=' + moduleId);
if (d.error) { document.getElementById('mod-detail-body').innerHTML = '<p style="color:#c07a6b">'+esc(d.error)+'</p>'; return; }
_modDetail = { moduleId: moduleId, data: d, sortKey: 'best_stars', sortDir: 'desc' };
renderModDetail();
}
var _modDetail = null;
function renderModDetail() {
if (!_modDetail) return;
var d = _modDetail.data;
var key = _modDetail.sortKey, dir = _modDetail.sortDir;
var stu = d.students.slice().sort(function(a, b) {
var ka, kb;
if (key === 'name') { ka = (a.displayName||'').toLowerCase(); kb = (b.displayName||'').toLowerCase(); }
else if (key === 'last') { ka = a.last_played || ''; kb = b.last_played || ''; }
else if (key.indexOf('hl:') === 0) {
var hk = key.substring(3);
ka = (a.highlights && a.highlights[hk] !== undefined && a.highlights[hk] !== null) ? a.highlights[hk] : -Infinity;
kb = (b.highlights && b.highlights[hk] !== undefined && b.highlights[hk] !== null) ? b.highlights[hk] : -Infinity;
}
else { ka = a[key] || 0; kb = b[key] || 0; }
var c = ka < kb ? -1 : ka > kb ? 1 : 0;
return dir === 'asc' ? c : -c;
});
function arr(k) { return _modDetail.sortKey === k ? (dir === 'asc' ? '↑' : '↓') : '↕'; }
var totalH = Math.round((d.classStats.totalDurationMs || 0) / 3600000 * 10) / 10;
var html = '<div style="display:flex;align-items:center;gap:.6rem;margin-bottom:.6rem">';
html += '<span style="font-size:2rem">'+d.module.icon+'</span>';
html += '<div><h3 style="margin:0">'+esc(d.module.name)+'</h3>';
html += '<div style="font-size:.7rem;color:#8a8a8a">Klasse: '+d.classStats.totalPlays+' Durchgänge · '+totalH+'h Spielzeit · Ø '+(d.classStats.avgStars||'')+'★ · '+d.classStats.studentsActive+'/'+d.classStats.studentsTotal+' aktiv</div></div></div>';
var hl = d.highlights || [];
var champs = d.champions || {};
function fmt(h, v) {
if (v === null || v === undefined) return '—';
if (h.format === 'percent') return Math.round(v * 100) + '%';
if (typeof v === 'number') {
var n = Math.abs(v) >= 100 ? Math.round(v) : (Math.round(v * 10) / 10);
return n + (h.unit ? ' ' + h.unit : '');
}
return v + (h.unit ? ' ' + h.unit : '');
}
html += '<table class="result-matrix" style="font-size:.72rem">';
html += '<thead><tr>'
+ '<th onclick="sortModDetail(\'name\')">Schüler:in '+arr('name')+'</th>'
+ '<th onclick="sortModDetail(\'best_stars\')">Sterne '+arr('best_stars')+'</th>'
+ '<th onclick="sortModDetail(\'plays\')">Durchg. '+arr('plays')+'</th>';
hl.forEach(function(h) {
html += '<th onclick="sortModDetail(\'hl:'+h.key+'\')" title="'+esc(h.label)+(h.agg==='min'?' (weniger ist besser)':' (mehr ist besser)')+'">'+esc(h.label)+' '+arr('hl:'+h.key)+'</th>';
});
html += '<th onclick="sortModDetail(\'last\')">Zuletzt '+arr('last')+'</th>'
+ '<th>Spielzeit</th>'
+ '<th></th>'
+ '</tr></thead><tbody>';
stu.forEach(function(s) {
var stars = s.best_stars || 0;
var starStr = '★'.repeat(stars) + '<span style="opacity:.3">'+'★'.repeat(5-stars)+'</span>';
var lastDate = s.last_played ? new Date(s.last_played.replace(' ','T')+'Z').toLocaleDateString('de-AT', {day:'2-digit',month:'2-digit'}) : '—';
var dur = s.total_duration_ms ? Math.round(s.total_duration_ms/60000)+' min' : '—';
var avatar = (typeof s.emoji === 'string' && s.emoji.indexOf('avatar:') === 0)
? '<img src="assets/img/avatars/avatar-'+s.emoji.substring(7)+'.png" alt="" style="width:1.2em;height:1.2em;border-radius:50%;object-fit:cover;vertical-align:middle">'
: s.emoji;
html += '<tr>'
+ '<td>'+avatar+' '+esc(s.displayName)+'</td>'
+ '<td style="color:#a37800">'+starStr+'</td>'
+ '<td>'+s.plays+'</td>';
hl.forEach(function(h) {
var v = (s.highlights || {})[h.key];
var isChamp = (champs[h.key] === s.id);
var text = fmt(h, v);
html += '<td'+(isChamp ? ' style="background:#fff5dc;font-weight:700"' : '')+'>'
+ (isChamp && v !== null && v !== undefined ? '🏆 ' : '')
+ text + '</td>';
});
html += '<td>'+lastDate+'</td>'
+ '<td>'+dur+'</td>'
+ '<td>'+(s.plays > 0 ? '<button class="btn btn-outline" style="font-size:.6rem;padding:.2rem .5rem" onclick="openStudentRuns('+s.id+')">Verlauf</button>' : '')+'</td>'
+ '</tr>';
});
html += '</tbody></table>';
html += '<div id="mod-runs-box"></div>';
document.getElementById('mod-detail-body').innerHTML = html;
}
function sortModDetail(key) {
if (_modDetail.sortKey === key) _modDetail.sortDir = _modDetail.sortDir === 'asc' ? 'desc' : 'asc';
else { _modDetail.sortKey = key; _modDetail.sortDir = (key === 'name') ? 'asc' : 'desc'; }
renderModDetail();
}
async function openStudentRuns(studentId) {
var box = document.getElementById('mod-runs-box');
box.innerHTML = '<p style="font-size:.7rem;color:#8a8a8a;padding:.4rem">Lade Verlauf…</p>';
var r = await api('results?class_id=' + currentClassId + '&view=module&module_id=' + _modDetail.moduleId + '&student_id=' + studentId);
if (r.error) { box.innerHTML = '<p style="color:#c07a6b">'+esc(r.error)+'</p>'; return; }
var html = '<div style="margin-top:.6rem;border-top:1px solid rgba(0,0,0,.08);padding-top:.5rem"><strong style="font-size:.74rem">Verlauf — letzte '+r.runs.length+' Durchgänge</strong>';
if (!r.runs.length) { html += '<p style="font-size:.7rem;color:#8a8a8a">Keine Durchgänge</p></div>'; box.innerHTML = html; return; }
html += '<table class="result-matrix" style="font-size:.7rem;margin-top:.4rem"><thead><tr><th>Datum</th><th>Dauer</th><th>Details</th></tr></thead><tbody>';
r.runs.forEach(function(run) {
var dt = new Date(run.submitted_at.replace(' ','T')+'Z').toLocaleString('de-AT', {day:'2-digit',month:'2-digit',year:'2-digit',hour:'2-digit',minute:'2-digit'});
var dur = run.duration_ms ? Math.round(run.duration_ms/60000)+' min' : '—';
var details = run.results ? '<details><summary style="cursor:pointer;color:#4a7c8a">Ergebnis öffnen</summary><pre style="font-size:.6rem;background:#f7f6f3;padding:.4rem;margin-top:.2rem;border-radius:4px;white-space:pre-wrap">'+esc(JSON.stringify(run.results, null, 2))+'</pre></details>' : '—';
html += '<tr><td>'+dt+'</td><td>'+dur+'</td><td>'+details+'</td></tr>';
});
html += '</tbody></table></div>';
box.innerHTML = html;
}
// === LIZENZEN ===
async function loadLicenses() {
var r = await api('licenses?class_id=' + currentClassId);
if (r.error) r = {assigned:[], free:[]};
var html = '<div class="card"><div class="stat-row">';
html += '<div class="stat"><div class="n">'+(r.free?.length||0)+'</div><div class="l">Freie Lizenzen</div></div>';
html += '<div class="stat"><div class="n">'+(r.assigned?.length||0)+'</div><div class="l">Zugewiesen</div></div>';
html += '</div>';
html += '<button class="btn" onclick="showModal(\'modal-license\')" style="margin-bottom:.8rem">🔑 Lizenzen einlösen</button>';
html += '</div>';
// Zugewiesene Lizenzen
html += '<div class="card"><div class="card-h">Zugewiesene Lizenzen</div>';
if (r.assigned && r.assigned.length > 0) {
html += '<table class="students"><tr><th>Schüler*in</th><th>Lizenzcode</th><th>Schuljahr</th></tr>';
r.assigned.forEach(function(l) {
html += '<tr><td>'+esc(l.student_display || l.student_name)+'</td>';
html += '<td style="font-family:\'Courier New\',monospace;font-size:.7rem;letter-spacing:.03em">'+l.code+'</td>';
html += '<td>'+l.school_year+'</td></tr>';
});
html += '</table>';
} else {
html += '<p style="font-size:.72rem;color:#8a8a8a">Noch keine Lizenzen zugewiesen.</p>';
}
html += '</div>';
// Freie Lizenzen
html += '<div class="card"><div class="card-h">Freie Lizenzen (nicht zugewiesen)</div>';
if (r.free && r.free.length > 0) {
html += '<div style="max-height:250px;overflow-y:auto"><table class="students"><tr><th>Lizenzcode</th><th>Schuljahr</th></tr>';
r.free.forEach(function(l) {
html += '<tr><td style="font-family:\'Courier New\',monospace;font-size:.7rem;letter-spacing:.03em">'+l.code+'</td>';
html += '<td>'+l.school_year+'</td></tr>';
});
html += '</table></div>';
} else {
html += '<p style="font-size:.72rem;color:#8a8a8a">Keine freien Lizenzen. Neue Codes über das Textfeld oben einlösen.</p>';
}
html += '</div>';
document.getElementById('tab-licenses').innerHTML = html;
}
// === BULK SELECTION ===
function bulkUpdate() {
var checked = document.querySelectorAll('.bulk-cb:checked');
var bar = document.getElementById('bulk-bar');
if (checked.length > 0) {
bar.classList.remove('hidden');
bar.style.display = 'flex';
document.getElementById('bulk-count').textContent = checked.length;
} else {
bar.classList.add('hidden');
bar.style.display = 'none';
}
}
function bulkToggleAll(masterCb) {
document.querySelectorAll('.bulk-cb:not(:disabled)').forEach(function(cb) { cb.checked = masterCb.checked; });
bulkUpdate();
}
function bulkSelectNone() {
document.querySelectorAll('.bulk-cb').forEach(function(cb) { cb.checked = false; });
var master = document.querySelector('th input[type=checkbox]');
if (master) master.checked = false;
bulkUpdate();
}
function bulkSelectWithout() {
document.querySelectorAll('.bulk-cb:not(:disabled)').forEach(function(cb) { cb.checked = true; });
bulkUpdate();
}
async function bulkAssignLicense() {
var ids = [];
document.querySelectorAll('.bulk-cb:checked').forEach(function(cb) { ids.push(parseInt(cb.value)); });
if (ids.length === 0) return;
if (!confirm(ids.length + ' Schüler*innen eine Lizenz zuweisen?')) return;
var ok = 0, fail = 0;
for (var i = 0; i < ids.length; i++) {
var r = await api('licenses', {action:'auto_assign', studentId:ids[i]});
if (r.error) fail++; else ok++;
}
var msg = ok + ' Lizenzen zugewiesen.';
if (fail) msg += ' ' + fail + ' fehlgeschlagen (keine freien Lizenzen?).';
alert(msg);
loadStudents();
loadLicenses();
}
async function assignLicense(studentId) {
var r = await api('licenses', {action:'auto_assign', studentId:studentId});
if (r.error) { alert(r.error); return; }
loadStudents();
loadLicenses();
}
async function redeemLicenses() {
var text = document.getElementById('ml-codes').value.trim();
if (!text) return;
var codes = text.split(/[\n,;]+/).map(function(c){return c.trim();}).filter(Boolean);
var r = codes.length === 1
? await api('licenses', {action:'redeem', code:codes[0]})
: await api('licenses', {action:'redeem_batch', codes:codes});
var el = document.getElementById('license-result');
if (r.error) { el.innerHTML = '<span style="color:#c07a6b">'+esc(r.error)+'</span>'; return; }
var msg = r.redeemed ? r.redeemed + ' Lizenzen eingelöst.' : 'Lizenz eingelöst!';
el.innerHTML = '<span style="color:#5a8a5e">'+msg+'</span>';
if (r.errors?.length) el.innerHTML += '<br><span style="color:#c07a6b">'+r.errors.join('<br>')+'</span>';
document.getElementById('ml-codes').value = '';
loadLicenses();
}
</script>
<script src="assets/js/version-tag.js" defer></script>
</body>
</html>