Files
Webklar-Kundenbereich/public/admin.js
T
ClaudeandClaude Opus 5.5 37489aa866 Admin-Chat: neue Unterhaltung mit jedem Kunden starten
Ueber der Chat-Liste sucht ein Feld alle Kunden (Name, E-Mail,
Kd.-Nr.) und oeffnet einen leeren Chat, auch wenn der Kunde noch
nie geschrieben hat. Der Server konnte das schon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:51:57 +00:00

1405 lines
68 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' })
}
// 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 '<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)}` : ''}${w.supportMinutes ? `, +${escapeHtml(w.supportMinutes)} min Unterstützung` : ''}): ${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>`
}
const FEATURE_STATES = {
off: ['Aus', 'pill-muted'],
ordered: ['Bestellt', 'pill-amber'],
setup: ['Einrichtung', 'pill-warn'],
active: ['Aktiv', 'pill-ok'],
disabled: ['Abgeschaltet', 'pill-red'],
}
function featureRowHtml(p) {
const n = p.features.news
const [label, cls] = FEATURE_STATES[n.state] || [n.state, 'pill-muted']
const ticket = n.ticketMissing
? '<span class="pill pill-red">Ticket fehlt</span>'
: n.ticketWoid ? `#${escapeHtml(n.ticketWoid)}` : '<span class="muted">–</span>'
return `
<tr data-feature-project="${escapeHtml(p.projectId)}">
<td>${escapeHtml(p.projectName || '–')}</td>
<td><span class="pill ${cls}">${escapeHtml(label)}</span>${n.adminNote ? `<div class="muted" title="${escapeHtml(n.adminNote)}">${escapeHtml(n.adminNote.slice(0, 60))}${n.adminNote.length > 60 ? '…' : ''}</div>` : ''}</td>
<td>${ticket}</td>
<td class="feature-actions">
${n.state === 'active' ? '' : '<button type="button" class="mini-btn primary" data-feature-ask="activate">Aktivieren (ohne Zahlung)</button>'}
${n.state === 'off' ? '' : n.state === 'disabled' ? '' : '<button type="button" class="mini-btn" data-feature-ask="disable">Abschalten</button>'}
</td>
</tr>`
}
function renderFeatures(projects) {
if (!projects.length) return '<p class="muted">Keine Projekte für diesen Kunden.</p>'
return `<table class="admin-table"><thead><tr><th>Projekt</th><th>News</th><th>Ticket</th><th>Aktionen</th></tr></thead><tbody>
${projects.map(featureRowHtml).join('')}
</tbody></table><p class="feature-message muted" role="status"></p>`
}
function bindFeatureRows(box, customerId) {
const message = box.querySelector('.feature-message')
box.querySelectorAll('[data-feature-project]').forEach((row) => {
const cell = row.querySelector('.feature-actions')
const original = cell.innerHTML
const bindAsk = () => cell.querySelectorAll('[data-feature-ask]').forEach((btn) =>
btn.addEventListener('click', () => askFeature(btn.dataset.featureAsk)))
const askFeature = (action) => {
const text = action === 'activate'
? 'News ohne Zahlung aktivieren?'
: 'News abschalten? Die Website zeigt dann keine Beiträge mehr.'
cell.innerHTML = `<span>${text}</span>
<input class="code-input" data-feature-note placeholder="Notiz (optional)" maxlength="120">
<button type="button" class="mini-btn primary" data-feature-yes>Ja</button>
<button type="button" class="mini-btn" data-feature-no>Abbrechen</button>`
cell.querySelector('[data-feature-no]').addEventListener('click', () => {
cell.innerHTML = original
bindAsk()
})
cell.querySelector('[data-feature-yes]').addEventListener('click', async () => {
const note = cell.querySelector('[data-feature-note]').value.trim()
cell.querySelectorAll('button').forEach((b) => { b.disabled = true })
try {
const { projects } = await api(`/api/portal-admin/funktionen/${encodeURIComponent(customerId)}/news`, {
method: 'POST',
body: JSON.stringify({ projectId: row.dataset.featureProject, action, note }),
})
row.outerHTML = featureRowHtml(projects[0])
bindFeatureRows(box, customerId)
box.querySelector('.feature-message').textContent = 'Gespeichert.'
} catch (err) {
cell.innerHTML = original
bindAsk()
message.textContent = err.message
message.className = 'feature-message error'
}
})
}
bindAsk()
})
}
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 if (kind === 'features') {
const { projects } = await api(`/api/portal-admin/funktionen/${encodeURIComponent(customerId)}`)
tr.innerHTML = `<td colspan="7"><h4>Funktionen</h4>${renderFeatures(projects)}</td>`
bindFeatureRows(tr, customerId)
} 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-features="${escapeHtml(c.id)}">Funktionen</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-features]').forEach((btn) =>
btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.features, 'features')))
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,
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 */
}
}
// Neue Unterhaltung: jeden Kunden anschreiben, auch wenn er noch nie geschrieben hat
const chatCustomerLabel = (c) =>
[c.name || '(ohne Name)', c.email, c.code ? `Kd.-Nr. ${c.code}` : ''].filter(Boolean).join(' · ')
async function setupNewChatPicker() {
const input = document.getElementById('admin-chat-new')
if (!input) return
let byLabel = new Map()
try {
const { customers } = await api('/api/portal-admin/customers')
byLabel = new Map(customers.map((c) => [chatCustomerLabel(c), c]))
document.getElementById('admin-chat-new-list').innerHTML = customers
.map((c) => `<option value="${escapeHtml(chatCustomerLabel(c))}"></option>`).join('')
} catch (err) {
input.placeholder = `Kunden konnten nicht geladen werden: ${err.message}`
return
}
const pick = () => {
const customer = byLabel.get(input.value.trim())
if (!customer) return
input.value = ''
openChatThread(customer.id, customer.name || customer.email || 'Kunde')
}
input.addEventListener('change', pick)
input.addEventListener('input', pick)
}
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
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 = `
<div style="margin-bottom:8px"><strong>${escapeHtml(adminChat.activeCustomerName)}</strong></div>
<div class="chat-messages" id="admin-chat-messages"></div>
<div id="admin-chat-composer"></div>`
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 = `<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>Abo</dt><dd>${s ? `${escapeHtml(productLabelOf(s.productKey))} ${aboPill}<br><span class="muted">Zusatzspeicher: ${storageLabel(s.storageUnits)}</span>` : aboPill}</dd>
${s ? `
<dt>Leistungsbeginn</dt><dd>${fmtDate(s.serviceStart)}</dd>
<dt>Nächste Abbuchung</dt><dd>${s.active ? fmtDate(s.currentPeriodEnd) : '–'}</dd>
${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
}
}
/* ----------------------------------------------------- E-Mail-Speicher --- */
const GB_INCLUDED = 5
const GB_PER_UNIT = 5
const gbOfUnits = (units) => GB_INCLUDED + GB_PER_UNIT * (Number(units) || 0)
// "2 Pakete (15 GB)" / "kein Paket (5 GB)"
function storageLabel(units) {
const n = Number(units) || 0
const packages = n === 0 ? 'kein Paket' : n === 1 ? '1 Paket' : `${n} Pakete`
return `${packages} (${gbOfUnits(n)} GB)`
}
function storageDueHtml(t) {
const overdue = t.dueAt && new Date(t.dueAt).getTime() <= Date.now()
if (t.kind === 'einrichten') {
return `<span class="pill pill-warn">jetzt einrichten</span><br><span class="muted">bestellt ${fmtDateTime(t.createdAt)}</span>`
}
return `<span class="pill ${overdue ? 'pill-warn' : 'pill-muted'}">zum Periodenende</span><br>${fmtDate(t.dueAt)}`
}
function storageTaskHtml(t) {
const action = t.kind === 'einrichten'
? `Postfachgrenze in konsoleH <strong>jetzt</strong> auf <strong>${escapeHtml(t.targetGb)} GB</strong> setzen`
: `Postfachgrenze in konsoleH <strong>am ${fmtDate(t.dueAt)}</strong> auf <strong>${escapeHtml(t.targetGb)} GB</strong> senken`
const partial = t.partial
? `<br><span class="storage-partial">Teilweise: Stripe steht noch auf der bezahlten Menge, nicht auf ${escapeHtml(t.toUnits)} Paket(en) – Stückzahl in Stripe von Hand nachziehen.${t.error ? ` Fehler: ${escapeHtml(t.error)}` : ''}</span>`
: ''
const scope = `<br><span class="muted">Gesamtspeicher des Kunden: <strong>${escapeHtml(t.targetGb)} GB</strong>, auf seine Postfächer verteilt nach seiner Vorgabe – § 3 Abs. 4${t.contactDomain ? ` · Kontakt: ${escapeHtml(t.contactDomain)}` : ''}</span>`
return `<div class="storage-task">${action}${scope}<br><span class="muted">${escapeHtml(storageLabel(t.fromUnits))} → ${escapeHtml(storageLabel(t.toUnits))}${t.viaCancellation ? ' · über die Kündigungsseite' : ''}</span>${partial}</div>`
}
async function loadStorageTasks() {
const summary = document.getElementById('storage-summary')
const tbody = document.getElementById('storage-rows')
const failedBox = document.getElementById('storage-failed')
const failedList = document.getElementById('storage-failed-list')
try {
const d = await api('/api/portal-admin/storage-tasks')
const warn = Number(d.allocatedGb) >= Number(d.warnGb)
summary.classList.toggle('warn', warn)
summary.innerHTML = `<strong>${escapeHtml(d.allocatedGb)} von ${escapeHtml(d.packageGb)} GB vergeben</strong> (Paket ${escapeHtml(d.packageName)})${
warn ? ` – <strong>Warnschwelle ${escapeHtml(d.warnGb)} GB erreicht: größeres Hetzner-Paket buchen.</strong>` : ` · Warnung ab ${escapeHtml(d.warnGb)} GB`
}`
tbody.innerHTML = d.tasks.length
? d.tasks.map((t) => `
<tr data-storage-row="${escapeHtml(t.id)}">
<td>${storageDueHtml(t)}</td>
<td><strong>${escapeHtml(t.customerName)}</strong>${t.customerCode ? `<br><span class="muted">Kd.-Nr. ${escapeHtml(t.customerCode)}</span>` : ''}${t.email ? `<br><span class="muted">${escapeHtml(t.email).replace('@', '@<wbr>')}</span>` : ''}<br><span class="muted">${escapeHtml(t.productLabel)}</span></td>
<td>${storageTaskHtml(t)}</td>
<td>${fmtDateTime(t.createdAt)}<br><span class="muted">${escapeHtml(t.toUnits > t.fromUnits ? 'Bestellung (Plus)' : 'Verringerung (Minus)')}</span></td>
<td><button class="mini-btn primary" data-storage-done="${escapeHtml(t.id)}"${t.partial ? ' data-partial="1"' : ''}>Erledigt</button></td>
</tr>`).join('')
: '<tr><td colspan="5" class="muted">Keine offenen Speicher-Aufgaben.</td></tr>'
failedBox.classList.toggle('hidden', !d.failed.length)
failedList.innerHTML = d.failed.map((f) => `
<li>${fmtDateTime(f.createdAt)} · <strong>${escapeHtml(f.customerName)}</strong> · ${escapeHtml(storageLabel(f.fromUnits))} → ${escapeHtml(storageLabel(f.toUnits))}
<span class="muted">– Stripe hat die Änderung abgelehnt, nichts berechnet, keine Aufgabe.${f.error ? ` Fehler: ${escapeHtml(f.error)}` : ''}</span></li>`).join('')
tbody.querySelectorAll('[data-storage-done]').forEach((btn) => btn.addEventListener('click', () => completeStorageTask(btn.dataset.storageDone, btn)))
} catch (err) {
summary.classList.remove('warn')
summary.textContent = ''
tbody.innerHTML = `<tr><td colspan="5" class="error">${escapeHtml(err.message)}</td></tr>`
}
}
async function completeStorageTask(id, btn) {
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 `<span class="pill ${cls}">${label}</span>${sub ? `<br><span class="muted">${sub}</span>` : ''}`
}
// Versandstand der Angebots-Mail; "nicht verschickt" nur, solange sie noch sinnvoll ist (mailSendable)
function offerMailStatus(o) {
if (o.notifiedAt) return `<br><span class="muted">E-Mail gesendet ${fmtDateTime(o.notifiedAt)}</span>`
if (o.mailSendable) return '<br><span style="color:#fbbf24">E-Mail nicht verschickt</span>'
if (o.status === 'offen' && !o.customerEmail) return '<br><span style="color:#fbbf24">Keine E-Mail-Adresse hinterlegt</span>'
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 = `<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) => {
const discount = offerDiscountLine(o)
return `
<tr>
<td>${fmtDateTime(o.createdAt)}<br><span class="muted">${escapeHtml(o.ref)}${o.createdBy ? ` · ${escapeHtml(o.createdBy.replace(/\s*<.*>$/, ''))}` : ''}</span></td>
<td><strong>${escapeHtml(o.customerName)}</strong>${o.customerEmail ? `<br><span class="muted">${escapeHtml(o.customerEmail).replace('@', '@<wbr>')}</span>` : ''}${o.customerCode ? `<br><span class="muted">Kd.-Nr. ${escapeHtml(o.customerCode)}</span>` : ''}</td>
<td><span class="muted">${escapeHtml(productLabelOf(o.productKey))}</span><br>${escapeHtml(o.text)}<br><span class="muted">Ersparnis ${euroFromCents(o.savingsCents)}${o.note ? ` · „${escapeHtml(o.note)}“` : ''}</span>
${discount ? `<br><span class="muted">${escapeHtml(discount)}</span>` : ''}
${o.internalNote ? `<br><span class="muted"><em>intern: ${escapeHtml(o.internalNote)}</em></span>` : ''}</td>
<td style="white-space:nowrap;overflow-wrap:normal">${o.scope === 'laufend' ? '<span class="muted">laufendes Abo</span>' : fmtDate(o.validUntil)}</td>
<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>` : ''}
${offerMailStatus(o)}
${['offen', 'abgelaufen'].includes(o.status) || o.mailSendable ? `<div class="cust-actions" style="margin-top:6px">
${o.mailSendable ? `<button class="mini-btn" data-send-offer-mail="${escapeHtml(o.id)}" title="Angebots-E-Mail an ${escapeHtml(o.customerEmail)} senden">${o.notifiedAt ? 'Erneut senden' : 'E-Mail senden'}</button>` : ''}
${['offen', 'abgelaufen'].includes(o.status) ? `<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)))
tbody.querySelectorAll('[data-send-offer-mail]').forEach((btn) =>
btn.addEventListener('click', () => sendOfferMailAction(btn.dataset.sendOfferMail, 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)
}
}
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) => `<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 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 = '<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-product">Produkt</label>
<select class="credit-input" id="offer-product">
<option value="wartung" selected>Wartungs-Abo</option>
<option value="hosting">Hosting-Abo</option>
</select></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-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()
setupNewChatPicker()
}
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=<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