Files
Webklar-Kundenbereich/public/admin.js
T
ClaudeandClaude Opus 5.5 bb2f46e191 Abo: individuelle Angebote (z. B. erste 2 Monate je 25 EUR) mit Stripe-Coupon
- Admin-Tab "Abo-Angebote" und Button "Angebot" in der Kundenliste: Preis, Monate,
  Gueltigkeit, Hinweis fuer den Kunden, interne Notiz, Vorschau vor dem Anlegen
- Je Angebot sofort ein Stripe-Coupon (amount_off, repeating ueber die
  Angebotsmonate, max. 1 Einloesung); danach automatisch wieder Normalpreis
- Kunde ohne Abo: Angebot im Portal, Angebotspreis in der Bestelluebersicht,
  Coupon im Checkout; Angebot schaltet die Bestellung auch ausserhalb der
  Pilotliste frei; Bestellung prueft, dass das angezeigte Angebot noch gilt
- Laufendes Abo: Coupon sofort am Abo fuer die naechsten Abbuchungen
- Testkonten: Testrabatt im Checkout, Angebot per Webhook zusaetzlich am Abo
- Angebot in Abo-Metadaten, Vertragsbestaetigung, Admin-Mail und Abo-Karte
- Zurueckziehen loescht den Coupon; abgelaufene Angebote nicht mehr bestellbar
- Stundenkontingent nach invoice.subtotal: auch voll rabattierte oder per
  Guthaben bezahlte Monate bringen die Stunde, die Probezeit nicht
- setup-abo-collections.mjs: aboOffers, subscriptions.offerId/offerText,
  Attribut-Abfrage mit Limit (sonst Haenger ab 26 Attributen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:32:08 +00:00

1103 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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' })
}
function isLead(customer) {
return String(customer.customerStatus || '').toLowerCase() === 'lead'
}
function stagePill(status) {
const s = String(status || '').toLowerCase()
if (s === 'lead') return '<span class="pill pill-warn">Lead</span>'
if (s === 'lost') return '<span class="pill pill-muted">Abgesagt</span>'
return '<span class="pill pill-ok">Fester Kunde</span>'
}
function statusPill(project) {
const s = String(project.provisioningStatus || project.status || '').toLowerCase()
if (['ready', 'deployed'].includes(s)) return '<span class="pill pill-ok">deployed</span>'
if (s.includes('fail')) return '<span class="pill pill-red">Fehler</span>'
if (!project.subdomain) return '<span class="pill pill-muted">ohne Website</span>'
return `<span class="pill pill-warn">${escapeHtml(project.status || 'pending')}</span>`
}
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) => `<div class="stat-tile"><span class="num">${s.num}</span><span class="lbl">${s.lbl}</span></div>`).join('')
logins.innerHTML = o.recentLogins.length
? o.recentLogins.map((l) => `<li>${escapeHtml(l.customerName)} <span class="muted">– ${fmtDateTime(l.lastLoginAt)}</span></li>`).join('')
: '<li class="muted">Noch keine Portal-Logins.</li>'
recent.innerHTML = o.recentProjects.length
? o.recentProjects.map((p) => `<li>${escapeHtml(p.projectName)}${p.customerName ? ` <span class="muted">(${escapeHtml(p.customerName)})</span>` : ''} <span class="muted">– ${fmtDateTime(p.updatedAt)}</span></li>`).join('')
: '<li class="muted">Keine Projekte.</li>'
} 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 = '<tr><td colspan="6" class="muted">Keine Projekte gefunden.</td></tr>'
return
}
tbody.innerHTML = projects.map((p) => `
<tr>
<td>${escapeHtml(p.projectName || p.repoFullName || '–')}</td>
<td>${p.subdomain ? escapeHtml(p.subdomain) : '<span class="muted">–</span>'}</td>
<td>${p.customerName ? escapeHtml(p.customerName) : '<span class="muted">–</span>'}</td>
<td>${statusPill(p)}</td>
<td>${p.projectType === 'repo' ? 'Repo' : 'Website'}</td>
<td>
${p.previewUrl ? `<a class="plain" href="${escapeHtml(p.previewUrl)}" target="_blank" rel="noreferrer">Vorschau</a>` : ''}
${p.giteaRepoUrl ? ` · <a class="plain" href="${escapeHtml(p.giteaRepoUrl)}" target="_blank" rel="noreferrer">Gitea</a>` : ''}
</td>
</tr>`).join('')
} catch (err) {
tbody.innerHTML = `<tr><td colspan="6" class="error">${escapeHtml(err.message)}</td></tr>`
}
}
/* --------------------------------------------------------------- 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 '<p class="muted">Keine Tickets für diesen Kunden.</p>'
return tickets.map((t) => `
<div class="detail-box">
<strong>${escapeHtml(t.woid ? `#${t.woid}` : '')} ${escapeHtml(t.topic || '–')}</strong>
<span class="pill pill-muted">${escapeHtml(t.status || '–')}</span>
<span class="muted">${escapeHtml(t.type || '')} · ${fmtDate(t.createdAt)}</span>
${(t.worksheets || []).map((w) => `
<div class="ws-line">→ ${fmtDate(w.date)} ${escapeHtml(w.employeeName)} (${escapeHtml(w.serviceType || '–')}${w.totalTime ? `, ${escapeHtml(w.totalTime)}` : ''}): ${escapeHtml((w.details || '').slice(0, 160))}</div>
`).join('')}
</div>`).join('')
}
function renderInvoices(data) {
if (!data.linked) return '<p class="muted">Kunde ist nicht mit InvoiceNinja verknüpft.</p>'
if (!data.invoices.length) return '<p class="muted">Keine Rechnungen vorhanden.</p>'
return `<table class="admin-table"><thead><tr><th>Nr.</th><th>Datum</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th></tr></thead><tbody>
${data.invoices.map((i) => `
<tr>
<td>${escapeHtml(i.number)}</td>
<td>${fmtDate(i.date)}</td>
<td>${fmtEuro(i.amount)}</td>
<td>${fmtEuro(i.balance)}</td>
<td><span class="pill ${i.overdue ? 'pill-red' : i.statusId === 4 ? 'pill-ok' : 'pill-warn'}">${escapeHtml(i.status)}</span></td>
<td>${i.portalUrl ? `<a class="plain" href="${escapeHtml(i.portalUrl)}" target="_blank" rel="noreferrer">Ansehen</a>` : ''}</td>
</tr>`).join('')}
</tbody></table>`
}
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 = `<td colspan="7"><div class="detail-box muted">Lade…</div></td>`
row.after(tr)
try {
if (kind === 'activity') {
const { tickets } = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}/activity`)
tr.innerHTML = `<td colspan="7"><h4>Tickets &amp; Arbeiten</h4>${renderActivity(tickets)}</td>`
} else {
const data = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}/invoices`)
tr.innerHTML = `<td colspan="7"><h4>Rechnungen</h4>${renderInvoices(data)}</td>`
}
} catch (err) {
tr.innerHTML = `<td colspan="7"><span class="error">${escapeHtml(err.message)}</span></td>`
}
}
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 = '<tr><td colspan="7" class="muted">Keine Kunden gefunden.</td></tr>'
return
}
tbody.innerHTML = customers.map((c) => `
<tr data-customer-row="${escapeHtml(c.id)}">
<td>
<div class="cust-actions">
<input class="code-input" data-code-for="${escapeHtml(c.id)}" value="${escapeHtml(c.code)}" placeholder="–">
<button class="mini-btn" data-save-code="${escapeHtml(c.id)}" title="Kundennummer speichern">✓</button>
</div>
</td>
<td>
<strong>${escapeHtml(c.name)}</strong><br>
<span class="muted">${escapeHtml(c.email || '–')}</span>
</td>
<td>${stagePill(c.customerStatus)}</td>
<td>${c.portalAccessEnabled ? '<span class="pill pill-ok">aktiv</span>' : '<span class="pill pill-muted">kein Zugang</span>'}</td>
<td>${fmtDateTime(c.lastLoginAt)}</td>
<td>${c.projectCount}</td>
<td>
<div class="cust-actions">
${isLead(c) ? `<a class="mini-btn primary" href="/kalkulation.html?customer=${encodeURIComponent(c.id)}"
target="_blank" rel="noreferrer"
title="Bedarf im Gespräch aufnehmen und Preis kalkulieren">Rechnungsanalyse</a>` : ''}
<button class="mini-btn${isLead(c) ? '' : ' primary'}" data-view-as="${escapeHtml(c.id)}">Als Kunde ansehen</button>
<button class="mini-btn" data-activity="${escapeHtml(c.id)}">Arbeiten</button>
<button class="mini-btn" data-invoices="${escapeHtml(c.id)}"${c.invoiceNinjaLinked ? '' : ' title="Nicht mit InvoiceNinja verknüpft"'}>Rechnungen</button>
<button class="mini-btn" data-offer-for="${escapeHtml(c.id)}" title="Individuelles Abo-Angebot anlegen">Angebot</button>
</div>
</td>
</tr>`).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 = `<tr><td colspan="7" class="error">${escapeHtml(err.message)}</td></tr>`
}
}
/* ----------------------------------------------------------------- Chat --- */
const adminChat = {
activeCustomerId: '',
activeCustomerName: '',
lastCreatedAt: '',
threadTimer: null,
}
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 = '<div class="chat-list-item"><span class="muted">Noch keine Unterhaltungen.</span></div>'
return
}
list.innerHTML = chats.map((c) => `
<div class="chat-list-item${c.customerId === adminChat.activeCustomerId ? ' active' : ''}" data-chat-customer="${escapeHtml(c.customerId)}" data-chat-name="${escapeHtml(c.customerName)}">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
<strong>${escapeHtml(c.customerName)}</strong>
${c.unreadFromCustomer ? `<span class="badge-pill">${c.unreadFromCustomer}</span>` : ''}
</div>
<div class="last">${c.lastSender === 'admin' ? 'Du: ' : ''}${escapeHtml(c.lastText)}</div>
<div class="last">${fmtDateTime(c.lastAt)}</div>
</div>`).join('')
list.querySelectorAll('[data-chat-customer]').forEach((el) =>
el.addEventListener('click', () => openChatThread(el.dataset.chatCustomer, el.dataset.chatName)))
} catch (err) {
list.innerHTML = `<div class="chat-list-item"><span class="error">${escapeHtml(err.message)}</span></div>`
}
}
function appendAdminChatMessages(messages) {
const box = document.getElementById('admin-chat-messages')
if (!box || !messages.length) return
for (const msg of messages) {
const div = document.createElement('div')
div.className = `chat-bubble ${msg.sender === 'admin' ? 'mine' : 'theirs'}`
div.innerHTML = `${escapeHtml(msg.text)}<span class="chat-meta">${msg.sender === 'admin' ? escapeHtml(msg.senderName || 'Admin') : escapeHtml(adminChat.activeCustomerName)} · ${fmtDateTime(msg.createdAt)}</span>`
box.appendChild(div)
if (msg.createdAt > adminChat.lastCreatedAt) adminChat.lastCreatedAt = msg.createdAt
}
box.scrollTop = box.scrollHeight
}
async function pollAdminThread() {
if (!adminChat.activeCustomerId || document.hidden) return
try {
const q = adminChat.lastCreatedAt ? `?since=${encodeURIComponent(adminChat.lastCreatedAt)}` : ''
const { messages } = await api(`/api/portal-admin/chats/${encodeURIComponent(adminChat.activeCustomerId)}/messages${q}`)
if (messages.length) {
appendAdminChatMessages(messages)
await api(`/api/portal-admin/chats/${encodeURIComponent(adminChat.activeCustomerId)}/read`, { method: 'POST' }).catch(() => {})
}
} catch {
/* naechster Poll */
}
}
async function sendAdminChatMessage() {
const input = document.getElementById('admin-chat-input')
const btn = document.getElementById('admin-chat-send')
const text = input.value.trim()
if (!text || !adminChat.activeCustomerId) return
btn.disabled = true
try {
const { message } = await api(`/api/portal-admin/chats/${encodeURIComponent(adminChat.activeCustomerId)}/messages`, {
method: 'POST',
body: JSON.stringify({ text }),
})
input.value = ''
appendAdminChatMessages([message])
loadChatList()
} catch (err) {
alert('Nachricht konnte nicht gesendet werden: ' + err.message)
} finally {
btn.disabled = false
input.focus()
}
}
async function openChatThread(customerId, customerName) {
adminChat.activeCustomerId = customerId
adminChat.activeCustomerName = customerName || 'Kunde'
adminChat.lastCreatedAt = ''
const thread = document.getElementById('admin-chat-thread')
thread.innerHTML = `
<div style="margin-bottom:8px"><strong>${escapeHtml(adminChat.activeCustomerName)}</strong></div>
<div class="chat-messages" id="admin-chat-messages"></div>
<div class="chat-input-row">
<textarea id="admin-chat-input" maxlength="2000" placeholder="Antwort an ${escapeHtml(adminChat.activeCustomerName)}…"></textarea>
<button type="button" class="mini-btn primary" id="admin-chat-send" style="padding:12px 18px">Senden</button>
</div>`
document.getElementById('admin-chat-send').addEventListener('click', sendAdminChatMessage)
document.getElementById('admin-chat-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendAdminChatMessage()
}
})
try {
const { messages } = await api(`/api/portal-admin/chats/${encodeURIComponent(customerId)}/messages`)
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 = `<span class="error">${escapeHtml(err.message)}</span>`
}
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 `<span class="pill ${cls}">${label}</span>`
}
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 = `<tr><td colspan="7" class="muted">${creditState.filter === 'open' ? 'Keine offenen Anträge.' : 'Noch keine Anträge.'}</td></tr>`
return
}
tbody.innerHTML = requests.map((r) => `
<tr data-credit-row="${escapeHtml(r.id)}"${r.id === creditState.activeId ? ' class="credit-active"' : ''}>
<td>${fmtDateTime(r.createdAt)}<br><span class="muted">${escapeHtml(r.ref)}</span></td>
<td><strong>${escapeHtml(r.customerName)}</strong>${r.customerCode ? `<br><span class="muted">Kd.-Nr. ${escapeHtml(r.customerCode)}</span>` : ''}</td>
<td>${escapeHtml(r.services)}<br><span class="muted">${escapeHtml(r.provider)}${r.domain ? ` · ${escapeHtml(r.domain)}` : ''}</span></td>
<td>${euroFromCents(r.invoiceAmountCents)}<br><span class="muted">für ${r.billingMonths} Mon.</span></td>
<td>${fmtDate(r.paidUntil)}</td>
<td>${creditStatusPill(r.status)}${r.status === 'genehmigt' ? `<br><span class="muted">${euroFromCents(r.amountCents)}${r.appliedAt ? '' : ' · nicht gebucht'}</span>` : ''}</td>
<td><button class="mini-btn primary" data-open-credit="${escapeHtml(r.id)}">${['offen', 'rueckfrage'].includes(r.status) ? 'Prüfen' : 'Ansehen'}</button></td>
</tr>`).join('')
tbody.querySelectorAll('[data-open-credit]').forEach((btn) =>
btn.addEventListener('click', () => openCreditDetail(btn.dataset.openCredit)))
} catch (err) {
tbody.innerHTML = `<tr><td colspan="7" class="error">${escapeHtml(err.message)}</td></tr>`
}
}
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 = '<p class="muted">Lade Antrag…</p>'
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 = `<p class="error">${escapeHtml(err.message)}</p>`
}
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 `
<h4>Prüfung durch dich</h4>
<div class="manual-checks">
${d.requiredChecks.map((k) => `<label><input type="checkbox" data-check="${escapeHtml(k.key)}"> ${escapeHtml(k.label)}</label>`).join('')}
</div>
<h4>Berechnung</h4>
<div class="calc-grid">
<div><label for="calc-amount">Rechnungsbetrag (€)</label>
<input class="credit-input" id="calc-amount" type="number" step="0.01" min="0.01" value="${(r.invoiceAmountCents / 100).toFixed(2)}"></div>
<div><label for="calc-months">Rechnung gilt für</label>
<select class="credit-input" id="calc-months">${CREDIT_MONTHS.map((m) => `<option value="${m}"${m === r.billingMonths ? ' selected' : ''}>${m} Monat${m === 1 ? '' : 'e'}</option>`).join('')}</select></div>
<div><label for="calc-paid-until">Bezahlt bis</label>
<input class="credit-input" id="calc-paid-until" type="date" value="${escapeHtml(r.paidUntilDate)}"></div>
<div><label for="calc-from">Übernahme ab</label>
<input class="credit-input" id="calc-from" type="date" value="${escapeHtml(r.takeoverFromDate)}"></div>
</div>
<div class="calc-result" id="calc-result">${d.calc ? escapeHtml(calcSummary(d.calc)) : 'Angaben unvollständig'}</div>
<p class="admin-note" style="margin-top:0">Werte weichen von der Rechnung ab? Hier korrigieren – die Korrektur wird beim Genehmigen im Verlauf vermerkt. „Übernahme ab“ ist der Leistungsbeginn des Abos.</p>
<div class="credit-field"><label for="credit-amount">Gutschrift (€)</label>
<input class="credit-input" id="credit-amount" type="number" step="0.01" min="0.01" value="${(suggested / 100).toFixed(2)}" style="max-width:180px"></div>
<div class="credit-field"><label for="credit-note">Nachricht an den Kunden (bei Rückfrage und Ablehnung Pflicht, beim Genehmigen optional)</label>
<textarea class="credit-input" id="credit-note" maxlength="4000"></textarea></div>
<div class="credit-actions">
<button class="mini-btn primary" id="credit-approve" disabled>Genehmigen &amp; gutschreiben</button>
<button class="mini-btn" id="credit-question">Rückfrage senden</button>
<button class="mini-btn danger" id="credit-reject">Ablehnen</button>
</div>
<p class="admin-note">Genehmigen geht, sobald alle Punkte abgehakt sind. Der Kunde bekommt jede Entscheidung per E-Mail.${d.subscription?.active ? '' : ' <strong>Achtung: Der Kunde hat kein laufendes Abo.</strong>'}</p>
<p class="error hidden" id="credit-error"></p>`
}
function creditDecisionSummaryHtml(d) {
const r = d.request
if (r.status === 'genehmigt') {
return `
<h4>Entscheidung</h4>
<dl class="kv">
<dt>Genehmigt</dt><dd>${fmtDateTime(r.decidedAt)} · ${escapeHtml(r.decidedBy)}</dd>
<dt>Gutschrift</dt><dd><strong>${euroFromCents(r.amountCents)}</strong></dd>
<dt>Berechnung</dt><dd>${escapeHtml(r.calculation)}</dd>
<dt>Geprüft</dt><dd>${r.checks ? `alle Punkte abgehakt (${escapeHtml(r.checks.by || '')})` : '–'}</dd>
${r.decisionNote ? `<dt>Nachricht</dt><dd class="pre">${escapeHtml(r.decisionNote)}</dd>` : ''}
<dt>Verrechnung</dt><dd>${r.appliedAt
? `Stripe-Guthaben gebucht am ${fmtDateTime(r.appliedAt)} <span class="muted">(${escapeHtml(r.stripeBalanceTransactionId)})</span>`
: '<span class="pill pill-warn">noch nicht in Stripe gebucht</span>'}</dd>
</dl>
${r.appliedAt ? '' : `
<div class="credit-actions"><button class="mini-btn primary" id="credit-apply">Jetzt in Stripe gutschreiben</button></div>
<p class="error hidden" id="credit-error"></p>`}`
}
return `
<h4>Entscheidung</h4>
<dl class="kv">
<dt>Abgelehnt</dt><dd>${fmtDateTime(r.decidedAt)} · ${escapeHtml(r.decidedBy)}</dd>
<dt>Begründung</dt><dd class="pre">${escapeHtml(r.decisionNote)}</dd>
</dl>`
}
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
? '<span class="pill pill-red">kein Abo</span>'
: `<span class="pill ${s.active ? 'pill-ok' : 'pill-red'}">${escapeHtml(s.status)}</span>`
const staff = (by) => (by && by !== 'customer' && by !== 'system' ? ` · ${escapeHtml(by)}` : '')
panel.innerHTML = `
<div class="credit-head">
<div>
<h3>Antrag ${escapeHtml(r.ref)} · ${escapeHtml(c.name || c.companyName)}</h3>
<span class="muted">eingegangen ${fmtDateTime(r.createdAt)}</span> ${creditStatusPill(r.status)}
</div>
<button class="mini-btn" id="credit-close">Schließen</button>
</div>
${message ? `<div class="calc-result">${escapeHtml(message)}</div>` : ''}
<div class="credit-cols">
<div>
<h4>Antrag</h4>
<dl class="kv">
<dt>Leistung</dt><dd>${escapeHtml(r.services)}</dd>
<dt>Bisher bei</dt><dd>${escapeHtml(r.provider)}</dd>
<dt>Domain</dt><dd>${r.domain ? `<a class="plain" href="https://${escapeHtml(r.domain)}" target="_blank" rel="noreferrer">${escapeHtml(r.domain)}</a>` : '–'}</dd>
<dt>Rechnung</dt><dd>${euroFromCents(r.invoiceAmountCents)} für ${r.billingMonths} Monat${r.billingMonths === 1 ? '' : 'e'}</dd>
<dt>Bezahlt bis</dt><dd>${fmtDate(r.paidUntil)}</dd>
<dt>Antwort an</dt><dd>${escapeHtml(r.email)}</dd>
<dt>Bestätigt</dt><dd>Angaben und Bedingungen (Stand ${escapeHtml(r.conditionsVersion || '–')}) am ${fmtDateTime(r.consentAt)}</dd>
</dl>
<h4>Grund / Nachricht</h4>
<p class="pre">${escapeHtml(r.message)}</p>
<h4>Nachweise</h4>
${r.files.length
? `<ul class="recent-list">${r.files.map((f) => `<li><a class="plain" href="${fileHref(f)}" target="_blank" rel="noreferrer">${escapeHtml(f.name)}</a> <span class="muted">(${fmtBytes(f.size)}, ${fmtDate(f.uploadedAt)})</span></li>`).join('')}</ul>`
: '<p class="muted">Keine Nachweise (mehr) vorhanden.</p>'}
<h4>Verlauf</h4>
${r.history.map((h) => `
<div class="history-item">
<strong>${escapeHtml(CREDIT_HISTORY_LABELS[h.type] || h.type)}</strong>
<span class="muted">${fmtDateTime(h.at)}${staff(h.by)}</span>
${h.text ? `<div class="pre">${escapeHtml(h.text)}</div>` : ''}
${(h.files || []).length ? `<div class="muted">Dateien: ${h.files.map((id) => escapeHtml(fileNames.get(id) || 'gelöscht')).join(', ')}</div>` : ''}
</div>`).join('')}
</div>
<div>
<h4>Kunde &amp; Vertrag</h4>
<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>Wartungs-Abo</dt><dd>${aboPill}</dd>
${s ? `
<dt>Leistungsbeginn</dt><dd>${fmtDate(s.serviceStart)}</dd>
<dt>Nächste Abbuchung</dt><dd>${s.active ? fmtDate(s.currentPeriodEnd) : '–'}</dd>
${s.cancelAt ? `<dt>Gekündigt zum</dt><dd>${fmtDate(s.cancelAt)}</dd>` : ''}
<dt>Stripe-Guthaben</dt><dd>${s.creditBalanceCents == null ? '–' : euroFromCents(s.creditBalanceCents)}</dd>` : ''}
</dl>
<div class="credit-actions">
<button class="mini-btn" id="credit-view-as">Als Kunde ansehen</button>
${s?.stripeUrl ? `<a class="mini-btn" href="${escapeHtml(s.stripeUrl)}" target="_blank" rel="noreferrer">In Stripe öffnen</a>` : ''}
</div>
${d.otherRequests.length ? `
<h4>Weitere Anträge des Kunden</h4>
<ul class="recent-list">${d.otherRequests.map((o) => `
<li><a class="plain" href="#" data-open-credit="${escapeHtml(o.id)}">${escapeHtml(o.ref)}</a> · ${escapeHtml(o.services)} · ${fmtDate(o.createdAt)}
${creditStatusPill(o.status)}${o.amountCents ? ` ${euroFromCents(o.amountCents)}` : ''}</li>`).join('')}</ul>` : ''}
<h4>Automatische Prüfung</h4>
<ul class="check-list">${d.autoChecks.map((k) => `
<li class="${escapeHtml(k.status)}">${escapeHtml(k.label)}${k.detail ? `<small>${escapeHtml(k.detail)}</small>` : ''}</li>`).join('')}</ul>
${pending ? creditDecisionFormHtml(d) : creditDecisionSummaryHtml(d)}
</div>
</div>`
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
}
}
/* ------------------------------------------------------- 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 }
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 `<span class="pill ${cls}">${label}</span>${sub ? `<br><span class="muted">${sub}</span>` : ''}`
}
// 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`
}
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')
const shown = offerState.filter === 'open' ? offers.filter((o) => ['offen', 'aktiv'].includes(o.status)) : offers
if (!shown.length) {
tbody.innerHTML = `<tr><td colspan="5" class="muted">${offerState.filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'}</td></tr>`
return
}
tbody.innerHTML = shown.map((o) => `
<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.customerCode ? `<br><span class="muted">Kd.-Nr. ${escapeHtml(o.customerCode)}</span>` : ''}</td>
<td>${escapeHtml(o.text)}<br><span class="muted">Ersparnis ${euroFromCents(o.savingsCents)}${o.note ? ` · „${escapeHtml(o.note)}“` : ''}</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>
<td>${offerStatusPill(o)}
<br><span class="muted">Stripe:</span> ${o.stripeCouponUrl ? `<a class="plain" href="${escapeHtml(o.stripeCouponUrl)}" target="_blank" rel="noreferrer">Coupon</a>` : '–'}${o.stripeSubscriptionUrl ? ` · <a class="plain" href="${escapeHtml(o.stripeSubscriptionUrl)}" target="_blank" rel="noreferrer">Abo</a>` : ''}
${['offen', 'abgelaufen'].includes(o.status) ? `<div style="margin-top:6px"><button class="mini-btn danger" data-withdraw-offer="${escapeHtml(o.id)}">Zurückziehen</button></div>` : ''}</td>
</tr>`).join('')
tbody.querySelectorAll('[data-withdraw-offer]').forEach((btn) =>
btn.addEventListener('click', () => withdrawOfferAction(btn.dataset.withdrawOffer, btn)))
} catch (err) {
tbody.innerHTML = `<tr><td colspan="5" class="error">${escapeHtml(err.message)}</td></tr>`
}
}
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)
}
}
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()) || '',
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.regularPriceCents
const running = Boolean(ctx.subscription?.active)
const lines = []
let ok = true
if (ctx.openOffer) {
lines.push(`Es gibt schon ein offenes Angebot (${ctx.openOffer.ref}: ${ctx.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)}`)
}
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.')
box.innerHTML = lines.map((l) => `<div>${escapeHtml(l)}</div>`).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 text = offerPreviewText(v.monthlyPriceCents, v.months, ctx.regularPriceCents, Boolean(ctx.subscription?.active))
if (!window.confirm(`Angebot für ${ctx.customer.name || 'den Kunden'} anlegen?\n\n${text}\n\nDer Coupon wird sofort in Stripe angelegt${ctx.subscription?.active ? ' 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')
showOfferMessage([
`Angebot ${o.ref} für ${o.customerName} angelegt: ${o.text}.`,
`Stripe-Coupon ${o.stripeCouponId} ist eingerichtet${o.status === 'aktiv' ? ' und am laufenden Abo aktiv' : ''}.`,
v.notifyCustomer ? (result.mailSent ? 'Die E-Mail an den Kunden ist raus.' : `E-Mail an den Kunden fehlgeschlagen (${result.mailError}).`) : '',
].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 = '<p class="muted">Lade Kunden…</p>'
let customers
try {
customers = await ensureOfferCustomers()
} catch (err) {
form.innerHTML = `<p class="error">${escapeHtml(err.message)}</p>`
return
}
const preselected = customers.find((c) => c.id === customerId)
form.innerHTML = `
<h3 style="margin-top:0">Neues Angebot</h3>
<div class="calc-grid">
<div style="grid-column:1/-1"><label for="offer-customer">Kunde</label>
<input class="credit-input" id="offer-customer" list="offer-customer-list" autocomplete="off"
placeholder="Name, E-Mail oder Kundennummer tippen …" value="${preselected ? escapeHtml(offerCustomerLabel(preselected)) : ''}">
<datalist id="offer-customer-list">${customers.map((c) => `<option value="${escapeHtml(offerCustomerLabel(c))}"></option>`).join('')}</datalist></div>
<div><label for="offer-price">Preis pro Monat im Angebot (€)</label>
<input class="credit-input" id="offer-price" type="number" min="0" step="0.01" value="25.00"></div>
<div><label for="offer-months">für</label>
<select class="credit-input" id="offer-months">${Array.from({ length: 12 }, (_, i) => i + 1)
.map((m) => `<option value="${m}"${m === 2 ? ' selected' : ''}>${m} Monat${m === 1 ? '' : 'e'}</option>`).join('')}</select></div>
<div><label for="offer-valid">Angebot gültig bis</label>
<input class="credit-input" id="offer-valid" type="date"></div>
</div>
<div class="credit-field"><label for="offer-note">Hinweis für den Kunden (optional, z. B. „Übernahme Ihres IONOS-Hostings“)</label>
<input class="credit-input" id="offer-note" maxlength="500"></div>
<div class="credit-field"><label for="offer-internal">Interne Notiz (optional, sieht nur das Team)</label>
<input class="credit-input" id="offer-internal" maxlength="2000"></div>
<div class="manual-checks" style="margin-top:8px"><label><input type="checkbox" id="offer-notify" checked> Kunden per E-Mail über das Angebot informieren</label></div>
<div class="calc-result" id="offer-preview">Bitte einen Kunden aus der Liste wählen.</div>
<div class="credit-actions">
<button class="mini-btn primary" id="offer-save" disabled>Angebot anlegen</button>
<button class="mini-btn" id="offer-cancel">Abbrechen</button>
</div>
<p class="error hidden" id="offer-error"></p>`
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-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()
}
})
})
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=<id>
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