33fb423967
- 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>
1833 lines
96 KiB
HTML
1833 lines
96 KiB
HTML
<!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> (<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>
|