/* Admin-Dashboard des Kundenportals (Ticketsystem-Mitarbeiter). */
async function api(path, options = {}) {
const response = await fetch(path, {
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
...options,
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
const err = new Error(data.error || `Fehler ${response.status}`)
err.status = response.status
throw err
}
return data
}
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
}
function fmtDate(value) {
if (!value) return '–'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return escapeHtml(value)
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
function fmtDateTime(value) {
if (!value) return '–'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return escapeHtml(value)
return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' })
}
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'
}
function stagePill(status) {
const s = String(status || '').toLowerCase()
if (s === 'lead') return 'Lead '
if (s === 'lost') return 'Abgesagt '
return 'Fester Kunde '
}
function statusPill(project) {
const s = String(project.provisioningStatus || project.status || '').toLowerCase()
if (['ready', 'deployed'].includes(s)) return 'deployed '
if (s.includes('fail')) return 'Fehler '
if (!project.subdomain) return 'ohne Website '
return `${escapeHtml(project.status || 'pending')} `
}
function showError(message) {
const el = document.getElementById('admin-error')
el.textContent = message
el.classList.remove('hidden')
}
/* ------------------------------------------------------------ Übersicht --- */
async function loadOverview() {
const grid = document.getElementById('stat-grid')
const logins = document.getElementById('recent-logins')
const recent = document.getElementById('recent-projects')
try {
const o = await api('/api/portal-admin/overview')
grid.innerHTML = [
{ num: o.projects.total, lbl: 'Projekte gesamt' },
{ num: o.projects.deployed, lbl: 'Websites online' },
{ num: o.projects.withoutWebsite, lbl: 'Projekte ohne Website' },
{ num: o.customers.total, lbl: 'Kunden gesamt' },
{ num: o.customers.leads, lbl: 'Leads' },
{ num: o.customers.portalEnabled, lbl: 'Portal-Zugänge' },
].map((s) => `
${s.num} ${s.lbl}
`).join('')
logins.innerHTML = o.recentLogins.length
? o.recentLogins.map((l) => `${escapeHtml(l.customerName)} – ${fmtDateTime(l.lastLoginAt)} `).join('')
: 'Noch keine Portal-Logins. '
recent.innerHTML = o.recentProjects.length
? o.recentProjects.map((p) => `${escapeHtml(p.projectName)}${p.customerName ? ` (${escapeHtml(p.customerName)}) ` : ''} – ${fmtDateTime(p.updatedAt)} `).join('')
: 'Keine Projekte. '
} catch (err) {
grid.innerHTML = ''
showError(err.message)
}
}
/* ------------------------------------------------------------- Projekte --- */
let projectSearchTimer = null
async function loadProjects(search = '') {
const tbody = document.getElementById('project-rows')
try {
const { projects } = await api(`/api/portal-admin/projects?search=${encodeURIComponent(search)}`)
if (!projects.length) {
tbody.innerHTML = 'Keine Projekte gefunden. '
return
}
tbody.innerHTML = projects.map((p) => `
${escapeHtml(p.projectName || p.repoFullName || '–')}
${p.subdomain ? escapeHtml(p.subdomain) : '– '}
${p.customerName ? escapeHtml(p.customerName) : '– '}
${statusPill(p)}
${p.projectType === 'repo' ? 'Repo' : 'Website'}
${p.previewUrl ? `Vorschau ` : ''}
${p.giteaRepoUrl ? ` · Gitea ` : ''}
`).join('')
} catch (err) {
tbody.innerHTML = `${escapeHtml(err.message)} `
}
}
/* --------------------------------------------------------------- Kunden --- */
let customerSearchTimer = null
async function saveCustomerCode(customerId, btn) {
const input = document.querySelector(`input[data-code-for="${customerId}"]`)
if (!input) return
btn.disabled = true
try {
await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}`, {
method: 'PATCH',
body: JSON.stringify({ code: input.value }),
})
btn.textContent = '✓ Gespeichert'
setTimeout(() => { btn.textContent = '✓'; btn.disabled = false }, 1200)
} catch (err) {
btn.disabled = false
alert('Kundennummer konnte nicht gespeichert werden: ' + err.message)
}
}
async function viewAsCustomer(customerId) {
try {
await api(`/api/portal-admin/view-as/${encodeURIComponent(customerId)}`, { method: 'POST' })
window.location.href = '/dashboard.html'
} catch (err) {
alert('Ansicht konnte nicht gewechselt werden: ' + err.message)
}
}
function renderActivity(tickets) {
if (!tickets.length) return 'Keine Tickets für diesen Kunden.
'
return tickets.map((t) => `
${escapeHtml(t.woid ? `#${t.woid}` : '')} ${escapeHtml(t.topic || '–')}
${escapeHtml(t.status || '–')}
${escapeHtml(t.type || '')} · ${fmtDate(t.createdAt)}
${(t.worksheets || []).map((w) => `
→ ${fmtDate(w.date)} ${escapeHtml(w.employeeName)} (${escapeHtml(w.serviceType || '–')}${w.totalTime ? `, ${escapeHtml(w.totalTime)}` : ''}${w.supportMinutes ? `, +${escapeHtml(w.supportMinutes)} min Unterstützung` : ''}): ${escapeHtml((w.details || '').slice(0, 160))}
`).join('')}
`).join('')
}
function renderInvoices(data) {
if (!data.linked) return 'Kunde ist nicht mit InvoiceNinja verknüpft.
'
if (!data.invoices.length) return 'Keine Rechnungen vorhanden.
'
return `Nr. Datum Betrag Offen Status
${data.invoices.map((i) => `
${escapeHtml(i.number)}
${fmtDate(i.date)}
${fmtEuro(i.amount)}
${fmtEuro(i.balance)}
${escapeHtml(i.status)}
${i.portalUrl ? `Ansehen ` : ''}
`).join('')}
`
}
async function toggleCustomerDetail(customerId, kind, cell) {
const existing = document.getElementById(`detail-${customerId}`)
if (existing) {
const wasKind = existing.dataset.kind
existing.remove()
if (wasKind === kind) return
}
const row = document.querySelector(`tr[data-customer-row="${customerId}"]`)
if (!row) return
const tr = document.createElement('tr')
tr.id = `detail-${customerId}`
tr.dataset.kind = kind
tr.className = 'detail-row'
tr.innerHTML = `Lade…
`
row.after(tr)
try {
if (kind === 'activity') {
const { tickets } = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}/activity`)
tr.innerHTML = `Tickets & Arbeiten ${renderActivity(tickets)} `
} else {
const data = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}/invoices`)
tr.innerHTML = `Rechnungen ${renderInvoices(data)} `
}
} catch (err) {
tr.innerHTML = `${escapeHtml(err.message)} `
}
}
async function loadCustomers(search = '') {
const tbody = document.getElementById('customer-rows')
try {
const { customers } = await api(`/api/portal-admin/customers?search=${encodeURIComponent(search)}`)
if (!customers.length) {
tbody.innerHTML = 'Keine Kunden gefunden. '
return
}
tbody.innerHTML = customers.map((c) => `
✓
${escapeHtml(c.name)}
${escapeHtml(c.email || '–')}
${stagePill(c.customerStatus)}
${c.portalAccessEnabled ? 'aktiv ' : 'kein Zugang '}
${fmtDateTime(c.lastLoginAt)}
${c.projectCount}
${isLead(c) ? `
Rechnungsanalyse ` : ''}
Als Kunde ansehen
Arbeiten
Rechnungen
Angebot
`).join('')
tbody.querySelectorAll('[data-save-code]').forEach((btn) =>
btn.addEventListener('click', () => saveCustomerCode(btn.dataset.saveCode, btn)))
tbody.querySelectorAll('[data-view-as]').forEach((btn) =>
btn.addEventListener('click', () => viewAsCustomer(btn.dataset.viewAs)))
tbody.querySelectorAll('[data-activity]').forEach((btn) =>
btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.activity, 'activity')))
tbody.querySelectorAll('[data-invoices]').forEach((btn) =>
btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.invoices, 'invoices')))
tbody.querySelectorAll('[data-offer-for]').forEach((btn) =>
btn.addEventListener('click', () => startOfferFor(btn.dataset.offerFor)))
} catch (err) {
tbody.innerHTML = `${escapeHtml(err.message)} `
}
}
/* ----------------------------------------------------------------- Chat --- */
const adminChat = {
activeCustomerId: '',
activeCustomerName: '',
lastCreatedAt: '',
threadTimer: null,
shownIds: new Set(),
composer: null,
}
const adminChatUrl = (customerId) => `/api/portal-admin/chats/${encodeURIComponent(customerId)}`
function updateAdminChatBadge(unread) {
const badge = document.getElementById('admin-chat-badge')
if (!badge) return
if (unread > 0) {
badge.textContent = unread
badge.classList.remove('hidden')
} else {
badge.classList.add('hidden')
}
}
async function pollAdminChatBadge() {
if (document.hidden) return
try {
const { unread } = await api('/api/portal-admin/chats/unread-count')
updateAdminChatBadge(unread)
} catch {
/* ignore */
}
}
async function loadChatList() {
const list = document.getElementById('admin-chat-list')
try {
const { chats } = await api('/api/portal-admin/chats')
if (!chats.length) {
list.innerHTML = 'Noch keine Unterhaltungen.
'
return
}
list.innerHTML = chats.map((c) => `
${escapeHtml(c.customerName)}
${c.unreadFromCustomer ? `${c.unreadFromCustomer} ` : ''}
${c.lastSender === 'admin' ? 'Du: ' : ''}${escapeHtml(c.lastText)}
${fmtDateTime(c.lastAt)}
`).join('')
list.querySelectorAll('[data-chat-customer]').forEach((el) =>
el.addEventListener('click', () => openChatThread(el.dataset.chatCustomer, el.dataset.chatName)))
} catch (err) {
list.innerHTML = `${escapeHtml(err.message)}
`
}
}
function appendAdminChatMessages(messages) {
const box = document.getElementById('admin-chat-messages')
if (!box || !messages.length) return
const base = adminChatUrl(adminChat.activeCustomerId)
for (const msg of messages) {
if (adminChat.shownIds.has(msg.id)) continue
adminChat.shownIds.add(msg.id)
const mine = msg.sender === 'admin'
box.appendChild(ChatComposer.renderMessage(msg, {
mine,
meta: `${mine ? msg.senderName || 'Admin' : adminChat.activeCustomerName} · ${fmtDateTime(msg.createdAt)}`,
fileUrl: (att) => `${base}/attachments/${encodeURIComponent(att.id)}`,
}))
if (msg.createdAt > adminChat.lastCreatedAt) adminChat.lastCreatedAt = msg.createdAt
}
box.scrollTop = box.scrollHeight
}
async function pollAdminThread() {
if (!adminChat.activeCustomerId || document.hidden) return
try {
const customerId = adminChat.activeCustomerId
const q = adminChat.lastCreatedAt ? `?since=${encodeURIComponent(adminChat.lastCreatedAt)}` : ''
const { messages } = await api(`/api/portal-admin/chats/${encodeURIComponent(customerId)}/messages${q}`)
if (customerId !== adminChat.activeCustomerId) return // inzwischen andere Unterhaltung geoeffnet
if (messages.length) {
appendAdminChatMessages(messages)
await api(`/api/portal-admin/chats/${encodeURIComponent(adminChat.activeCustomerId)}/read`, { method: 'POST' }).catch(() => {})
}
} catch {
/* naechster Poll */
}
}
async function openChatThread(customerId, customerName) {
adminChat.activeCustomerId = customerId
adminChat.activeCustomerName = customerName || 'Kunde'
adminChat.lastCreatedAt = ''
adminChat.shownIds = new Set()
if (adminChat.composer) adminChat.composer.destroy()
const thread = document.getElementById('admin-chat-thread')
thread.innerHTML = `
${escapeHtml(adminChat.activeCustomerName)}
`
ChatComposer.trackScroll(document.getElementById('admin-chat-messages'))
// Text, Bilder, Kamerafoto, Dateien und Sprachnachrichten (public/chat-composer.js)
adminChat.composer = ChatComposer.create({
root: document.getElementById('admin-chat-composer'),
dropZone: thread,
uploadUrl: `${adminChatUrl(customerId)}/attachments`,
placeholder: `Antwort an ${adminChat.activeCustomerName}…`,
placeholderShort: 'Antwort…',
send: async ({ text, attachmentIds }) => {
const { message } = await api(`${adminChatUrl(customerId)}/messages`, {
method: 'POST',
body: JSON.stringify({ text, attachmentIds }),
})
return message
},
onSent: (message) => {
if (adminChat.activeCustomerId !== customerId) return
appendAdminChatMessages([message])
loadChatList()
},
})
try {
const { messages } = await api(`/api/portal-admin/chats/${encodeURIComponent(customerId)}/messages`)
if (customerId !== adminChat.activeCustomerId) return // inzwischen andere Unterhaltung geoeffnet
appendAdminChatMessages(messages)
await api(`/api/portal-admin/chats/${encodeURIComponent(customerId)}/read`, { method: 'POST' }).catch(() => {})
updateAdminChatBadge(0)
pollAdminChatBadge()
loadChatList()
} catch (err) {
document.getElementById('admin-chat-messages').innerHTML = `${escapeHtml(err.message)} `
}
clearInterval(adminChat.threadTimer)
adminChat.threadTimer = setInterval(pollAdminThread, 15000)
}
/* ---------------------------------------------- Übernahmegutschriften --- */
const CREDIT_STATUS = {
offen: ['pill-warn', 'Zu prüfen'],
rueckfrage: ['pill-muted', 'Rückfrage beim Kunden'],
genehmigt: ['pill-ok', 'Genehmigt'],
abgelehnt: ['pill-red', 'Abgelehnt'],
}
const CREDIT_HISTORY_LABELS = {
eingereicht: 'Antrag eingereicht',
rueckfrage: 'Rückfrage an den Kunden',
antwort: 'Antwort des Kunden',
genehmigt: 'Genehmigt',
abgelehnt: 'Abgelehnt',
verrechnet: 'In Stripe gutgeschrieben',
'nachweise-geloescht': 'Nachweise gelöscht',
}
const CREDIT_MONTHS = [1, 3, 6, 12, 24, 36]
const creditState = { filter: 'open', activeId: '', detail: null, calcTimer: null, amountTouched: false }
const euroFromCents = (cents) => fmtEuro((Number(cents) || 0) / 100)
function fmtBytes(bytes) {
const n = Number(bytes || 0)
return n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`
}
function creditStatusPill(status) {
const [cls, label] = CREDIT_STATUS[status] || ['pill-muted', status]
return `${label} `
}
function updateCreditBadge(open) {
const badge = document.getElementById('admin-credit-badge')
if (!badge) return
badge.textContent = open
badge.classList.toggle('hidden', !(open > 0))
}
async function pollCreditBadge() {
if (document.hidden) return
try {
const { open } = await api('/api/portal-admin/takeover-credits/open-count')
updateCreditBadge(open)
} catch {
/* ignore */
}
}
async function loadCredits() {
const tbody = document.getElementById('credit-rows')
try {
const { requests, openCount } = await api(`/api/portal-admin/takeover-credits?status=${creditState.filter}`)
updateCreditBadge(openCount)
if (!requests.length) {
tbody.innerHTML = `${creditState.filter === 'open' ? 'Keine offenen Anträge.' : 'Noch keine Anträge.'} `
return
}
tbody.innerHTML = requests.map((r) => `
${fmtDateTime(r.createdAt)}${escapeHtml(r.ref)}
${escapeHtml(r.customerName)} ${r.customerCode ? `Kd.-Nr. ${escapeHtml(r.customerCode)} ` : ''}
${escapeHtml(r.services)}${escapeHtml(r.provider)}${r.domain ? ` · ${escapeHtml(r.domain)}` : ''}
${euroFromCents(r.invoiceAmountCents)}für ${r.billingMonths} Mon.
${fmtDate(r.paidUntil)}
${creditStatusPill(r.status)}${r.status === 'genehmigt' ? `${euroFromCents(r.amountCents)}${r.appliedAt ? '' : ' · nicht gebucht'} ` : ''}
${['offen', 'rueckfrage'].includes(r.status) ? 'Prüfen' : 'Ansehen'}
`).join('')
tbody.querySelectorAll('[data-open-credit]').forEach((btn) =>
btn.addEventListener('click', () => openCreditDetail(btn.dataset.openCredit)))
} catch (err) {
tbody.innerHTML = `${escapeHtml(err.message)} `
}
}
function setCreditUrl(id) {
const url = new URL(window.location.href)
if (id) url.searchParams.set('gutschrift', id)
else url.searchParams.delete('gutschrift')
window.history.replaceState(null, '', url)
}
function closeCreditDetail() {
creditState.activeId = ''
document.getElementById('credit-detail').classList.add('hidden')
document.querySelectorAll('[data-credit-row]').forEach((tr) => tr.classList.remove('credit-active'))
setCreditUrl('')
}
async function openCreditDetail(id, { message = '' } = {}) {
creditState.activeId = id
creditState.amountTouched = false
const panel = document.getElementById('credit-detail')
panel.classList.remove('hidden')
panel.innerHTML = 'Lade Antrag…
'
document.querySelectorAll('[data-credit-row]').forEach((tr) =>
tr.classList.toggle('credit-active', tr.dataset.creditRow === id))
setCreditUrl(id)
try {
const d = await api(`/api/portal-admin/takeover-credits/${encodeURIComponent(id)}`)
creditState.detail = d
renderCreditDetail(panel, d, message)
} catch (err) {
panel.innerHTML = `${escapeHtml(err.message)}
`
}
panel.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
function calcSummary(calc) {
return `Rechnungszeitraum ${fmtDate(calc.periodStart)} – ${fmtDate(calc.periodEnd)} (${calc.periodDays} Tage). ` +
`Doppelt bezahlt ab ${fmtDate(calc.overlapStart)}: ${calc.overlapDays} Tage → Vorschlag ${euroFromCents(calc.suggestedCents)}`
}
function creditDecisionFormHtml(d) {
const r = d.request
const suggested = d.calc ? d.calc.suggestedCents : 0
return `
Prüfung durch dich
${d.requiredChecks.map((k) => ` ${escapeHtml(k.label)} `).join('')}
Berechnung
${d.calc ? escapeHtml(calcSummary(d.calc)) : 'Angaben unvollständig'}
Werte weichen von der Rechnung ab? Hier korrigieren – die Korrektur wird beim Genehmigen im Verlauf vermerkt. „Übernahme ab“ ist der Leistungsbeginn des Abos.
Gutschrift (€)
Nachricht an den Kunden (bei Rückfrage und Ablehnung Pflicht, beim Genehmigen optional)
Genehmigen & gutschreiben
Rückfrage senden
Ablehnen
Genehmigen geht, sobald alle Punkte abgehakt sind. Der Kunde bekommt jede Entscheidung per E-Mail.${d.subscription?.active ? '' : ' Achtung: Der Kunde hat kein laufendes Abo. '}
`
}
function creditDecisionSummaryHtml(d) {
const r = d.request
if (r.status === 'genehmigt') {
return `
Entscheidung
Genehmigt ${fmtDateTime(r.decidedAt)} · ${escapeHtml(r.decidedBy)}
Gutschrift ${euroFromCents(r.amountCents)}
Berechnung ${escapeHtml(r.calculation)}
Geprüft ${r.checks ? `alle Punkte abgehakt (${escapeHtml(r.checks.by || '')})` : '–'}
${r.decisionNote ? `Nachricht ${escapeHtml(r.decisionNote)} ` : ''}
Verrechnung ${r.appliedAt
? `Stripe-Guthaben gebucht am ${fmtDateTime(r.appliedAt)} (${escapeHtml(r.stripeBalanceTransactionId)}) `
: 'noch nicht in Stripe gebucht '}
${r.appliedAt ? '' : `
Jetzt in Stripe gutschreiben
`}`
}
return `
Entscheidung
Abgelehnt ${fmtDateTime(r.decidedAt)} · ${escapeHtml(r.decidedBy)}
Begründung ${escapeHtml(r.decisionNote)}
`
}
function renderCreditDetail(panel, d, message) {
const r = d.request
const c = d.customer
const s = d.subscription
const pending = r.status === 'offen' || r.status === 'rueckfrage'
const fileNames = new Map(r.files.map((f) => [f.id, f.name]))
const fileHref = (f) => `/api/portal-admin/takeover-credits/${encodeURIComponent(r.id)}/files/${encodeURIComponent(f.id)}`
const aboPill = !s
? 'kein Abo '
: `${escapeHtml(s.status)} `
const staff = (by) => (by && by !== 'customer' && by !== 'system' ? ` · ${escapeHtml(by)}` : '')
panel.innerHTML = `
Antrag ${escapeHtml(r.ref)} · ${escapeHtml(c.name || c.companyName)}
eingegangen ${fmtDateTime(r.createdAt)} ${creditStatusPill(r.status)}
Schließen
${message ? `${escapeHtml(message)}
` : ''}
Antrag
Leistung ${escapeHtml(r.services)}
Bisher bei ${escapeHtml(r.provider)}
Domain ${r.domain ? `${escapeHtml(r.domain)} ` : '–'}
Rechnung ${euroFromCents(r.invoiceAmountCents)} für ${r.billingMonths} Monat${r.billingMonths === 1 ? '' : 'e'}
Bezahlt bis ${fmtDate(r.paidUntil)}
Antwort an ${escapeHtml(r.email)}
Bestätigt Angaben und Bedingungen (Stand ${escapeHtml(r.conditionsVersion || '–')}) am ${fmtDateTime(r.consentAt)}
Grund / Nachricht
${escapeHtml(r.message)}
Nachweise
${r.files.length
? `
`
: '
Keine Nachweise (mehr) vorhanden.
'}
Verlauf
${r.history.map((h) => `
${escapeHtml(CREDIT_HISTORY_LABELS[h.type] || h.type)}
${fmtDateTime(h.at)}${staff(h.by)}
${h.text ? `
${escapeHtml(h.text)}
` : ''}
${(h.files || []).length ? `
Dateien: ${h.files.map((id) => escapeHtml(fileNames.get(id) || 'gelöscht')).join(', ')}
` : ''}
`).join('')}
Kunde & Vertrag
Kunde ${escapeHtml(c.name || c.companyName)}${c.code ? ` · Kd.-Nr. ${escapeHtml(c.code)}` : ''}
E-Mail ${escapeHtml(c.email || '–')}
Abo ${s ? `${escapeHtml(productLabelOf(s.productKey))} ${aboPill}Zusatzspeicher: ${storageLabel(s.storageUnits)} ` : aboPill}
${s ? `
Leistungsbeginn ${fmtDate(s.serviceStart)}
Nächste Abbuchung ${s.active ? fmtDate(s.currentPeriodEnd) : '–'}
${s.cancelAt ? `Gekündigt zum ${fmtDate(s.cancelAt)} ` : ''}
Stripe-Guthaben ${s.creditBalanceCents == null ? '–' : euroFromCents(s.creditBalanceCents)} ` : ''}
${d.otherRequests.length ? `
Weitere Anträge des Kunden
${d.otherRequests.map((o) => `
${escapeHtml(o.ref)} · ${escapeHtml(o.services)} · ${fmtDate(o.createdAt)}
${creditStatusPill(o.status)}${o.amountCents ? ` ${euroFromCents(o.amountCents)}` : ''} `).join('')} ` : ''}
Automatische Prüfung
${d.autoChecks.map((k) => `
${escapeHtml(k.label)}${k.detail ? `${escapeHtml(k.detail)} ` : ''} `).join('')}
${pending ? creditDecisionFormHtml(d) : creditDecisionSummaryHtml(d)}
`
panel.querySelector('#credit-close')?.addEventListener('click', closeCreditDetail)
panel.querySelector('#credit-view-as')?.addEventListener('click', () => viewAsCustomer(c.id))
panel.querySelectorAll('[data-open-credit]').forEach((a) =>
a.addEventListener('click', (e) => {
e.preventDefault()
openCreditDetail(a.dataset.openCredit)
}))
panel.querySelector('#credit-apply')?.addEventListener('click', () => applyCredit(r.id))
if (pending) bindCreditDecision(panel, r.id)
}
function refreshApproveState(panel) {
const btn = panel.querySelector('#credit-approve')
if (!btn) return
const allChecked = [...panel.querySelectorAll('[data-check]')].every((cb) => cb.checked)
btn.disabled = !allChecked || !(Number(panel.querySelector('#credit-amount').value) > 0)
}
function calcFigures(panel) {
return {
invoiceAmountCents: Math.round(Number(panel.querySelector('#calc-amount').value) * 100),
billingMonths: Number(panel.querySelector('#calc-months').value),
paidUntil: panel.querySelector('#calc-paid-until').value,
takeoverFrom: panel.querySelector('#calc-from').value,
}
}
async function recalcCredit(panel) {
const out = panel.querySelector('#calc-result')
try {
const { calc } = await api('/api/portal-admin/takeover-credits/calc', {
method: 'POST',
body: JSON.stringify(calcFigures(panel)),
})
out.textContent = calcSummary(calc)
if (!creditState.amountTouched) panel.querySelector('#credit-amount').value = (calc.suggestedCents / 100).toFixed(2)
} catch (err) {
out.textContent = err.message
}
refreshApproveState(panel)
}
function bindCreditDecision(panel, id) {
panel.querySelectorAll('[data-check]').forEach((cb) => cb.addEventListener('change', () => refreshApproveState(panel)))
panel.querySelector('#credit-amount').addEventListener('input', () => {
creditState.amountTouched = true
refreshApproveState(panel)
})
panel.querySelectorAll('#calc-amount, #calc-months, #calc-paid-until, #calc-from').forEach((el) =>
el.addEventListener('input', () => {
clearTimeout(creditState.calcTimer)
creditState.calcTimer = setTimeout(() => recalcCredit(panel), 300)
}))
panel.querySelector('#credit-approve').addEventListener('click', () => submitCreditDecision(id, 'approve', panel))
panel.querySelector('#credit-question').addEventListener('click', () => submitCreditDecision(id, 'question', panel))
panel.querySelector('#credit-reject').addEventListener('click', () => submitCreditDecision(id, 'reject', panel))
}
async function submitCreditDecision(id, action, panel) {
const errEl = panel.querySelector('#credit-error')
errEl.classList.add('hidden')
const note = panel.querySelector('#credit-note').value.trim()
const amountCents = Math.round(Number(panel.querySelector('#credit-amount').value) * 100)
const who = creditState.detail?.customer?.name || 'den Kunden'
if (action !== 'approve' && note.length < 5) {
errEl.textContent = action === 'question'
? 'Bitte die Rückfrage an den Kunden ins Nachrichtenfeld schreiben.'
: 'Bitte die Begründung für den Kunden ins Nachrichtenfeld schreiben.'
errEl.classList.remove('hidden')
return
}
const question = {
approve: `Gutschrift über ${euroFromCents(amountCents)} genehmigen, in Stripe gutschreiben und ${who} per E-Mail informieren?`,
question: `Rückfrage per E-Mail an ${who} senden?`,
reject: `Antrag ablehnen und ${who} die Begründung per E-Mail senden?`,
}[action]
if (!window.confirm(question)) return
const buttons = panel.querySelectorAll('.credit-actions button')
buttons.forEach((b) => { b.disabled = true })
try {
const checks = Object.fromEntries([...panel.querySelectorAll('[data-check]')].map((cb) => [cb.dataset.check, cb.checked]))
const result = await api(`/api/portal-admin/takeover-credits/${encodeURIComponent(id)}/decision`, {
method: 'POST',
body: JSON.stringify({ action, note, amountCents, checks, ...calcFigures(panel) }),
})
const parts = [{ genehmigt: 'Genehmigt.', rueckfrage: 'Rückfrage gesendet.', abgelehnt: 'Abgelehnt.' }[result.status] || 'Gespeichert.']
if (action === 'approve') {
parts.push(result.applied ? 'Das Guthaben ist in Stripe gebucht.' : `Noch nicht in Stripe gebucht: ${result.reason}`)
}
parts.push(result.mailSent
? 'Die E-Mail an den Kunden ist raus.'
: `E-Mail an den Kunden fehlgeschlagen (${result.mailError || 'unbekannt'}) – bitte selbst informieren.`)
await loadCredits()
await openCreditDetail(id, { message: parts.join(' ') })
} catch (err) {
errEl.textContent = err.message
errEl.classList.remove('hidden')
buttons.forEach((b) => { b.disabled = false })
refreshApproveState(panel)
}
}
async function applyCredit(id) {
const panel = document.getElementById('credit-detail')
const btn = panel.querySelector('#credit-apply')
btn.disabled = true
try {
const result = await api(`/api/portal-admin/takeover-credits/${encodeURIComponent(id)}/apply`, { method: 'POST' })
await loadCredits()
await openCreditDetail(id, {
message: result.applied ? 'Das Guthaben ist in Stripe gebucht.' : `Nicht gebucht: ${result.reason}`,
})
} catch (err) {
const errEl = panel.querySelector('#credit-error')
errEl.textContent = err.message
errEl.classList.remove('hidden')
btn.disabled = false
}
}
/* ----------------------------------------------------- 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 `jetzt einrichten bestellt ${fmtDateTime(t.createdAt)} `
}
return `zum Periodenende ${fmtDate(t.dueAt)}`
}
function storageTaskHtml(t) {
const action = t.kind === 'einrichten'
? `Postfachgrenze in konsoleH jetzt auf ${escapeHtml(t.targetGb)} GB setzen`
: `Postfachgrenze in konsoleH am ${fmtDate(t.dueAt)} auf ${escapeHtml(t.targetGb)} GB senken`
const partial = t.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)}` : ''} `
: ''
const scope = `Gesamtspeicher des Kunden: ${escapeHtml(t.targetGb)} GB , auf seine Postfächer verteilt nach seiner Vorgabe – § 3 Abs. 4${t.contactDomain ? ` · Kontakt: ${escapeHtml(t.contactDomain)}` : ''} `
return `${action}${scope}${escapeHtml(storageLabel(t.fromUnits))} → ${escapeHtml(storageLabel(t.toUnits))}${t.viaCancellation ? ' · über die Kündigungsseite' : ''} ${partial}
`
}
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 = `${escapeHtml(d.allocatedGb)} von ${escapeHtml(d.packageGb)} GB vergeben (Paket ${escapeHtml(d.packageName)})${
warn ? ` – Warnschwelle ${escapeHtml(d.warnGb)} GB erreicht: größeres Hetzner-Paket buchen. ` : ` · Warnung ab ${escapeHtml(d.warnGb)} GB`
}`
tbody.innerHTML = d.tasks.length
? d.tasks.map((t) => `
${storageDueHtml(t)}
${escapeHtml(t.customerName)} ${t.customerCode ? `Kd.-Nr. ${escapeHtml(t.customerCode)} ` : ''}${t.email ? `${escapeHtml(t.email).replace('@', '@')} ` : ''}${escapeHtml(t.productLabel)}
${storageTaskHtml(t)}
${fmtDateTime(t.createdAt)}${escapeHtml(t.toUnits > t.fromUnits ? 'Bestellung (Plus)' : 'Verringerung (Minus)')}
Erledigt
`).join('')
: 'Keine offenen Speicher-Aufgaben. '
failedBox.classList.toggle('hidden', !d.failed.length)
failedList.innerHTML = d.failed.map((f) => `
${fmtDateTime(f.createdAt)} · ${escapeHtml(f.customerName)} · ${escapeHtml(storageLabel(f.fromUnits))} → ${escapeHtml(storageLabel(f.toUnits))}
– Stripe hat die Änderung abgelehnt, nichts berechnet, keine Aufgabe.${f.error ? ` Fehler: ${escapeHtml(f.error)}` : ''} `).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 = `${escapeHtml(err.message)} `
}
}
async function completeStorageTask(id, btn) {
if (btn.dataset.partial === '1' && !confirm('Stripe-Menge von Hand korrigiert?')) return
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'],
aktiv: ['pill-ok', 'aktiv'],
abgelaufen: ['pill-muted', 'abgelaufen'],
zurueckgezogen: ['pill-muted', 'zurückgezogen'],
}
const offerState = { filter: 'open', customers: null, labelToId: new Map(), context: null, offers: new Map() }
const OFFER_PRODUCT_LABEL = PRODUCT_LABEL
function offerStatusPill(o) {
const [cls, label] = OFFER_STATUS[o.status] || ['pill-muted', o.status]
const sub = o.status === 'aktiv' && o.appliedAt ? `seit ${fmtDate(o.appliedAt)}` : o.status === 'offen' ? 'wartet auf Abschluss' : ''
return `${label} ${sub ? `${sub} ` : ''}`
}
// Versandstand der Angebots-Mail; "nicht verschickt" nur, solange sie noch sinnvoll ist (mailSendable)
function offerMailStatus(o) {
if (o.notifiedAt) return `E-Mail gesendet ${fmtDateTime(o.notifiedAt)} `
if (o.mailSendable) return 'E-Mail nicht verschickt '
if (o.status === 'offen' && !o.customerEmail) return 'Keine E-Mail-Adresse hinterlegt '
return ''
}
// Vorschau im Formular; verbindlich ist der Text, den der Server beim Anlegen erzeugt
function offerPreviewText(price, months, regular, running) {
const eur = (cents) => euroFromCents(cents)
const lead = months === 1 ? (running ? 'Nächster Monat' : 'Erster Monat') : `${running ? 'Nächste' : 'Erste'} ${months} Monate`
const amount = price === 0 ? 'kostenlos' : `${months === 1 ? '' : 'je '}${eur(price)} statt ${eur(regular)}`
return `${lead} ${amount}, danach ${eur(regular)} pro Monat`
}
// Datumsfeld "2026-10-27" -> "27.10.2026", ohne Umweg über Date (keine Zeitzonen-Verschiebung)
const fmtYmd = (ymd) => String(ymd || '').split('-').reverse().join('.')
// Zeitpunkt -> Kalendertag in Deutschland (YYYY-MM-DD); Abbuchungen und Fristen zählen nach deutschem Kalender
const berlinYmd = (value) => new Date(value).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
const fmtBerlin = (value) => fmtYmd(berlinYmd(value))
// Kalendertag + n Monate, Monatsende wie bei Stripe (31.01. + 1 Monat = 28./29.02.)
function addMonthsYmd(ymd, n) {
const [y, m, d] = ymd.split('-').map(Number)
const last = new Date(Date.UTC(y, m + n, 0)).getUTCDate()
return new Date(Date.UTC(y, m - 1 + n, Math.min(d, last))).toISOString().slice(0, 10)
}
/* Rabattende eines noch nicht eingelösten Angebots: Der Rabatt läuft N Monate ab Abschluss, der Kunde
kann heute oder spätestens am letzten Gültigkeitstag abschließen. Mit Beginn nach der Widerrufsfrist
starten Leistung und Rabatt gut 2 Wochen später (deadlines.js lateStartAt). */
function offerDiscountEstimate(months, validUntilYmd, testAccount = false) {
const today = berlinYmd(new Date())
// Testkonto: Coupon kommt erst nach der ersten Abbuchung ans Abo (offers.js), der Rabatt endet einen Monat später
const n = months + (testAccount ? 1 : 0)
return { earliest: addMonthsYmd(today, n), latest: addMonthsYmd(validUntilYmd < today ? today : validUntilYmd, n) }
}
// Zeile "Rabatt endet …" in der Angebotsliste
function offerDiscountLine(o) {
if (o.status === 'offen' && o.validUntil) {
const { earliest, latest } = offerDiscountEstimate(o.months, String(o.validUntil).slice(0, 10), o.testAccount)
return earliest === latest
? `Rabatt endet voraussichtlich am ${fmtYmd(earliest)}`
: `Rabatt endet voraussichtlich zwischen ${fmtYmd(earliest)} und ${fmtYmd(latest)}`
}
if (o.status !== 'aktiv' || !o.discount) return ''
const d = o.discount
if (!d.known) return 'Rabattende unbekannt (Abo nicht gefunden)'
const end = d.subscriptionEndsAt || d.endsAt
const past = new Date(end) <= new Date()
return d.subscriptionEndsAt
? `Rabatt ${past ? 'endete' : 'endet'} mit dem Abo am ${fmtBerlin(end)}`
: `Rabatt ${past ? 'endete' : 'endet'} am ${fmtBerlin(end)}`
}
function showOfferMessage(text) {
const el = document.getElementById('offer-message')
el.textContent = text
el.classList.toggle('hidden', !text)
}
async function loadOffers() {
const tbody = document.getElementById('offer-rows')
try {
const { offers } = await api('/api/portal-admin/offers')
offerState.offers = new Map(offers.map((o) => [o.id, o]))
const shown = offerState.filter === 'open' ? offers.filter((o) => ['offen', 'aktiv'].includes(o.status)) : offers
if (!shown.length) {
tbody.innerHTML = `${offerState.filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'} `
return
}
tbody.innerHTML = shown.map((o) => {
const discount = offerDiscountLine(o)
return `
${fmtDateTime(o.createdAt)}${escapeHtml(o.ref)}${o.createdBy ? ` · ${escapeHtml(o.createdBy.replace(/\s*<.*>$/, ''))}` : ''}
${escapeHtml(o.customerName)} ${o.customerEmail ? `${escapeHtml(o.customerEmail).replace('@', '@')} ` : ''}${o.customerCode ? `Kd.-Nr. ${escapeHtml(o.customerCode)} ` : ''}
${escapeHtml(productLabelOf(o.productKey))} ${escapeHtml(o.text)}Ersparnis ${euroFromCents(o.savingsCents)}${o.note ? ` · „${escapeHtml(o.note)}“` : ''}
${discount ? `${escapeHtml(discount)} ` : ''}
${o.internalNote ? `intern: ${escapeHtml(o.internalNote)} ` : ''}
${o.scope === 'laufend' ? 'laufendes Abo ' : fmtDate(o.validUntil)}
${offerStatusPill(o)}
Stripe: ${o.stripeCouponUrl ? `Coupon ` : '–'}${o.stripeSubscriptionUrl ? ` · Abo ` : ''}
${offerMailStatus(o)}
${['offen', 'abgelaufen'].includes(o.status) || o.mailSendable ? `
${o.mailSendable ? `${o.notifiedAt ? 'Erneut senden' : 'E-Mail senden'} ` : ''}
${['offen', 'abgelaufen'].includes(o.status) ? `Zurückziehen ` : ''}
` : ''}
`
}).join('')
tbody.querySelectorAll('[data-withdraw-offer]').forEach((btn) =>
btn.addEventListener('click', () => withdrawOfferAction(btn.dataset.withdrawOffer, btn)))
tbody.querySelectorAll('[data-send-offer-mail]').forEach((btn) =>
btn.addEventListener('click', () => sendOfferMailAction(btn.dataset.sendOfferMail, btn)))
} catch (err) {
tbody.innerHTML = `${escapeHtml(err.message)} `
}
}
async function withdrawOfferAction(id, btn) {
if (!window.confirm('Angebot zurückziehen? Der Coupon wird in Stripe gelöscht, der Kunde sieht das Angebot nicht mehr.')) return
btn.disabled = true
try {
await api(`/api/portal-admin/offers/${encodeURIComponent(id)}/withdraw`, { method: 'POST' })
showOfferMessage('Angebot zurückgezogen, Coupon in Stripe gelöscht.')
loadOffers()
} catch (err) {
btn.disabled = false
alert('Zurückziehen fehlgeschlagen: ' + err.message)
}
}
async function sendOfferMailAction(id, btn) {
const o = offerState.offers.get(id)
if (!o) return
const again = o.notifiedAt ? `\n\nZuletzt gesendet ${fmtDateTime(o.notifiedAt)}.` : ''
if (!window.confirm(`E-Mail mit dem Angebot an ${o.customerName} (${o.customerEmail}) ${o.notifiedAt ? 'erneut ' : ''}senden?${again}`)) return
btn.disabled = true
try {
await api(`/api/portal-admin/offers/${encodeURIComponent(id)}/notify`, { method: 'POST' })
showOfferMessage(`Die E-Mail an ${o.customerEmail} ist raus, der Mailserver hat sie angenommen. Kommt sie nicht an: Spam-Ordner prüfen, bei Gmail auch den Tab „Werbung“.`)
loadOffers()
} catch (err) {
btn.disabled = false
alert(err.message)
}
}
const offerCustomerLabel = (c) =>
[c.name || '(ohne Name)', c.email, c.code ? `Kd.-Nr. ${c.code}` : ''].filter(Boolean).join(' · ')
async function ensureOfferCustomers() {
if (offerState.customers) return offerState.customers
const { customers } = await api('/api/portal-admin/customers')
offerState.customers = customers
offerState.labelToId = new Map(customers.map((c) => [offerCustomerLabel(c), c.id]))
return customers
}
function offerFormValues(form) {
return {
customerId: offerState.labelToId.get(form.querySelector('#offer-customer').value.trim()) || '',
productKey: form.querySelector('#offer-product').value,
monthlyPriceCents: Math.round(Number(form.querySelector('#offer-price').value) * 100),
months: Number(form.querySelector('#offer-months').value),
validUntil: form.querySelector('#offer-valid').value,
note: form.querySelector('#offer-note').value.trim(),
internalNote: form.querySelector('#offer-internal').value.trim(),
notifyCustomer: form.querySelector('#offer-notify').checked,
}
}
function renderOfferPreview(form) {
const box = form.querySelector('#offer-preview')
const save = form.querySelector('#offer-save')
const ctx = offerState.context
const v = offerFormValues(form)
save.disabled = true
if (!v.customerId || !ctx || ctx.customer.id !== v.customerId) {
box.textContent = v.customerId ? 'Lade Kunde…' : 'Bitte einen Kunden aus der Liste wählen.'
return
}
const regular = ctx.products[v.productKey].regularPriceCents
// Sofort anwendbar ist ein Angebot nur, wenn das laufende Abo dasselbe Produkt betrifft (F32)
const running = Boolean(ctx.subscription?.active && ctx.subscription.productKey === v.productKey)
const openOffer = ctx.openOffers[v.productKey]
const lines = []
let ok = true
if (openOffer) {
lines.push(`Es gibt schon ein offenes ${OFFER_PRODUCT_LABEL[v.productKey]}-Angebot (${openOffer.ref}: ${openOffer.text}) – bitte zuerst zurückziehen.`)
ok = false
}
if (!Number.isInteger(v.monthlyPriceCents) || v.monthlyPriceCents < 0 || v.monthlyPriceCents >= regular) {
lines.push(`Angebotspreis bitte zwischen 0,00 € und ${euroFromCents(regular - 1)} angeben.`)
ok = false
}
if (!v.validUntil && !running) {
lines.push('Bitte „Gültig bis“ angeben.')
ok = false
}
if (ok) {
lines.push(`${offerPreviewText(v.monthlyPriceCents, v.months, regular, running)} · Ersparnis ${euroFromCents((regular - v.monthlyPriceCents) * v.months)}`)
if (running) {
const charges = ctx.subscription.upcomingCharges || []
if (charges.length > v.months) lines.push(`Rabatt endet am ${fmtBerlin(charges[v.months])} (nach ${v.months === 1 ? '1 Abbuchung' : `${v.months} Abbuchungen`}).`)
} else {
lines.push(`Angebot gültig bis ${fmtYmd(v.validUntil)}`)
const { earliest, latest } = offerDiscountEstimate(v.months, v.validUntil, ctx.customer.testAccount)
const lead = `Rabatt läuft ${v.months === 1 ? '1 Monat' : `${v.months} Monate`} ab Abschluss und endet voraussichtlich`
lines.push(`${earliest === latest
? `${lead} am ${fmtYmd(earliest)}.`
: `${lead} zwischen ${fmtYmd(earliest)} (Abschluss heute) und ${fmtYmd(latest)} (Abschluss am letzten Tag).`} Bei Beginn nach der Widerrufsfrist gut 2 Wochen später.`)
}
}
lines.push(running
? `Laufendes Abo: gilt ab der nächsten Abbuchung${ctx.subscription.currentPeriodEnd ? ` (${fmtDate(ctx.subscription.currentPeriodEnd)})` : ''} und wird sofort in Stripe angewendet.`
: 'Noch kein laufendes Abo: Der Coupon wird angelegt und beim Abschluss im Kundenportal angewendet.')
if (!running && !ctx.customer.portalAccessEnabled) lines.push('Achtung: Kunde hat keinen Portalzugang – er kann erst bestellen, wenn der Zugang freigeschaltet ist.')
if (ctx.customer.testAccount) lines.push(`Testkonto: Im Checkout bleibt der 0,50-€-Testrabatt, das Angebot wird danach zusätzlich angehängt${running ? '' : ' und gilt deshalb erst ab der zweiten Abbuchung'}.`)
box.innerHTML = lines.map((l) => `${escapeHtml(l)}
`).join('')
save.disabled = !ok
save.textContent = running ? 'Angebot anlegen und aufs laufende Abo anwenden' : 'Angebot anlegen'
}
async function loadOfferContext(form) {
const v = offerFormValues(form)
offerState.context = null
renderOfferPreview(form)
if (!v.customerId) return
try {
offerState.context = await api(`/api/portal-admin/offers/context/${encodeURIComponent(v.customerId)}`)
if (!form.querySelector('#offer-valid').value) form.querySelector('#offer-valid').value = offerState.context.defaultValidUntil
} catch (err) {
form.querySelector('#offer-preview').textContent = err.message
return
}
renderOfferPreview(form)
}
async function saveOffer(form) {
const errEl = form.querySelector('#offer-error')
errEl.classList.add('hidden')
const v = offerFormValues(form)
const ctx = offerState.context
const running = Boolean(ctx.subscription?.active && ctx.subscription.productKey === v.productKey)
const text = offerPreviewText(v.monthlyPriceCents, v.months, ctx.products[v.productKey].regularPriceCents, running)
if (!window.confirm(`Angebot für ${ctx.customer.name || 'den Kunden'} anlegen?\n\n${text}${running ? '' : `\nGültig bis ${fmtYmd(v.validUntil)}`}\n\nDer Coupon wird sofort in Stripe angelegt${running ? ' und auf das laufende Abo angewendet' : ''}.`)) return
const save = form.querySelector('#offer-save')
save.disabled = true
try {
const result = await api('/api/portal-admin/offers', { method: 'POST', body: JSON.stringify(v) })
const o = result.offer
form.classList.add('hidden')
const until = o.scope !== 'laufend'
? `, gültig bis ${fmtDate(o.validUntil)}`
: o.discount?.known ? `, Rabatt endet am ${fmtBerlin(o.discount.endsAt)}` : ''
showOfferMessage([
`Angebot ${o.ref} für ${o.customerName} angelegt: ${o.text}${until}.`,
`Stripe-Coupon ${o.stripeCouponId} ist eingerichtet${o.status === 'aktiv' ? ' und am laufenden Abo aktiv' : ''}.`,
v.notifyCustomer
? (result.mailSent
? `Die E-Mail an ${o.customerEmail} ist raus.`
: `E-Mail an den Kunden fehlgeschlagen (${result.mailError})${o.mailSendable ? ', sie lässt sich in der Liste erneut senden' : ''}.`)
: '',
].filter(Boolean).join(' '))
loadOffers()
} catch (err) {
errEl.textContent = err.message
errEl.classList.remove('hidden')
renderOfferPreview(form)
}
}
async function openOfferForm(customerId = '') {
const form = document.getElementById('offer-form')
showOfferMessage('')
form.classList.remove('hidden')
form.innerHTML = 'Lade Kunden…
'
let customers
try {
customers = await ensureOfferCustomers()
} catch (err) {
form.innerHTML = `${escapeHtml(err.message)}
`
return
}
const preselected = customers.find((c) => c.id === customerId)
form.innerHTML = `
Neues Angebot
Hinweis für den Kunden (optional, z. B. „Übernahme Ihres IONOS-Hostings“)
Interne Notiz (optional, sieht nur das Team)
Kunden per E-Mail über das Angebot informieren
Bitte einen Kunden aus der Liste wählen.
Angebot anlegen
Abbrechen
`
form.querySelector('#offer-customer').addEventListener('change', () => loadOfferContext(form))
form.querySelector('#offer-customer').addEventListener('input', () => {
if (offerState.labelToId.has(form.querySelector('#offer-customer').value.trim())) loadOfferContext(form)
})
form.querySelectorAll('#offer-price, #offer-months, #offer-valid').forEach((el) => el.addEventListener('input', () => renderOfferPreview(form)))
form.querySelector('#offer-product').addEventListener('change', () => renderOfferPreview(form))
form.querySelector('#offer-save').addEventListener('click', () => saveOffer(form))
form.querySelector('#offer-cancel').addEventListener('click', () => form.classList.add('hidden'))
if (preselected) loadOfferContext(form)
form.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
/** Aus der Kundenliste: Tab wechseln und Formular mit dem Kunden oeffnen. */
function startOfferFor(customerId) {
document.querySelector('.admin-tab[data-tab="offers"]')?.click()
openOfferForm(customerId)
}
/* ----------------------------------------------------------------- Init --- */
async function initAdminPage() {
document.getElementById('logout-btn')?.addEventListener('click', async () => {
await api('/api/auth/logout', { method: 'POST' }).catch(() => {})
window.location.href = '/login.html'
})
// Nach dem Login zurück zur aufgerufenen Adresse (z. B. Link aus der Gutschrift-Mail)
const loginUrl = `/login.html?next=${encodeURIComponent(window.location.pathname + window.location.search)}`
try {
const me = await api('/api/auth/me')
if (!me.authenticated || me.role !== 'admin') {
window.location.href = loginUrl
return
}
if (me.viewAs) {
// Admin ist gerade in der Kundenansicht -> zurückholen
await api('/api/portal-admin/exit-view-as', { method: 'POST' }).catch(() => {})
}
document.getElementById('admin-meta').textContent =
`${me.admin?.name || 'Admin'} (${me.admin?.email || ''})`
} catch {
window.location.href = loginUrl
return
}
let chatListLoaded = false
let creditsLoaded = false
let offersLoaded = false
document.querySelectorAll('.admin-tab').forEach((tab) => {
tab.addEventListener('click', () => {
document.querySelectorAll('.admin-tab').forEach((t) => t.classList.remove('active'))
document.querySelectorAll('.admin-section').forEach((s) => s.classList.remove('active'))
tab.classList.add('active')
document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active')
if (tab.dataset.tab === 'chat' && !chatListLoaded) {
chatListLoaded = true
loadChatList()
}
if (tab.dataset.tab === 'credits' && !creditsLoaded) {
creditsLoaded = true
loadCredits()
}
if (tab.dataset.tab === 'offers' && !offersLoaded) {
offersLoaded = true
loadOffers()
}
if (tab.dataset.tab === 'storage') {
// Bei jedem Wechsel auf den Reiter neu laden: der Stand aendert sich, sobald jemand bestellt
loadStorageTasks()
}
})
})
document.querySelectorAll('[data-offer-filter]').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('[data-offer-filter]').forEach((b) => b.classList.toggle('active', b === btn))
offerState.filter = btn.dataset.offerFilter
loadOffers()
})
})
document.getElementById('offer-new')?.addEventListener('click', () => openOfferForm())
document.querySelectorAll('[data-credit-filter]').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('[data-credit-filter]').forEach((b) => b.classList.toggle('active', b === btn))
creditState.filter = btn.dataset.creditFilter
loadCredits()
})
})
// Direktlink aus der Admin-Mail: /admin.html?gutschrift=
const creditFromLink = new URLSearchParams(window.location.search).get('gutschrift')
if (creditFromLink) {
document.querySelector('.admin-tab[data-tab="credits"]')?.click()
openCreditDetail(creditFromLink)
}
document.getElementById('project-search')?.addEventListener('input', (e) => {
clearTimeout(projectSearchTimer)
projectSearchTimer = setTimeout(() => loadProjects(e.target.value.trim()), 250)
})
document.getElementById('customer-search')?.addEventListener('input', (e) => {
clearTimeout(customerSearchTimer)
customerSearchTimer = setTimeout(() => loadCustomers(e.target.value.trim()), 250)
})
loadOverview()
loadProjects()
loadCustomers()
// Chat- und Gutschrift-Badge global pollen
pollAdminChatBadge()
setInterval(pollAdminChatBadge, 60000)
pollCreditBadge()
setInterval(pollCreditBadge, 60000)
}
window.initAdminPage = initAdminPage