Das "Angebot gültig bis" stand bisher nur in der Angebotsliste, auf der Bestellseite und in der Mail. Jetzt auch: - Admin, Formular "Neues Angebot": eigene Zeile "Angebot gültig bis TT.MM.JJJJ" in der Vorschau, in der Bestätigungsabfrage und in der Erfolgsmeldung (bei laufendem Abo nicht, dort gilt das Angebot sofort) - Kundenbereich, Abo-Karte (Seitenleiste bzw. Tab "Wartung"): Zeile "Angebot gültig bis TT.MM.JJJJ" unter dem Preis Das Datum aus dem Datumsfeld wird ohne Date-Umweg formatiert, im Portal nach Berliner Kalender (wie bisher). Nebenbei: "danach 50 €" mit geschütztem Leerzeichen, damit das € auf dem Handy nicht allein umbricht. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1111 lines
52 KiB
JavaScript
1111 lines
52 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>
|
||
<button class="mini-btn" data-offer-for="${escapeHtml(c.id)}" title="Individuelles Abo-Angebot anlegen">Angebot</button>
|
||
</div>
|
||
</td>
|
||
</tr>`).join('')
|
||
|
||
tbody.querySelectorAll('[data-save-code]').forEach((btn) =>
|
||
btn.addEventListener('click', () => saveCustomerCode(btn.dataset.saveCode, btn)))
|
||
tbody.querySelectorAll('[data-view-as]').forEach((btn) =>
|
||
btn.addEventListener('click', () => viewAsCustomer(btn.dataset.viewAs)))
|
||
tbody.querySelectorAll('[data-activity]').forEach((btn) =>
|
||
btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.activity, 'activity')))
|
||
tbody.querySelectorAll('[data-invoices]').forEach((btn) =>
|
||
btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.invoices, 'invoices')))
|
||
tbody.querySelectorAll('[data-offer-for]').forEach((btn) =>
|
||
btn.addEventListener('click', () => startOfferFor(btn.dataset.offerFor)))
|
||
} catch (err) {
|
||
tbody.innerHTML = `<tr><td colspan="7" class="error">${escapeHtml(err.message)}</td></tr>`
|
||
}
|
||
}
|
||
|
||
/* ----------------------------------------------------------------- Chat --- */
|
||
const adminChat = {
|
||
activeCustomerId: '',
|
||
activeCustomerName: '',
|
||
lastCreatedAt: '',
|
||
threadTimer: null,
|
||
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 = '<div class="chat-list-item"><span class="muted">Noch keine Unterhaltungen.</span></div>'
|
||
return
|
||
}
|
||
list.innerHTML = chats.map((c) => `
|
||
<div class="chat-list-item${c.customerId === adminChat.activeCustomerId ? ' active' : ''}" data-chat-customer="${escapeHtml(c.customerId)}" data-chat-name="${escapeHtml(c.customerName)}">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
|
||
<strong>${escapeHtml(c.customerName)}</strong>
|
||
${c.unreadFromCustomer ? `<span class="badge-pill">${c.unreadFromCustomer}</span>` : ''}
|
||
</div>
|
||
<div class="last">${c.lastSender === 'admin' ? 'Du: ' : ''}${escapeHtml(c.lastText)}</div>
|
||
<div class="last">${fmtDateTime(c.lastAt)}</div>
|
||
</div>`).join('')
|
||
list.querySelectorAll('[data-chat-customer]').forEach((el) =>
|
||
el.addEventListener('click', () => openChatThread(el.dataset.chatCustomer, el.dataset.chatName)))
|
||
} catch (err) {
|
||
list.innerHTML = `<div class="chat-list-item"><span class="error">${escapeHtml(err.message)}</span></div>`
|
||
}
|
||
}
|
||
|
||
function appendAdminChatMessages(messages) {
|
||
const box = document.getElementById('admin-chat-messages')
|
||
if (!box || !messages.length) return
|
||
const base = adminChatUrl(adminChat.activeCustomerId)
|
||
for (const msg of messages) {
|
||
if (adminChat.shownIds.has(msg.id)) continue
|
||
adminChat.shownIds.add(msg.id)
|
||
const mine = msg.sender === 'admin'
|
||
box.appendChild(ChatComposer.renderMessage(msg, {
|
||
mine,
|
||
meta: `${mine ? msg.senderName || 'Admin' : adminChat.activeCustomerName} · ${fmtDateTime(msg.createdAt)}`,
|
||
fileUrl: (att) => `${base}/attachments/${encodeURIComponent(att.id)}`,
|
||
}))
|
||
if (msg.createdAt > adminChat.lastCreatedAt) adminChat.lastCreatedAt = msg.createdAt
|
||
}
|
||
box.scrollTop = box.scrollHeight
|
||
}
|
||
|
||
async function pollAdminThread() {
|
||
if (!adminChat.activeCustomerId || document.hidden) return
|
||
try {
|
||
const customerId = adminChat.activeCustomerId
|
||
const q = adminChat.lastCreatedAt ? `?since=${encodeURIComponent(adminChat.lastCreatedAt)}` : ''
|
||
const { messages } = await api(`/api/portal-admin/chats/${encodeURIComponent(customerId)}/messages${q}`)
|
||
if (customerId !== adminChat.activeCustomerId) return // inzwischen andere Unterhaltung geoeffnet
|
||
if (messages.length) {
|
||
appendAdminChatMessages(messages)
|
||
await api(`/api/portal-admin/chats/${encodeURIComponent(adminChat.activeCustomerId)}/read`, { method: 'POST' }).catch(() => {})
|
||
}
|
||
} catch {
|
||
/* naechster Poll */
|
||
}
|
||
}
|
||
|
||
async function openChatThread(customerId, customerName) {
|
||
adminChat.activeCustomerId = customerId
|
||
adminChat.activeCustomerName = customerName || 'Kunde'
|
||
adminChat.lastCreatedAt = ''
|
||
adminChat.shownIds = new Set()
|
||
if (adminChat.composer) adminChat.composer.destroy()
|
||
|
||
const thread = document.getElementById('admin-chat-thread')
|
||
thread.innerHTML = `
|
||
<div style="margin-bottom:8px"><strong>${escapeHtml(adminChat.activeCustomerName)}</strong></div>
|
||
<div class="chat-messages" id="admin-chat-messages"></div>
|
||
<div id="admin-chat-composer"></div>`
|
||
ChatComposer.trackScroll(document.getElementById('admin-chat-messages'))
|
||
|
||
// Text, Bilder, Kamerafoto, Dateien und Sprachnachrichten (public/chat-composer.js)
|
||
adminChat.composer = ChatComposer.create({
|
||
root: document.getElementById('admin-chat-composer'),
|
||
dropZone: thread,
|
||
uploadUrl: `${adminChatUrl(customerId)}/attachments`,
|
||
placeholder: `Antwort an ${adminChat.activeCustomerName}…`,
|
||
placeholderShort: 'Antwort…',
|
||
send: async ({ text, attachmentIds }) => {
|
||
const { message } = await api(`${adminChatUrl(customerId)}/messages`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ text, attachmentIds }),
|
||
})
|
||
return message
|
||
},
|
||
onSent: (message) => {
|
||
if (adminChat.activeCustomerId !== customerId) return
|
||
appendAdminChatMessages([message])
|
||
loadChatList()
|
||
},
|
||
})
|
||
|
||
try {
|
||
const { messages } = await api(`/api/portal-admin/chats/${encodeURIComponent(customerId)}/messages`)
|
||
if (customerId !== adminChat.activeCustomerId) return // inzwischen andere Unterhaltung geoeffnet
|
||
appendAdminChatMessages(messages)
|
||
await api(`/api/portal-admin/chats/${encodeURIComponent(customerId)}/read`, { method: 'POST' }).catch(() => {})
|
||
updateAdminChatBadge(0)
|
||
pollAdminChatBadge()
|
||
loadChatList()
|
||
} catch (err) {
|
||
document.getElementById('admin-chat-messages').innerHTML = `<span class="error">${escapeHtml(err.message)}</span>`
|
||
}
|
||
|
||
clearInterval(adminChat.threadTimer)
|
||
adminChat.threadTimer = setInterval(pollAdminThread, 15000)
|
||
}
|
||
|
||
/* ---------------------------------------------- Übernahmegutschriften --- */
|
||
const CREDIT_STATUS = {
|
||
offen: ['pill-warn', 'Zu prüfen'],
|
||
rueckfrage: ['pill-muted', 'Rückfrage beim Kunden'],
|
||
genehmigt: ['pill-ok', 'Genehmigt'],
|
||
abgelehnt: ['pill-red', 'Abgelehnt'],
|
||
}
|
||
|
||
const CREDIT_HISTORY_LABELS = {
|
||
eingereicht: 'Antrag eingereicht',
|
||
rueckfrage: 'Rückfrage an den Kunden',
|
||
antwort: 'Antwort des Kunden',
|
||
genehmigt: 'Genehmigt',
|
||
abgelehnt: 'Abgelehnt',
|
||
verrechnet: 'In Stripe gutgeschrieben',
|
||
'nachweise-geloescht': 'Nachweise gelöscht',
|
||
}
|
||
|
||
const CREDIT_MONTHS = [1, 3, 6, 12, 24, 36]
|
||
|
||
const creditState = { filter: 'open', activeId: '', detail: null, calcTimer: null, amountTouched: false }
|
||
|
||
const euroFromCents = (cents) => fmtEuro((Number(cents) || 0) / 100)
|
||
|
||
function fmtBytes(bytes) {
|
||
const n = Number(bytes || 0)
|
||
return n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`
|
||
}
|
||
|
||
function creditStatusPill(status) {
|
||
const [cls, label] = CREDIT_STATUS[status] || ['pill-muted', status]
|
||
return `<span class="pill ${cls}">${label}</span>`
|
||
}
|
||
|
||
function updateCreditBadge(open) {
|
||
const badge = document.getElementById('admin-credit-badge')
|
||
if (!badge) return
|
||
badge.textContent = open
|
||
badge.classList.toggle('hidden', !(open > 0))
|
||
}
|
||
|
||
async function pollCreditBadge() {
|
||
if (document.hidden) return
|
||
try {
|
||
const { open } = await api('/api/portal-admin/takeover-credits/open-count')
|
||
updateCreditBadge(open)
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
async function loadCredits() {
|
||
const tbody = document.getElementById('credit-rows')
|
||
try {
|
||
const { requests, openCount } = await api(`/api/portal-admin/takeover-credits?status=${creditState.filter}`)
|
||
updateCreditBadge(openCount)
|
||
if (!requests.length) {
|
||
tbody.innerHTML = `<tr><td colspan="7" class="muted">${creditState.filter === 'open' ? 'Keine offenen Anträge.' : 'Noch keine Anträge.'}</td></tr>`
|
||
return
|
||
}
|
||
tbody.innerHTML = requests.map((r) => `
|
||
<tr data-credit-row="${escapeHtml(r.id)}"${r.id === creditState.activeId ? ' class="credit-active"' : ''}>
|
||
<td>${fmtDateTime(r.createdAt)}<br><span class="muted">${escapeHtml(r.ref)}</span></td>
|
||
<td><strong>${escapeHtml(r.customerName)}</strong>${r.customerCode ? `<br><span class="muted">Kd.-Nr. ${escapeHtml(r.customerCode)}</span>` : ''}</td>
|
||
<td>${escapeHtml(r.services)}<br><span class="muted">${escapeHtml(r.provider)}${r.domain ? ` · ${escapeHtml(r.domain)}` : ''}</span></td>
|
||
<td>${euroFromCents(r.invoiceAmountCents)}<br><span class="muted">für ${r.billingMonths} Mon.</span></td>
|
||
<td>${fmtDate(r.paidUntil)}</td>
|
||
<td>${creditStatusPill(r.status)}${r.status === 'genehmigt' ? `<br><span class="muted">${euroFromCents(r.amountCents)}${r.appliedAt ? '' : ' · nicht gebucht'}</span>` : ''}</td>
|
||
<td><button class="mini-btn primary" data-open-credit="${escapeHtml(r.id)}">${['offen', 'rueckfrage'].includes(r.status) ? 'Prüfen' : 'Ansehen'}</button></td>
|
||
</tr>`).join('')
|
||
tbody.querySelectorAll('[data-open-credit]').forEach((btn) =>
|
||
btn.addEventListener('click', () => openCreditDetail(btn.dataset.openCredit)))
|
||
} catch (err) {
|
||
tbody.innerHTML = `<tr><td colspan="7" class="error">${escapeHtml(err.message)}</td></tr>`
|
||
}
|
||
}
|
||
|
||
function setCreditUrl(id) {
|
||
const url = new URL(window.location.href)
|
||
if (id) url.searchParams.set('gutschrift', id)
|
||
else url.searchParams.delete('gutschrift')
|
||
window.history.replaceState(null, '', url)
|
||
}
|
||
|
||
function closeCreditDetail() {
|
||
creditState.activeId = ''
|
||
document.getElementById('credit-detail').classList.add('hidden')
|
||
document.querySelectorAll('[data-credit-row]').forEach((tr) => tr.classList.remove('credit-active'))
|
||
setCreditUrl('')
|
||
}
|
||
|
||
async function openCreditDetail(id, { message = '' } = {}) {
|
||
creditState.activeId = id
|
||
creditState.amountTouched = false
|
||
const panel = document.getElementById('credit-detail')
|
||
panel.classList.remove('hidden')
|
||
panel.innerHTML = '<p class="muted">Lade Antrag…</p>'
|
||
document.querySelectorAll('[data-credit-row]').forEach((tr) =>
|
||
tr.classList.toggle('credit-active', tr.dataset.creditRow === id))
|
||
setCreditUrl(id)
|
||
try {
|
||
const d = await api(`/api/portal-admin/takeover-credits/${encodeURIComponent(id)}`)
|
||
creditState.detail = d
|
||
renderCreditDetail(panel, d, message)
|
||
} catch (err) {
|
||
panel.innerHTML = `<p class="error">${escapeHtml(err.message)}</p>`
|
||
}
|
||
panel.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||
}
|
||
|
||
function calcSummary(calc) {
|
||
return `Rechnungszeitraum ${fmtDate(calc.periodStart)} – ${fmtDate(calc.periodEnd)} (${calc.periodDays} Tage). ` +
|
||
`Doppelt bezahlt ab ${fmtDate(calc.overlapStart)}: ${calc.overlapDays} Tage → Vorschlag ${euroFromCents(calc.suggestedCents)}`
|
||
}
|
||
|
||
function creditDecisionFormHtml(d) {
|
||
const r = d.request
|
||
const suggested = d.calc ? d.calc.suggestedCents : 0
|
||
return `
|
||
<h4>Prüfung durch dich</h4>
|
||
<div class="manual-checks">
|
||
${d.requiredChecks.map((k) => `<label><input type="checkbox" data-check="${escapeHtml(k.key)}"> ${escapeHtml(k.label)}</label>`).join('')}
|
||
</div>
|
||
<h4>Berechnung</h4>
|
||
<div class="calc-grid">
|
||
<div><label for="calc-amount">Rechnungsbetrag (€)</label>
|
||
<input class="credit-input" id="calc-amount" type="number" step="0.01" min="0.01" value="${(r.invoiceAmountCents / 100).toFixed(2)}"></div>
|
||
<div><label for="calc-months">Rechnung gilt für</label>
|
||
<select class="credit-input" id="calc-months">${CREDIT_MONTHS.map((m) => `<option value="${m}"${m === r.billingMonths ? ' selected' : ''}>${m} Monat${m === 1 ? '' : 'e'}</option>`).join('')}</select></div>
|
||
<div><label for="calc-paid-until">Bezahlt bis</label>
|
||
<input class="credit-input" id="calc-paid-until" type="date" value="${escapeHtml(r.paidUntilDate)}"></div>
|
||
<div><label for="calc-from">Übernahme ab</label>
|
||
<input class="credit-input" id="calc-from" type="date" value="${escapeHtml(r.takeoverFromDate)}"></div>
|
||
</div>
|
||
<div class="calc-result" id="calc-result">${d.calc ? escapeHtml(calcSummary(d.calc)) : 'Angaben unvollständig'}</div>
|
||
<p class="admin-note" style="margin-top:0">Werte weichen von der Rechnung ab? Hier korrigieren – die Korrektur wird beim Genehmigen im Verlauf vermerkt. „Übernahme ab“ ist der Leistungsbeginn des Abos.</p>
|
||
<div class="credit-field"><label for="credit-amount">Gutschrift (€)</label>
|
||
<input class="credit-input" id="credit-amount" type="number" step="0.01" min="0.01" value="${(suggested / 100).toFixed(2)}" style="max-width:180px"></div>
|
||
<div class="credit-field"><label for="credit-note">Nachricht an den Kunden (bei Rückfrage und Ablehnung Pflicht, beim Genehmigen optional)</label>
|
||
<textarea class="credit-input" id="credit-note" maxlength="4000"></textarea></div>
|
||
<div class="credit-actions">
|
||
<button class="mini-btn primary" id="credit-approve" disabled>Genehmigen & 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
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------- Abo-Angebote --- */
|
||
const OFFER_STATUS = {
|
||
offen: ['pill-warn', 'offen'],
|
||
aktiv: ['pill-ok', 'aktiv'],
|
||
abgelaufen: ['pill-muted', 'abgelaufen'],
|
||
zurueckgezogen: ['pill-muted', 'zurückgezogen'],
|
||
}
|
||
|
||
const offerState = { filter: 'open', customers: null, labelToId: new Map(), context: null }
|
||
|
||
function offerStatusPill(o) {
|
||
const [cls, label] = OFFER_STATUS[o.status] || ['pill-muted', o.status]
|
||
const sub = o.status === 'aktiv' && o.appliedAt ? `seit ${fmtDate(o.appliedAt)}` : o.status === 'offen' ? 'wartet auf Abschluss' : ''
|
||
return `<span class="pill ${cls}">${label}</span>${sub ? `<br><span class="muted">${sub}</span>` : ''}`
|
||
}
|
||
|
||
// Vorschau im Formular; verbindlich ist der Text, den der Server beim Anlegen erzeugt
|
||
function offerPreviewText(price, months, regular, running) {
|
||
const eur = (cents) => euroFromCents(cents)
|
||
const lead = months === 1 ? (running ? 'Nächster Monat' : 'Erster Monat') : `${running ? 'Nächste' : 'Erste'} ${months} Monate`
|
||
const amount = price === 0 ? 'kostenlos' : `${months === 1 ? '' : 'je '}${eur(price)} statt ${eur(regular)}`
|
||
return `${lead} ${amount}, danach ${eur(regular)} pro Monat`
|
||
}
|
||
|
||
// Datumsfeld "2026-10-27" -> "27.10.2026", ohne Umweg über Date (keine Zeitzonen-Verschiebung)
|
||
const fmtYmd = (ymd) => String(ymd || '').split('-').reverse().join('.')
|
||
|
||
function showOfferMessage(text) {
|
||
const el = document.getElementById('offer-message')
|
||
el.textContent = text
|
||
el.classList.toggle('hidden', !text)
|
||
}
|
||
|
||
async function loadOffers() {
|
||
const tbody = document.getElementById('offer-rows')
|
||
try {
|
||
const { offers } = await api('/api/portal-admin/offers')
|
||
const shown = offerState.filter === 'open' ? offers.filter((o) => ['offen', 'aktiv'].includes(o.status)) : offers
|
||
if (!shown.length) {
|
||
tbody.innerHTML = `<tr><td colspan="5" class="muted">${offerState.filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'}</td></tr>`
|
||
return
|
||
}
|
||
tbody.innerHTML = shown.map((o) => `
|
||
<tr>
|
||
<td>${fmtDateTime(o.createdAt)}<br><span class="muted">${escapeHtml(o.ref)}${o.createdBy ? ` · ${escapeHtml(o.createdBy.replace(/\s*<.*>$/, ''))}` : ''}</span></td>
|
||
<td><strong>${escapeHtml(o.customerName)}</strong>${o.customerCode ? `<br><span class="muted">Kd.-Nr. ${escapeHtml(o.customerCode)}</span>` : ''}</td>
|
||
<td>${escapeHtml(o.text)}<br><span class="muted">Ersparnis ${euroFromCents(o.savingsCents)}${o.note ? ` · „${escapeHtml(o.note)}“` : ''}</span>
|
||
${o.internalNote ? `<br><span class="muted"><em>intern: ${escapeHtml(o.internalNote)}</em></span>` : ''}</td>
|
||
<td style="white-space:nowrap;overflow-wrap:normal">${o.scope === 'laufend' ? '<span class="muted">laufendes Abo</span>' : fmtDate(o.validUntil)}</td>
|
||
<td>${offerStatusPill(o)}
|
||
<br><span class="muted">Stripe:</span> ${o.stripeCouponUrl ? `<a class="plain" href="${escapeHtml(o.stripeCouponUrl)}" target="_blank" rel="noreferrer">Coupon</a>` : '–'}${o.stripeSubscriptionUrl ? ` · <a class="plain" href="${escapeHtml(o.stripeSubscriptionUrl)}" target="_blank" rel="noreferrer">Abo</a>` : ''}
|
||
${['offen', 'abgelaufen'].includes(o.status) ? `<div style="margin-top:6px"><button class="mini-btn danger" data-withdraw-offer="${escapeHtml(o.id)}">Zurückziehen</button></div>` : ''}</td>
|
||
</tr>`).join('')
|
||
tbody.querySelectorAll('[data-withdraw-offer]').forEach((btn) =>
|
||
btn.addEventListener('click', () => withdrawOfferAction(btn.dataset.withdrawOffer, btn)))
|
||
} catch (err) {
|
||
tbody.innerHTML = `<tr><td colspan="5" class="error">${escapeHtml(err.message)}</td></tr>`
|
||
}
|
||
}
|
||
|
||
async function withdrawOfferAction(id, btn) {
|
||
if (!window.confirm('Angebot zurückziehen? Der Coupon wird in Stripe gelöscht, der Kunde sieht das Angebot nicht mehr.')) return
|
||
btn.disabled = true
|
||
try {
|
||
await api(`/api/portal-admin/offers/${encodeURIComponent(id)}/withdraw`, { method: 'POST' })
|
||
showOfferMessage('Angebot zurückgezogen, Coupon in Stripe gelöscht.')
|
||
loadOffers()
|
||
} catch (err) {
|
||
btn.disabled = false
|
||
alert('Zurückziehen fehlgeschlagen: ' + err.message)
|
||
}
|
||
}
|
||
|
||
const offerCustomerLabel = (c) =>
|
||
[c.name || '(ohne Name)', c.email, c.code ? `Kd.-Nr. ${c.code}` : ''].filter(Boolean).join(' · ')
|
||
|
||
async function ensureOfferCustomers() {
|
||
if (offerState.customers) return offerState.customers
|
||
const { customers } = await api('/api/portal-admin/customers')
|
||
offerState.customers = customers
|
||
offerState.labelToId = new Map(customers.map((c) => [offerCustomerLabel(c), c.id]))
|
||
return customers
|
||
}
|
||
|
||
function offerFormValues(form) {
|
||
return {
|
||
customerId: offerState.labelToId.get(form.querySelector('#offer-customer').value.trim()) || '',
|
||
monthlyPriceCents: Math.round(Number(form.querySelector('#offer-price').value) * 100),
|
||
months: Number(form.querySelector('#offer-months').value),
|
||
validUntil: form.querySelector('#offer-valid').value,
|
||
note: form.querySelector('#offer-note').value.trim(),
|
||
internalNote: form.querySelector('#offer-internal').value.trim(),
|
||
notifyCustomer: form.querySelector('#offer-notify').checked,
|
||
}
|
||
}
|
||
|
||
function renderOfferPreview(form) {
|
||
const box = form.querySelector('#offer-preview')
|
||
const save = form.querySelector('#offer-save')
|
||
const ctx = offerState.context
|
||
const v = offerFormValues(form)
|
||
save.disabled = true
|
||
if (!v.customerId || !ctx || ctx.customer.id !== v.customerId) {
|
||
box.textContent = v.customerId ? 'Lade Kunde…' : 'Bitte einen Kunden aus der Liste wählen.'
|
||
return
|
||
}
|
||
const regular = ctx.regularPriceCents
|
||
const running = Boolean(ctx.subscription?.active)
|
||
const lines = []
|
||
let ok = true
|
||
if (ctx.openOffer) {
|
||
lines.push(`Es gibt schon ein offenes Angebot (${ctx.openOffer.ref}: ${ctx.openOffer.text}) – bitte zuerst zurückziehen.`)
|
||
ok = false
|
||
}
|
||
if (!Number.isInteger(v.monthlyPriceCents) || v.monthlyPriceCents < 0 || v.monthlyPriceCents >= regular) {
|
||
lines.push(`Angebotspreis bitte zwischen 0,00 € und ${euroFromCents(regular - 1)} angeben.`)
|
||
ok = false
|
||
}
|
||
if (!v.validUntil && !running) {
|
||
lines.push('Bitte „Gültig bis“ angeben.')
|
||
ok = false
|
||
}
|
||
if (ok) {
|
||
lines.push(`${offerPreviewText(v.monthlyPriceCents, v.months, regular, running)} · Ersparnis ${euroFromCents((regular - v.monthlyPriceCents) * v.months)}`)
|
||
if (!running) lines.push(`Angebot gültig bis ${fmtYmd(v.validUntil)}`)
|
||
}
|
||
lines.push(running
|
||
? `Laufendes Abo: gilt ab der nächsten Abbuchung${ctx.subscription.currentPeriodEnd ? ` (${fmtDate(ctx.subscription.currentPeriodEnd)})` : ''} und wird sofort in Stripe angewendet.`
|
||
: 'Noch kein laufendes Abo: Der Coupon wird angelegt und beim Abschluss im Kundenportal angewendet.')
|
||
if (!running && !ctx.customer.portalAccessEnabled) lines.push('Achtung: Kunde hat keinen Portalzugang – er kann erst bestellen, wenn der Zugang freigeschaltet ist.')
|
||
if (ctx.customer.testAccount) lines.push('Testkonto: Im Checkout bleibt der 0,50-€-Testrabatt, das Angebot wird danach zusätzlich angehängt.')
|
||
box.innerHTML = lines.map((l) => `<div>${escapeHtml(l)}</div>`).join('')
|
||
save.disabled = !ok
|
||
save.textContent = running ? 'Angebot anlegen und aufs laufende Abo anwenden' : 'Angebot anlegen'
|
||
}
|
||
|
||
async function loadOfferContext(form) {
|
||
const v = offerFormValues(form)
|
||
offerState.context = null
|
||
renderOfferPreview(form)
|
||
if (!v.customerId) return
|
||
try {
|
||
offerState.context = await api(`/api/portal-admin/offers/context/${encodeURIComponent(v.customerId)}`)
|
||
if (!form.querySelector('#offer-valid').value) form.querySelector('#offer-valid').value = offerState.context.defaultValidUntil
|
||
} catch (err) {
|
||
form.querySelector('#offer-preview').textContent = err.message
|
||
return
|
||
}
|
||
renderOfferPreview(form)
|
||
}
|
||
|
||
async function saveOffer(form) {
|
||
const errEl = form.querySelector('#offer-error')
|
||
errEl.classList.add('hidden')
|
||
const v = offerFormValues(form)
|
||
const ctx = offerState.context
|
||
const running = Boolean(ctx.subscription?.active)
|
||
const text = offerPreviewText(v.monthlyPriceCents, v.months, ctx.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')
|
||
showOfferMessage([
|
||
`Angebot ${o.ref} für ${o.customerName} angelegt: ${o.text}${o.scope === 'laufend' ? '' : `, gültig bis ${fmtDate(o.validUntil)}`}.`,
|
||
`Stripe-Coupon ${o.stripeCouponId} ist eingerichtet${o.status === 'aktiv' ? ' und am laufenden Abo aktiv' : ''}.`,
|
||
v.notifyCustomer ? (result.mailSent ? 'Die E-Mail an den Kunden ist raus.' : `E-Mail an den Kunden fehlgeschlagen (${result.mailError}).`) : '',
|
||
].filter(Boolean).join(' '))
|
||
loadOffers()
|
||
} catch (err) {
|
||
errEl.textContent = err.message
|
||
errEl.classList.remove('hidden')
|
||
renderOfferPreview(form)
|
||
}
|
||
}
|
||
|
||
async function openOfferForm(customerId = '') {
|
||
const form = document.getElementById('offer-form')
|
||
showOfferMessage('')
|
||
form.classList.remove('hidden')
|
||
form.innerHTML = '<p class="muted">Lade Kunden…</p>'
|
||
let customers
|
||
try {
|
||
customers = await ensureOfferCustomers()
|
||
} catch (err) {
|
||
form.innerHTML = `<p class="error">${escapeHtml(err.message)}</p>`
|
||
return
|
||
}
|
||
const preselected = customers.find((c) => c.id === customerId)
|
||
form.innerHTML = `
|
||
<h3 style="margin-top:0">Neues Angebot</h3>
|
||
<div class="calc-grid">
|
||
<div style="grid-column:1/-1"><label for="offer-customer">Kunde</label>
|
||
<input class="credit-input" id="offer-customer" list="offer-customer-list" autocomplete="off"
|
||
placeholder="Name, E-Mail oder Kundennummer tippen …" value="${preselected ? escapeHtml(offerCustomerLabel(preselected)) : ''}">
|
||
<datalist id="offer-customer-list">${customers.map((c) => `<option value="${escapeHtml(offerCustomerLabel(c))}"></option>`).join('')}</datalist></div>
|
||
<div><label for="offer-price">Preis pro Monat im Angebot (€)</label>
|
||
<input class="credit-input" id="offer-price" type="number" min="0" step="0.01" value="25.00"></div>
|
||
<div><label for="offer-months">für</label>
|
||
<select class="credit-input" id="offer-months">${Array.from({ length: 12 }, (_, i) => i + 1)
|
||
.map((m) => `<option value="${m}"${m === 2 ? ' selected' : ''}>${m} Monat${m === 1 ? '' : 'e'}</option>`).join('')}</select></div>
|
||
<div><label for="offer-valid">Angebot gültig bis</label>
|
||
<input class="credit-input" id="offer-valid" type="date"></div>
|
||
</div>
|
||
<div class="credit-field"><label for="offer-note">Hinweis für den Kunden (optional, z. B. „Übernahme Ihres IONOS-Hostings“)</label>
|
||
<input class="credit-input" id="offer-note" maxlength="500"></div>
|
||
<div class="credit-field"><label for="offer-internal">Interne Notiz (optional, sieht nur das Team)</label>
|
||
<input class="credit-input" id="offer-internal" maxlength="2000"></div>
|
||
<div class="manual-checks" style="margin-top:8px"><label><input type="checkbox" id="offer-notify" checked> Kunden per E-Mail über das Angebot informieren</label></div>
|
||
<div class="calc-result" id="offer-preview">Bitte einen Kunden aus der Liste wählen.</div>
|
||
<div class="credit-actions">
|
||
<button class="mini-btn primary" id="offer-save" disabled>Angebot anlegen</button>
|
||
<button class="mini-btn" id="offer-cancel">Abbrechen</button>
|
||
</div>
|
||
<p class="error hidden" id="offer-error"></p>`
|
||
|
||
form.querySelector('#offer-customer').addEventListener('change', () => loadOfferContext(form))
|
||
form.querySelector('#offer-customer').addEventListener('input', () => {
|
||
if (offerState.labelToId.has(form.querySelector('#offer-customer').value.trim())) loadOfferContext(form)
|
||
})
|
||
form.querySelectorAll('#offer-price, #offer-months, #offer-valid').forEach((el) => el.addEventListener('input', () => renderOfferPreview(form)))
|
||
form.querySelector('#offer-save').addEventListener('click', () => saveOffer(form))
|
||
form.querySelector('#offer-cancel').addEventListener('click', () => form.classList.add('hidden'))
|
||
if (preselected) loadOfferContext(form)
|
||
form.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||
}
|
||
|
||
/** Aus der Kundenliste: Tab wechseln und Formular mit dem Kunden oeffnen. */
|
||
function startOfferFor(customerId) {
|
||
document.querySelector('.admin-tab[data-tab="offers"]')?.click()
|
||
openOfferForm(customerId)
|
||
}
|
||
|
||
/* ----------------------------------------------------------------- Init --- */
|
||
async function initAdminPage() {
|
||
document.getElementById('logout-btn')?.addEventListener('click', async () => {
|
||
await api('/api/auth/logout', { method: 'POST' }).catch(() => {})
|
||
window.location.href = '/login.html'
|
||
})
|
||
|
||
// Nach dem Login zurück zur aufgerufenen Adresse (z. B. Link aus der Gutschrift-Mail)
|
||
const loginUrl = `/login.html?next=${encodeURIComponent(window.location.pathname + window.location.search)}`
|
||
try {
|
||
const me = await api('/api/auth/me')
|
||
if (!me.authenticated || me.role !== 'admin') {
|
||
window.location.href = loginUrl
|
||
return
|
||
}
|
||
if (me.viewAs) {
|
||
// Admin ist gerade in der Kundenansicht -> zurückholen
|
||
await api('/api/portal-admin/exit-view-as', { method: 'POST' }).catch(() => {})
|
||
}
|
||
document.getElementById('admin-meta').textContent =
|
||
`${me.admin?.name || 'Admin'} (${me.admin?.email || ''})`
|
||
} catch {
|
||
window.location.href = loginUrl
|
||
return
|
||
}
|
||
|
||
let chatListLoaded = false
|
||
let creditsLoaded = false
|
||
let offersLoaded = false
|
||
document.querySelectorAll('.admin-tab').forEach((tab) => {
|
||
tab.addEventListener('click', () => {
|
||
document.querySelectorAll('.admin-tab').forEach((t) => t.classList.remove('active'))
|
||
document.querySelectorAll('.admin-section').forEach((s) => s.classList.remove('active'))
|
||
tab.classList.add('active')
|
||
document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active')
|
||
if (tab.dataset.tab === 'chat' && !chatListLoaded) {
|
||
chatListLoaded = true
|
||
loadChatList()
|
||
}
|
||
if (tab.dataset.tab === 'credits' && !creditsLoaded) {
|
||
creditsLoaded = true
|
||
loadCredits()
|
||
}
|
||
if (tab.dataset.tab === 'offers' && !offersLoaded) {
|
||
offersLoaded = true
|
||
loadOffers()
|
||
}
|
||
})
|
||
})
|
||
|
||
document.querySelectorAll('[data-offer-filter]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
document.querySelectorAll('[data-offer-filter]').forEach((b) => b.classList.toggle('active', b === btn))
|
||
offerState.filter = btn.dataset.offerFilter
|
||
loadOffers()
|
||
})
|
||
})
|
||
document.getElementById('offer-new')?.addEventListener('click', () => openOfferForm())
|
||
|
||
document.querySelectorAll('[data-credit-filter]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
document.querySelectorAll('[data-credit-filter]').forEach((b) => b.classList.toggle('active', b === btn))
|
||
creditState.filter = btn.dataset.creditFilter
|
||
loadCredits()
|
||
})
|
||
})
|
||
|
||
// Direktlink aus der Admin-Mail: /admin.html?gutschrift=<id>
|
||
const creditFromLink = new URLSearchParams(window.location.search).get('gutschrift')
|
||
if (creditFromLink) {
|
||
document.querySelector('.admin-tab[data-tab="credits"]')?.click()
|
||
openCreditDetail(creditFromLink)
|
||
}
|
||
|
||
document.getElementById('project-search')?.addEventListener('input', (e) => {
|
||
clearTimeout(projectSearchTimer)
|
||
projectSearchTimer = setTimeout(() => loadProjects(e.target.value.trim()), 250)
|
||
})
|
||
document.getElementById('customer-search')?.addEventListener('input', (e) => {
|
||
clearTimeout(customerSearchTimer)
|
||
customerSearchTimer = setTimeout(() => loadCustomers(e.target.value.trim()), 250)
|
||
})
|
||
|
||
loadOverview()
|
||
loadProjects()
|
||
loadCustomers()
|
||
|
||
// Chat- und Gutschrift-Badge global pollen
|
||
pollAdminChatBadge()
|
||
setInterval(pollAdminChatBadge, 60000)
|
||
pollCreditBadge()
|
||
setInterval(pollCreditBadge, 60000)
|
||
}
|
||
|
||
window.initAdminPage = initAdminPage
|