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:
ClaudeandClaude Opus 5.5 committed 2026-09-25 19:34:33 +00:00
1 parent dbabbf1c0b
commit 48b13f4488
14 files changed
+2040 -9

No files matched your search

+62
View File
@@ -82,6 +82,49 @@
background: rgba(255,255,255,0.06); color: inherit; font: inherit;
}
.chat-empty { display: flex; align-items: center; justify-content: center; flex: 1; opacity: 0.6; }
/* Übernahmegutschriften */
.credit-filter { display: flex; gap: 8px; margin-bottom: 12px; }
.credit-filter button {
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.14);
color: inherit; padding: 5px 13px; border-radius: 999px; cursor: pointer; font: inherit; font-size: 13px;
}
.credit-filter button.active { background: rgba(94,234,212,0.16); border-color: rgba(94,234,212,0.5); }
tr.credit-active td { background: rgba(94,234,212,0.07); }
.credit-detail { margin-top: 18px; }
.credit-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
.credit-head h3 { margin: 0; }
.credit-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 22px; }
.credit-cols h4 { margin: 16px 0 6px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; }
.credit-cols h4:first-child { margin-top: 4px; }
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; font-size: 14px; margin: 0; }
.kv dt { opacity: 0.6; }
.kv dd { margin: 0; overflow-wrap: anywhere; }
.pre { white-space: pre-wrap; font-size: 14px; margin: 0; overflow-wrap: anywhere; }
.check-list { list-style: none; margin: 0; padding: 0; font-size: 14px; }
.check-list li { padding: 5px 0 5px 26px; position: relative; border-bottom: 1px solid rgba(255,255,255,0.06); }
.check-list li:last-child { border-bottom: none; }
.check-list li::before { position: absolute; left: 2px; top: 5px; font-weight: 700; }
.check-list li.ok::before { content: '✓'; color: #4ade80; }
.check-list li.warn::before { content: '!'; color: #fbbf24; left: 6px; }
.check-list li.info::before { content: 'i'; color: #7dd3fc; left: 6px; font-style: italic; }
.check-list small { display: block; opacity: 0.6; font-size: 12px; overflow-wrap: anywhere; }
.manual-checks label { display: flex; gap: 9px; align-items: flex-start; font-size: 14px; padding: 4px 0; cursor: pointer; }
.manual-checks input { margin-top: 3px; width: 16px; height: 16px; accent-color: #5eead4; flex: none; }
.calc-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; }
.calc-grid label, .credit-field label { display: block; font-size: 12px; opacity: 0.7; margin-bottom: 4px; }
.credit-input { width: 100%; box-sizing: border-box; padding: 7px 10px; border-radius: 9px; border: 1px solid rgba(255,255,255,0.2);
background: rgba(255,255,255,0.06); color: inherit; font: inherit; font-size: 14px; }
.credit-input option { color: #111; }
textarea.credit-input { min-height: 80px; resize: vertical; }
.calc-result { margin: 10px 0; padding: 9px 12px; border-radius: 10px; font-size: 14px; background: rgba(94,234,212,0.08); border: 1px solid rgba(94,234,212,0.3); }
.credit-field { margin-top: 10px; }
.credit-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.mini-btn:disabled { opacity: 0.45; cursor: default; }
.mini-btn.danger { border-color: rgba(248,113,113,0.5); }
.credit-msg { margin-top: 10px; font-size: 14px; }
.history-item { font-size: 13px; padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.06); }
.history-item:last-child { border-bottom: none; }
</style>
</head>
<body class="portal-page dashboard-page">
@@ -107,6 +150,7 @@
<button class="admin-tab" data-tab="projects">Projekte</button>
<button class="admin-tab" data-tab="customers">Kunden</button>
<button class="admin-tab" data-tab="chat">Chat <span id="admin-chat-badge" class="badge-pill hidden"></span></button>
<button class="admin-tab" data-tab="credits">Gutschriften <span id="admin-credit-badge" class="badge-pill hidden"></span></button>
</div>
<!-- Übersicht -->
@@ -169,6 +213,24 @@
</div>
</div>
</section>
<!-- Übernahmegutschriften -->
<section class="admin-section" id="tab-credits">
<div class="credit-filter">
<button type="button" class="active" data-credit-filter="open">Offen</button>
<button type="button" data-credit-filter="all">Alle</button>
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr><th>Eingang</th><th>Kunde</th><th>Leistung / bisher bei</th><th>Rechnung</th><th>Bezahlt bis</th><th>Status</th><th></th></tr>
</thead>
<tbody id="credit-rows"><tr><td colspan="7" class="muted">Lade…</td></tr></tbody>
</table>
</div>
<div id="credit-detail" class="portal-card credit-detail hidden"></div>
<p class="admin-note">Übernahmegutschrift: Der Kunde hat Domain, Hosting oder E-Mail beim bisherigen Anbieter schon bezahlt. Nach der Prüfung wird der doppelt bezahlte Anteil als Guthaben in Stripe gebucht und mit den nächsten Abo-Rechnungen verrechnet.</p>
</section>
</main>
<script src="/admin.js"></script>
+400 -3
View File
@@ -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 &amp; gutschreiben</button>
<button class="mini-btn" id="credit-question">Rückfrage senden</button>
<button class="mini-btn danger" id="credit-reject">Ablehnen</button>
</div>
<p class="admin-note">Genehmigen geht, sobald alle Punkte abgehakt sind. Der Kunde bekommt jede Entscheidung per E-Mail.${d.subscription?.active ? '' : ' <strong>Achtung: Der Kunde hat kein laufendes Abo.</strong>'}</p>
<p class="error hidden" id="credit-error"></p>`
}
function creditDecisionSummaryHtml(d) {
const r = d.request
if (r.status === 'genehmigt') {
return `
<h4>Entscheidung</h4>
<dl class="kv">
<dt>Genehmigt</dt><dd>${fmtDateTime(r.decidedAt)} · ${escapeHtml(r.decidedBy)}</dd>
<dt>Gutschrift</dt><dd><strong>${euroFromCents(r.amountCents)}</strong></dd>
<dt>Berechnung</dt><dd>${escapeHtml(r.calculation)}</dd>
<dt>Geprüft</dt><dd>${r.checks ? `alle Punkte abgehakt (${escapeHtml(r.checks.by || '')})` : '–'}</dd>
${r.decisionNote ? `<dt>Nachricht</dt><dd class="pre">${escapeHtml(r.decisionNote)}</dd>` : ''}
<dt>Verrechnung</dt><dd>${r.appliedAt
? `Stripe-Guthaben gebucht am ${fmtDateTime(r.appliedAt)} <span class="muted">(${escapeHtml(r.stripeBalanceTransactionId)})</span>`
: '<span class="pill pill-warn">noch nicht in Stripe gebucht</span>'}</dd>
</dl>
${r.appliedAt ? '' : `
<div class="credit-actions"><button class="mini-btn primary" id="credit-apply">Jetzt in Stripe gutschreiben</button></div>
<p class="error hidden" id="credit-error"></p>`}`
}
return `
<h4>Entscheidung</h4>
<dl class="kv">
<dt>Abgelehnt</dt><dd>${fmtDateTime(r.decidedAt)} · ${escapeHtml(r.decidedBy)}</dd>
<dt>Begründung</dt><dd class="pre">${escapeHtml(r.decisionNote)}</dd>
</dl>`
}
function renderCreditDetail(panel, d, message) {
const r = d.request
const c = d.customer
const s = d.subscription
const pending = r.status === 'offen' || r.status === 'rueckfrage'
const fileNames = new Map(r.files.map((f) => [f.id, f.name]))
const fileHref = (f) => `/api/portal-admin/takeover-credits/${encodeURIComponent(r.id)}/files/${encodeURIComponent(f.id)}`
const aboPill = !s
? '<span class="pill pill-red">kein Abo</span>'
: `<span class="pill ${s.active ? 'pill-ok' : 'pill-red'}">${escapeHtml(s.status)}</span>`
const staff = (by) => (by && by !== 'customer' && by !== 'system' ? ` · ${escapeHtml(by)}` : '')
panel.innerHTML = `
<div class="credit-head">
<div>
<h3>Antrag ${escapeHtml(r.ref)} · ${escapeHtml(c.name || c.companyName)}</h3>
<span class="muted">eingegangen ${fmtDateTime(r.createdAt)}</span> ${creditStatusPill(r.status)}
</div>
<button class="mini-btn" id="credit-close">Schließen</button>
</div>
${message ? `<div class="calc-result">${escapeHtml(message)}</div>` : ''}
<div class="credit-cols">
<div>
<h4>Antrag</h4>
<dl class="kv">
<dt>Leistung</dt><dd>${escapeHtml(r.services)}</dd>
<dt>Bisher bei</dt><dd>${escapeHtml(r.provider)}</dd>
<dt>Domain</dt><dd>${r.domain ? `<a class="plain" href="https://${escapeHtml(r.domain)}" target="_blank" rel="noreferrer">${escapeHtml(r.domain)}</a>` : '–'}</dd>
<dt>Rechnung</dt><dd>${euroFromCents(r.invoiceAmountCents)} für ${r.billingMonths} Monat${r.billingMonths === 1 ? '' : 'e'}</dd>
<dt>Bezahlt bis</dt><dd>${fmtDate(r.paidUntil)}</dd>
<dt>Antwort an</dt><dd>${escapeHtml(r.email)}</dd>
<dt>Bestätigt</dt><dd>Angaben und Bedingungen (Stand ${escapeHtml(r.conditionsVersion || '–')}) am ${fmtDateTime(r.consentAt)}</dd>
</dl>
<h4>Grund / Nachricht</h4>
<p class="pre">${escapeHtml(r.message)}</p>
<h4>Nachweise</h4>
${r.files.length
? `<ul class="recent-list">${r.files.map((f) => `<li><a class="plain" href="${fileHref(f)}" target="_blank" rel="noreferrer">${escapeHtml(f.name)}</a> <span class="muted">(${fmtBytes(f.size)}, ${fmtDate(f.uploadedAt)})</span></li>`).join('')}</ul>`
: '<p class="muted">Keine Nachweise (mehr) vorhanden.</p>'}
<h4>Verlauf</h4>
${r.history.map((h) => `
<div class="history-item">
<strong>${escapeHtml(CREDIT_HISTORY_LABELS[h.type] || h.type)}</strong>
<span class="muted">${fmtDateTime(h.at)}${staff(h.by)}</span>
${h.text ? `<div class="pre">${escapeHtml(h.text)}</div>` : ''}
${(h.files || []).length ? `<div class="muted">Dateien: ${h.files.map((id) => escapeHtml(fileNames.get(id) || 'gelöscht')).join(', ')}</div>` : ''}
</div>`).join('')}
</div>
<div>
<h4>Kunde &amp; Vertrag</h4>
<dl class="kv">
<dt>Kunde</dt><dd>${escapeHtml(c.name || c.companyName)}${c.code ? ` · Kd.-Nr. ${escapeHtml(c.code)}` : ''}</dd>
<dt>E-Mail</dt><dd>${escapeHtml(c.email || '–')}</dd>
<dt>Wartungs-Abo</dt><dd>${aboPill}</dd>
${s ? `
<dt>Leistungsbeginn</dt><dd>${fmtDate(s.serviceStart)}</dd>
<dt>Nächste Abbuchung</dt><dd>${s.active ? fmtDate(s.currentPeriodEnd) : '–'}</dd>
${s.cancelAt ? `<dt>Gekündigt zum</dt><dd>${fmtDate(s.cancelAt)}</dd>` : ''}
<dt>Stripe-Guthaben</dt><dd>${s.creditBalanceCents == null ? '–' : euroFromCents(s.creditBalanceCents)}</dd>` : ''}
</dl>
<div class="credit-actions">
<button class="mini-btn" id="credit-view-as">Als Kunde ansehen</button>
${s?.stripeUrl ? `<a class="mini-btn" href="${escapeHtml(s.stripeUrl)}" target="_blank" rel="noreferrer">In Stripe öffnen</a>` : ''}
</div>
${d.otherRequests.length ? `
<h4>Weitere Anträge des Kunden</h4>
<ul class="recent-list">${d.otherRequests.map((o) => `
<li><a class="plain" href="#" data-open-credit="${escapeHtml(o.id)}">${escapeHtml(o.ref)}</a> · ${escapeHtml(o.services)} · ${fmtDate(o.createdAt)}
${creditStatusPill(o.status)}${o.amountCents ? ` ${euroFromCents(o.amountCents)}` : ''}</li>`).join('')}</ul>` : ''}
<h4>Automatische Prüfung</h4>
<ul class="check-list">${d.autoChecks.map((k) => `
<li class="${escapeHtml(k.status)}">${escapeHtml(k.label)}${k.detail ? `<small>${escapeHtml(k.detail)}</small>` : ''}</li>`).join('')}</ul>
${pending ? creditDecisionFormHtml(d) : creditDecisionSummaryHtml(d)}
</div>
</div>`
panel.querySelector('#credit-close')?.addEventListener('click', closeCreditDetail)
panel.querySelector('#credit-view-as')?.addEventListener('click', () => viewAsCustomer(c.id))
panel.querySelectorAll('[data-open-credit]').forEach((a) =>
a.addEventListener('click', (e) => {
e.preventDefault()
openCreditDetail(a.dataset.openCredit)
}))
panel.querySelector('#credit-apply')?.addEventListener('click', () => applyCredit(r.id))
if (pending) bindCreditDecision(panel, r.id)
}
function refreshApproveState(panel) {
const btn = panel.querySelector('#credit-approve')
if (!btn) return
const allChecked = [...panel.querySelectorAll('[data-check]')].every((cb) => cb.checked)
btn.disabled = !allChecked || !(Number(panel.querySelector('#credit-amount').value) > 0)
}
function calcFigures(panel) {
return {
invoiceAmountCents: Math.round(Number(panel.querySelector('#calc-amount').value) * 100),
billingMonths: Number(panel.querySelector('#calc-months').value),
paidUntil: panel.querySelector('#calc-paid-until').value,
takeoverFrom: panel.querySelector('#calc-from').value,
}
}
async function recalcCredit(panel) {
const out = panel.querySelector('#calc-result')
try {
const { calc } = await api('/api/portal-admin/takeover-credits/calc', {
method: 'POST',
body: JSON.stringify(calcFigures(panel)),
})
out.textContent = calcSummary(calc)
if (!creditState.amountTouched) panel.querySelector('#credit-amount').value = (calc.suggestedCents / 100).toFixed(2)
} catch (err) {
out.textContent = err.message
}
refreshApproveState(panel)
}
function bindCreditDecision(panel, id) {
panel.querySelectorAll('[data-check]').forEach((cb) => cb.addEventListener('change', () => refreshApproveState(panel)))
panel.querySelector('#credit-amount').addEventListener('input', () => {
creditState.amountTouched = true
refreshApproveState(panel)
})
panel.querySelectorAll('#calc-amount, #calc-months, #calc-paid-until, #calc-from').forEach((el) =>
el.addEventListener('input', () => {
clearTimeout(creditState.calcTimer)
creditState.calcTimer = setTimeout(() => recalcCredit(panel), 300)
}))
panel.querySelector('#credit-approve').addEventListener('click', () => submitCreditDecision(id, 'approve', panel))
panel.querySelector('#credit-question').addEventListener('click', () => submitCreditDecision(id, 'question', panel))
panel.querySelector('#credit-reject').addEventListener('click', () => submitCreditDecision(id, 'reject', panel))
}
async function submitCreditDecision(id, action, panel) {
const errEl = panel.querySelector('#credit-error')
errEl.classList.add('hidden')
const note = panel.querySelector('#credit-note').value.trim()
const amountCents = Math.round(Number(panel.querySelector('#credit-amount').value) * 100)
const who = creditState.detail?.customer?.name || 'den Kunden'
if (action !== 'approve' && note.length < 5) {
errEl.textContent = action === 'question'
? 'Bitte die Rückfrage an den Kunden ins Nachrichtenfeld schreiben.'
: 'Bitte die Begründung für den Kunden ins Nachrichtenfeld schreiben.'
errEl.classList.remove('hidden')
return
}
const question = {
approve: `Gutschrift über ${euroFromCents(amountCents)} genehmigen, in Stripe gutschreiben und ${who} per E-Mail informieren?`,
question: `Rückfrage per E-Mail an ${who} senden?`,
reject: `Antrag ablehnen und ${who} die Begründung per E-Mail senden?`,
}[action]
if (!window.confirm(question)) return
const buttons = panel.querySelectorAll('.credit-actions button')
buttons.forEach((b) => { b.disabled = true })
try {
const checks = Object.fromEntries([...panel.querySelectorAll('[data-check]')].map((cb) => [cb.dataset.check, cb.checked]))
const result = await api(`/api/portal-admin/takeover-credits/${encodeURIComponent(id)}/decision`, {
method: 'POST',
body: JSON.stringify({ action, note, amountCents, checks, ...calcFigures(panel) }),
})
const parts = [{ genehmigt: 'Genehmigt.', rueckfrage: 'Rückfrage gesendet.', abgelehnt: 'Abgelehnt.' }[result.status] || 'Gespeichert.']
if (action === 'approve') {
parts.push(result.applied ? 'Das Guthaben ist in Stripe gebucht.' : `Noch nicht in Stripe gebucht: ${result.reason}`)
}
parts.push(result.mailSent
? 'Die E-Mail an den Kunden ist raus.'
: `E-Mail an den Kunden fehlgeschlagen (${result.mailError || 'unbekannt'}) – bitte selbst informieren.`)
await loadCredits()
await openCreditDetail(id, { message: parts.join(' ') })
} catch (err) {
errEl.textContent = err.message
errEl.classList.remove('hidden')
buttons.forEach((b) => { b.disabled = false })
refreshApproveState(panel)
}
}
async function applyCredit(id) {
const panel = document.getElementById('credit-detail')
const btn = panel.querySelector('#credit-apply')
btn.disabled = true
try {
const result = await api(`/api/portal-admin/takeover-credits/${encodeURIComponent(id)}/apply`, { method: 'POST' })
await loadCredits()
await openCreditDetail(id, {
message: result.applied ? 'Das Guthaben ist in Stripe gebucht.' : `Nicht gebucht: ${result.reason}`,
})
} catch (err) {
const errEl = panel.querySelector('#credit-error')
errEl.textContent = err.message
errEl.classList.remove('hidden')
btn.disabled = false
}
}
/* ----------------------------------------------------------------- Init --- */
async function initAdminPage() {
document.getElementById('logout-btn')?.addEventListener('click', async () => {
@@ -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
+262 -2
View File
@@ -282,6 +282,12 @@ async function postLoginRedirect() {
window.location.href = await resolvePostLoginTarget()
}
// Links aus Admin-Mails (z. B. /admin.html?gutschrift=<id>) nach dem Login wieder aufnehmen
function adminLoginTarget() {
const next = getQueryParam('next')
return /^\/admin\.html(\?[A-Za-z0-9=&_-]*)?$/.test(next) ? next : '/admin.html'
}
/* -------------------------------------------------------------- Login-Page --- */
async function initLoginPage() {
const form = document.getElementById('login-form')
@@ -328,7 +334,7 @@ async function initLoginPage() {
try {
const me = await api('/api/auth/me')
if (me.authenticated && me.role === 'admin' && !me.viewAs) {
window.location.href = '/admin.html'
window.location.href = adminLoginTarget()
return
}
if (me.authenticated && me.customer) {
@@ -356,7 +362,7 @@ async function initLoginPage() {
}),
})
if (result.role === 'admin') {
window.location.href = '/admin.html'
window.location.href = adminLoginTarget()
return
}
await postLoginRedirect()
@@ -616,6 +622,7 @@ function renderAboOffer(card, data, { ended } = {}) {
<div class="abo-price"><strong>50 €</strong><span>pro Monat · 12 Monate Mindestlaufzeit</span></div>
</div>
${aboFeaturesHtml()}
${available ? '<p class="abo-note" style="margin:-6px 0 16px">Schon bei einem anderen Anbieter für Domain, Hosting oder E-Mail bezahlt? Nach dem Abschluss können Sie eine Übernahmegutschrift beantragen – wir rechnen die doppelt bezahlte Zeit anteilig an.</p>' : ''}
<div class="abo-actions">
<button type="button" class="abo-btn" id="abo-start" ${available ? '' : 'disabled'}>Abo abschließen</button>
<span class="abo-note">${available
@@ -779,11 +786,263 @@ async function loadAboCard({ afterCheckout = false } = {}) {
renderAboOffer(card, data, { ended: Boolean(data.subscription) })
}
card.classList.remove('hidden')
loadTakeoverCard({ abo: data })
} catch (err) {
console.warn('Abo konnte nicht geladen werden:', err.message)
}
}
// ---------------------------------------------------------------------------
// Übernahmegutschrift: Antrag stellen, Stand sehen, Rückfragen beantworten
// ---------------------------------------------------------------------------
const takeoverState = { abo: null, customer: null }
const TAKEOVER_STATUS = {
offen: ['pill-warn', 'In Prüfung'],
rueckfrage: ['pill-amber', 'Rückfrage'],
genehmigt: ['pill-ok', 'Genehmigt'],
abgelehnt: ['pill-muted', 'Nicht genehmigt'],
}
// Bei Textänderungen config.takeover.conditionsVersion auf dem Server hochziehen
const TAKEOVER_CONDITIONS = [
'Die Übernahmegutschrift gibt es für Leistungen, die das Wartungs-Abo ersetzt (Domain, Hosting bzw. Betrieb der Website, E-Mail-Postfach) und die Sie bei Ihrem bisherigen Anbieter bereits im Voraus bezahlt haben.',
'Wir schreiben den Anteil der Rechnung gut, der auf die Zeit ab Leistungsbeginn Ihres Abos bis zum Ende des bezahlten Zeitraums entfällt, tagesgenau berechnet.',
'Nachweis ist die Rechnung des bisherigen Anbieters, ausgestellt auf Sie, mit Leistung, Zeitraum und Betrag. Wir können weitere Nachweise anfordern, etwa die Kündigungsbestätigung. Erstattet Ihr bisheriger Anbieter Beträge für diesen Zeitraum, teilen Sie uns das bitte mit.',
'Die Gutschrift wird mit Ihren nächsten Monatsgebühren verrechnet. Sie wird nicht ausgezahlt und ist nicht übertragbar; ein bei Vertragsende noch nicht verrechneter Rest entfällt.',
'Die Übernahmegutschrift ist eine freiwillige Leistung von WEBklar. Über Ihren Antrag entscheiden wir nach Prüfung und antworten Ihnen per E-Mail.',
]
const TAKEOVER_MONTHS = [[1, '1 Monat'], [3, '3 Monate'], [6, '6 Monate'], [12, '12 Monate (Jahresrechnung)'], [24, '24 Monate'], [36, '36 Monate']]
function takeoverStatusPill(status) {
const [cls, label] = TAKEOVER_STATUS[status] || ['pill-muted', status]
return `<span class="pill ${cls}">${label}</span>`
}
function renderTakeoverItem(r) {
const facts = [
`eingereicht am ${fmtDateShort(r.createdAt)}`,
`bisher bei ${escapeHtml(r.provider)}${r.domain ? ` (${escapeHtml(r.domain)})` : ''}`,
`${fmtEuro(r.invoiceAmountCents / 100)} für ${r.billingMonths === 1 ? '1 Monat' : `${r.billingMonths} Monate`}, bezahlt bis ${fmtDateShort(r.paidUntil)}`,
]
let body
if (r.status === 'genehmigt') {
body = `<p>Gutschrift: <strong>${fmtEuro(r.amountCents / 100)}</strong> – wird mit Ihren nächsten Monatsgebühren verrechnet.</p>
${r.decisionNote ? `<p class="abo-note" style="white-space:pre-wrap">${escapeHtml(r.decisionNote)}</p>` : ''}`
} else if (r.status === 'abgelehnt') {
body = `<p class="abo-note" style="white-space:pre-wrap">${escapeHtml(r.decisionNote)}</p>`
} else if (r.status === 'rueckfrage') {
body = `
<div class="tk-question"><strong>Unsere Rückfrage:</strong><br>${escapeHtml(r.question)}</div>
<form class="tk-reply" data-reply-for="${escapeAttr(r.id)}" novalidate>
<textarea name="message" maxlength="4000" placeholder="Ihre Antwort…"></textarea>
<input type="file" name="files" accept="application/pdf,image/jpeg,image/png" multiple aria-label="Weitere Nachweise">
<p class="error hidden"></p>
<div class="abo-actions"><button type="submit" class="abo-btn"${chatState.viewAs ? ' disabled' : ''}>Antwort senden</button></div>
</form>`
} else {
body = '<p class="abo-note">Wir prüfen Ihre Angaben und antworten Ihnen per E-Mail.</p>'
}
return `<div class="tk-item">
<div class="tk-item-head"><strong>Antrag ${escapeHtml(r.ref)} · ${escapeHtml(r.services)}</strong>${takeoverStatusPill(r.status)}</div>
<div class="abo-note">${facts.join(' · ')}</div>
${body}
</div>`
}
function takeoverFormHtml(limits) {
const email = takeoverState.customer?.email || ''
const maxMb = Math.round(limits.maxTotalBytes / 1024 / 1024)
return `
<form id="tk-form" class="tk-form hidden" novalidate>
<fieldset class="tk-field">
<legend>Welche Leistung haben Sie bisher woanders bezahlt?</legend>
<div class="tk-services">
<label><input type="checkbox" name="services" value="domain"> Domain</label>
<label><input type="checkbox" name="services" value="hosting"> Hosting / Website-Betrieb</label>
<label><input type="checkbox" name="services" value="email"> E-Mail-Postfach</label>
<label><input type="checkbox" name="services" value="sonstiges"> Sonstiges</label>
</div>
</fieldset>
<div class="tk-row">
<div class="tk-field"><label for="tk-provider">Bisheriger Anbieter</label>
<input id="tk-provider" maxlength="200" placeholder="z. B. IONOS, STRATO"></div>
<div class="tk-field"><label for="tk-domain">Domain <small>(falls vorhanden)</small></label>
<input id="tk-domain" maxlength="255" placeholder="meine-firma.de" autocapitalize="off" spellcheck="false"></div>
</div>
<div class="tk-row">
<div class="tk-field"><label for="tk-amount">Rechnungsbetrag <small>(brutto, in €)</small></label>
<input id="tk-amount" type="number" min="0.01" step="0.01" inputmode="decimal" placeholder="119,88"></div>
<div class="tk-field"><label for="tk-months">Die Rechnung gilt für</label>
<select id="tk-months"><option value="">bitte wählen</option>
${TAKEOVER_MONTHS.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select></div>
<div class="tk-field"><label for="tk-paid-until">Bezahlt bis <small>(letzter Tag)</small></label>
<input id="tk-paid-until" type="date"></div>
</div>
<div class="tk-field"><label for="tk-message">Grund / Nachricht an uns</label>
<textarea id="tk-message" maxlength="4000" placeholder="z. B.: Domain und Postfach liegen noch bis Ende März bei IONOS, der Vertrag dort ist gekündigt."></textarea></div>
<div class="tk-field"><label for="tk-files">Rechnungsnachweis <small>(PDF, JPG oder PNG · bis zu ${limits.maxFiles} Dateien · zusammen max. ${maxMb} MB)</small></label>
<input id="tk-files" type="file" accept="application/pdf,image/jpeg,image/png" multiple></div>
<p class="abo-note" style="margin-top:-6px">Tipp: Laden Sie auch die Kündigungsbestätigung Ihres bisherigen Anbieters hoch – daraus sehen wir, wann der Vertrag dort endet.</p>
<div class="tk-field"><label for="tk-email">E-Mail-Adresse für unsere Antwort</label>
<input id="tk-email" type="email" maxlength="255" autocomplete="email" value="${escapeAttr(email)}"></div>
<details class="tk-conditions">
<summary>Bedingungen der Übernahmegutschrift</summary>
<ol>${TAKEOVER_CONDITIONS.map((c) => `<li>${c}</li>`).join('')}</ol>
</details>
<label class="abo-check"><input type="checkbox" id="tk-consent">
<span>Ich versichere, dass meine Angaben stimmen und die Rechnung auf mich bzw. mein Unternehmen ausgestellt ist. Die Bedingungen der Übernahmegutschrift habe ich gelesen. Angaben und Nachweise verwendet WEBklar zur Prüfung und Abwicklung des Antrags (<a href="/datenschutz.html#uebernahmegutschrift" target="_blank" rel="noopener">Datenschutz</a>).</span></label>
<p class="error hidden" id="tk-error"></p>
<div class="abo-actions">
<button type="submit" class="abo-btn" id="tk-submit">Antrag absenden</button>
<button type="button" class="portal-btn-outline" id="tk-cancel">Abbrechen</button>
</div>
</form>`
}
function readFileAsBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result).split(',')[1] || '')
reader.onerror = () => reject(new Error(`„${file.name}“ konnte nicht gelesen werden.`))
reader.readAsDataURL(file)
})
}
async function collectTakeoverFiles(input, limits, { required }) {
const files = [...(input?.files || [])]
const mb = (bytes) => Math.round(bytes / 1024 / 1024)
if (required && !files.length) throw new Error('Bitte laden Sie die Rechnung Ihres bisherigen Anbieters hoch.')
if (files.length > limits.maxFiles) throw new Error(`Bitte höchstens ${limits.maxFiles} Dateien auf einmal hochladen.`)
const tooBig = files.find((f) => f.size > limits.maxFileBytes)
if (tooBig) throw new Error(`„${tooBig.name}“ ist größer als ${mb(limits.maxFileBytes)} MB.`)
if (files.reduce((s, f) => s + f.size, 0) > limits.maxTotalBytes) {
throw new Error(`Die Dateien sind zusammen größer als ${mb(limits.maxTotalBytes)} MB.`)
}
return Promise.all(files.map(async (f) => ({ name: f.name, data: await readFileAsBase64(f) })))
}
async function submitTakeoverForm(form, limits) {
const errEl = form.querySelector('#tk-error')
const btn = form.querySelector('#tk-submit')
hideError(errEl)
const value = (id) => form.querySelector(id).value.trim()
const payload = {
services: [...form.querySelectorAll('input[name="services"]:checked')].map((i) => i.value),
provider: value('#tk-provider'),
domain: value('#tk-domain'),
invoiceAmount: value('#tk-amount'),
billingMonths: Number(value('#tk-months')),
paidUntil: value('#tk-paid-until'),
message: value('#tk-message'),
email: value('#tk-email'),
consent: form.querySelector('#tk-consent').checked,
}
try {
if (!payload.services.length) throw new Error('Bitte wählen Sie, welche Leistung Sie bisher woanders bezahlt haben.')
if (!payload.provider) throw new Error('Bitte nennen Sie Ihren bisherigen Anbieter.')
if (!payload.invoiceAmount || !payload.billingMonths || !payload.paidUntil) {
throw new Error('Bitte geben Sie Rechnungsbetrag, Zeitraum und „Bezahlt bis“ an.')
}
if (!payload.consent) throw new Error('Bitte bestätigen Sie Ihre Angaben und die Bedingungen.')
btn.disabled = true
btn.textContent = 'Wird gesendet…'
payload.files = await collectTakeoverFiles(form.querySelector('#tk-files'), limits, { required: true })
await api('/api/abo/takeover-credits', { method: 'POST', body: JSON.stringify(payload) })
await loadTakeoverCard({
notice: `Vielen Dank! Ihr Antrag ist eingegangen. Die Eingangsbestätigung haben wir an ${payload.email} geschickt; unsere Antwort kommt ebenfalls per E-Mail.`,
})
} catch (err) {
showError(errEl, err.message)
btn.disabled = false
btn.textContent = 'Antrag absenden'
}
}
async function submitTakeoverReply(form, limits) {
const errEl = form.querySelector('.error')
const btn = form.querySelector('button[type="submit"]')
hideError(errEl)
btn.disabled = true
try {
const message = form.querySelector('textarea').value.trim()
const files = await collectTakeoverFiles(form.querySelector('input[type="file"]'), limits, { required: false })
if (!message && !files.length) throw new Error('Bitte schreiben Sie eine Antwort oder laden Sie einen Nachweis hoch.')
await api(`/api/abo/takeover-credits/${encodeURIComponent(form.dataset.replyFor)}/reply`, {
method: 'POST',
body: JSON.stringify({ message, files }),
})
await loadTakeoverCard({ notice: 'Danke für Ihre Antwort! Wir prüfen Ihren Antrag weiter und melden uns per E-Mail.' })
} catch (err) {
showError(errEl, err.message)
btn.disabled = false
}
}
async function loadTakeoverCard({ abo, notice = '' } = {}) {
const card = document.getElementById('takeover-card')
if (!card) return
if (abo) takeoverState.abo = abo
const active = Boolean(takeoverState.abo?.subscription?.active)
if (!takeoverState.abo?.subscription) {
card.classList.add('hidden')
return
}
let data
try {
data = await api('/api/abo/takeover-credits')
} catch (err) {
console.warn('Übernahmegutschrift konnte nicht geladen werden:', err.message)
return
}
const requests = data.requests || []
if (!active && !requests.length) {
card.classList.add('hidden')
return
}
card.innerHTML = `
<div class="abo-head">
<div>
<p class="abo-kicker">Wechsel ohne doppelte Kosten</p>
<h2 class="abo-title">Übernahmegutschrift</h2>
</div>
</div>
<p class="abo-note tk-intro">Sie haben Domain, Hosting oder E-Mail bei Ihrem bisherigen Anbieter schon im Voraus bezahlt? Den Anteil, den Sie ab Beginn Ihres Abos doppelt zahlen, schreiben wir Ihnen gut und verrechnen ihn mit Ihren nächsten Monatsgebühren. Schicken Sie uns dazu einfach die Rechnung.</p>
${notice ? `<div class="tk-notice">${escapeHtml(notice)}</div>` : ''}
${active ? `
<div class="abo-actions">
<button type="button" class="abo-btn" id="tk-start"${chatState.viewAs ? ' disabled' : ''}>Gutschrift beantragen</button>
${chatState.viewAs ? '<span class="abo-note">Im Ansichtsmodus kann kein Antrag gestellt werden.</span>' : ''}
</div>
${takeoverFormHtml(data.limits)}` : ''}
${requests.length ? `<div class="tk-list">${requests.map(renderTakeoverItem).join('')}</div>` : ''}`
card.classList.remove('hidden')
const form = card.querySelector('#tk-form')
const startBtn = card.querySelector('#tk-start')
startBtn?.addEventListener('click', () => {
form.classList.remove('hidden')
startBtn.classList.add('hidden')
form.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
})
card.querySelector('#tk-cancel')?.addEventListener('click', () => {
form.classList.add('hidden')
startBtn?.classList.remove('hidden')
})
form?.addEventListener('submit', (ev) => {
ev.preventDefault()
submitTakeoverForm(form, data.limits)
})
card.querySelectorAll('form[data-reply-for]').forEach((f) =>
f.addEventListener('submit', (ev) => {
ev.preventDefault()
submitTakeoverReply(f, data.limits)
}))
}
async function initDashboardPage() {
const meta = document.getElementById('customer-meta')
const list = document.getElementById('projects')
@@ -840,6 +1099,7 @@ async function initDashboardPage() {
const customer = me.customer
chatState.viewAs = Boolean(me.viewAs)
takeoverState.customer = customer
renderViewAsBanner(me)
const codeSuffix = customer.code ? ` · Kundennr. ${customer.code}` : ''
+37
View File
@@ -157,6 +157,40 @@
.abo-summary td:last-child { text-align: left; }
.abo-withdraw { color: #fff; font-weight: 700; padding: 9px 16px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.6); text-decoration: none; }
.abo-withdraw:hover { background: #fff; color: #1c1c1c; }
/* Übernahmegutschrift */
.pill-amber { background: rgba(251,191,36,0.15); border-color: rgba(251,191,36,0.45); }
.takeover-card {
margin: -8px 0 26px; padding: 22px 28px; border-radius: 22px;
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.14);
}
.takeover-card .abo-title { font-size: 20px; }
.takeover-card a { color: #d4b4ff; }
.tk-intro { margin: 8px 0 14px; max-width: 70ch; }
.tk-notice { padding: 10px 14px; border-radius: 12px; margin: 12px 0; font-size: 14px; background: rgba(74,222,128,0.12); border: 1px solid rgba(74,222,128,0.4); }
.tk-form { margin-top: 18px; padding-top: 18px; border-top: 1px solid rgba(255,255,255,0.14); display: grid; gap: 14px; }
.tk-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 14px; }
.tk-field { border: none; padding: 0; margin: 0; min-width: 0; }
.tk-field > label, .tk-field > legend { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; }
.tk-field small { font-weight: 400; opacity: 0.65; }
.tk-field input:not([type=checkbox]), .tk-field select, .tk-field textarea, .tk-reply textarea, .tk-reply input[type=file] {
width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 12px;
border: 1px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.06); color: inherit; font: inherit;
}
.tk-field select option { color: #111; }
.tk-field textarea, .tk-reply textarea { min-height: 90px; resize: vertical; }
.tk-services { display: flex; flex-wrap: wrap; gap: 8px 20px; }
.tk-services label { display: inline-flex; gap: 8px; align-items: center; font-size: 14px; cursor: pointer; }
.tk-services input { width: 17px; height: 17px; accent-color: #9d3dfe; }
.tk-conditions summary { cursor: pointer; font-size: 14px; }
.tk-conditions ol { margin: 8px 0 0; padding-left: 20px; font-size: 13.5px; opacity: 0.85; }
.tk-conditions li { margin: 4px 0; }
.tk-list { margin-top: 16px; display: grid; gap: 10px; }
.tk-item { padding: 12px 16px; border-radius: 14px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.1); font-size: 14px; }
.tk-item p { margin: 8px 0 0; }
.tk-item-head { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; align-items: center; margin-bottom: 4px; }
.tk-question { margin: 10px 0 0; padding: 10px 12px; border-radius: 12px; white-space: pre-wrap; background: rgba(251,191,36,0.12); border: 1px solid rgba(251,191,36,0.45); }
.tk-reply { margin-top: 10px; display: grid; gap: 8px; }
</style>
</head>
<body class="portal-page dashboard-page">
@@ -182,6 +216,9 @@
<!-- Wartungs-Abo: Angebot bzw. Stundenkonto -->
<section id="abo-card" class="abo-card hidden" aria-live="polite"></section>
<!-- Übernahmegutschrift: beim bisherigen Anbieter schon bezahlte Zeit anrechnen -->
<section id="takeover-card" class="takeover-card hidden" aria-live="polite"></section>
<div class="portal-tabs">
<button class="portal-tab active" data-tab="projects">Projekte</button>
<button class="portal-tab" data-tab="activity">Was wurde gemacht</button>
+1
View File
@@ -84,6 +84,7 @@
<p>Schließen Sie das Wartungs-Abo ab, verarbeiten wir Name, E-Mail-Adresse, Rechnungsanschrift, Abo- und Zahlungsstatus sowie Ihre Rechnungen. Zahlungsdaten wie Kartennummer oder IBAN geben Sie direkt bei unserem Zahlungsdienstleister ein. Wir erhalten davon nur eingeschränkte Angaben, etwa die Art des Zahlungsmittels und die letzten vier Ziffern.</p>
<p>Um den Vertragsschluss nachweisen zu können, speichern wir bei der Bestellung Zeitpunkt, Version der Vertragsbedingungen, Ihre Angaben zum Leistungsbeginn sowie IP-Adresse und Browserkennung. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b, c und f DSGVO. Unser berechtigtes Interesse ist der Nachweis des Vertragsschlusses. Diese Angaben löschen wir drei Jahre nach Vertragsende.</p>
<p>Die Zahlungen wickelt die Stripe Payments Europe, Limited, 1 Grand Canal Street Lower, Grand Canal Dock, Dublin, D02 H210, Irland („Stripe“) ab. Stripe verarbeitet die Daten teils in unserem Auftrag und teils als eigener Verantwortlicher, etwa zur Betrugsvorbeugung und zur Erfüllung gesetzlicher Pflichten. Dabei können Daten an die Stripe, LLC in den USA übermittelt werden. Die Stripe, LLC ist nach dem EU-US Data Privacy Framework zertifiziert, für das ein Angemessenheitsbeschluss der EU-Kommission besteht. Auf der Zahlungsseite von Stripe gelten dessen Datenschutzhinweise: stripe.com/de/privacy. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.</p>
<p id="uebernahmegutschrift">Beantragen Sie im Kundenportal eine Übernahmegutschrift, verarbeiten wir Ihre Angaben zum bisherigen Anbieter (Anbieter, Leistung, Domain, Rechnungsbetrag, bezahlter Zeitraum), Ihre Nachricht, die hochgeladenen Nachweise wie Rechnung oder Kündigungsbestätigung, die E-Mail-Adresse für unsere Antwort sowie Zeitpunkt und IP-Adresse Ihrer Bestätigung. Die Nachweise speichern wir auf unserem Server; unsere Mitarbeiter erhalten per E-Mail nur einen Hinweis auf den Antrag. Zur Prüfung fragen wir die öffentlichen DNS-Einträge der angegebenen Domain ab (Nameserver, Mailserver, IP-Adresse). Eine genehmigte Gutschrift buchen wir bei Stripe als Guthaben auf Ihr Kundenkonto. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, für den Nachweis Ihrer Bestätigung Art. 6 Abs. 1 lit. f DSGVO. Nachweise zu abgelehnten Anträgen löschen wir sechs Monate nach der Entscheidung. Bei genehmigten Anträgen sind Antrag und Nachweise Beleg für die Gutschrift und werden wie in Abschnitt 9 beschrieben aufbewahrt.</p>
<h2>8. E-Mail</h2>
<p>Vertragsbestätigungen, Benachrichtigungen und unsere sonstige Korrespondenz versenden und empfangen wir derzeit über den E-Mail-Dienst der Porkbun LLC, 11575 SW Pacific Hwy PMB 40649, Tigard, OR 97223, USA. Dabei werden Inhalte und Verbindungsdaten der E-Mails in den USA verarbeitet. Für Porkbun besteht kein Angemessenheitsbeschluss der EU-Kommission. Die Übermittlung erfolgt, soweit sie zur Durchführung eines Vertrags mit Ihnen oder zur Beantwortung Ihrer Anfrage erforderlich ist (Art. 49 Abs. 1 lit. b DSGVO). Wir stellen unseren E-Mail-Dienst in Kürze auf die Hetzner Online GmbH (Rechenzentrum Nürnberg, Deutschland) um.</p>