- 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>
856 lines
38 KiB
JavaScript
856 lines
38 KiB
JavaScript
/* Admin-Dashboard des Kundenportals (Ticketsystem-Mitarbeiter). */
|
||
|
||
async function api(path, options = {}) {
|
||
const response = await fetch(path, {
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
|
||
...options,
|
||
})
|
||
const data = await response.json().catch(() => ({}))
|
||
if (!response.ok) {
|
||
const err = new Error(data.error || `Fehler ${response.status}`)
|
||
err.status = response.status
|
||
throw err
|
||
}
|
||
return data
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
return String(str ?? '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
}
|
||
|
||
function fmtDate(value) {
|
||
if (!value) return '–'
|
||
const d = new Date(value)
|
||
if (Number.isNaN(d.getTime())) return escapeHtml(value)
|
||
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||
}
|
||
|
||
function fmtDateTime(value) {
|
||
if (!value) return '–'
|
||
const d = new Date(value)
|
||
if (Number.isNaN(d.getTime())) return escapeHtml(value)
|
||
return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||
}
|
||
|
||
function fmtEuro(value) {
|
||
return Number(value || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })
|
||
}
|
||
|
||
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 & 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 & 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 & 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
|