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:
+105
-19
@@ -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 & 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 1–5000.'; 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>
|
||||
|
||||
Reference in New Issue
Block a user