502f271f36
- 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>
281 lines
18 KiB
HTML
281 lines
18 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 — 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. 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 & 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" <span class="d-code">26</span> / <span class="d-code">26+1</span> = <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">A–Z ohne I,O</span> + <span class="code">2–9</span> (kein 0, kein 1)<br>
|
||
→ 3 Blöcke à 4 Zeichen + Jahr ⟶ Beispiel: <span class="d-code">K7QF-3M9P-RT2W-2026</span> · 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>
|