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
@@ -123,6 +123,12 @@
|
||||
.credit-input option { color: #111; }
|
||||
textarea.credit-input { min-height: 80px; resize: vertical; }
|
||||
.calc-result { margin: 10px 0; padding: 9px 12px; border-radius: 10px; font-size: 14px; background: rgba(94,234,212,0.08); border: 1px solid rgba(94,234,212,0.3); }
|
||||
/* E-Mail-Speicher: ab der Warnschwelle des Hetzner-Pakets rot */
|
||||
.calc-result.warn { background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.45); color: #fca5a5; }
|
||||
.storage-task strong { font-size: 15px; }
|
||||
.storage-partial { color: #fca5a5; }
|
||||
.storage-failed { margin: 14px 0 0; }
|
||||
.storage-failed li { margin: 4px 0; font-size: 14px; }
|
||||
.credit-field { margin-top: 10px; }
|
||||
.credit-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
.mini-btn:disabled { opacity: 0.45; cursor: default; }
|
||||
@@ -158,6 +164,7 @@
|
||||
<button class="admin-tab" data-tab="chat">Chat <span id="admin-chat-badge" class="badge-pill hidden"></span></button>
|
||||
<button class="admin-tab" data-tab="credits">Gutschriften <span id="admin-credit-badge" class="badge-pill hidden"></span></button>
|
||||
<button class="admin-tab" data-tab="offers">Abo-Angebote</button>
|
||||
<button class="admin-tab" data-tab="storage">E-Mail-Speicher</button>
|
||||
</div>
|
||||
|
||||
<!-- Übersicht -->
|
||||
@@ -263,6 +270,27 @@
|
||||
</div>
|
||||
<p class="admin-note">Ein Angebot legt sofort einen einmal einlösbaren Coupon in Stripe an. Ohne laufendes Abo wird er beim Abschluss angewendet (und schaltet die Bestellung für diesen Kunden frei), bei laufendem Abo gilt er ab der nächsten Abbuchung. Nach den Angebotsmonaten gilt automatisch wieder der reguläre Preis.</p>
|
||||
</section>
|
||||
|
||||
<!-- E-Mail-Speicher (Zusatzspeicher-Aufgaben fuers Team, Hetzner-Paket) -->
|
||||
<section class="admin-section" id="tab-storage">
|
||||
<div id="storage-summary" class="calc-result">Lade…</div>
|
||||
<div class="admin-table-wrap no-scroll">
|
||||
<table class="admin-table">
|
||||
<colgroup>
|
||||
<col style="width:14%"><col style="width:22%"><col style="width:34%"><col style="width:18%"><col style="width:12%">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr><th>Fällig</th><th>Kunde</th><th>Aufgabe in konsoleH</th><th>Bestellt</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody id="storage-rows"><tr><td colspan="5" class="muted">Lade…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="storage-failed" class="storage-failed hidden">
|
||||
<h4>Gescheiterte Änderungen (letzte 30 Tage)</h4>
|
||||
<ul class="recent-list" id="storage-failed-list"></ul>
|
||||
</div>
|
||||
<p class="admin-note">Jedes Abo bringt 5 GB E-Mail-Speicher mit, jedes Zusatzpaket 5 GB dazu. „Einrichten“: Postfachgrenze in konsoleH jetzt auf den Zielwert setzen. „Zum Periodenende“: erst am genannten Tag auf den Zielwert senken. Danach „Erledigt“ drücken – der Kunde hat seine Bestätigung schon bekommen.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/chat-composer.js"></script>
|
||||
|
||||
+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