Files
Webklar-Kundenbereich/public/admin.js
T
ClaudeandClaude Opus 5.5 48b13f4488 Abo: Uebernahmegutschrift fuer beim Altanbieter bezahlte Leistungen
- Kunde (Dashboard, nur mit laufendem Abo): Antrag mit Leistung, Anbieter,
  Domain, Rechnungsbetrag/-zeitraum, Grund, Rechnungsnachweis (PDF/JPG/PNG)
  und E-Mail fuer die Antwort; Eingangsbestaetigung per Mail, Status und
  Antwort auf Rueckfragen im Portal
- Admin-Mail mit Link admin.html?gutschrift=<id> (Login leitet zurueck)
- Admin-Tab "Gutschriften": Kunde und Vertrag, Nachweise, Verlauf,
  automatische Pruefungen (Duplikate per Datei-Hash/Domain, Abo, Zeitraum,
  DNS-Abgleich Nameserver/MX mit Anbieter), Pflicht-Checkliste, Rechner
  (tagesgenau anteilig), Genehmigen/Rueckfrage/Ablehnen mit Kundenmail
- Genehmigte Gutschrift als Stripe-Kundenguthaben (idempotent, Nachbuchen
  bei Stripe-Stoerung)
- Nachweise in upload-data/ (gitignored), Loeschung abgelehnter nach 6 Monaten
- Stundenkontingent auch fuer Monate, die ein Guthaben komplett deckt
  (invoice.total statt amount_paid)
- Datenschutzerklaerung Abschnitt 7 ergaenzt, PDF neu erzeugt
- Collection takeoverCredits in setup-abo-collections.mjs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:34:33 +00:00

856 lines
38 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>
</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')))
} 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
}
}
/* ----------------------------------------------------------------- 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
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()
}
})
})
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