3dba841830
- 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>
691 lines
40 KiB
HTML
691 lines
40 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}
|
||
/* ---- 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 & 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 & 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. 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>
|
||
|
||
<!-- 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 {'&':'&','<':'<','>':'>','"':'"'}[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 1–5000.'; 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 & 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>
|