Abo: individuelle Angebote (z. B. erste 2 Monate je 25 EUR) mit Stripe-Coupon
- Admin-Tab "Abo-Angebote" und Button "Angebot" in der Kundenliste: Preis, Monate, Gueltigkeit, Hinweis fuer den Kunden, interne Notiz, Vorschau vor dem Anlegen - Je Angebot sofort ein Stripe-Coupon (amount_off, repeating ueber die Angebotsmonate, max. 1 Einloesung); danach automatisch wieder Normalpreis - Kunde ohne Abo: Angebot im Portal, Angebotspreis in der Bestelluebersicht, Coupon im Checkout; Angebot schaltet die Bestellung auch ausserhalb der Pilotliste frei; Bestellung prueft, dass das angezeigte Angebot noch gilt - Laufendes Abo: Coupon sofort am Abo fuer die naechsten Abbuchungen - Testkonten: Testrabatt im Checkout, Angebot per Webhook zusaetzlich am Abo - Angebot in Abo-Metadaten, Vertragsbestaetigung, Admin-Mail und Abo-Karte - Zurueckziehen loescht den Coupon; abgelaufene Angebote nicht mehr bestellbar - Stundenkontingent nach invoice.subtotal: auch voll rabattierte oder per Guthaben bezahlte Monate bringen die Stunde, die Probezeit nicht - setup-abo-collections.mjs: aboOffers, subscriptions.offerId/offerText, Attribut-Abfrage mit Limit (sonst Haenger ab 26 Attributen) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
48b13f4488
commit
bb2f46e191
11 files changed
+856
-30
No files matched your search
+247
@@ -243,6 +243,7 @@ async function loadCustomers(search = '') {
|
||||
<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('')
|
||||
@@ -255,6 +256,8 @@ async function loadCustomers(search = '') {
|
||||
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>`
|
||||
}
|
||||
@@ -772,6 +775,236 @@ async function applyCredit(id) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- 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`
|
||||
}
|
||||
|
||||
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)}`)
|
||||
}
|
||||
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 text = offerPreviewText(v.monthlyPriceCents, v.months, ctx.regularPriceCents, Boolean(ctx.subscription?.active))
|
||||
if (!window.confirm(`Angebot für ${ctx.customer.name || 'den Kunden'} anlegen?\n\n${text}\n\nDer Coupon wird sofort in Stripe angelegt${ctx.subscription?.active ? ' 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}.`,
|
||||
`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 () => {
|
||||
@@ -800,6 +1033,7 @@ async function initAdminPage() {
|
||||
|
||||
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'))
|
||||
@@ -814,9 +1048,22 @@ async function initAdminPage() {
|
||||
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))
|
||||
|
||||
Reference in new issue
Block a user