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:
1 parent
19f9c747a2
commit
63d3bb11bf
10 files changed
+525
-4
No files matched your search
+85
-4
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user