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>
This commit is contained in:
2026-08-26 01:39:14 +02:00
parent 7691649f37
commit 96a73e22b2
7 changed files with 852 additions and 8 deletions
+334
View File
@@ -62,6 +62,31 @@
.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>
@@ -98,6 +123,75 @@
</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">
@@ -200,6 +294,17 @@
</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'; } }
@@ -268,12 +373,241 @@ function exportFree() {
});
}
// ═══════════════ 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>