/* Admin-Dashboard des Kundenportals (Ticketsystem-Mitarbeiter). */ async function api(path, options = {}) { const response = await fetch(path, { credentials: 'same-origin', headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, ...options, }) const data = await response.json().catch(() => ({})) if (!response.ok) { const err = new Error(data.error || `Fehler ${response.status}`) err.status = response.status throw err } return data } function escapeHtml(str) { return String(str ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } function fmtDate(value) { if (!value) return '–' const d = new Date(value) if (Number.isNaN(d.getTime())) return escapeHtml(value) return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) } function fmtDateTime(value) { if (!value) return '–' const d = new Date(value) if (Number.isNaN(d.getTime())) return escapeHtml(value) return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) } function fmtEuro(value) { return Number(value || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }) } // Katalogname wie im Formular ("Hosting-Abo"/"Wartungs-Abo"); Altbestand ohne Produkt gilt als Wartungs-Abo const PRODUCT_LABEL = { hosting: 'Hosting-Abo', wartung: 'Wartungs-Abo' } const productLabelOf = (key) => PRODUCT_LABEL[key] || (key ? String(key) : 'Abo') function isLead(customer) { return String(customer.customerStatus || '').toLowerCase() === 'lead' } function stagePill(status) { const s = String(status || '').toLowerCase() if (s === 'lead') return 'Lead' if (s === 'lost') return 'Abgesagt' return 'Fester Kunde' } function statusPill(project) { const s = String(project.provisioningStatus || project.status || '').toLowerCase() if (['ready', 'deployed'].includes(s)) return 'deployed' if (s.includes('fail')) return 'Fehler' if (!project.subdomain) return 'ohne Website' return `${escapeHtml(project.status || 'pending')}` } function showError(message) { const el = document.getElementById('admin-error') el.textContent = message el.classList.remove('hidden') } /* ------------------------------------------------------------ Übersicht --- */ async function loadOverview() { const grid = document.getElementById('stat-grid') const logins = document.getElementById('recent-logins') const recent = document.getElementById('recent-projects') try { const o = await api('/api/portal-admin/overview') grid.innerHTML = [ { num: o.projects.total, lbl: 'Projekte gesamt' }, { num: o.projects.deployed, lbl: 'Websites online' }, { num: o.projects.withoutWebsite, lbl: 'Projekte ohne Website' }, { num: o.customers.total, lbl: 'Kunden gesamt' }, { num: o.customers.leads, lbl: 'Leads' }, { num: o.customers.portalEnabled, lbl: 'Portal-Zugänge' }, ].map((s) => `
${s.num}${s.lbl}
`).join('') logins.innerHTML = o.recentLogins.length ? o.recentLogins.map((l) => `
  • ${escapeHtml(l.customerName)} – ${fmtDateTime(l.lastLoginAt)}
  • `).join('') : '
  • Noch keine Portal-Logins.
  • ' recent.innerHTML = o.recentProjects.length ? o.recentProjects.map((p) => `
  • ${escapeHtml(p.projectName)}${p.customerName ? ` (${escapeHtml(p.customerName)})` : ''} – ${fmtDateTime(p.updatedAt)}
  • `).join('') : '
  • Keine Projekte.
  • ' } catch (err) { grid.innerHTML = '' showError(err.message) } } /* ------------------------------------------------------------- Projekte --- */ let projectSearchTimer = null async function loadProjects(search = '') { const tbody = document.getElementById('project-rows') try { const { projects } = await api(`/api/portal-admin/projects?search=${encodeURIComponent(search)}`) if (!projects.length) { tbody.innerHTML = 'Keine Projekte gefunden.' return } tbody.innerHTML = projects.map((p) => ` ${escapeHtml(p.projectName || p.repoFullName || '–')} ${p.subdomain ? escapeHtml(p.subdomain) : '–'} ${p.customerName ? escapeHtml(p.customerName) : '–'} ${statusPill(p)} ${p.projectType === 'repo' ? 'Repo' : 'Website'} ${p.previewUrl ? `Vorschau` : ''} ${p.giteaRepoUrl ? ` · Gitea` : ''} `).join('') } catch (err) { tbody.innerHTML = `${escapeHtml(err.message)}` } } /* --------------------------------------------------------------- Kunden --- */ let customerSearchTimer = null async function saveCustomerCode(customerId, btn) { const input = document.querySelector(`input[data-code-for="${customerId}"]`) if (!input) return btn.disabled = true try { await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}`, { method: 'PATCH', body: JSON.stringify({ code: input.value }), }) btn.textContent = '✓ Gespeichert' setTimeout(() => { btn.textContent = '✓'; btn.disabled = false }, 1200) } catch (err) { btn.disabled = false alert('Kundennummer konnte nicht gespeichert werden: ' + err.message) } } async function viewAsCustomer(customerId) { try { await api(`/api/portal-admin/view-as/${encodeURIComponent(customerId)}`, { method: 'POST' }) window.location.href = '/dashboard.html' } catch (err) { alert('Ansicht konnte nicht gewechselt werden: ' + err.message) } } function renderActivity(tickets) { if (!tickets.length) return '

    Keine Tickets für diesen Kunden.

    ' return tickets.map((t) => `
    ${escapeHtml(t.woid ? `#${t.woid}` : '')} ${escapeHtml(t.topic || '–')} ${escapeHtml(t.status || '–')} ${escapeHtml(t.type || '')} · ${fmtDate(t.createdAt)} ${(t.worksheets || []).map((w) => `
    → ${fmtDate(w.date)} ${escapeHtml(w.employeeName)} (${escapeHtml(w.serviceType || '–')}${w.totalTime ? `, ${escapeHtml(w.totalTime)}` : ''}${w.supportMinutes ? `, +${escapeHtml(w.supportMinutes)} min Unterstützung` : ''}): ${escapeHtml((w.details || '').slice(0, 160))}
    `).join('')}
    `).join('') } function renderInvoices(data) { if (!data.linked) return '

    Kunde ist nicht mit InvoiceNinja verknüpft.

    ' if (!data.invoices.length) return '

    Keine Rechnungen vorhanden.

    ' return ` ${data.invoices.map((i) => ` `).join('')}
    Nr.DatumBetragOffenStatus
    ${escapeHtml(i.number)} ${fmtDate(i.date)} ${fmtEuro(i.amount)} ${fmtEuro(i.balance)} ${escapeHtml(i.status)} ${i.portalUrl ? `Ansehen` : ''}
    ` } async function toggleCustomerDetail(customerId, kind, cell) { const existing = document.getElementById(`detail-${customerId}`) if (existing) { const wasKind = existing.dataset.kind existing.remove() if (wasKind === kind) return } const row = document.querySelector(`tr[data-customer-row="${customerId}"]`) if (!row) return const tr = document.createElement('tr') tr.id = `detail-${customerId}` tr.dataset.kind = kind tr.className = 'detail-row' tr.innerHTML = `
    Lade…
    ` row.after(tr) try { if (kind === 'activity') { const { tickets } = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}/activity`) tr.innerHTML = `

    Tickets & Arbeiten

    ${renderActivity(tickets)}` } else { const data = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}/invoices`) tr.innerHTML = `

    Rechnungen

    ${renderInvoices(data)}` } } catch (err) { tr.innerHTML = `${escapeHtml(err.message)}` } } async function loadCustomers(search = '') { const tbody = document.getElementById('customer-rows') try { const { customers } = await api(`/api/portal-admin/customers?search=${encodeURIComponent(search)}`) if (!customers.length) { tbody.innerHTML = 'Keine Kunden gefunden.' return } tbody.innerHTML = customers.map((c) => `
    ${escapeHtml(c.name)}
    ${escapeHtml(c.email || '–')} ${stagePill(c.customerStatus)} ${c.portalAccessEnabled ? 'aktiv' : 'kein Zugang'} ${fmtDateTime(c.lastLoginAt)} ${c.projectCount}
    ${isLead(c) ? `Rechnungsanalyse` : ''}
    `).join('') tbody.querySelectorAll('[data-save-code]').forEach((btn) => btn.addEventListener('click', () => saveCustomerCode(btn.dataset.saveCode, btn))) tbody.querySelectorAll('[data-view-as]').forEach((btn) => btn.addEventListener('click', () => viewAsCustomer(btn.dataset.viewAs))) tbody.querySelectorAll('[data-activity]').forEach((btn) => btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.activity, 'activity'))) tbody.querySelectorAll('[data-invoices]').forEach((btn) => btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.invoices, 'invoices'))) tbody.querySelectorAll('[data-offer-for]').forEach((btn) => btn.addEventListener('click', () => startOfferFor(btn.dataset.offerFor))) } catch (err) { tbody.innerHTML = `${escapeHtml(err.message)}` } } /* ----------------------------------------------------------------- Chat --- */ const adminChat = { activeCustomerId: '', activeCustomerName: '', lastCreatedAt: '', threadTimer: null, shownIds: new Set(), composer: null, } const adminChatUrl = (customerId) => `/api/portal-admin/chats/${encodeURIComponent(customerId)}` function updateAdminChatBadge(unread) { const badge = document.getElementById('admin-chat-badge') if (!badge) return if (unread > 0) { badge.textContent = unread badge.classList.remove('hidden') } else { badge.classList.add('hidden') } } async function pollAdminChatBadge() { if (document.hidden) return try { const { unread } = await api('/api/portal-admin/chats/unread-count') updateAdminChatBadge(unread) } catch { /* ignore */ } } async function loadChatList() { const list = document.getElementById('admin-chat-list') try { const { chats } = await api('/api/portal-admin/chats') if (!chats.length) { list.innerHTML = '
    Noch keine Unterhaltungen.
    ' return } list.innerHTML = chats.map((c) => `
    ${escapeHtml(c.customerName)} ${c.unreadFromCustomer ? `${c.unreadFromCustomer}` : ''}
    ${c.lastSender === 'admin' ? 'Du: ' : ''}${escapeHtml(c.lastText)}
    ${fmtDateTime(c.lastAt)}
    `).join('') list.querySelectorAll('[data-chat-customer]').forEach((el) => el.addEventListener('click', () => openChatThread(el.dataset.chatCustomer, el.dataset.chatName))) } catch (err) { list.innerHTML = `
    ${escapeHtml(err.message)}
    ` } } function appendAdminChatMessages(messages) { const box = document.getElementById('admin-chat-messages') if (!box || !messages.length) return const base = adminChatUrl(adminChat.activeCustomerId) for (const msg of messages) { if (adminChat.shownIds.has(msg.id)) continue adminChat.shownIds.add(msg.id) const mine = msg.sender === 'admin' box.appendChild(ChatComposer.renderMessage(msg, { mine, meta: `${mine ? msg.senderName || 'Admin' : adminChat.activeCustomerName} · ${fmtDateTime(msg.createdAt)}`, fileUrl: (att) => `${base}/attachments/${encodeURIComponent(att.id)}`, })) if (msg.createdAt > adminChat.lastCreatedAt) adminChat.lastCreatedAt = msg.createdAt } box.scrollTop = box.scrollHeight } async function pollAdminThread() { if (!adminChat.activeCustomerId || document.hidden) return try { const customerId = adminChat.activeCustomerId const q = adminChat.lastCreatedAt ? `?since=${encodeURIComponent(adminChat.lastCreatedAt)}` : '' const { messages } = await api(`/api/portal-admin/chats/${encodeURIComponent(customerId)}/messages${q}`) if (customerId !== adminChat.activeCustomerId) return // inzwischen andere Unterhaltung geoeffnet if (messages.length) { appendAdminChatMessages(messages) await api(`/api/portal-admin/chats/${encodeURIComponent(adminChat.activeCustomerId)}/read`, { method: 'POST' }).catch(() => {}) } } catch { /* naechster Poll */ } } async function openChatThread(customerId, customerName) { adminChat.activeCustomerId = customerId adminChat.activeCustomerName = customerName || 'Kunde' adminChat.lastCreatedAt = '' adminChat.shownIds = new Set() if (adminChat.composer) adminChat.composer.destroy() const thread = document.getElementById('admin-chat-thread') thread.innerHTML = `
    ${escapeHtml(adminChat.activeCustomerName)}
    ` ChatComposer.trackScroll(document.getElementById('admin-chat-messages')) // Text, Bilder, Kamerafoto, Dateien und Sprachnachrichten (public/chat-composer.js) adminChat.composer = ChatComposer.create({ root: document.getElementById('admin-chat-composer'), dropZone: thread, uploadUrl: `${adminChatUrl(customerId)}/attachments`, placeholder: `Antwort an ${adminChat.activeCustomerName}…`, placeholderShort: 'Antwort…', send: async ({ text, attachmentIds }) => { const { message } = await api(`${adminChatUrl(customerId)}/messages`, { method: 'POST', body: JSON.stringify({ text, attachmentIds }), }) return message }, onSent: (message) => { if (adminChat.activeCustomerId !== customerId) return appendAdminChatMessages([message]) loadChatList() }, }) try { const { messages } = await api(`/api/portal-admin/chats/${encodeURIComponent(customerId)}/messages`) if (customerId !== adminChat.activeCustomerId) return // inzwischen andere Unterhaltung geoeffnet appendAdminChatMessages(messages) await api(`/api/portal-admin/chats/${encodeURIComponent(customerId)}/read`, { method: 'POST' }).catch(() => {}) updateAdminChatBadge(0) pollAdminChatBadge() loadChatList() } catch (err) { document.getElementById('admin-chat-messages').innerHTML = `${escapeHtml(err.message)}` } clearInterval(adminChat.threadTimer) adminChat.threadTimer = setInterval(pollAdminThread, 15000) } /* ---------------------------------------------- Übernahmegutschriften --- */ const CREDIT_STATUS = { offen: ['pill-warn', 'Zu prüfen'], rueckfrage: ['pill-muted', 'Rückfrage beim Kunden'], genehmigt: ['pill-ok', 'Genehmigt'], abgelehnt: ['pill-red', 'Abgelehnt'], } const CREDIT_HISTORY_LABELS = { eingereicht: 'Antrag eingereicht', rueckfrage: 'Rückfrage an den Kunden', antwort: 'Antwort des Kunden', genehmigt: 'Genehmigt', abgelehnt: 'Abgelehnt', verrechnet: 'In Stripe gutgeschrieben', 'nachweise-geloescht': 'Nachweise gelöscht', } const CREDIT_MONTHS = [1, 3, 6, 12, 24, 36] const creditState = { filter: 'open', activeId: '', detail: null, calcTimer: null, amountTouched: false } const euroFromCents = (cents) => fmtEuro((Number(cents) || 0) / 100) function fmtBytes(bytes) { const n = Number(bytes || 0) return n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB` } function creditStatusPill(status) { const [cls, label] = CREDIT_STATUS[status] || ['pill-muted', status] return `${label}` } function updateCreditBadge(open) { const badge = document.getElementById('admin-credit-badge') if (!badge) return badge.textContent = open badge.classList.toggle('hidden', !(open > 0)) } async function pollCreditBadge() { if (document.hidden) return try { const { open } = await api('/api/portal-admin/takeover-credits/open-count') updateCreditBadge(open) } catch { /* ignore */ } } async function loadCredits() { const tbody = document.getElementById('credit-rows') try { const { requests, openCount } = await api(`/api/portal-admin/takeover-credits?status=${creditState.filter}`) updateCreditBadge(openCount) if (!requests.length) { tbody.innerHTML = `${creditState.filter === 'open' ? 'Keine offenen Anträge.' : 'Noch keine Anträge.'}` return } tbody.innerHTML = requests.map((r) => ` ${fmtDateTime(r.createdAt)}
    ${escapeHtml(r.ref)} ${escapeHtml(r.customerName)}${r.customerCode ? `
    Kd.-Nr. ${escapeHtml(r.customerCode)}` : ''} ${escapeHtml(r.services)}
    ${escapeHtml(r.provider)}${r.domain ? ` · ${escapeHtml(r.domain)}` : ''} ${euroFromCents(r.invoiceAmountCents)}
    für ${r.billingMonths} Mon. ${fmtDate(r.paidUntil)} ${creditStatusPill(r.status)}${r.status === 'genehmigt' ? `
    ${euroFromCents(r.amountCents)}${r.appliedAt ? '' : ' · nicht gebucht'}` : ''} `).join('') tbody.querySelectorAll('[data-open-credit]').forEach((btn) => btn.addEventListener('click', () => openCreditDetail(btn.dataset.openCredit))) } catch (err) { tbody.innerHTML = `${escapeHtml(err.message)}` } } function setCreditUrl(id) { const url = new URL(window.location.href) if (id) url.searchParams.set('gutschrift', id) else url.searchParams.delete('gutschrift') window.history.replaceState(null, '', url) } function closeCreditDetail() { creditState.activeId = '' document.getElementById('credit-detail').classList.add('hidden') document.querySelectorAll('[data-credit-row]').forEach((tr) => tr.classList.remove('credit-active')) setCreditUrl('') } async function openCreditDetail(id, { message = '' } = {}) { creditState.activeId = id creditState.amountTouched = false const panel = document.getElementById('credit-detail') panel.classList.remove('hidden') panel.innerHTML = '

    Lade Antrag…

    ' document.querySelectorAll('[data-credit-row]').forEach((tr) => tr.classList.toggle('credit-active', tr.dataset.creditRow === id)) setCreditUrl(id) try { const d = await api(`/api/portal-admin/takeover-credits/${encodeURIComponent(id)}`) creditState.detail = d renderCreditDetail(panel, d, message) } catch (err) { panel.innerHTML = `

    ${escapeHtml(err.message)}

    ` } panel.scrollIntoView({ behavior: 'smooth', block: 'start' }) } function calcSummary(calc) { return `Rechnungszeitraum ${fmtDate(calc.periodStart)} – ${fmtDate(calc.periodEnd)} (${calc.periodDays} Tage). ` + `Doppelt bezahlt ab ${fmtDate(calc.overlapStart)}: ${calc.overlapDays} Tage → Vorschlag ${euroFromCents(calc.suggestedCents)}` } function creditDecisionFormHtml(d) { const r = d.request const suggested = d.calc ? d.calc.suggestedCents : 0 return `

    Prüfung durch dich

    ${d.requiredChecks.map((k) => ``).join('')}

    Berechnung

    ${d.calc ? escapeHtml(calcSummary(d.calc)) : 'Angaben unvollständig'}

    Werte weichen von der Rechnung ab? Hier korrigieren – die Korrektur wird beim Genehmigen im Verlauf vermerkt. „Übernahme ab“ ist der Leistungsbeginn des Abos.

    Genehmigen geht, sobald alle Punkte abgehakt sind. Der Kunde bekommt jede Entscheidung per E-Mail.${d.subscription?.active ? '' : ' Achtung: Der Kunde hat kein laufendes Abo.'}

    ` } function creditDecisionSummaryHtml(d) { const r = d.request if (r.status === 'genehmigt') { return `

    Entscheidung

    Genehmigt
    ${fmtDateTime(r.decidedAt)} · ${escapeHtml(r.decidedBy)}
    Gutschrift
    ${euroFromCents(r.amountCents)}
    Berechnung
    ${escapeHtml(r.calculation)}
    Geprüft
    ${r.checks ? `alle Punkte abgehakt (${escapeHtml(r.checks.by || '')})` : '–'}
    ${r.decisionNote ? `
    Nachricht
    ${escapeHtml(r.decisionNote)}
    ` : ''}
    Verrechnung
    ${r.appliedAt ? `Stripe-Guthaben gebucht am ${fmtDateTime(r.appliedAt)} (${escapeHtml(r.stripeBalanceTransactionId)})` : 'noch nicht in Stripe gebucht'}
    ${r.appliedAt ? '' : `
    `}` } return `

    Entscheidung

    Abgelehnt
    ${fmtDateTime(r.decidedAt)} · ${escapeHtml(r.decidedBy)}
    Begründung
    ${escapeHtml(r.decisionNote)}
    ` } function renderCreditDetail(panel, d, message) { const r = d.request const c = d.customer const s = d.subscription const pending = r.status === 'offen' || r.status === 'rueckfrage' const fileNames = new Map(r.files.map((f) => [f.id, f.name])) const fileHref = (f) => `/api/portal-admin/takeover-credits/${encodeURIComponent(r.id)}/files/${encodeURIComponent(f.id)}` const aboPill = !s ? 'kein Abo' : `${escapeHtml(s.status)}` const staff = (by) => (by && by !== 'customer' && by !== 'system' ? ` · ${escapeHtml(by)}` : '') panel.innerHTML = `

    Antrag ${escapeHtml(r.ref)} · ${escapeHtml(c.name || c.companyName)}

    eingegangen ${fmtDateTime(r.createdAt)} ${creditStatusPill(r.status)}
    ${message ? `
    ${escapeHtml(message)}
    ` : ''}

    Antrag

    Leistung
    ${escapeHtml(r.services)}
    Bisher bei
    ${escapeHtml(r.provider)}
    Domain
    ${r.domain ? `${escapeHtml(r.domain)}` : '–'}
    Rechnung
    ${euroFromCents(r.invoiceAmountCents)} für ${r.billingMonths} Monat${r.billingMonths === 1 ? '' : 'e'}
    Bezahlt bis
    ${fmtDate(r.paidUntil)}
    Antwort an
    ${escapeHtml(r.email)}
    Bestätigt
    Angaben und Bedingungen (Stand ${escapeHtml(r.conditionsVersion || '–')}) am ${fmtDateTime(r.consentAt)}

    Grund / Nachricht

    ${escapeHtml(r.message)}

    Nachweise

    ${r.files.length ? `` : '

    Keine Nachweise (mehr) vorhanden.

    '}

    Verlauf

    ${r.history.map((h) => `
    ${escapeHtml(CREDIT_HISTORY_LABELS[h.type] || h.type)} ${fmtDateTime(h.at)}${staff(h.by)} ${h.text ? `
    ${escapeHtml(h.text)}
    ` : ''} ${(h.files || []).length ? `
    Dateien: ${h.files.map((id) => escapeHtml(fileNames.get(id) || 'gelöscht')).join(', ')}
    ` : ''}
    `).join('')}

    Kunde & Vertrag

    Kunde
    ${escapeHtml(c.name || c.companyName)}${c.code ? ` · Kd.-Nr. ${escapeHtml(c.code)}` : ''}
    E-Mail
    ${escapeHtml(c.email || '–')}
    Abo
    ${s ? `${escapeHtml(productLabelOf(s.productKey))} ${aboPill}
    Zusatzspeicher: ${storageLabel(s.storageUnits)}` : aboPill}
    ${s ? `
    Leistungsbeginn
    ${fmtDate(s.serviceStart)}
    Nächste Abbuchung
    ${s.active ? fmtDate(s.currentPeriodEnd) : '–'}
    ${s.cancelAt ? `
    Gekündigt zum
    ${fmtDate(s.cancelAt)}
    ` : ''}
    Stripe-Guthaben
    ${s.creditBalanceCents == null ? '–' : euroFromCents(s.creditBalanceCents)}
    ` : ''}
    ${s?.stripeUrl ? `In Stripe öffnen` : ''}
    ${d.otherRequests.length ? `

    Weitere Anträge des Kunden

    ` : ''}

    Automatische Prüfung

    ${pending ? creditDecisionFormHtml(d) : creditDecisionSummaryHtml(d)}
    ` panel.querySelector('#credit-close')?.addEventListener('click', closeCreditDetail) panel.querySelector('#credit-view-as')?.addEventListener('click', () => viewAsCustomer(c.id)) panel.querySelectorAll('[data-open-credit]').forEach((a) => a.addEventListener('click', (e) => { e.preventDefault() openCreditDetail(a.dataset.openCredit) })) panel.querySelector('#credit-apply')?.addEventListener('click', () => applyCredit(r.id)) if (pending) bindCreditDecision(panel, r.id) } function refreshApproveState(panel) { const btn = panel.querySelector('#credit-approve') if (!btn) return const allChecked = [...panel.querySelectorAll('[data-check]')].every((cb) => cb.checked) btn.disabled = !allChecked || !(Number(panel.querySelector('#credit-amount').value) > 0) } function calcFigures(panel) { return { invoiceAmountCents: Math.round(Number(panel.querySelector('#calc-amount').value) * 100), billingMonths: Number(panel.querySelector('#calc-months').value), paidUntil: panel.querySelector('#calc-paid-until').value, takeoverFrom: panel.querySelector('#calc-from').value, } } async function recalcCredit(panel) { const out = panel.querySelector('#calc-result') try { const { calc } = await api('/api/portal-admin/takeover-credits/calc', { method: 'POST', body: JSON.stringify(calcFigures(panel)), }) out.textContent = calcSummary(calc) if (!creditState.amountTouched) panel.querySelector('#credit-amount').value = (calc.suggestedCents / 100).toFixed(2) } catch (err) { out.textContent = err.message } refreshApproveState(panel) } function bindCreditDecision(panel, id) { panel.querySelectorAll('[data-check]').forEach((cb) => cb.addEventListener('change', () => refreshApproveState(panel))) panel.querySelector('#credit-amount').addEventListener('input', () => { creditState.amountTouched = true refreshApproveState(panel) }) panel.querySelectorAll('#calc-amount, #calc-months, #calc-paid-until, #calc-from').forEach((el) => el.addEventListener('input', () => { clearTimeout(creditState.calcTimer) creditState.calcTimer = setTimeout(() => recalcCredit(panel), 300) })) panel.querySelector('#credit-approve').addEventListener('click', () => submitCreditDecision(id, 'approve', panel)) panel.querySelector('#credit-question').addEventListener('click', () => submitCreditDecision(id, 'question', panel)) panel.querySelector('#credit-reject').addEventListener('click', () => submitCreditDecision(id, 'reject', panel)) } async function submitCreditDecision(id, action, panel) { const errEl = panel.querySelector('#credit-error') errEl.classList.add('hidden') const note = panel.querySelector('#credit-note').value.trim() const amountCents = Math.round(Number(panel.querySelector('#credit-amount').value) * 100) const who = creditState.detail?.customer?.name || 'den Kunden' if (action !== 'approve' && note.length < 5) { errEl.textContent = action === 'question' ? 'Bitte die Rückfrage an den Kunden ins Nachrichtenfeld schreiben.' : 'Bitte die Begründung für den Kunden ins Nachrichtenfeld schreiben.' errEl.classList.remove('hidden') return } const question = { approve: `Gutschrift über ${euroFromCents(amountCents)} genehmigen, in Stripe gutschreiben und ${who} per E-Mail informieren?`, question: `Rückfrage per E-Mail an ${who} senden?`, reject: `Antrag ablehnen und ${who} die Begründung per E-Mail senden?`, }[action] if (!window.confirm(question)) return const buttons = panel.querySelectorAll('.credit-actions button') buttons.forEach((b) => { b.disabled = true }) try { const checks = Object.fromEntries([...panel.querySelectorAll('[data-check]')].map((cb) => [cb.dataset.check, cb.checked])) const result = await api(`/api/portal-admin/takeover-credits/${encodeURIComponent(id)}/decision`, { method: 'POST', body: JSON.stringify({ action, note, amountCents, checks, ...calcFigures(panel) }), }) const parts = [{ genehmigt: 'Genehmigt.', rueckfrage: 'Rückfrage gesendet.', abgelehnt: 'Abgelehnt.' }[result.status] || 'Gespeichert.'] if (action === 'approve') { parts.push(result.applied ? 'Das Guthaben ist in Stripe gebucht.' : `Noch nicht in Stripe gebucht: ${result.reason}`) } parts.push(result.mailSent ? 'Die E-Mail an den Kunden ist raus.' : `E-Mail an den Kunden fehlgeschlagen (${result.mailError || 'unbekannt'}) – bitte selbst informieren.`) await loadCredits() await openCreditDetail(id, { message: parts.join(' ') }) } catch (err) { errEl.textContent = err.message errEl.classList.remove('hidden') buttons.forEach((b) => { b.disabled = false }) refreshApproveState(panel) } } async function applyCredit(id) { const panel = document.getElementById('credit-detail') const btn = panel.querySelector('#credit-apply') btn.disabled = true try { const result = await api(`/api/portal-admin/takeover-credits/${encodeURIComponent(id)}/apply`, { method: 'POST' }) await loadCredits() await openCreditDetail(id, { message: result.applied ? 'Das Guthaben ist in Stripe gebucht.' : `Nicht gebucht: ${result.reason}`, }) } catch (err) { const errEl = panel.querySelector('#credit-error') errEl.textContent = err.message errEl.classList.remove('hidden') btn.disabled = false } } /* ----------------------------------------------------- E-Mail-Speicher --- */ const GB_INCLUDED = 5 const GB_PER_UNIT = 5 const gbOfUnits = (units) => GB_INCLUDED + GB_PER_UNIT * (Number(units) || 0) // "2 Pakete (15 GB)" / "kein Paket (5 GB)" function storageLabel(units) { const n = Number(units) || 0 const packages = n === 0 ? 'kein Paket' : n === 1 ? '1 Paket' : `${n} Pakete` return `${packages} (${gbOfUnits(n)} GB)` } function storageDueHtml(t) { const overdue = t.dueAt && new Date(t.dueAt).getTime() <= Date.now() if (t.kind === 'einrichten') { return `jetzt einrichten
    bestellt ${fmtDateTime(t.createdAt)}` } return `zum Periodenende
    ${fmtDate(t.dueAt)}` } function storageTaskHtml(t) { const action = t.kind === 'einrichten' ? `Postfachgrenze in konsoleH jetzt auf ${escapeHtml(t.targetGb)} GB setzen` : `Postfachgrenze in konsoleH am ${fmtDate(t.dueAt)} auf ${escapeHtml(t.targetGb)} GB senken` const partial = t.partial ? `
    Teilweise: Stripe steht noch auf der bezahlten Menge, nicht auf ${escapeHtml(t.toUnits)} Paket(en) – Stückzahl in Stripe von Hand nachziehen.${t.error ? ` Fehler: ${escapeHtml(t.error)}` : ''}` : '' const scope = `
    Gesamtspeicher des Kunden: ${escapeHtml(t.targetGb)} GB, auf seine Postfächer verteilt nach seiner Vorgabe – § 3 Abs. 4${t.contactDomain ? ` · Kontakt: ${escapeHtml(t.contactDomain)}` : ''}` return `
    ${action}${scope}
    ${escapeHtml(storageLabel(t.fromUnits))} → ${escapeHtml(storageLabel(t.toUnits))}${t.viaCancellation ? ' · über die Kündigungsseite' : ''}${partial}
    ` } async function loadStorageTasks() { const summary = document.getElementById('storage-summary') const tbody = document.getElementById('storage-rows') const failedBox = document.getElementById('storage-failed') const failedList = document.getElementById('storage-failed-list') try { const d = await api('/api/portal-admin/storage-tasks') const warn = Number(d.allocatedGb) >= Number(d.warnGb) summary.classList.toggle('warn', warn) summary.innerHTML = `${escapeHtml(d.allocatedGb)} von ${escapeHtml(d.packageGb)} GB vergeben (Paket ${escapeHtml(d.packageName)})${ warn ? ` – Warnschwelle ${escapeHtml(d.warnGb)} GB erreicht: größeres Hetzner-Paket buchen.` : ` · Warnung ab ${escapeHtml(d.warnGb)} GB` }` tbody.innerHTML = d.tasks.length ? d.tasks.map((t) => ` ${storageDueHtml(t)} ${escapeHtml(t.customerName)}${t.customerCode ? `
    Kd.-Nr. ${escapeHtml(t.customerCode)}` : ''}${t.email ? `
    ${escapeHtml(t.email).replace('@', '@')}` : ''}
    ${escapeHtml(t.productLabel)} ${storageTaskHtml(t)} ${fmtDateTime(t.createdAt)}
    ${escapeHtml(t.toUnits > t.fromUnits ? 'Bestellung (Plus)' : 'Verringerung (Minus)')} `).join('') : 'Keine offenen Speicher-Aufgaben.' failedBox.classList.toggle('hidden', !d.failed.length) failedList.innerHTML = d.failed.map((f) => `
  • ${fmtDateTime(f.createdAt)} · ${escapeHtml(f.customerName)} · ${escapeHtml(storageLabel(f.fromUnits))} → ${escapeHtml(storageLabel(f.toUnits))} – Stripe hat die Änderung abgelehnt, nichts berechnet, keine Aufgabe.${f.error ? ` Fehler: ${escapeHtml(f.error)}` : ''}
  • `).join('') tbody.querySelectorAll('[data-storage-done]').forEach((btn) => btn.addEventListener('click', () => completeStorageTask(btn.dataset.storageDone, btn))) } catch (err) { summary.classList.remove('warn') summary.textContent = '' tbody.innerHTML = `${escapeHtml(err.message)}` } } async function completeStorageTask(id, btn) { if (btn.dataset.partial === '1' && !confirm('Stripe-Menge von Hand korrigiert?')) return btn.disabled = true try { await api(`/api/portal-admin/storage-tasks/${encodeURIComponent(id)}/done`, { method: 'POST', body: '{}' }) await loadStorageTasks() } catch (err) { btn.disabled = false alert('Aufgabe konnte nicht als erledigt gespeichert werden: ' + err.message) } } /* ------------------------------------------------------- Abo-Angebote --- */ const OFFER_STATUS = { offen: ['pill-warn', 'offen'], aktiv: ['pill-ok', 'aktiv'], abgelaufen: ['pill-muted', 'abgelaufen'], zurueckgezogen: ['pill-muted', 'zurückgezogen'], } const offerState = { filter: 'open', customers: null, labelToId: new Map(), context: null, offers: new Map() } const OFFER_PRODUCT_LABEL = PRODUCT_LABEL function offerStatusPill(o) { const [cls, label] = OFFER_STATUS[o.status] || ['pill-muted', o.status] const sub = o.status === 'aktiv' && o.appliedAt ? `seit ${fmtDate(o.appliedAt)}` : o.status === 'offen' ? 'wartet auf Abschluss' : '' return `${label}${sub ? `
    ${sub}` : ''}` } // Versandstand der Angebots-Mail; "nicht verschickt" nur, solange sie noch sinnvoll ist (mailSendable) function offerMailStatus(o) { if (o.notifiedAt) return `
    E-Mail gesendet ${fmtDateTime(o.notifiedAt)}` if (o.mailSendable) return '
    E-Mail nicht verschickt' if (o.status === 'offen' && !o.customerEmail) return '
    Keine E-Mail-Adresse hinterlegt' return '' } // Vorschau im Formular; verbindlich ist der Text, den der Server beim Anlegen erzeugt function offerPreviewText(price, months, regular, running) { const eur = (cents) => euroFromCents(cents) const lead = months === 1 ? (running ? 'Nächster Monat' : 'Erster Monat') : `${running ? 'Nächste' : 'Erste'} ${months} Monate` const amount = price === 0 ? 'kostenlos' : `${months === 1 ? '' : 'je '}${eur(price)} statt ${eur(regular)}` return `${lead} ${amount}, danach ${eur(regular)} pro Monat` } // Datumsfeld "2026-10-27" -> "27.10.2026", ohne Umweg über Date (keine Zeitzonen-Verschiebung) const fmtYmd = (ymd) => String(ymd || '').split('-').reverse().join('.') // Zeitpunkt -> Kalendertag in Deutschland (YYYY-MM-DD); Abbuchungen und Fristen zählen nach deutschem Kalender const berlinYmd = (value) => new Date(value).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }) const fmtBerlin = (value) => fmtYmd(berlinYmd(value)) // Kalendertag + n Monate, Monatsende wie bei Stripe (31.01. + 1 Monat = 28./29.02.) function addMonthsYmd(ymd, n) { const [y, m, d] = ymd.split('-').map(Number) const last = new Date(Date.UTC(y, m + n, 0)).getUTCDate() return new Date(Date.UTC(y, m - 1 + n, Math.min(d, last))).toISOString().slice(0, 10) } /* Rabattende eines noch nicht eingelösten Angebots: Der Rabatt läuft N Monate ab Abschluss, der Kunde kann heute oder spätestens am letzten Gültigkeitstag abschließen. Mit Beginn nach der Widerrufsfrist starten Leistung und Rabatt gut 2 Wochen später (deadlines.js lateStartAt). */ function offerDiscountEstimate(months, validUntilYmd, testAccount = false) { const today = berlinYmd(new Date()) // Testkonto: Coupon kommt erst nach der ersten Abbuchung ans Abo (offers.js), der Rabatt endet einen Monat später const n = months + (testAccount ? 1 : 0) return { earliest: addMonthsYmd(today, n), latest: addMonthsYmd(validUntilYmd < today ? today : validUntilYmd, n) } } // Zeile "Rabatt endet …" in der Angebotsliste function offerDiscountLine(o) { if (o.status === 'offen' && o.validUntil) { const { earliest, latest } = offerDiscountEstimate(o.months, String(o.validUntil).slice(0, 10), o.testAccount) return earliest === latest ? `Rabatt endet voraussichtlich am ${fmtYmd(earliest)}` : `Rabatt endet voraussichtlich zwischen ${fmtYmd(earliest)} und ${fmtYmd(latest)}` } if (o.status !== 'aktiv' || !o.discount) return '' const d = o.discount if (!d.known) return 'Rabattende unbekannt (Abo nicht gefunden)' const end = d.subscriptionEndsAt || d.endsAt const past = new Date(end) <= new Date() return d.subscriptionEndsAt ? `Rabatt ${past ? 'endete' : 'endet'} mit dem Abo am ${fmtBerlin(end)}` : `Rabatt ${past ? 'endete' : 'endet'} am ${fmtBerlin(end)}` } function showOfferMessage(text) { const el = document.getElementById('offer-message') el.textContent = text el.classList.toggle('hidden', !text) } async function loadOffers() { const tbody = document.getElementById('offer-rows') try { const { offers } = await api('/api/portal-admin/offers') offerState.offers = new Map(offers.map((o) => [o.id, o])) const shown = offerState.filter === 'open' ? offers.filter((o) => ['offen', 'aktiv'].includes(o.status)) : offers if (!shown.length) { tbody.innerHTML = `${offerState.filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'}` return } tbody.innerHTML = shown.map((o) => { const discount = offerDiscountLine(o) return ` ${fmtDateTime(o.createdAt)}
    ${escapeHtml(o.ref)}${o.createdBy ? ` · ${escapeHtml(o.createdBy.replace(/\s*<.*>$/, ''))}` : ''} ${escapeHtml(o.customerName)}${o.customerEmail ? `
    ${escapeHtml(o.customerEmail).replace('@', '@')}` : ''}${o.customerCode ? `
    Kd.-Nr. ${escapeHtml(o.customerCode)}` : ''} ${escapeHtml(productLabelOf(o.productKey))}
    ${escapeHtml(o.text)}
    Ersparnis ${euroFromCents(o.savingsCents)}${o.note ? ` · „${escapeHtml(o.note)}“` : ''} ${discount ? `
    ${escapeHtml(discount)}` : ''} ${o.internalNote ? `
    intern: ${escapeHtml(o.internalNote)}` : ''} ${o.scope === 'laufend' ? 'laufendes Abo' : fmtDate(o.validUntil)} ${offerStatusPill(o)}
    Stripe: ${o.stripeCouponUrl ? `Coupon` : '–'}${o.stripeSubscriptionUrl ? ` · Abo` : ''} ${offerMailStatus(o)} ${['offen', 'abgelaufen'].includes(o.status) || o.mailSendable ? `
    ${o.mailSendable ? `` : ''} ${['offen', 'abgelaufen'].includes(o.status) ? `` : ''}
    ` : ''} ` }).join('') tbody.querySelectorAll('[data-withdraw-offer]').forEach((btn) => btn.addEventListener('click', () => withdrawOfferAction(btn.dataset.withdrawOffer, btn))) tbody.querySelectorAll('[data-send-offer-mail]').forEach((btn) => btn.addEventListener('click', () => sendOfferMailAction(btn.dataset.sendOfferMail, btn))) } catch (err) { tbody.innerHTML = `${escapeHtml(err.message)}` } } async function withdrawOfferAction(id, btn) { if (!window.confirm('Angebot zurückziehen? Der Coupon wird in Stripe gelöscht, der Kunde sieht das Angebot nicht mehr.')) return btn.disabled = true try { await api(`/api/portal-admin/offers/${encodeURIComponent(id)}/withdraw`, { method: 'POST' }) showOfferMessage('Angebot zurückgezogen, Coupon in Stripe gelöscht.') loadOffers() } catch (err) { btn.disabled = false alert('Zurückziehen fehlgeschlagen: ' + err.message) } } async function sendOfferMailAction(id, btn) { const o = offerState.offers.get(id) if (!o) return const again = o.notifiedAt ? `\n\nZuletzt gesendet ${fmtDateTime(o.notifiedAt)}.` : '' if (!window.confirm(`E-Mail mit dem Angebot an ${o.customerName} (${o.customerEmail}) ${o.notifiedAt ? 'erneut ' : ''}senden?${again}`)) return btn.disabled = true try { await api(`/api/portal-admin/offers/${encodeURIComponent(id)}/notify`, { method: 'POST' }) showOfferMessage(`Die E-Mail an ${o.customerEmail} ist raus, der Mailserver hat sie angenommen. Kommt sie nicht an: Spam-Ordner prüfen, bei Gmail auch den Tab „Werbung“.`) loadOffers() } catch (err) { btn.disabled = false alert(err.message) } } const offerCustomerLabel = (c) => [c.name || '(ohne Name)', c.email, c.code ? `Kd.-Nr. ${c.code}` : ''].filter(Boolean).join(' · ') async function ensureOfferCustomers() { if (offerState.customers) return offerState.customers const { customers } = await api('/api/portal-admin/customers') offerState.customers = customers offerState.labelToId = new Map(customers.map((c) => [offerCustomerLabel(c), c.id])) return customers } function offerFormValues(form) { return { customerId: offerState.labelToId.get(form.querySelector('#offer-customer').value.trim()) || '', productKey: form.querySelector('#offer-product').value, monthlyPriceCents: Math.round(Number(form.querySelector('#offer-price').value) * 100), months: Number(form.querySelector('#offer-months').value), validUntil: form.querySelector('#offer-valid').value, note: form.querySelector('#offer-note').value.trim(), internalNote: form.querySelector('#offer-internal').value.trim(), notifyCustomer: form.querySelector('#offer-notify').checked, } } function renderOfferPreview(form) { const box = form.querySelector('#offer-preview') const save = form.querySelector('#offer-save') const ctx = offerState.context const v = offerFormValues(form) save.disabled = true if (!v.customerId || !ctx || ctx.customer.id !== v.customerId) { box.textContent = v.customerId ? 'Lade Kunde…' : 'Bitte einen Kunden aus der Liste wählen.' return } const regular = ctx.products[v.productKey].regularPriceCents // Sofort anwendbar ist ein Angebot nur, wenn das laufende Abo dasselbe Produkt betrifft (F32) const running = Boolean(ctx.subscription?.active && ctx.subscription.productKey === v.productKey) const openOffer = ctx.openOffers[v.productKey] const lines = [] let ok = true if (openOffer) { lines.push(`Es gibt schon ein offenes ${OFFER_PRODUCT_LABEL[v.productKey]}-Angebot (${openOffer.ref}: ${openOffer.text}) – bitte zuerst zurückziehen.`) ok = false } if (!Number.isInteger(v.monthlyPriceCents) || v.monthlyPriceCents < 0 || v.monthlyPriceCents >= regular) { lines.push(`Angebotspreis bitte zwischen 0,00 € und ${euroFromCents(regular - 1)} angeben.`) ok = false } if (!v.validUntil && !running) { lines.push('Bitte „Gültig bis“ angeben.') ok = false } if (ok) { lines.push(`${offerPreviewText(v.monthlyPriceCents, v.months, regular, running)} · Ersparnis ${euroFromCents((regular - v.monthlyPriceCents) * v.months)}`) if (running) { const charges = ctx.subscription.upcomingCharges || [] if (charges.length > v.months) lines.push(`Rabatt endet am ${fmtBerlin(charges[v.months])} (nach ${v.months === 1 ? '1 Abbuchung' : `${v.months} Abbuchungen`}).`) } else { lines.push(`Angebot gültig bis ${fmtYmd(v.validUntil)}`) const { earliest, latest } = offerDiscountEstimate(v.months, v.validUntil, ctx.customer.testAccount) const lead = `Rabatt läuft ${v.months === 1 ? '1 Monat' : `${v.months} Monate`} ab Abschluss und endet voraussichtlich` lines.push(`${earliest === latest ? `${lead} am ${fmtYmd(earliest)}.` : `${lead} zwischen ${fmtYmd(earliest)} (Abschluss heute) und ${fmtYmd(latest)} (Abschluss am letzten Tag).`} Bei Beginn nach der Widerrufsfrist gut 2 Wochen später.`) } } lines.push(running ? `Laufendes Abo: gilt ab der nächsten Abbuchung${ctx.subscription.currentPeriodEnd ? ` (${fmtDate(ctx.subscription.currentPeriodEnd)})` : ''} und wird sofort in Stripe angewendet.` : 'Noch kein laufendes Abo: Der Coupon wird angelegt und beim Abschluss im Kundenportal angewendet.') if (!running && !ctx.customer.portalAccessEnabled) lines.push('Achtung: Kunde hat keinen Portalzugang – er kann erst bestellen, wenn der Zugang freigeschaltet ist.') if (ctx.customer.testAccount) lines.push(`Testkonto: Im Checkout bleibt der 0,50-€-Testrabatt, das Angebot wird danach zusätzlich angehängt${running ? '' : ' und gilt deshalb erst ab der zweiten Abbuchung'}.`) box.innerHTML = lines.map((l) => `
    ${escapeHtml(l)}
    `).join('') save.disabled = !ok save.textContent = running ? 'Angebot anlegen und aufs laufende Abo anwenden' : 'Angebot anlegen' } async function loadOfferContext(form) { const v = offerFormValues(form) offerState.context = null renderOfferPreview(form) if (!v.customerId) return try { offerState.context = await api(`/api/portal-admin/offers/context/${encodeURIComponent(v.customerId)}`) if (!form.querySelector('#offer-valid').value) form.querySelector('#offer-valid').value = offerState.context.defaultValidUntil } catch (err) { form.querySelector('#offer-preview').textContent = err.message return } renderOfferPreview(form) } async function saveOffer(form) { const errEl = form.querySelector('#offer-error') errEl.classList.add('hidden') const v = offerFormValues(form) const ctx = offerState.context const running = Boolean(ctx.subscription?.active && ctx.subscription.productKey === v.productKey) const text = offerPreviewText(v.monthlyPriceCents, v.months, ctx.products[v.productKey].regularPriceCents, running) if (!window.confirm(`Angebot für ${ctx.customer.name || 'den Kunden'} anlegen?\n\n${text}${running ? '' : `\nGültig bis ${fmtYmd(v.validUntil)}`}\n\nDer Coupon wird sofort in Stripe angelegt${running ? ' und auf das laufende Abo angewendet' : ''}.`)) return const save = form.querySelector('#offer-save') save.disabled = true try { const result = await api('/api/portal-admin/offers', { method: 'POST', body: JSON.stringify(v) }) const o = result.offer form.classList.add('hidden') const until = o.scope !== 'laufend' ? `, gültig bis ${fmtDate(o.validUntil)}` : o.discount?.known ? `, Rabatt endet am ${fmtBerlin(o.discount.endsAt)}` : '' showOfferMessage([ `Angebot ${o.ref} für ${o.customerName} angelegt: ${o.text}${until}.`, `Stripe-Coupon ${o.stripeCouponId} ist eingerichtet${o.status === 'aktiv' ? ' und am laufenden Abo aktiv' : ''}.`, v.notifyCustomer ? (result.mailSent ? `Die E-Mail an ${o.customerEmail} ist raus.` : `E-Mail an den Kunden fehlgeschlagen (${result.mailError})${o.mailSendable ? ', sie lässt sich in der Liste erneut senden' : ''}.`) : '', ].filter(Boolean).join(' ')) loadOffers() } catch (err) { errEl.textContent = err.message errEl.classList.remove('hidden') renderOfferPreview(form) } } async function openOfferForm(customerId = '') { const form = document.getElementById('offer-form') showOfferMessage('') form.classList.remove('hidden') form.innerHTML = '

    Lade Kunden…

    ' let customers try { customers = await ensureOfferCustomers() } catch (err) { form.innerHTML = `

    ${escapeHtml(err.message)}

    ` return } const preselected = customers.find((c) => c.id === customerId) form.innerHTML = `

    Neues Angebot

    ${customers.map((c) => ``).join('')}
    Bitte einen Kunden aus der Liste wählen.
    ` form.querySelector('#offer-customer').addEventListener('change', () => loadOfferContext(form)) form.querySelector('#offer-customer').addEventListener('input', () => { if (offerState.labelToId.has(form.querySelector('#offer-customer').value.trim())) loadOfferContext(form) }) form.querySelectorAll('#offer-price, #offer-months, #offer-valid').forEach((el) => el.addEventListener('input', () => renderOfferPreview(form))) form.querySelector('#offer-product').addEventListener('change', () => renderOfferPreview(form)) form.querySelector('#offer-save').addEventListener('click', () => saveOffer(form)) form.querySelector('#offer-cancel').addEventListener('click', () => form.classList.add('hidden')) if (preselected) loadOfferContext(form) form.scrollIntoView({ behavior: 'smooth', block: 'start' }) } /** Aus der Kundenliste: Tab wechseln und Formular mit dem Kunden oeffnen. */ function startOfferFor(customerId) { document.querySelector('.admin-tab[data-tab="offers"]')?.click() openOfferForm(customerId) } /* ----------------------------------------------------------------- Init --- */ async function initAdminPage() { document.getElementById('logout-btn')?.addEventListener('click', async () => { await api('/api/auth/logout', { method: 'POST' }).catch(() => {}) window.location.href = '/login.html' }) // Nach dem Login zurück zur aufgerufenen Adresse (z. B. Link aus der Gutschrift-Mail) const loginUrl = `/login.html?next=${encodeURIComponent(window.location.pathname + window.location.search)}` try { const me = await api('/api/auth/me') if (!me.authenticated || me.role !== 'admin') { window.location.href = loginUrl return } if (me.viewAs) { // Admin ist gerade in der Kundenansicht -> zurückholen await api('/api/portal-admin/exit-view-as', { method: 'POST' }).catch(() => {}) } document.getElementById('admin-meta').textContent = `${me.admin?.name || 'Admin'} (${me.admin?.email || ''})` } catch { window.location.href = loginUrl return } let chatListLoaded = false let creditsLoaded = false let offersLoaded = false document.querySelectorAll('.admin-tab').forEach((tab) => { tab.addEventListener('click', () => { document.querySelectorAll('.admin-tab').forEach((t) => t.classList.remove('active')) document.querySelectorAll('.admin-section').forEach((s) => s.classList.remove('active')) tab.classList.add('active') document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active') if (tab.dataset.tab === 'chat' && !chatListLoaded) { chatListLoaded = true loadChatList() } if (tab.dataset.tab === 'credits' && !creditsLoaded) { creditsLoaded = true loadCredits() } if (tab.dataset.tab === 'offers' && !offersLoaded) { offersLoaded = true loadOffers() } if (tab.dataset.tab === 'storage') { // Bei jedem Wechsel auf den Reiter neu laden: der Stand aendert sich, sobald jemand bestellt loadStorageTasks() } }) }) document.querySelectorAll('[data-offer-filter]').forEach((btn) => { btn.addEventListener('click', () => { document.querySelectorAll('[data-offer-filter]').forEach((b) => b.classList.toggle('active', b === btn)) offerState.filter = btn.dataset.offerFilter loadOffers() }) }) document.getElementById('offer-new')?.addEventListener('click', () => openOfferForm()) document.querySelectorAll('[data-credit-filter]').forEach((btn) => { btn.addEventListener('click', () => { document.querySelectorAll('[data-credit-filter]').forEach((b) => b.classList.toggle('active', b === btn)) creditState.filter = btn.dataset.creditFilter loadCredits() }) }) // Direktlink aus der Admin-Mail: /admin.html?gutschrift= const creditFromLink = new URLSearchParams(window.location.search).get('gutschrift') if (creditFromLink) { document.querySelector('.admin-tab[data-tab="credits"]')?.click() openCreditDetail(creditFromLink) } document.getElementById('project-search')?.addEventListener('input', (e) => { clearTimeout(projectSearchTimer) projectSearchTimer = setTimeout(() => loadProjects(e.target.value.trim()), 250) }) document.getElementById('customer-search')?.addEventListener('input', (e) => { clearTimeout(customerSearchTimer) customerSearchTimer = setTimeout(() => loadCustomers(e.target.value.trim()), 250) }) loadOverview() loadProjects() loadCustomers() // Chat- und Gutschrift-Badge global pollen pollAdminChatBadge() setInterval(pollAdminChatBadge, 60000) pollCreditBadge() setInterval(pollCreditBadge, 60000) } window.initAdminPage = initAdminPage