Abo: Uebernahmegutschrift fuer beim Altanbieter bezahlte Leistungen
- Kunde (Dashboard, nur mit laufendem Abo): Antrag mit Leistung, Anbieter, Domain, Rechnungsbetrag/-zeitraum, Grund, Rechnungsnachweis (PDF/JPG/PNG) und E-Mail fuer die Antwort; Eingangsbestaetigung per Mail, Status und Antwort auf Rueckfragen im Portal - Admin-Mail mit Link admin.html?gutschrift=<id> (Login leitet zurueck) - Admin-Tab "Gutschriften": Kunde und Vertrag, Nachweise, Verlauf, automatische Pruefungen (Duplikate per Datei-Hash/Domain, Abo, Zeitraum, DNS-Abgleich Nameserver/MX mit Anbieter), Pflicht-Checkliste, Rechner (tagesgenau anteilig), Genehmigen/Rueckfrage/Ablehnen mit Kundenmail - Genehmigte Gutschrift als Stripe-Kundenguthaben (idempotent, Nachbuchen bei Stripe-Stoerung) - Nachweise in upload-data/ (gitignored), Loeschung abgelehnter nach 6 Monaten - Stundenkontingent auch fuer Monate, die ein Guthaben komplett deckt (invoice.total statt amount_paid) - Datenschutzerklaerung Abschnitt 7 ergaenzt, PDF neu erzeugt - Collection takeoverCredits in setup-abo-collections.mjs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dbabbf1c0b
commit
48b13f4488
14 files changed
+2040
-9
No files matched your search
+400
-3
@@ -399,6 +399,379 @@ async function openChatThread(customerId, customerName) {
|
||||
adminChat.threadTimer = setInterval(pollAdminThread, 15000)
|
||||
}
|
||||
|
||||
/* ---------------------------------------------- Übernahmegutschriften --- */
|
||||
const CREDIT_STATUS = {
|
||||
offen: ['pill-warn', 'Zu prüfen'],
|
||||
rueckfrage: ['pill-muted', 'Rückfrage beim Kunden'],
|
||||
genehmigt: ['pill-ok', 'Genehmigt'],
|
||||
abgelehnt: ['pill-red', 'Abgelehnt'],
|
||||
}
|
||||
|
||||
const CREDIT_HISTORY_LABELS = {
|
||||
eingereicht: 'Antrag eingereicht',
|
||||
rueckfrage: 'Rückfrage an den Kunden',
|
||||
antwort: 'Antwort des Kunden',
|
||||
genehmigt: 'Genehmigt',
|
||||
abgelehnt: 'Abgelehnt',
|
||||
verrechnet: 'In Stripe gutgeschrieben',
|
||||
'nachweise-geloescht': 'Nachweise gelöscht',
|
||||
}
|
||||
|
||||
const CREDIT_MONTHS = [1, 3, 6, 12, 24, 36]
|
||||
|
||||
const creditState = { filter: 'open', activeId: '', detail: null, calcTimer: null, amountTouched: false }
|
||||
|
||||
const euroFromCents = (cents) => fmtEuro((Number(cents) || 0) / 100)
|
||||
|
||||
function fmtBytes(bytes) {
|
||||
const n = Number(bytes || 0)
|
||||
return n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`
|
||||
}
|
||||
|
||||
function creditStatusPill(status) {
|
||||
const [cls, label] = CREDIT_STATUS[status] || ['pill-muted', status]
|
||||
return `<span class="pill ${cls}">${label}</span>`
|
||||
}
|
||||
|
||||
function updateCreditBadge(open) {
|
||||
const badge = document.getElementById('admin-credit-badge')
|
||||
if (!badge) return
|
||||
badge.textContent = open
|
||||
badge.classList.toggle('hidden', !(open > 0))
|
||||
}
|
||||
|
||||
async function pollCreditBadge() {
|
||||
if (document.hidden) return
|
||||
try {
|
||||
const { open } = await api('/api/portal-admin/takeover-credits/open-count')
|
||||
updateCreditBadge(open)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCredits() {
|
||||
const tbody = document.getElementById('credit-rows')
|
||||
try {
|
||||
const { requests, openCount } = await api(`/api/portal-admin/takeover-credits?status=${creditState.filter}`)
|
||||
updateCreditBadge(openCount)
|
||||
if (!requests.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="7" class="muted">${creditState.filter === 'open' ? 'Keine offenen Anträge.' : 'Noch keine Anträge.'}</td></tr>`
|
||||
return
|
||||
}
|
||||
tbody.innerHTML = requests.map((r) => `
|
||||
<tr data-credit-row="${escapeHtml(r.id)}"${r.id === creditState.activeId ? ' class="credit-active"' : ''}>
|
||||
<td>${fmtDateTime(r.createdAt)}<br><span class="muted">${escapeHtml(r.ref)}</span></td>
|
||||
<td><strong>${escapeHtml(r.customerName)}</strong>${r.customerCode ? `<br><span class="muted">Kd.-Nr. ${escapeHtml(r.customerCode)}</span>` : ''}</td>
|
||||
<td>${escapeHtml(r.services)}<br><span class="muted">${escapeHtml(r.provider)}${r.domain ? ` · ${escapeHtml(r.domain)}` : ''}</span></td>
|
||||
<td>${euroFromCents(r.invoiceAmountCents)}<br><span class="muted">für ${r.billingMonths} Mon.</span></td>
|
||||
<td>${fmtDate(r.paidUntil)}</td>
|
||||
<td>${creditStatusPill(r.status)}${r.status === 'genehmigt' ? `<br><span class="muted">${euroFromCents(r.amountCents)}${r.appliedAt ? '' : ' · nicht gebucht'}</span>` : ''}</td>
|
||||
<td><button class="mini-btn primary" data-open-credit="${escapeHtml(r.id)}">${['offen', 'rueckfrage'].includes(r.status) ? 'Prüfen' : 'Ansehen'}</button></td>
|
||||
</tr>`).join('')
|
||||
tbody.querySelectorAll('[data-open-credit]').forEach((btn) =>
|
||||
btn.addEventListener('click', () => openCreditDetail(btn.dataset.openCredit)))
|
||||
} catch (err) {
|
||||
tbody.innerHTML = `<tr><td colspan="7" class="error">${escapeHtml(err.message)}</td></tr>`
|
||||
}
|
||||
}
|
||||
|
||||
function setCreditUrl(id) {
|
||||
const url = new URL(window.location.href)
|
||||
if (id) url.searchParams.set('gutschrift', id)
|
||||
else url.searchParams.delete('gutschrift')
|
||||
window.history.replaceState(null, '', url)
|
||||
}
|
||||
|
||||
function closeCreditDetail() {
|
||||
creditState.activeId = ''
|
||||
document.getElementById('credit-detail').classList.add('hidden')
|
||||
document.querySelectorAll('[data-credit-row]').forEach((tr) => tr.classList.remove('credit-active'))
|
||||
setCreditUrl('')
|
||||
}
|
||||
|
||||
async function openCreditDetail(id, { message = '' } = {}) {
|
||||
creditState.activeId = id
|
||||
creditState.amountTouched = false
|
||||
const panel = document.getElementById('credit-detail')
|
||||
panel.classList.remove('hidden')
|
||||
panel.innerHTML = '<p class="muted">Lade Antrag…</p>'
|
||||
document.querySelectorAll('[data-credit-row]').forEach((tr) =>
|
||||
tr.classList.toggle('credit-active', tr.dataset.creditRow === id))
|
||||
setCreditUrl(id)
|
||||
try {
|
||||
const d = await api(`/api/portal-admin/takeover-credits/${encodeURIComponent(id)}`)
|
||||
creditState.detail = d
|
||||
renderCreditDetail(panel, d, message)
|
||||
} catch (err) {
|
||||
panel.innerHTML = `<p class="error">${escapeHtml(err.message)}</p>`
|
||||
}
|
||||
panel.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
function calcSummary(calc) {
|
||||
return `Rechnungszeitraum ${fmtDate(calc.periodStart)} – ${fmtDate(calc.periodEnd)} (${calc.periodDays} Tage). ` +
|
||||
`Doppelt bezahlt ab ${fmtDate(calc.overlapStart)}: ${calc.overlapDays} Tage → Vorschlag ${euroFromCents(calc.suggestedCents)}`
|
||||
}
|
||||
|
||||
function creditDecisionFormHtml(d) {
|
||||
const r = d.request
|
||||
const suggested = d.calc ? d.calc.suggestedCents : 0
|
||||
return `
|
||||
<h4>Prüfung durch dich</h4>
|
||||
<div class="manual-checks">
|
||||
${d.requiredChecks.map((k) => `<label><input type="checkbox" data-check="${escapeHtml(k.key)}"> ${escapeHtml(k.label)}</label>`).join('')}
|
||||
</div>
|
||||
<h4>Berechnung</h4>
|
||||
<div class="calc-grid">
|
||||
<div><label for="calc-amount">Rechnungsbetrag (€)</label>
|
||||
<input class="credit-input" id="calc-amount" type="number" step="0.01" min="0.01" value="${(r.invoiceAmountCents / 100).toFixed(2)}"></div>
|
||||
<div><label for="calc-months">Rechnung gilt für</label>
|
||||
<select class="credit-input" id="calc-months">${CREDIT_MONTHS.map((m) => `<option value="${m}"${m === r.billingMonths ? ' selected' : ''}>${m} Monat${m === 1 ? '' : 'e'}</option>`).join('')}</select></div>
|
||||
<div><label for="calc-paid-until">Bezahlt bis</label>
|
||||
<input class="credit-input" id="calc-paid-until" type="date" value="${escapeHtml(r.paidUntilDate)}"></div>
|
||||
<div><label for="calc-from">Übernahme ab</label>
|
||||
<input class="credit-input" id="calc-from" type="date" value="${escapeHtml(r.takeoverFromDate)}"></div>
|
||||
</div>
|
||||
<div class="calc-result" id="calc-result">${d.calc ? escapeHtml(calcSummary(d.calc)) : 'Angaben unvollständig'}</div>
|
||||
<p class="admin-note" style="margin-top:0">Werte weichen von der Rechnung ab? Hier korrigieren – die Korrektur wird beim Genehmigen im Verlauf vermerkt. „Übernahme ab“ ist der Leistungsbeginn des Abos.</p>
|
||||
<div class="credit-field"><label for="credit-amount">Gutschrift (€)</label>
|
||||
<input class="credit-input" id="credit-amount" type="number" step="0.01" min="0.01" value="${(suggested / 100).toFixed(2)}" style="max-width:180px"></div>
|
||||
<div class="credit-field"><label for="credit-note">Nachricht an den Kunden (bei Rückfrage und Ablehnung Pflicht, beim Genehmigen optional)</label>
|
||||
<textarea class="credit-input" id="credit-note" maxlength="4000"></textarea></div>
|
||||
<div class="credit-actions">
|
||||
<button class="mini-btn primary" id="credit-approve" disabled>Genehmigen & gutschreiben</button>
|
||||
<button class="mini-btn" id="credit-question">Rückfrage senden</button>
|
||||
<button class="mini-btn danger" id="credit-reject">Ablehnen</button>
|
||||
</div>
|
||||
<p class="admin-note">Genehmigen geht, sobald alle Punkte abgehakt sind. Der Kunde bekommt jede Entscheidung per E-Mail.${d.subscription?.active ? '' : ' <strong>Achtung: Der Kunde hat kein laufendes Abo.</strong>'}</p>
|
||||
<p class="error hidden" id="credit-error"></p>`
|
||||
}
|
||||
|
||||
function creditDecisionSummaryHtml(d) {
|
||||
const r = d.request
|
||||
if (r.status === 'genehmigt') {
|
||||
return `
|
||||
<h4>Entscheidung</h4>
|
||||
<dl class="kv">
|
||||
<dt>Genehmigt</dt><dd>${fmtDateTime(r.decidedAt)} · ${escapeHtml(r.decidedBy)}</dd>
|
||||
<dt>Gutschrift</dt><dd><strong>${euroFromCents(r.amountCents)}</strong></dd>
|
||||
<dt>Berechnung</dt><dd>${escapeHtml(r.calculation)}</dd>
|
||||
<dt>Geprüft</dt><dd>${r.checks ? `alle Punkte abgehakt (${escapeHtml(r.checks.by || '')})` : '–'}</dd>
|
||||
${r.decisionNote ? `<dt>Nachricht</dt><dd class="pre">${escapeHtml(r.decisionNote)}</dd>` : ''}
|
||||
<dt>Verrechnung</dt><dd>${r.appliedAt
|
||||
? `Stripe-Guthaben gebucht am ${fmtDateTime(r.appliedAt)} <span class="muted">(${escapeHtml(r.stripeBalanceTransactionId)})</span>`
|
||||
: '<span class="pill pill-warn">noch nicht in Stripe gebucht</span>'}</dd>
|
||||
</dl>
|
||||
${r.appliedAt ? '' : `
|
||||
<div class="credit-actions"><button class="mini-btn primary" id="credit-apply">Jetzt in Stripe gutschreiben</button></div>
|
||||
<p class="error hidden" id="credit-error"></p>`}`
|
||||
}
|
||||
return `
|
||||
<h4>Entscheidung</h4>
|
||||
<dl class="kv">
|
||||
<dt>Abgelehnt</dt><dd>${fmtDateTime(r.decidedAt)} · ${escapeHtml(r.decidedBy)}</dd>
|
||||
<dt>Begründung</dt><dd class="pre">${escapeHtml(r.decisionNote)}</dd>
|
||||
</dl>`
|
||||
}
|
||||
|
||||
function renderCreditDetail(panel, d, message) {
|
||||
const r = d.request
|
||||
const c = d.customer
|
||||
const s = d.subscription
|
||||
const pending = r.status === 'offen' || r.status === 'rueckfrage'
|
||||
const fileNames = new Map(r.files.map((f) => [f.id, f.name]))
|
||||
const fileHref = (f) => `/api/portal-admin/takeover-credits/${encodeURIComponent(r.id)}/files/${encodeURIComponent(f.id)}`
|
||||
const aboPill = !s
|
||||
? '<span class="pill pill-red">kein Abo</span>'
|
||||
: `<span class="pill ${s.active ? 'pill-ok' : 'pill-red'}">${escapeHtml(s.status)}</span>`
|
||||
const staff = (by) => (by && by !== 'customer' && by !== 'system' ? ` · ${escapeHtml(by)}` : '')
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="credit-head">
|
||||
<div>
|
||||
<h3>Antrag ${escapeHtml(r.ref)} · ${escapeHtml(c.name || c.companyName)}</h3>
|
||||
<span class="muted">eingegangen ${fmtDateTime(r.createdAt)}</span> ${creditStatusPill(r.status)}
|
||||
</div>
|
||||
<button class="mini-btn" id="credit-close">Schließen</button>
|
||||
</div>
|
||||
${message ? `<div class="calc-result">${escapeHtml(message)}</div>` : ''}
|
||||
<div class="credit-cols">
|
||||
<div>
|
||||
<h4>Antrag</h4>
|
||||
<dl class="kv">
|
||||
<dt>Leistung</dt><dd>${escapeHtml(r.services)}</dd>
|
||||
<dt>Bisher bei</dt><dd>${escapeHtml(r.provider)}</dd>
|
||||
<dt>Domain</dt><dd>${r.domain ? `<a class="plain" href="https://${escapeHtml(r.domain)}" target="_blank" rel="noreferrer">${escapeHtml(r.domain)}</a>` : '–'}</dd>
|
||||
<dt>Rechnung</dt><dd>${euroFromCents(r.invoiceAmountCents)} für ${r.billingMonths} Monat${r.billingMonths === 1 ? '' : 'e'}</dd>
|
||||
<dt>Bezahlt bis</dt><dd>${fmtDate(r.paidUntil)}</dd>
|
||||
<dt>Antwort an</dt><dd>${escapeHtml(r.email)}</dd>
|
||||
<dt>Bestätigt</dt><dd>Angaben und Bedingungen (Stand ${escapeHtml(r.conditionsVersion || '–')}) am ${fmtDateTime(r.consentAt)}</dd>
|
||||
</dl>
|
||||
<h4>Grund / Nachricht</h4>
|
||||
<p class="pre">${escapeHtml(r.message)}</p>
|
||||
<h4>Nachweise</h4>
|
||||
${r.files.length
|
||||
? `<ul class="recent-list">${r.files.map((f) => `<li><a class="plain" href="${fileHref(f)}" target="_blank" rel="noreferrer">${escapeHtml(f.name)}</a> <span class="muted">(${fmtBytes(f.size)}, ${fmtDate(f.uploadedAt)})</span></li>`).join('')}</ul>`
|
||||
: '<p class="muted">Keine Nachweise (mehr) vorhanden.</p>'}
|
||||
<h4>Verlauf</h4>
|
||||
${r.history.map((h) => `
|
||||
<div class="history-item">
|
||||
<strong>${escapeHtml(CREDIT_HISTORY_LABELS[h.type] || h.type)}</strong>
|
||||
<span class="muted">${fmtDateTime(h.at)}${staff(h.by)}</span>
|
||||
${h.text ? `<div class="pre">${escapeHtml(h.text)}</div>` : ''}
|
||||
${(h.files || []).length ? `<div class="muted">Dateien: ${h.files.map((id) => escapeHtml(fileNames.get(id) || 'gelöscht')).join(', ')}</div>` : ''}
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<div>
|
||||
<h4>Kunde & Vertrag</h4>
|
||||
<dl class="kv">
|
||||
<dt>Kunde</dt><dd>${escapeHtml(c.name || c.companyName)}${c.code ? ` · Kd.-Nr. ${escapeHtml(c.code)}` : ''}</dd>
|
||||
<dt>E-Mail</dt><dd>${escapeHtml(c.email || '–')}</dd>
|
||||
<dt>Wartungs-Abo</dt><dd>${aboPill}</dd>
|
||||
${s ? `
|
||||
<dt>Leistungsbeginn</dt><dd>${fmtDate(s.serviceStart)}</dd>
|
||||
<dt>Nächste Abbuchung</dt><dd>${s.active ? fmtDate(s.currentPeriodEnd) : '–'}</dd>
|
||||
${s.cancelAt ? `<dt>Gekündigt zum</dt><dd>${fmtDate(s.cancelAt)}</dd>` : ''}
|
||||
<dt>Stripe-Guthaben</dt><dd>${s.creditBalanceCents == null ? '–' : euroFromCents(s.creditBalanceCents)}</dd>` : ''}
|
||||
</dl>
|
||||
<div class="credit-actions">
|
||||
<button class="mini-btn" id="credit-view-as">Als Kunde ansehen</button>
|
||||
${s?.stripeUrl ? `<a class="mini-btn" href="${escapeHtml(s.stripeUrl)}" target="_blank" rel="noreferrer">In Stripe öffnen</a>` : ''}
|
||||
</div>
|
||||
${d.otherRequests.length ? `
|
||||
<h4>Weitere Anträge des Kunden</h4>
|
||||
<ul class="recent-list">${d.otherRequests.map((o) => `
|
||||
<li><a class="plain" href="#" data-open-credit="${escapeHtml(o.id)}">${escapeHtml(o.ref)}</a> · ${escapeHtml(o.services)} · ${fmtDate(o.createdAt)}
|
||||
${creditStatusPill(o.status)}${o.amountCents ? ` ${euroFromCents(o.amountCents)}` : ''}</li>`).join('')}</ul>` : ''}
|
||||
<h4>Automatische Prüfung</h4>
|
||||
<ul class="check-list">${d.autoChecks.map((k) => `
|
||||
<li class="${escapeHtml(k.status)}">${escapeHtml(k.label)}${k.detail ? `<small>${escapeHtml(k.detail)}</small>` : ''}</li>`).join('')}</ul>
|
||||
${pending ? creditDecisionFormHtml(d) : creditDecisionSummaryHtml(d)}
|
||||
</div>
|
||||
</div>`
|
||||
|
||||
panel.querySelector('#credit-close')?.addEventListener('click', closeCreditDetail)
|
||||
panel.querySelector('#credit-view-as')?.addEventListener('click', () => viewAsCustomer(c.id))
|
||||
panel.querySelectorAll('[data-open-credit]').forEach((a) =>
|
||||
a.addEventListener('click', (e) => {
|
||||
e.preventDefault()
|
||||
openCreditDetail(a.dataset.openCredit)
|
||||
}))
|
||||
panel.querySelector('#credit-apply')?.addEventListener('click', () => applyCredit(r.id))
|
||||
if (pending) bindCreditDecision(panel, r.id)
|
||||
}
|
||||
|
||||
function refreshApproveState(panel) {
|
||||
const btn = panel.querySelector('#credit-approve')
|
||||
if (!btn) return
|
||||
const allChecked = [...panel.querySelectorAll('[data-check]')].every((cb) => cb.checked)
|
||||
btn.disabled = !allChecked || !(Number(panel.querySelector('#credit-amount').value) > 0)
|
||||
}
|
||||
|
||||
function calcFigures(panel) {
|
||||
return {
|
||||
invoiceAmountCents: Math.round(Number(panel.querySelector('#calc-amount').value) * 100),
|
||||
billingMonths: Number(panel.querySelector('#calc-months').value),
|
||||
paidUntil: panel.querySelector('#calc-paid-until').value,
|
||||
takeoverFrom: panel.querySelector('#calc-from').value,
|
||||
}
|
||||
}
|
||||
|
||||
async function recalcCredit(panel) {
|
||||
const out = panel.querySelector('#calc-result')
|
||||
try {
|
||||
const { calc } = await api('/api/portal-admin/takeover-credits/calc', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(calcFigures(panel)),
|
||||
})
|
||||
out.textContent = calcSummary(calc)
|
||||
if (!creditState.amountTouched) panel.querySelector('#credit-amount').value = (calc.suggestedCents / 100).toFixed(2)
|
||||
} catch (err) {
|
||||
out.textContent = err.message
|
||||
}
|
||||
refreshApproveState(panel)
|
||||
}
|
||||
|
||||
function bindCreditDecision(panel, id) {
|
||||
panel.querySelectorAll('[data-check]').forEach((cb) => cb.addEventListener('change', () => refreshApproveState(panel)))
|
||||
panel.querySelector('#credit-amount').addEventListener('input', () => {
|
||||
creditState.amountTouched = true
|
||||
refreshApproveState(panel)
|
||||
})
|
||||
panel.querySelectorAll('#calc-amount, #calc-months, #calc-paid-until, #calc-from').forEach((el) =>
|
||||
el.addEventListener('input', () => {
|
||||
clearTimeout(creditState.calcTimer)
|
||||
creditState.calcTimer = setTimeout(() => recalcCredit(panel), 300)
|
||||
}))
|
||||
panel.querySelector('#credit-approve').addEventListener('click', () => submitCreditDecision(id, 'approve', panel))
|
||||
panel.querySelector('#credit-question').addEventListener('click', () => submitCreditDecision(id, 'question', panel))
|
||||
panel.querySelector('#credit-reject').addEventListener('click', () => submitCreditDecision(id, 'reject', panel))
|
||||
}
|
||||
|
||||
async function submitCreditDecision(id, action, panel) {
|
||||
const errEl = panel.querySelector('#credit-error')
|
||||
errEl.classList.add('hidden')
|
||||
const note = panel.querySelector('#credit-note').value.trim()
|
||||
const amountCents = Math.round(Number(panel.querySelector('#credit-amount').value) * 100)
|
||||
const who = creditState.detail?.customer?.name || 'den Kunden'
|
||||
if (action !== 'approve' && note.length < 5) {
|
||||
errEl.textContent = action === 'question'
|
||||
? 'Bitte die Rückfrage an den Kunden ins Nachrichtenfeld schreiben.'
|
||||
: 'Bitte die Begründung für den Kunden ins Nachrichtenfeld schreiben.'
|
||||
errEl.classList.remove('hidden')
|
||||
return
|
||||
}
|
||||
const question = {
|
||||
approve: `Gutschrift über ${euroFromCents(amountCents)} genehmigen, in Stripe gutschreiben und ${who} per E-Mail informieren?`,
|
||||
question: `Rückfrage per E-Mail an ${who} senden?`,
|
||||
reject: `Antrag ablehnen und ${who} die Begründung per E-Mail senden?`,
|
||||
}[action]
|
||||
if (!window.confirm(question)) return
|
||||
|
||||
const buttons = panel.querySelectorAll('.credit-actions button')
|
||||
buttons.forEach((b) => { b.disabled = true })
|
||||
try {
|
||||
const checks = Object.fromEntries([...panel.querySelectorAll('[data-check]')].map((cb) => [cb.dataset.check, cb.checked]))
|
||||
const result = await api(`/api/portal-admin/takeover-credits/${encodeURIComponent(id)}/decision`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action, note, amountCents, checks, ...calcFigures(panel) }),
|
||||
})
|
||||
const parts = [{ genehmigt: 'Genehmigt.', rueckfrage: 'Rückfrage gesendet.', abgelehnt: 'Abgelehnt.' }[result.status] || 'Gespeichert.']
|
||||
if (action === 'approve') {
|
||||
parts.push(result.applied ? 'Das Guthaben ist in Stripe gebucht.' : `Noch nicht in Stripe gebucht: ${result.reason}`)
|
||||
}
|
||||
parts.push(result.mailSent
|
||||
? 'Die E-Mail an den Kunden ist raus.'
|
||||
: `E-Mail an den Kunden fehlgeschlagen (${result.mailError || 'unbekannt'}) – bitte selbst informieren.`)
|
||||
await loadCredits()
|
||||
await openCreditDetail(id, { message: parts.join(' ') })
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message
|
||||
errEl.classList.remove('hidden')
|
||||
buttons.forEach((b) => { b.disabled = false })
|
||||
refreshApproveState(panel)
|
||||
}
|
||||
}
|
||||
|
||||
async function applyCredit(id) {
|
||||
const panel = document.getElementById('credit-detail')
|
||||
const btn = panel.querySelector('#credit-apply')
|
||||
btn.disabled = true
|
||||
try {
|
||||
const result = await api(`/api/portal-admin/takeover-credits/${encodeURIComponent(id)}/apply`, { method: 'POST' })
|
||||
await loadCredits()
|
||||
await openCreditDetail(id, {
|
||||
message: result.applied ? 'Das Guthaben ist in Stripe gebucht.' : `Nicht gebucht: ${result.reason}`,
|
||||
})
|
||||
} catch (err) {
|
||||
const errEl = panel.querySelector('#credit-error')
|
||||
errEl.textContent = err.message
|
||||
errEl.classList.remove('hidden')
|
||||
btn.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- Init --- */
|
||||
async function initAdminPage() {
|
||||
document.getElementById('logout-btn')?.addEventListener('click', async () => {
|
||||
@@ -406,10 +779,12 @@ async function initAdminPage() {
|
||||
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 = '/login.html'
|
||||
window.location.href = loginUrl
|
||||
return
|
||||
}
|
||||
if (me.viewAs) {
|
||||
@@ -419,11 +794,12 @@ async function initAdminPage() {
|
||||
document.getElementById('admin-meta').textContent =
|
||||
`${me.admin?.name || 'Admin'} (${me.admin?.email || ''})`
|
||||
} catch {
|
||||
window.location.href = '/login.html'
|
||||
window.location.href = loginUrl
|
||||
return
|
||||
}
|
||||
|
||||
let chatListLoaded = false
|
||||
let creditsLoaded = false
|
||||
document.querySelectorAll('.admin-tab').forEach((tab) => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.admin-tab').forEach((t) => t.classList.remove('active'))
|
||||
@@ -434,9 +810,28 @@ async function initAdminPage() {
|
||||
chatListLoaded = true
|
||||
loadChatList()
|
||||
}
|
||||
if (tab.dataset.tab === 'credits' && !creditsLoaded) {
|
||||
creditsLoaded = true
|
||||
loadCredits()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
document.querySelectorAll('[data-credit-filter]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('[data-credit-filter]').forEach((b) => b.classList.toggle('active', b === btn))
|
||||
creditState.filter = btn.dataset.creditFilter
|
||||
loadCredits()
|
||||
})
|
||||
})
|
||||
|
||||
// Direktlink aus der Admin-Mail: /admin.html?gutschrift=<id>
|
||||
const creditFromLink = new URLSearchParams(window.location.search).get('gutschrift')
|
||||
if (creditFromLink) {
|
||||
document.querySelector('.admin-tab[data-tab="credits"]')?.click()
|
||||
openCreditDetail(creditFromLink)
|
||||
}
|
||||
|
||||
document.getElementById('project-search')?.addEventListener('input', (e) => {
|
||||
clearTimeout(projectSearchTimer)
|
||||
projectSearchTimer = setTimeout(() => loadProjects(e.target.value.trim()), 250)
|
||||
@@ -450,9 +845,11 @@ async function initAdminPage() {
|
||||
loadProjects()
|
||||
loadCustomers()
|
||||
|
||||
// Chat-Badge global pollen
|
||||
// Chat- und Gutschrift-Badge global pollen
|
||||
pollAdminChatBadge()
|
||||
setInterval(pollAdminChatBadge, 60000)
|
||||
pollCreditBadge()
|
||||
setInterval(pollCreditBadge, 60000)
|
||||
}
|
||||
|
||||
window.initAdminPage = initAdminPage
|
||||
Reference in new issue
Block a user