Admin: Reiter E-Mail-Speicher, Abo mit Produkt; Schnittstelle fürs Ticketsystem

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Fable 5.1 committed 2026-09-29 14:37:42 +00:00
1 parent 19f9c747a2
commit 63d3bb11bf
10 files changed
+525 -4

No files matched your search

+85 -4
View File
@@ -41,6 +41,10 @@ function fmtEuro(value) {
return Number(value || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })
}
// Katalogname wie im Formular ("Hosting-Abo"/"Wartungs-Abo"); Altbestand ohne Produkt gilt als Wartungs-Abo
const PRODUCT_LABEL = { hosting: 'Hosting-Abo', wartung: 'Wartungs-Abo' }
const productLabelOf = (key) => PRODUCT_LABEL[key] || (key ? String(key) : 'Abo')
function isLead(customer) {
return String(customer.customerStatus || '').toLowerCase() === 'lead'
}
@@ -634,7 +638,7 @@ function renderCreditDetail(panel, d, message) {
<dl class="kv">
<dt>Kunde</dt><dd>${escapeHtml(c.name || c.companyName)}${c.code ? ` · Kd.-Nr. ${escapeHtml(c.code)}` : ''}</dd>
<dt>E-Mail</dt><dd>${escapeHtml(c.email || '–')}</dd>
<dt>${escapeHtml(s?.productName?.replace(/^WEBklar /, '') || 'Abo')}</dt><dd>${aboPill}</dd>
<dt>Abo</dt><dd>${s ? `${escapeHtml(productLabelOf(s.productKey))} ${aboPill}<br><span class="muted">Zusatzspeicher: ${storageLabel(s.storageUnits)}</span>` : aboPill}</dd>
${s ? `
<dt>Leistungsbeginn</dt><dd>${fmtDate(s.serviceStart)}</dd>
<dt>Nächste Abbuchung</dt><dd>${s.active ? fmtDate(s.currentPeriodEnd) : '–'}</dd>
@@ -778,6 +782,80 @@ async function applyCredit(id) {
}
}
/* ----------------------------------------------------- E-Mail-Speicher --- */
const GB_INCLUDED = 5
const GB_PER_UNIT = 5
const gbOfUnits = (units) => GB_INCLUDED + GB_PER_UNIT * (Number(units) || 0)
// "2 Pakete (15 GB)" / "kein Paket (5 GB)"
function storageLabel(units) {
const n = Number(units) || 0
const packages = n === 0 ? 'kein Paket' : n === 1 ? '1 Paket' : `${n} Pakete`
return `${packages} (${gbOfUnits(n)} GB)`
}
function storageDueHtml(t) {
const overdue = t.dueAt && new Date(t.dueAt).getTime() <= Date.now()
if (t.kind === 'einrichten') {
return `<span class="pill pill-warn">jetzt einrichten</span><br><span class="muted">bestellt ${fmtDateTime(t.createdAt)}</span>`
}
return `<span class="pill ${overdue ? 'pill-warn' : 'pill-muted'}">zum Periodenende</span><br>${fmtDate(t.dueAt)}`
}
function storageTaskHtml(t) {
const action = t.kind === 'einrichten'
? `Postfachgrenze in konsoleH <strong>jetzt</strong> auf <strong>${escapeHtml(t.targetGb)} GB</strong> setzen`
: `Postfachgrenze in konsoleH <strong>am ${fmtDate(t.dueAt)}</strong> auf <strong>${escapeHtml(t.targetGb)} GB</strong> senken`
const partial = t.partial
? `<br><span class="storage-partial">Teilweise: Stripe steht noch auf der bezahlten Menge, nicht auf ${escapeHtml(t.toUnits)} Paket(en) – Stückzahl in Stripe von Hand nachziehen.${t.error ? ` Fehler: ${escapeHtml(t.error)}` : ''}</span>`
: ''
return `<div class="storage-task">${action}<br><span class="muted">${escapeHtml(storageLabel(t.fromUnits))} → ${escapeHtml(storageLabel(t.toUnits))}${t.viaCancellation ? ' · über die Kündigungsseite' : ''}</span>${partial}</div>`
}
async function loadStorageTasks() {
const summary = document.getElementById('storage-summary')
const tbody = document.getElementById('storage-rows')
const failedBox = document.getElementById('storage-failed')
const failedList = document.getElementById('storage-failed-list')
try {
const d = await api('/api/portal-admin/storage-tasks')
const warn = Number(d.allocatedGb) >= Number(d.warnGb)
summary.classList.toggle('warn', warn)
summary.innerHTML = `<strong>${escapeHtml(d.allocatedGb)} von ${escapeHtml(d.packageGb)} GB vergeben</strong> (Paket ${escapeHtml(d.packageName)})${
warn ? ` – <strong>Warnschwelle ${escapeHtml(d.warnGb)} GB erreicht: größeres Hetzner-Paket buchen.</strong>` : ` · Warnung ab ${escapeHtml(d.warnGb)} GB`
}`
tbody.innerHTML = d.tasks.length
? d.tasks.map((t) => `
<tr data-storage-row="${escapeHtml(t.id)}">
<td>${storageDueHtml(t)}</td>
<td><strong>${escapeHtml(t.customerName)}</strong>${t.customerCode ? `<br><span class="muted">Kd.-Nr. ${escapeHtml(t.customerCode)}</span>` : ''}${t.email ? `<br><span class="muted">${escapeHtml(t.email).replace('@', '@<wbr>')}</span>` : ''}<br><span class="muted">${escapeHtml(t.productLabel)}</span></td>
<td>${storageTaskHtml(t)}</td>
<td>${fmtDateTime(t.createdAt)}<br><span class="muted">${escapeHtml(t.toUnits > t.fromUnits ? 'Bestellung (Plus)' : 'Verringerung (Minus)')}</span></td>
<td><button class="mini-btn primary" data-storage-done="${escapeHtml(t.id)}">Erledigt</button></td>
</tr>`).join('')
: '<tr><td colspan="5" class="muted">Keine offenen Speicher-Aufgaben.</td></tr>'
failedBox.classList.toggle('hidden', !d.failed.length)
failedList.innerHTML = d.failed.map((f) => `
<li>${fmtDateTime(f.createdAt)} · <strong>${escapeHtml(f.customerName)}</strong> · ${escapeHtml(storageLabel(f.fromUnits))} → ${escapeHtml(storageLabel(f.toUnits))}
<span class="muted">– Stripe hat die Änderung abgelehnt, nichts berechnet, keine Aufgabe.${f.error ? ` Fehler: ${escapeHtml(f.error)}` : ''}</span></li>`).join('')
tbody.querySelectorAll('[data-storage-done]').forEach((btn) => btn.addEventListener('click', () => completeStorageTask(btn.dataset.storageDone, btn)))
} catch (err) {
summary.classList.remove('warn')
summary.textContent = ''
tbody.innerHTML = `<tr><td colspan="5" class="error">${escapeHtml(err.message)}</td></tr>`
}
}
async function completeStorageTask(id, btn) {
btn.disabled = true
try {
await api(`/api/portal-admin/storage-tasks/${encodeURIComponent(id)}/done`, { method: 'POST', body: '{}' })
await loadStorageTasks()
} catch (err) {
btn.disabled = false
alert('Aufgabe konnte nicht als erledigt gespeichert werden: ' + err.message)
}
}
/* ------------------------------------------------------- Abo-Angebote --- */
const OFFER_STATUS = {
offen: ['pill-warn', 'offen'],
@@ -788,8 +866,7 @@ const OFFER_STATUS = {
const offerState = { filter: 'open', customers: null, labelToId: new Map(), context: null, offers: new Map() }
// Katalogname wie im Formular ("Hosting-Abo"/"Wartungs-Abo")
const OFFER_PRODUCT_LABEL = { hosting: 'Hosting-Abo', wartung: 'Wartungs-Abo' }
const OFFER_PRODUCT_LABEL = PRODUCT_LABEL
function offerStatusPill(o) {
const [cls, label] = OFFER_STATUS[o.status] || ['pill-muted', o.status]
@@ -876,7 +953,7 @@ async function loadOffers() {
<tr>
<td>${fmtDateTime(o.createdAt)}<br><span class="muted">${escapeHtml(o.ref)}${o.createdBy ? ` · ${escapeHtml(o.createdBy.replace(/\s*<.*>$/, ''))}` : ''}</span></td>
<td><strong>${escapeHtml(o.customerName)}</strong>${o.customerEmail ? `<br><span class="muted">${escapeHtml(o.customerEmail).replace('@', '@<wbr>')}</span>` : ''}${o.customerCode ? `<br><span class="muted">Kd.-Nr. ${escapeHtml(o.customerCode)}</span>` : ''}</td>
<td><span class="muted">${OFFER_PRODUCT_LABEL[o.productKey] || o.productKey}</span><br>${escapeHtml(o.text)}<br><span class="muted">Ersparnis ${euroFromCents(o.savingsCents)}${o.note ? ` · „${escapeHtml(o.note)}“` : ''}</span>
<td><span class="muted">${escapeHtml(productLabelOf(o.productKey))}</span><br>${escapeHtml(o.text)}<br><span class="muted">Ersparnis ${euroFromCents(o.savingsCents)}${o.note ? ` · „${escapeHtml(o.note)}“` : ''}</span>
${discount ? `<br><span class="muted">${escapeHtml(discount)}</span>` : ''}
${o.internalNote ? `<br><span class="muted"><em>intern: ${escapeHtml(o.internalNote)}</em></span>` : ''}</td>
<td style="white-space:nowrap;overflow-wrap:normal">${o.scope === 'laufend' ? '<span class="muted">laufendes Abo</span>' : fmtDate(o.validUntil)}</td>
@@ -1161,6 +1238,10 @@ async function initAdminPage() {
offersLoaded = true
loadOffers()
}
if (tab.dataset.tab === 'storage') {
// Bei jedem Wechsel auf den Reiter neu laden: der Stand aendert sich, sobald jemand bestellt
loadStorageTasks()
}
})
})