Lizenz-Vertrieb Feinschliff: Zugabe +10%, sichere Datei-Einloesung, Mail-Config

Umsetzung des Thomas-Feedbacks:
- Reihenfolge: freie Lizenzen beim Lehrer jetzt ORDER BY id (= Reihenfolge der
  reinkopierten Liste); stornierte ausgeblendet.
- Gratis-Zugabe: Checkbox (Default an) "+10 % aufgerundet" -> geliefert =
  ceil(bestellt*1.1) (14 -> 16). Batch speichert qty_ordered (verrechnet) und
  qty (geliefert); Live-Vorschau im Formular.
- Einloeselink/QR ENTFERNT (Sicherheitsrisiko: falscher eingeloggter Account).
  einloesen.html + pages/einloesen.php geloescht, Sammellink aus UI/Mail raus.
- CSV: Gueltig-bis + Metadaten nur in der KOPFZEILE, danach reine Code-Zeilen
  (per Copy-Paste einloesbar). Dateiname: <ExtBestellnr|Datum>_geograsim_<intNr>.
- Interne Bestellnummer = batch.id (laufend, nicht editierbar, storno-stabil).
- Lehrer-Einloesen: sichere .txt/.csv-Ablage (mehrstufig: Endung+MIME, Groessen-
  limit, nur Text lesen, Whitelist-Regex extrahiert nur Code-Tokens, Server
  validiert) — kein Upload, keine Ausfuehrung/Rendering.
- Mailsystem: zentrale Config in neuer app_settings (Provider server|aws,
  Absender-Name/-Adresse, oeffentliche URL), pflegbar in Admin + Test-Versand.
  Mailer liest Absender aus Config (SMTP-Zugang bleibt in .env). Mailtext nutzt
  kanonische geograsim.at (nicht v3/localhost).

Migrationen: -2 (qty_ordered), -3 (app_settings). Beide additiv, auf v3 angewandt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 11:15:53 +02:00
parent 96a73e22b2
commit 50c72fa8d8
8 changed files with 253 additions and 117 deletions
+105 -19
View File
@@ -128,8 +128,8 @@
<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">
<label>Anzahl (bestellt)</label>
<input type="number" id="iss-count" value="12" min="1" max="5000" oninput="updatePreview()">
</div>
<div>
<label>Typ</label>
@@ -168,6 +168,12 @@
</div>
<datalist id="cust-hints"></datalist>
<datalist id="order-hints"></datalist>
<div style="margin-top:.6rem;display:flex;align-items:center;gap:.7rem;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:.4rem;font-size:.75rem;font-weight:600;color:#4a4a4a">
<input type="checkbox" id="iss-bonus" checked onchange="updatePreview()"> +10 % Gratis-Zugabe (aufgerundet)
</label>
<span id="iss-preview" class="mini"></span>
</div>
<div style="margin-top:.7rem" class="row-act">
<button class="btn" onclick="issueBatch()">Codes erzeugen &amp; ausgeben</button>
<span id="iss-msg" class="mini"></span>
@@ -192,6 +198,41 @@
<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">
@@ -409,16 +450,18 @@ async function issueBatch(){
var name = document.getElementById('iss-name').value.trim();
var vf = document.getElementById('iss-from').value;
var vu = document.getElementById('iss-until').value;
var bonus = document.getElementById('iss-bonus').checked;
var msg = document.getElementById('iss-msg');
if(count<1||count>5000){ msg.textContent='Anzahl 15000.'; return; }
var delivered = calcDelivered(count, bonus);
var who = name||email||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;
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, sales_type:type, order_no:order,
var r = await api('licenses', {action:'issue_batch', count:count, bonus:bonus, 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};
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();
@@ -429,17 +472,15 @@ function renderIssueResult(){
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 (mit Gültig-bis)</button>'
+ '<button class="btn btn-outline" onclick="downloadCodes(\'csv\')">⬇ .csv</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>'
+ '<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>'
@@ -457,20 +498,65 @@ function buildMailText(m){
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'
+ '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');}); }
// CSV: Metadaten (inkl. Gültig-bis) in die KOPFZEILE, danach reine Code-Zeilen
// -> der Code-Block bleibt per Copy-Paste einlösbar (kein Datum je Zeile).
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();
if(fmt==='csv'){
var head='Bestellung;#'+(m.internal_no||'')+'\nExterne Bestellnummer;'+(m.order_no||'')+'\nGültig bis;'+fmtDate(m.valid_until)+'\nAnzahl;'+m.count+'\n\n';
content=head+m.codes.join('\r\n')+'\r\n'; mime='text/csv';
} else { content=m.codes.join('\r\n')+'\r\n'; mime='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){
@@ -542,8 +628,6 @@ async function openBatch(id){
+ ' · 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>';
@@ -608,6 +692,8 @@ fetch(BASE + '/api/admin', {method:'POST', headers:{'Content-Type':'application/
loadLicenses();
prefillDefaults();
loadBatches();
loadMailConfig();
updatePreview();
});
</script>
</body>