Files
geograsim/App/admin-licenses.html
T
Adminator 502f271f36 Einheitliche Sim-Kopfzeile (Entwurf B) als Komponente + Lizenz-Datenmodell
- ggs-header.css/.js: schwebende Pillen-Kopfzeile aus vorhandenem
  .ggs-header-Markup (Kreis-Logo/Titel/Level als eine Pille, eigene
  SVG-Icons, Tempo-Panel links + Zustandsbutton, Musik-Panel unten,
  30% transparente Untermenues, Glossar target=_blank, Lehrplan raus).
  Enhance-Ansatz: Spiel-Logik (IDs/Klassen, data-speed) bleibt unberuehrt.
- Eingebaut in Klima 2D/3D, Fluss, Logistik (Auto-Tempo), Energiemanager,
  Kofferdetektiv 3D.
- admin-licenses.html: Datenmodell-Sektion (licenses-Tabelle, Lebenszyklus
  Generiert->Eingeloest->Zugewiesen, Beziehungen, Jahr->Schuljahr->Code).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 14:06:41 +02:00

281 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoGraSim — Lizenzverwaltung</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;min-height:100vh}
.topbar{background:#fff;padding:.6rem 1.2rem;display:flex;align-items:center;gap:.8rem;border-bottom:1px solid rgba(0,0,0,.06)}
.topbar h1{font-size:.95rem;font-weight:800;color:#4a7c8a;flex:1;display:flex;align-items:center;gap:.5rem}
.topbar h1 img{height:28px}
.btn-sm{padding:.3rem .6rem;border:1px solid rgba(0,0,0,.08);border-radius:6px;background:#fff;font-size:.68rem;font-weight:600;cursor:pointer;color:#4a4a4a;text-decoration:none}
.btn-sm:hover{background:#dae8ec;color:#4a7c8a}
.wrap{max-width:1000px;margin:1rem auto;padding:0 1rem}
.card{background:#fff;border-radius:10px;padding:1rem;box-shadow:0 1px 4px rgba(0,0,0,.03);border:1px solid rgba(0,0,0,.04);margin-bottom:1rem}
.card-h{font-size:.62rem;font-weight:700;color:#4a7c8a;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.5rem}
.stat-row{display:flex;gap:1rem;margin-bottom:1rem}
.stat{background:#f7f6f3;border-radius:8px;padding:.8rem 1.2rem;text-align:center;flex:1}
.stat .n{font-size:1.5rem;font-weight:900;color:#4a7c8a}
.stat .l{font-size:.62rem;color:#8a8a8a}
.btn{padding:.45rem .9rem;border:none;border-radius:6px;background:#4a7c8a;color:#fff;font-size:.75rem;font-weight:700;cursor:pointer}
.btn:hover{background:#3a6470}
.btn-outline{background:transparent;color:#4a7c8a;border:1.5px solid #4a7c8a}
table{width:100%;border-collapse:collapse;font-size:.72rem}
th{background:#f7f6f3;padding:.35rem .5rem;text-align:left;font-weight:600;font-size:.6rem;color:#8a8a8a}
td{padding:.35rem .5rem;border-bottom:1px solid rgba(0,0,0,.03)}
.code{font-family:'Courier New',monospace;font-weight:700;letter-spacing:.04em;font-size:.72rem}
.free{color:#5a8a5e}
.used{color:#8a8a8a}
.filter-bar{display:flex;gap:.4rem;margin-bottom:.8rem;flex-wrap:wrap;align-items:center}
.filter-bar select,.filter-bar input{padding:.35rem .5rem;border:1.5px solid rgba(0,0,0,.1);border-radius:6px;font-size:.72rem;font-family:inherit}
.toolbar{display:flex;gap:.4rem;margin-bottom:.8rem;flex-wrap:wrap}
.msg{font-size:.72rem;padding:.4rem .6rem;border-radius:6px;margin-top:.5rem}
.msg.ok{background:#dceadd;color:#5a8a5e}
/* ---- Datenmodell-Sektion ---- */
.dm-lead{font-size:.74rem;color:#4a4a4a;line-height:1.55;margin-bottom:.6rem}
.dm-sub{font-size:.6rem;font-weight:800;color:#4a7c8a;text-transform:uppercase;letter-spacing:.05em;margin:1.2rem 0 .5rem}
.dm-note{font-size:.66rem;color:#8a6a2a;background:#fdf6e3;border:1px solid #efe0b8;border-radius:6px;padding:.45rem .6rem;margin-top:.5rem;line-height:1.5}
.dm-flow{display:flex;gap:.4rem;align-items:stretch;flex-wrap:wrap}
.dm-step{flex:1;min-width:190px;background:#f7f6f3;border-radius:8px;border-left:4px solid #c4a97a;padding:.65rem .75rem}
.dm-step.s-pool{border-left-color:#b8b2a6}
.dm-step.s-redeem{border-left-color:#4a7c8a}
.dm-step.s-assign{border-left-color:#5a8a5e}
.dm-step .s-top{display:flex;align-items:center;gap:.4rem;margin-bottom:.35rem}
.dm-step .s-n{width:18px;height:18px;border-radius:50%;background:#4a7c8a;color:#fff;font-size:.62rem;font-weight:800;display:flex;align-items:center;justify-content:center;flex:none}
.dm-step.s-pool .s-n{background:#b8b2a6}
.dm-step.s-assign .s-n{background:#5a8a5e}
.dm-step .s-t{font-size:.74rem;font-weight:800;color:#2f3b3f}
.dm-step .s-who{font-size:.6rem;font-weight:700;color:#8a8a8a;text-transform:uppercase;letter-spacing:.03em}
.dm-step .s-d{font-size:.68rem;color:#4a4a4a;line-height:1.45;margin-top:.15rem}
.dm-step code{font-family:'Courier New',monospace;font-size:.66rem;background:rgba(0,0,0,.05);padding:.05rem .3rem;border-radius:4px;color:#c85c4a}
.dm-arrow{display:flex;align-items:center;justify-content:center;color:#c4a97a;font-size:1.4rem;font-weight:900;padding:0 .1rem}
.dm-er{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap;font-size:.68rem}
.dm-ent{background:#fff;border:1.5px solid #4a7c8a;color:#4a7c8a;border-radius:6px;padding:.3rem .55rem;font-weight:800;font-family:'Courier New',monospace}
.dm-ent.e-lic{background:#4a7c8a;color:#fff}
.dm-rel{color:#8a8a8a;display:flex;flex-direction:column;align-items:center;line-height:1;font-family:'Courier New',monospace;font-size:.58rem}
.dm-rel b{color:#c4a97a;font-size:1.1rem}
.dm-derive{background:#f7f6f3;border-radius:8px;padding:.7rem .8rem;font-size:.7rem;line-height:1.7}
.dm-derive .d-code{font-family:'Courier New',monospace;font-weight:800;color:#4a7c8a;letter-spacing:.04em}
.dm-derive .d-hi{background:#e8c547;color:#4a4a4a;padding:.02rem .28rem;border-radius:4px;font-weight:800}
td.mono{font-family:'Courier New',monospace;font-size:.68rem;color:#c85c4a}
</style>
</head>
<body>
<div class="topbar">
<h1><img src="assets/img/geograsim_t.svg" alt="GeoGraSim"> Lizenzverwaltung (Admin)</h1>
<a href="admin-modules.html" class="btn-sm">🧩 Module</a>
<a href="admin-levels.html" class="btn-sm">🎮 Level</a>
<a href="admin-styleguide.html" class="btn-sm">🎨 Styleguide</a>
<a href="admin-accounts.html" class="btn-sm">👤 Admins</a>
<a href="admin-weltkueche.html" class="btn-sm">🍽️ Weltküche-Herkunft</a>
<a href="admin-todo.html" class="btn-sm">📋 ToDo / Review</a>
<a href="admin.html" class="btn-sm">Logout</a>
</div>
<div class="wrap">
<div class="card">
<div class="card-h">🛠️ Backend-Werkzeuge (nur Admin)</div>
<div style="display:flex;flex-wrap:wrap;gap:.5rem">
<a class="btn btn-outline" href="sims/tourismusregion/editor.php">🗺️ Tourismus-Karten-Editor (Region)</a>
<a class="btn btn-outline" href="sims/tourismustal/editor.php">🏠 Tourismustal-Sprite-Editor</a>
<a class="btn btn-outline" href="stadt-editor">🏙️ Stadt-Raumplanung-Editor</a>
<a class="btn btn-outline" href="foto-upload">📷 Schulbuch-Bildupload</a>
<a class="btn btn-outline" href="admin-weltkueche.html">🍽️ Weltküche-Herkunftsübersicht (QA)</a>
<a class="btn btn-outline" href="admin-todo.html">📋 ToDo / Review-Liste</a>
</div>
<div style="font-size:.66rem;color:#8a8a8a;margin-top:.55rem">Alle nur mit Admin-Login erreichbar. Schulbuch-Upload zusätzlich per PIN (0815). Heli-Audio-Werkzeuge liegen nur lokal (<code>.LocalDeveloperTools</code>) und sind nicht auf dem Server.</div>
</div>
<div class="card">
<div class="stat-row">
<div class="stat"><div class="n" id="stat-total"></div><div class="l">Gesamt</div></div>
<div class="stat"><div class="n free" id="stat-free"></div><div class="l">Frei</div></div>
<div class="stat"><div class="n used" id="stat-used"></div><div class="l">Zugewiesen</div></div>
</div>
</div>
<div class="card">
<div class="card-h">Neue Lizenzen generieren</div>
<div style="display:flex;gap:.5rem;align-items:flex-end;flex-wrap:wrap">
<div>
<label style="font-size:.65rem;font-weight:600;color:#4a4a4a;display:block">Anzahl</label>
<input type="number" id="gen-count" value="100" min="1" max="1000" style="width:80px;padding:.35rem .5rem;border:1.5px solid rgba(0,0,0,.1);border-radius:6px;font-size:.78rem">
</div>
<div>
<label style="font-size:.65rem;font-weight:600;color:#4a4a4a;display:block">Jahr</label>
<input type="number" id="gen-year" value="2026" min="2025" max="2035" style="width:80px;padding:.35rem .5rem;border:1.5px solid rgba(0,0,0,.1);border-radius:6px;font-size:.78rem">
</div>
<button class="btn" onclick="generate()">Generieren</button>
</div>
<div id="gen-msg"></div>
</div>
<div class="card">
<div class="card-h">Lizenzen</div>
<div class="filter-bar">
<select id="filter-status" onchange="loadLicenses()">
<option value="">Alle</option>
<option value="free">Frei</option>
<option value="used">Zugewiesen</option>
</select>
<button class="btn btn-outline" onclick="exportFree()">Freie exportieren (CSV)</button>
</div>
<div id="license-table" style="max-height:500px;overflow-y:auto"></div>
<div style="display:flex;gap:.4rem;margin-top:.5rem">
<button class="btn btn-outline" id="btn-prev" onclick="prevPage()" disabled>← Zurück</button>
<span style="font-size:.68rem;color:#8a8a8a;padding:.35rem" id="page-info"></span>
<button class="btn btn-outline" id="btn-next" onclick="nextPage()">Weiter →</button>
</div>
</div>
<!-- ============ Datenmodell-Referenz ============ -->
<div class="card">
<div class="card-h">📐 Datenmodell — Lizenznummern</div>
<p class="dm-lead">Eine Lizenz = <b>ein Sitzplatz für eine:n Schüler:in in einem Schuljahr</b>. Der Admin erzeugt einen Vorrat an Codes, eine Lehrperson <b>löst</b> Codes ein (sie werden ihrem Konto zugeordnet) und <b>weist</b> sie dann einzelnen Schüler:innen zu. Alles steht in <b>einer</b> Tabelle <span class="code">licenses</span> — die drei Zustände ergeben sich allein aus zwei Feldern: <span class="code">teacher_id</span> und <span class="code">student_id</span>.</p>
<div class="dm-sub">Tabelle „licenses"</div>
<table>
<tr><th>Spalte</th><th>Typ</th><th>Bedeutung</th></tr>
<tr><td class="mono">id</td><td>INT · PK</td><td>Interne ID</td></tr>
<tr><td class="mono">code</td><td>VARCHAR · UNIQUE</td><td>Der Lizenzcode, Format <span class="code">XXXX-XXXX-XXXX-JAHR</span></td></tr>
<tr><td class="mono">school_year</td><td>VARCHAR</td><td>Schuljahr, z.&nbsp;B. „26/27" — aus dem Jahr abgeleitet</td></tr>
<tr><td class="mono">teacher_id</td><td>FK → teachers</td><td>Einlösende Lehrperson · <b>NULL = noch im Pool</b></td></tr>
<tr><td class="mono">student_id</td><td>FK → students</td><td>Platz-Inhaber:in · <b>NULL = noch frei</b></td></tr>
<tr><td class="mono">redeemed_at</td><td>TIMESTAMP · NULL</td><td>Zeitpunkt des Einlösens durch die Lehrperson</td></tr>
<tr><td class="mono">created_at</td><td>TIMESTAMP</td><td>Erstellung (Admin-Generierung)</td></tr>
</table>
<div class="dm-sub">Lebenszyklus &amp; Einlösen</div>
<div class="dm-flow">
<div class="dm-step s-pool">
<div class="s-top"><span class="s-n">1</span><span class="s-t">Generiert</span></div>
<div class="s-who">Admin</div>
<div class="s-d"><code>teacher_id</code> = NULL · <code>student_id</code> = NULL. Frisch erzeugter Code im Pool.<br>Aktion: <code>generate</code></div>
</div>
<div class="dm-arrow"></div>
<div class="dm-step s-redeem">
<div class="s-top"><span class="s-n">2</span><span class="s-t">Eingelöst</span></div>
<div class="s-who">Lehrperson</div>
<div class="s-d"><code>teacher_id</code> gesetzt · <code>redeemed_at</code> = jetzt · <code>student_id</code> noch NULL. Code gehört nun der Lehrperson.<br>Aktion: <code>redeem</code> / <code>redeem_batch</code></div>
</div>
<div class="dm-arrow"></div>
<div class="dm-step s-assign">
<div class="s-top"><span class="s-n">3</span><span class="s-t">Zugewiesen</span></div>
<div class="s-who">Lehrperson</div>
<div class="s-d"><code>student_id</code> gesetzt. Der Platz ist von einer konkreten Person belegt.<br>Aktion: <code>assign</code> / <code>auto_assign</code></div>
</div>
</div>
<div class="dm-note">⚠️ In der Statistik oben zählt <b>„Frei"</b> = <span class="code">student_id IS NULL</span> — also auch schon <b>eingelöste</b>, aber noch nicht zugewiesene Codes. <b>„Zugewiesen"</b> = <span class="code">student_id</span> gesetzt. Ob ein freier Code schon einer Lehrperson gehört, sieht man an <span class="code">teacher_id</span> / <span class="code">redeemed_at</span>.</div>
<div class="dm-sub">Beziehungen</div>
<div class="dm-er" style="margin-bottom:.5rem">
<span class="dm-ent e-lic">licenses</span>
<span class="dm-rel"><b></b>teacher_id</span>
<span class="dm-ent">teachers</span>
</div>
<div class="dm-er">
<span class="dm-ent e-lic">licenses</span>
<span class="dm-rel"><b></b>student_id</span>
<span class="dm-ent">students</span>
<span class="dm-rel"><b></b>class_id</span>
<span class="dm-ent">classes</span>
<span class="dm-rel"><b></b>teacher_id</span>
<span class="dm-ent">teachers</span>
</div>
<p class="dm-lead" style="margin-top:.5rem"><span class="code">licenses.teacher_id</span> merkt sich die <b>einlösende</b> Lehrperson, <span class="code">licenses.student_id</span> den <b>Platz-Inhaber</b>. Über <span class="code">students.class_id → classes.teacher_id</span> hängt jede:r Schüler:in ohnehin an einer Klasse einer Lehrperson — der direkte <span class="code">teacher_id</span>-Eintrag macht aber auch <b>eingelöste, noch nicht zugewiesene</b> Codes eindeutig einer Lehrperson zuordenbar.</p>
<div class="dm-sub">Jahr → Schuljahr → Code</div>
<div class="dm-derive">
Eingabe beim Generieren: <b>Jahr</b> = <span class="d-code">2026</span> (Kalenderjahr, in dem das Schuljahr <b>beginnt</b>).<br>
<b>school_year</b> = „letzte 2 Ziffern" &nbsp;<span class="d-code">26</span>&nbsp; / &nbsp;<span class="d-code">26+1</span>&nbsp; = <span class="d-hi">26/27</span><br>
<b>Code-Suffix</b> = das volle Jahr: <span class="d-code">-2026</span><br>
<b>Zeichen-Alphabet</b> (ohne Verwechsler): <span class="code">AZ ohne I,O</span> + <span class="code">29</span> (kein 0, kein 1)<br>
→ 3 Blöcke à 4 Zeichen + Jahr &nbsp;&nbsp; Beispiel: <span class="d-code">K7QF-3M9P-RT2W-2026</span> &nbsp;·&nbsp; Schuljahr <span class="d-hi">26/27</span>
</div>
<div class="dm-note" style="background:#eef4f6;border-color:#cfe0e6;color:#3a5b64">Das <b>Jahr</b> steckt fest im Code (Suffix) und bestimmt <b>school_year</b>. Für ein neues Schuljahr wird ein neuer Vorrat generiert; Filter/Export laufen über <span class="code">school_year</span>.</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 page = 0, pageSize = 100;
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'};
var r = await fetch(BASE + '/api/' + endpoint, opts);
return await r.json();
}
async function loadLicenses() {
var status = document.getElementById('filter-status').value;
var url = 'licenses?admin=1&limit='+pageSize+'&offset='+(page*pageSize);
if (status) url += '&status=' + status;
var r = await api(url);
if (r.error) return;
document.getElementById('stat-total').textContent = r.total;
document.getElementById('stat-free').textContent = r.free;
document.getElementById('stat-used').textContent = r.used;
var html = '<table><tr><th>Code</th><th>Schuljahr</th><th>Status</th><th>Schüler*in</th><th>Lehrperson</th></tr>';
r.licenses.forEach(function(l) {
var st = l.student_id ? '<span class="used">zugewiesen</span>' : '<span class="free">frei</span>';
html += '<tr>';
html += '<td class="code">'+l.code+'</td>';
html += '<td>'+l.school_year+'</td>';
html += '<td>'+st+'</td>';
html += '<td>'+(l.student_name||'—')+'</td>';
html += '<td>'+(l.teacher_name||'—')+'</td>';
html += '</tr>';
});
html += '</table>';
document.getElementById('license-table').innerHTML = html;
var totalPages = Math.ceil(r.total / pageSize);
document.getElementById('page-info').textContent = 'Seite '+(page+1)+' von '+totalPages;
document.getElementById('btn-prev').disabled = page === 0;
document.getElementById('btn-next').disabled = page >= totalPages - 1;
}
function prevPage() { if(page>0){page--;loadLicenses();} }
function nextPage() { page++; loadLicenses(); }
async function generate() {
var count = parseInt(document.getElementById('gen-count').value) || 100;
var year = parseInt(document.getElementById('gen-year').value) || 2026;
var r = await api('licenses', {action:'generate', count:count, year:year});
if (r.error) { document.getElementById('gen-msg').innerHTML = '<div class="msg" style="background:#f2ddd8;color:#c07a6b">'+r.error+'</div>'; return; }
document.getElementById('gen-msg').innerHTML = '<div class="msg ok">'+r.generated+' Lizenzen generiert.</div>';
loadLicenses();
}
function exportFree() {
api('licenses?admin=1&status=free&limit=10000').then(function(r) {
if (!r.licenses) return;
var csv = 'Code;Schuljahr\n';
r.licenses.forEach(function(l) { csv += l.code + ';' + l.school_year + '\n'; });
var blob = new Blob([csv], {type:'text/csv'});
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'lizenzen_frei.csv';
a.click();
});
}
// Admin auth check (2FA required)
fetch(BASE + '/api/admin', {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body:JSON.stringify({action:'status'})})
.then(function(r){return r.json()})
.then(function(r) {
if (!r.authenticated) { location.href = 'admin.html'; return; }
loadLicenses();
});
</script>
</body>
</html>