Files
geograsim/App/admin-licenses.html
T
Adminator 3dba841830 Lizenz-Feinschliff: CSV-Mailanhang, Lehrer-Listenspalten, Klassenliste-Haken
- Mailanhang bei grossen Chargen: zusaetzlich zur .txt jetzt auch .csv
  (gleiche reine Codeliste), Dateiname <ExtBestellnr|Datum>_geograsim_<intNr>.
- Lehrer-Lizenzlisten (zugewiesen + frei): vorne laufende Nr., Eingeloest am,
  Gueltig bis (Backend liefert redeemed_at + valid_until).
- Klassenliste Lizenz-Spalte: nur Haken (gruen) bzw. "fehlt" (rot); Tooltip
  zeigt Lizenznummer + gueltig bis. "Gueltig" via unlicensed-Liste (nicht
  storniert/abgelaufen). Bulk-Auswahl/Zaehler nutzen jetzt dieselbe Wahrheit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 11:33:44 +02:00

691 lines
40 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}
/* ---- Vertrieb / Ausgabe ---- */
.frm{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:.6rem}
.frm label{font-size:.6rem;font-weight:700;color:#6a6a6a;text-transform:uppercase;letter-spacing:.03em;display:block;margin-bottom:.2rem}
.frm input,.frm select,.frm textarea{width:100%;padding:.4rem .55rem;border:1.5px solid rgba(0,0,0,.12);border-radius:6px;font-size:.78rem;font-family:inherit;background:#fff}
.frm textarea{resize:vertical;line-height:1.5}
.hint-s{font-size:.62rem;color:#8a8a8a;margin-top:.2rem}
.badge{display:inline-block;padding:.1rem .45rem;border-radius:20px;font-size:.58rem;font-weight:800;letter-spacing:.02em;color:#fff}
.b-issued{background:#4a7c8a}.b-redeemed{background:#5a8a5e}.b-assigned{background:#3f7a45}
.b-canceled{background:#b0453a}.b-expired{background:#c98a2a}.b-free{background:#9a9a9a}
.kpi{display:inline-flex;flex-direction:column;align-items:center;min-width:52px}
.kpi b{font-size:.9rem;font-weight:900;color:#3a4a4f}.kpi span{font-size:.52rem;color:#8a8a8a;text-transform:uppercase}
.out{background:#f2f8f9;border:1px solid #cfe3e8;border-radius:8px;padding:.8rem;margin-top:.8rem}
.out h4{font-size:.66rem;color:#2f6373;margin-bottom:.4rem;text-transform:uppercase;letter-spacing:.04em}
.row-act{display:flex;gap:.35rem;flex-wrap:wrap;align-items:center}
.lnk{font-family:'Courier New',monospace;font-size:.66rem;background:#fff;border:1px solid rgba(0,0,0,.12);border-radius:6px;padding:.35rem .5rem;flex:1;min-width:180px;color:#3a4a4f}
.ovl{position:fixed;inset:0;background:rgba(20,30,35,.55);display:none;align-items:flex-start;justify-content:center;z-index:100;overflow-y:auto;padding:2rem 1rem}
.ovl.open{display:flex}
.modal{background:#fff;border-radius:12px;max-width:820px;width:100%;padding:1.1rem;box-shadow:0 12px 40px rgba(0,0,0,.25)}
.modal-h{display:flex;align-items:center;gap:.6rem;margin-bottom:.6rem}
.modal-h h3{font-size:.9rem;font-weight:800;color:#2f3b3f;flex:1}
.x{cursor:pointer;font-size:1.1rem;color:#8a8a8a;background:none;border:none;padding:.2rem .5rem}
.mini{font-size:.66rem;color:#8a8a8a}
.chip-copy{cursor:pointer}
details.batchdet{border:1px solid rgba(0,0,0,.06);border-radius:8px;margin-bottom:.4rem;padding:.2rem .5rem}
details.batchdet summary{cursor:pointer;font-size:.72rem;font-weight:600;padding:.3rem 0}
</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>
<!-- ============ Vertrieb: Charge ausgeben ============ -->
<div class="card">
<div class="card-h">📦 Lizenzen ausgeben (Vertrieb)</div>
<div class="frm">
<div>
<label>Anzahl (bestellt)</label>
<input type="number" id="iss-count" value="12" min="1" max="5000" oninput="updatePreview()">
</div>
<div>
<label>Typ</label>
<select id="iss-type">
<option value="marktplatz">Marktplatz Lernapps</option>
<option value="direktvertrieb">Direktvertrieb Schule</option>
<option value="privat" selected>Privat</option>
</select>
</div>
<div>
<label>Externe Bestellnummer</label>
<input type="text" id="iss-order" placeholder="z. B. Marktplatz/Ministerium" list="order-hints">
<div class="hint-s">Geht in die Mail (für die Rechnung des Kunden).</div>
</div>
<div>
<label>Kunde — Name / Verlag / Schule</label>
<input type="text" id="iss-name" placeholder="z. B. Verlag XY · VS Musterstadt">
</div>
<div>
<label>E-Mail</label>
<input type="email" id="iss-email" placeholder="kunde@schule.at">
</div>
<div>
<label>Gültig von</label>
<input type="date" id="iss-from">
</div>
<div>
<label>Gültig bis</label>
<input type="date" id="iss-until">
<div class="hint-s">Standard: 1. Okt. Folgejahr (merkt sich die letzte Wahl).</div>
</div>
</div>
<datalist id="order-hints"></datalist>
<div style="margin-top:.6rem;display:flex;align-items:center;gap:.7rem;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:.4rem;font-size:.75rem;font-weight:600;color:#4a4a4a">
<input type="checkbox" id="iss-bonus" checked onchange="updatePreview()"> +10 % Gratis-Zugabe (aufgerundet)
</label>
<span id="iss-preview" class="mini"></span>
</div>
<div style="margin-top:.7rem" class="row-act">
<button class="btn" onclick="issueBatch()">Codes erzeugen &amp; ausgeben</button>
<span id="iss-msg" class="mini"></span>
</div>
<div id="iss-result"></div>
</div>
<!-- ============ Bestellungen / Chargen-Übersicht ============ -->
<div class="card">
<div class="card-h">🧾 Bestellungen &amp; Gültigkeit</div>
<div class="filter-bar">
<select id="bf-type" onchange="loadBatches()">
<option value="">Alle Typen</option>
<option value="marktplatz">Marktplatz Lernapps</option>
<option value="direktvertrieb">Direktvertrieb Schule</option>
<option value="privat">Privat</option>
</select>
<input type="text" id="bf-customer" placeholder="Kunde (E-Mail/Name/Nr.)" oninput="debouncedBatches()">
<input type="text" id="bf-order" placeholder="Bestellnummer" oninput="debouncedBatches()">
<button class="btn btn-outline" onclick="loadBatches()">Aktualisieren</button>
</div>
<div id="batch-table" style="overflow-x:auto"></div>
</div>
<!-- ============ Mail-Einstellungen ============ -->
<div class="card">
<div class="card-h">✉️ Mail-Einstellungen</div>
<div class="frm">
<div>
<label>Versand-Treiber</label>
<select id="mc-provider">
<option value="server">Eigener Server (SMTP)</option>
<option value="aws">Amazon SES (später)</option>
</select>
<div class="hint-s">AWS wird aktiv, sobald konfiguriert.</div>
</div>
<div>
<label>Absender-Name</label>
<input type="text" id="mc-fromname" placeholder="GeoGraSim">
</div>
<div>
<label>Absender-Adresse</label>
<input type="email" id="mc-fromemail" placeholder="support@geograsim.at">
<div class="hint-s">SMTP-Zugang bleibt geheim in der .env.</div>
</div>
<div>
<label>Öffentliche Basis-URL</label>
<input type="text" id="mc-publicurl" placeholder="https://geograsim.at">
<div class="hint-s">Für Links in Mails (nicht v3/localhost).</div>
</div>
</div>
<div class="row-act" style="margin-top:.6rem">
<button class="btn" onclick="saveMailConfig()">Speichern</button>
<input type="email" id="mc-testto" placeholder="Test an…" style="padding:.4rem .55rem;border:1.5px solid rgba(0,0,0,.12);border-radius:6px;font-size:.78rem">
<button class="btn btn-outline" onclick="testMail()">Test senden</button>
<span id="mc-msg" class="mini"></span>
</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>
<!-- Detail-Modal einer Bestellung -->
<div class="ovl" id="modal-ovl">
<div class="modal">
<div class="modal-h">
<h3 id="m-title">Bestellung</h3>
<button class="x" onclick="closeModal()"></button>
</div>
<div id="m-body"></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();
});
}
// ═══════════════ Vertrieb: Ausgeben ═══════════════
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g, function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }
function today(){ return new Date().toISOString().slice(0,10); }
function fmtDate(d){ if(!d) return '—'; var p=String(d).slice(0,10).split('-'); return p.length===3 ? p[2]+'.'+p[1]+'.'+p[0] : d; }
function absUrl(path){ return location.origin + BASE + path; }
var lastIssue = null;
async function prefillDefaults(){
var r = await api('licenses?admin=1&sales_default=1');
if (r && r.valid_from) {
if(!document.getElementById('iss-from').value) document.getElementById('iss-from').value = r.valid_from;
if(!document.getElementById('iss-until').value) document.getElementById('iss-until').value = r.valid_until;
}
}
var custTimer=null;
function custSuggest(v){
if(custTimer) clearTimeout(custTimer);
if(!v || v.length<2) return;
custTimer = setTimeout(async function(){
var r = await api('licenses?admin=1&customers='+encodeURIComponent(v));
if(!r || !r.customers) return;
var dl = document.getElementById('cust-hints');
dl.innerHTML = r.customers.map(function(c){ return '<option value="'+esc(c.email||'')+'">'+esc((c.name||'')+(c.customer_no?' · '+c.customer_no:''))+'</option>'; }).join('');
}, 250);
}
async function issueBatch(){
var count = parseInt(document.getElementById('iss-count').value)||0;
var type = document.getElementById('iss-type').value;
var order = document.getElementById('iss-order').value.trim();
var email = document.getElementById('iss-email').value.trim();
var name = document.getElementById('iss-name').value.trim();
var vf = document.getElementById('iss-from').value;
var vu = document.getElementById('iss-until').value;
var bonus = document.getElementById('iss-bonus').checked;
var msg = document.getElementById('iss-msg');
if(count<1||count>5000){ msg.textContent='Anzahl 15000.'; return; }
var delivered = calcDelivered(count, bonus);
var who = name||email||'(kein Kunde)';
if(count>=100 && !confirm(count+' bestellt → '+delivered+' Codes im Paket für „'+who+'"'+(order?' (Best.-Nr. '+order+')':'')+', gültig bis '+fmtDate(vu)+', Typ '+type+'.\n\nWirklich erzeugen?')) return;
msg.textContent = 'Erzeuge …';
var r = await api('licenses', {action:'issue_batch', count:count, bonus:bonus, sales_type:type, order_no:order,
customer:{email:email, name:name}, valid_from:vf, valid_until:vu});
if(r.error){ msg.innerHTML='<span style="color:#c07a6b">'+esc(r.error)+'</span>'; return; }
msg.innerHTML='<span style="color:#5a8a5e">✓ '+r.count+' Codes erzeugt (Bestellung #'+r.internal_no+', bestellt '+r.ordered+').</span>';
lastIssue = {batch_id:r.batch_id, internal_no:r.internal_no, ordered:r.ordered, codes:r.codes, count:r.count, valid_until:r.valid_until, token:r.redeem_token, order_no:order, email:email};
renderIssueResult();
loadBatches();
loadLicenses();
}
function renderIssueResult(){
var m = lastIssue; if(!m) return;
var codesText = m.codes.join('\n');
var mailText = buildMailText(m);
var h = '<div class="out">'
+ '<div class="mini" style="margin-bottom:.5rem">Bestellung <b>#'+m.internal_no+'</b> · bestellt <b>'+m.ordered+'</b> · geliefert <b>'+m.count+'</b>'+(m.count>m.ordered?' <span style="color:#5a8a5e">(+'+(m.count-m.ordered)+' gratis)</span>':'')+' · gültig bis '+fmtDate(m.valid_until)+'</div>'
+ '<h4>1 · Codes (' + m.count + ') — kopieren</h4>'
+ '<textarea id="codes-ta" rows="'+Math.min(10,Math.max(3,m.codes.length))+'" readonly>'+esc(codesText)+'</textarea>'
+ '<div class="row-act" style="margin-top:.4rem">'
+ '<button class="btn btn-outline" onclick="copyEl(\'codes-ta\')">📋 Kopieren</button>'
+ '<button class="btn btn-outline" onclick="downloadCodes(\'txt\')">⬇ .txt</button>'
+ '<button class="btn btn-outline" onclick="downloadCodes(\'csv\')">⬇ .csv</button>'
+ '</div>'
+ '<h4 style="margin-top:.9rem">2 · Per Mail senden</h4>'
+ '<div class="frm" style="grid-template-columns:1fr 2fr">'
+ '<div><label>An</label><input type="email" id="mail-to" value="'+esc(m.email||'')+'"></div>'
+ '<div><label>Betreff</label><input type="text" id="mail-subj" value="Ihre GeoGraSim-Lizenzcodes'+(m.order_no?' — Best.-Nr. '+esc(m.order_no):'')+'"></div>'
+ '</div>'
+ '<label style="margin-top:.5rem;display:block">Mailtext (editierbar)</label>'
+ '<textarea id="mail-body" rows="10">'+esc(mailText)+'</textarea>'
+ '<div class="hint-s">'+(m.count>40?'Große Charge: die Codes gehen automatisch als .txt- und .csv-Anhang mit.':'Die Codes stehen im Text.')+' Der Text ist auch für den Selbstversand kopierbar.</div>'
+ '<div class="row-act" style="margin-top:.4rem"><button class="btn" onclick="sendMail('+m.batch_id+')">✉ Absenden</button><span id="mail-msg" class="mini"></span></div>'
+ '</div>';
document.getElementById('iss-result').innerHTML = h;
}
function buildMailText(m){
var codesPart = (m.count>40) ? 'Die '+m.count+' Codes finden Sie in der angehängten Datei „lizenzcodes.txt".' : m.codes.join('\n');
var order = m.order_no ? ('\nBestellnummer: '+m.order_no) : '';
return 'Guten Tag,\n\nvielen Dank für Ihre Bestellung. Anbei Ihre '+m.count+' GeoGraSim-Lizenzcodes, gültig bis '+fmtDate(m.valid_until)+'.'+order+'\n\n'
+ codesPart + '\n\n'
+ 'Einlösen: Als Lehrperson unter '+PUBLIC_URL+' anmelden und die Codes unter „Lizenzen" eingeben.\n\n'
+ 'Freundliche Grüße\nGeoGraSim';
}
function copyEl(id){ var el=document.getElementById(id); el.select(); navigator.clipboard.writeText(el.value).catch(function(){document.execCommand('copy');}); }
// Nur die Codes (eine pro Zeile), KEINE Kopfzeile — direkt per Copy-Paste einlösbar.
function downloadCodes(fmt){
var m = lastIssue; if(!m) return;
var content = m.codes.join('\r\n')+'\r\n';
var mime = fmt==='csv' ? 'text/csv' : 'text/plain';
var ext = m.order_no ? m.order_no.replace(/[^a-zA-Z0-9_\-]/g,'_') : (new Date().toISOString().slice(0,16).replace(/[:T]/g,'-'));
var fname = ext+'_geograsim_'+(m.internal_no||'')+'.'+fmt;
var a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([content],{type:mime})); a.download=fname; a.click();
}
function calcDelivered(ordered, bonus){ return bonus ? Math.ceil(ordered*1.1) : ordered; }
function updatePreview(){
var o=parseInt(document.getElementById('iss-count').value)||0;
var b=document.getElementById('iss-bonus').checked;
var el=document.getElementById('iss-preview'); if(!el) return;
if(o<1){ el.textContent=''; return; }
var d=calcDelivered(o,b);
el.innerHTML = b ? ('→ im Paket <b>'+d+'</b> Codes (+'+(d-o)+' gratis)') : ('→ <b>'+d+'</b> Codes (keine Zugabe)');
}
// ── Mail-Einstellungen ──
var PUBLIC_URL='https://geograsim.at';
async function loadMailConfig(){
var r=await api('licenses?admin=1&mail_config=1');
if(!r || r.error) return;
PUBLIC_URL=r.public_url||PUBLIC_URL;
document.getElementById('mc-provider').value=r.provider||'server';
document.getElementById('mc-fromname').value=r.from_name||'';
document.getElementById('mc-fromemail').value=r.from_email||'';
document.getElementById('mc-publicurl').value=r.public_url||'';
}
async function saveMailConfig(){
var mm=document.getElementById('mc-msg'); mm.textContent='Speichere …';
var r=await api('licenses',{action:'set_mail_config',
provider:document.getElementById('mc-provider').value,
from_name:document.getElementById('mc-fromname').value.trim(),
from_email:document.getElementById('mc-fromemail').value.trim(),
public_url:document.getElementById('mc-publicurl').value.trim()});
if(r.error){ mm.innerHTML='<span style="color:#c07a6b">'+esc(r.error)+'</span>'; return; }
mm.innerHTML='<span style="color:#5a8a5e">✓ Gespeichert.</span>'; loadMailConfig();
}
async function testMail(){
var to=document.getElementById('mc-testto').value.trim();
var mm=document.getElementById('mc-msg');
if(!to){ mm.innerHTML='<span style="color:#c07a6b">Test-Adresse fehlt.</span>'; return; }
mm.textContent='Sende Test …';
var r=await api('licenses',{action:'test_mail', to_email:to});
if(r.error){ mm.innerHTML='<span style="color:#c07a6b">'+esc(r.error)+'</span>'; return; }
mm.innerHTML='<span style="color:#5a8a5e">✓ Test gesendet.</span>';
}
async function sendMail(batchId){
var to=document.getElementById('mail-to').value.trim();
var subj=document.getElementById('mail-subj').value.trim();
var body=document.getElementById('mail-body').value;
var mm=document.getElementById('mail-msg');
if(!to){ mm.innerHTML='<span style="color:#c07a6b">Empfänger fehlt.</span>'; return; }
mm.textContent='Sende …';
var r=await api('licenses',{action:'send_batch_mail', batch_id:batchId, to_email:to, subject:subj, body_text:body});
if(r.error){ mm.innerHTML='<span style="color:#c07a6b">'+esc(r.error)+'</span>'; return; }
mm.innerHTML='<span style="color:#5a8a5e">✓ Gesendet &amp; archiviert.</span>';
}
// ═══════════════ Bestellungen / Übersicht ═══════════════
var batchTimer=null;
function debouncedBatches(){ if(batchTimer)clearTimeout(batchTimer); batchTimer=setTimeout(loadBatches,300); }
async function loadBatches(){
var qs = 'licenses?admin=1&view=batches';
var t=document.getElementById('bf-type').value; if(t) qs+='&type='+encodeURIComponent(t);
var c=document.getElementById('bf-customer').value.trim(); if(c) qs+='&customer='+encodeURIComponent(c);
var o=document.getElementById('bf-order').value.trim(); if(o) qs+='&order_no='+encodeURIComponent(o);
var r=await api(qs);
if(!r || !r.batches){ document.getElementById('batch-table').innerHTML='<div class="mini">—</div>'; return; }
var typeLabel={marktplatz:'Marktplatz',direktvertrieb:'Direktvertrieb',privat:'Privat'};
var html='<table><tr><th>#</th><th>Bestellnr.</th><th>Kunde</th><th>Typ</th><th>Erstellt</th><th>Gültig bis</th>'
+ '<th>Ausgeg.</th><th>Storno</th><th>Netto</th><th>Eingelöst</th><th>Abgel.</th><th></th></tr>';
r.batches.forEach(function(b){
var net=(b.n_total|0)-(b.n_canceled|0);
var cust=esc(b.customer_name||b.customer_email||b.customer_no||'—');
var canceled=b.canceled_at?' <span class="badge b-canceled">storniert</span>':'';
html+='<tr>'
+'<td>'+b.id+'</td>'
+'<td class="mono">'+esc(b.order_no||'—')+'</td>'
+'<td>'+cust+canceled+'</td>'
+'<td>'+esc(typeLabel[b.sales_type]||b.sales_type)+'</td>'
+'<td>'+fmtDate(b.created_at)+'</td>'
+'<td>'+fmtDate(b.default_valid_until)+'</td>'
+'<td>'+(b.n_total|0)+'</td>'
+'<td>'+((b.n_canceled|0)?'<b style="color:#b0453a">'+b.n_canceled+'</b>':'0')+'</td>'
+'<td><b>'+net+'</b></td>'
+'<td>'+(b.n_redeemed|0)+'</td>'
+'<td>'+((b.n_expired|0)?'<b style="color:#c98a2a">'+b.n_expired+'</b>':'0')+'</td>'
+'<td><button class="btn-sm" onclick="openBatch('+b.id+')">Details</button></td>'
+'</tr>';
});
html+='</table>';
document.getElementById('batch-table').innerHTML=html;
}
function codeStatus(l){
if(l.canceled_at) return '<span class="badge b-canceled">storniert</span>';
var base = l.student_id ? '<span class="badge b-assigned">zugewiesen</span>'
: l.redeemed_at ? '<span class="badge b-redeemed">eingelöst</span>'
: l.issued_at ? '<span class="badge b-issued">vergeben</span>'
: '<span class="badge b-free">frei</span>';
var exp = l.valid_until && String(l.valid_until).slice(0,10) < today();
return base + (exp?' <span class="badge b-expired">abgelaufen</span>':'');
}
async function openBatch(id){
var r=await api('licenses?admin=1&view=batch_detail&batch_id='+id);
if(r.error){ alert(r.error); return; }
var b=r.batch;
var cust=esc(b.customer_name||'')+(b.customer_email?' · '+esc(b.customer_email):'')+(b.customer_no?' · Nr. '+esc(b.customer_no):'');
var h='<div class="mini" style="margin-bottom:.6rem">'
+ 'Typ <b>'+esc(b.sales_type)+'</b> · Bestellnr. <b>'+esc(b.order_no||'—')+'</b> · Kunde '+(cust||'—')
+ ' · gültig '+fmtDate(b.default_valid_from)+''+fmtDate(b.default_valid_until)
+ (b.canceled_at?' · <span class="badge b-canceled">Charge storniert</span>':'')+'</div>';
h+='<div class="row-act" style="margin-bottom:.6rem">'
+ '<button class="btn-sm" onclick="extendBatch('+b.id+',\''+esc(b.default_valid_until||'')+'\')">📅 Charge verlängern</button>'
+ '<button class="btn-sm" onclick="cancelBatch('+b.id+')">✖ Charge stornieren</button></div>';
h+='<div style="max-height:320px;overflow:auto"><table><tr><th>Code</th><th>Status</th><th>Gültig bis</th><th>Lehrperson</th><th></th></tr>';
r.codes.forEach(function(l){
h+='<tr><td class="code">'+esc(l.code)+'</td><td>'+codeStatus(l)+'</td><td>'+fmtDate(l.valid_until)+'</td>'
+'<td class="mini">'+esc(l.teacher_name||'—')+'</td>'
+'<td class="row-act">'
+ (l.canceled_at?'':'<button class="btn-sm" onclick="extendLicense('+l.id+',\''+esc(l.valid_until||'')+'\')">📅</button>'
+ '<button class="btn-sm" onclick="cancelLicense('+l.id+')">✖</button>')
+'</td></tr>';
});
h+='</table></div>';
if(r.mails && r.mails.length){
h+='<div class="dm-sub">Mail-Verlauf</div><table>';
r.mails.forEach(function(mm){ h+='<tr><td class="mini">'+fmtDate(mm.created_at)+'</td><td class="mini">'+esc(mm.to_email)+'</td><td><span class="badge '+(mm.status==='sent'?'b-redeemed':mm.status==='failed'?'b-canceled':'b-free')+'">'+esc(mm.status)+'</span></td><td class="mini">'+esc(mm.error||'')+'</td></tr>'; });
h+='</table>';
}
if(r.events && r.events.length){
h+='<div class="dm-sub">Verlauf</div><div class="mini">';
r.events.forEach(function(e){ h+=fmtDate(e.created_at)+' · <b>'+esc(e.event)+'</b> '+esc(e.detail||'')+'<br>'; });
h+='</div>';
}
document.getElementById('m-title').textContent='Bestellung #'+b.id;
document.getElementById('m-body').innerHTML=h;
document.getElementById('modal-ovl').classList.add('open');
}
function closeModal(){ document.getElementById('modal-ovl').classList.remove('open'); }
function copyEl2(btn){ var inp=btn.previousElementSibling; inp.select(); navigator.clipboard.writeText(inp.value).catch(function(){document.execCommand('copy');}); }
async function cancelBatch(id){
var reason=prompt('Ganze Charge stornieren? Grund (optional):'); if(reason===null) return;
var r=await api('licenses',{action:'cancel_batch', batch_id:id, reason:reason});
if(r.error){ alert(r.error); return; }
openBatch(id); loadBatches();
}
async function cancelLicense(id){
var reason=prompt('Diesen Code stornieren? Grund (optional):'); if(reason===null) return;
var r=await api('licenses',{action:'cancel_license', license_id:id, reason:reason});
if(r.error){ alert(r.error); return; }
reopenCurrent(); loadBatches();
}
async function extendBatch(id,cur){
var vu=prompt('Charge verlängern — neues „Gültig bis" (JJJJ-MM-TT):', cur||''); if(!vu) return;
var r=await api('licenses',{action:'extend_batch', batch_id:id, valid_until:vu});
if(r.error){ alert(r.error); return; }
openBatch(id); loadBatches();
}
async function extendLicense(id,cur){
var vu=prompt('Code verlängern — neues „Gültig bis" (JJJJ-MM-TT):', cur||''); if(!vu) return;
var r=await api('licenses',{action:'extend_license', license_id:id, valid_until:vu});
if(r.error){ alert(r.error); return; }
reopenCurrent(); loadBatches();
}
var _curBatch=null;
function reopenCurrent(){ var t=document.getElementById('m-title').textContent.match(/#(\d+)/); if(t) openBatch(parseInt(t[1])); }
// 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();
prefillDefaults();
loadBatches();
loadMailConfig();
updatePreview();
});
</script>
</body>
</html>