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:
@@ -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 & 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>
|
||||
|
||||
<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 {'&':'&','<':'<','>':'>','"':'"'}[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 1–5000.'; 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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user