Files
geograsim/App/admin-licenses.html
T
Adminator 96a73e22b2 Lizenz-Vertrieb: Chargen-Vergabe, Storno, Verlaengerung, Mail, Einloeselink
Erweitert das Lizenzmodul um eine Vertriebs-/Vergabe-Schicht ueber dem
bestehenden Einloese-Flow (non-breaking, alle Alt-Codes + generate/redeem
/assign unveraendert).

Schema (Migration 2026-08-26-lizenz-vertrieb.sql, additiv):
- license_customers (Besteller: Kundennummer/Email/Name)
- license_batches (Bestellung/Charge: externe Bestellnummer, Typ, Menge,
  Gueltigkeit, Sammel-Einloesetoken, Storno)
- licenses += batch_id, issued_at, valid_from/until (pro Code -> Verlaengerung),
  canceled_at/reason; code auf VARCHAR(32) (laengere Codes)
- license_mails (Mail-Archiv, unabh. vom IMAP), license_redeem_attempts
  (Brute-Force-Schutz), license_events (Audit-Log)

Backend (php/api/licenses.php):
- issue_batch: N frische 100-Bit-Codes in Transaktion, sofort vergeben,
  an Kunde/Bestellung/Gueltigkeit gebunden; sticky Default Gueltig-bis
- Uebersicht (view=batches mit Aggregat ausgegeben/storniert/netto/eingeloest
  /abgelaufen), batch_detail, Kunden-Autocomplete, sales_default
- cancel_batch/cancel_license, extend_batch/extend_license
- send_batch_mail (Server-Treiber via Mailer, archiviert, Anhang bei >40)
- redeem_charge (Sammel-Einloeselink)
- Brute-Force-Bremse + Audit + Storno-Ablehnung im redeem/redeem_batch

UI (admin-licenses.html): Ausgabe-Formular (Bestaetigung bei Grossmengen),
Ergebnis mit Copy/Download(txt+csv)/Mail-Composer/Sammellink, Bestellungs-
Uebersicht mit Filtern + Detail-Modal (Storno/Verlaengern/Verlauf/Mails).

Einloese-Landingpage (einloesen.html + pages/einloesen.php): ?charge=Token
(alle auf einmal) und ?code=CODE, mit Lehrer-Login-Gate.

Lib: Database Transaktionen; Mailer sendWithAttachments + renderPlainBody.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-26 01:39:14 +02:00

615 lines
37 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</label>
<input type="number" id="iss-count" value="12" min="1" max="5000">
</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 — E-Mail</label>
<input type="email" id="iss-email" placeholder="kunde@schule.at" list="cust-hints" oninput="custSuggest(this.value)">
</div>
<div>
<label>Kundennummer (intern)</label>
<input type="text" id="iss-custno" placeholder="optional">
</div>
<div>
<label>Name / Verlag / Schule</label>
<input type="text" id="iss-name" placeholder="optional">
</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="cust-hints"></datalist>
<datalist id="order-hints"></datalist>
<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>
<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 custno = document.getElementById('iss-custno').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 msg = document.getElementById('iss-msg');
if(count<1||count>5000){ msg.textContent='Anzahl 15000.'; return; }
var who = name||email||custno||'(kein Kunde)';
if(count>=100 && !confirm(count+' Codes 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, sales_type:type, order_no:order,
customer:{email:email, customer_no:custno, 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 (Charge #'+r.batch_id+').</span>';
lastIssue = {batch_id:r.batch_id, 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">'
+ '<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 (mit Gültig-bis)</button>'
+ '</div>'
+ '<h4 style="margin-top:.9rem">2 · Sammel-Einlöse-Link (alle auf einmal)</h4>'
+ '<div class="row-act"><input class="lnk" id="charge-link" readonly value="'+esc(absUrl('/einloesen?charge='+m.token))+'"><button class="btn btn-outline" onclick="copyEl(\'charge-link\')">📋</button></div>'
+ '<div class="hint-s">Pro Code funktioniert auch: '+esc(absUrl('/einloesen?code=CODE'))+'</div>'
+ '<h4 style="margin-top:.9rem">3 · 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 Datei-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 '+absUrl('/')+' anmelden und die Codes unter „Lizenzen" eingeben.\n'
+ 'Oder alle auf einmal über diesen Link: '+absUrl('/einloesen?charge='+m.token)+'\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');}); }
function downloadCodes(fmt){
var m = lastIssue; if(!m) return;
var content, mime;
if(fmt==='csv'){ content='Code;Gueltig_bis\n'+m.codes.map(function(c){return c+';'+m.valid_until;}).join('\n')+'\n'; mime='text/csv'; }
else { content=m.codes.join('\r\n')+'\r\n'; mime='text/plain'; }
var base = m.order_no ? m.order_no.replace(/[^a-zA-Z0-9_\-]/g,'_') : (new Date().toISOString().slice(0,16).replace(/[:T]/g,'-'));
var a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([content],{type:mime})); a.download=base+'.'+fmt; a.click();
}
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">'
+ '<input class="lnk" readonly value="'+esc(absUrl('/einloesen?charge='+b.redeem_token))+'">'
+ '<button class="btn-sm" onclick="copyEl2(this)">📋 Sammellink</button>'
+ '<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();
});
</script>
</body>
</html>