Abo-Angebote: Mail-Status in der Liste und E-Mail erneut senden
Bisher sah man nirgends, ob die Angebots-Mail rausging, Fehler landeten nicht im Log, und senden ließ sie sich nur beim Anlegen. - Angebotsliste: Kunden-Adresse unter dem Namen, im Status "E-Mail gesendet <Zeitpunkt>" bzw. "E-Mail nicht verschickt" / "Keine E-Mail-Adresse hinterlegt" - Knopf "E-Mail senden" / "Erneut senden" mit Rückfrage (Adresse, letzter Versand), solange das Angebot offen und gültig ist; bei laufendem Abo, solange die erste rabattierte Abbuchung noch bevorsteht (die Mail nennt sie). POST /api/portal-admin/offers/:id/notify setzt notifiedAt. - Server-Log: "[offers] Angebots-Mail <Ref> vom Mailserver angenommen: <Message-ID> 250 … queued as …" bzw. "… fehlgeschlagen: <Grund>" - Lehnt der Mailserver nur den Kunden ab (die BCC-Kopie ans Team geht trotzdem raus), gilt die Mail als nicht verschickt - sendCustomerMail gibt die nodemailer-Info zurück Tests: npm test (40), Server-Durchlauf gegen Appwrite-Mock, SMTP-Sink und Stripe-Double (28 Prüfungen), Oberfläche (18 Prüfungen). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
81119c19bc
commit
32e441c8bc
5 files changed
+161
-18
No files matched your search
+38
-4
@@ -786,7 +786,7 @@ const OFFER_STATUS = {
|
||||
zurueckgezogen: ['pill-muted', 'zurückgezogen'],
|
||||
}
|
||||
|
||||
const offerState = { filter: 'open', customers: null, labelToId: new Map(), context: null }
|
||||
const offerState = { filter: 'open', customers: null, labelToId: new Map(), context: null, offers: new Map() }
|
||||
|
||||
function offerStatusPill(o) {
|
||||
const [cls, label] = OFFER_STATUS[o.status] || ['pill-muted', o.status]
|
||||
@@ -794,6 +794,14 @@ function offerStatusPill(o) {
|
||||
return `<span class="pill ${cls}">${label}</span>${sub ? `<br><span class="muted">${sub}</span>` : ''}`
|
||||
}
|
||||
|
||||
// Versandstand der Angebots-Mail; "nicht verschickt" nur, solange sie noch sinnvoll ist (mailSendable)
|
||||
function offerMailStatus(o) {
|
||||
if (o.notifiedAt) return `<br><span class="muted">E-Mail gesendet ${fmtDateTime(o.notifiedAt)}</span>`
|
||||
if (o.mailSendable) return '<br><span style="color:#fbbf24">E-Mail nicht verschickt</span>'
|
||||
if (o.status === 'offen' && !o.customerEmail) return '<br><span style="color:#fbbf24">Keine E-Mail-Adresse hinterlegt</span>'
|
||||
return ''
|
||||
}
|
||||
|
||||
// Vorschau im Formular; verbindlich ist der Text, den der Server beim Anlegen erzeugt
|
||||
function offerPreviewText(price, months, regular, running) {
|
||||
const eur = (cents) => euroFromCents(cents)
|
||||
@@ -853,6 +861,7 @@ async function loadOffers() {
|
||||
const tbody = document.getElementById('offer-rows')
|
||||
try {
|
||||
const { offers } = await api('/api/portal-admin/offers')
|
||||
offerState.offers = new Map(offers.map((o) => [o.id, o]))
|
||||
const shown = offerState.filter === 'open' ? offers.filter((o) => ['offen', 'aktiv'].includes(o.status)) : offers
|
||||
if (!shown.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="muted">${offerState.filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'}</td></tr>`
|
||||
@@ -863,18 +872,23 @@ async function loadOffers() {
|
||||
return `
|
||||
<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><strong>${escapeHtml(o.customerName)}</strong>${o.customerEmail ? `<br><span class="muted">${escapeHtml(o.customerEmail).replace('@', '@<wbr>')}</span>` : ''}${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>
|
||||
${discount ? `<br><span class="muted">${escapeHtml(discount)}</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>
|
||||
${offerMailStatus(o)}
|
||||
${['offen', 'abgelaufen'].includes(o.status) || o.mailSendable ? `<div class="cust-actions" style="margin-top:6px">
|
||||
${o.mailSendable ? `<button class="mini-btn" data-send-offer-mail="${escapeHtml(o.id)}" title="Angebots-E-Mail an ${escapeHtml(o.customerEmail)} senden">${o.notifiedAt ? 'Erneut senden' : 'E-Mail senden'}</button>` : ''}
|
||||
${['offen', 'abgelaufen'].includes(o.status) ? `<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)))
|
||||
tbody.querySelectorAll('[data-send-offer-mail]').forEach((btn) =>
|
||||
btn.addEventListener('click', () => sendOfferMailAction(btn.dataset.sendOfferMail, btn)))
|
||||
} catch (err) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="error">${escapeHtml(err.message)}</td></tr>`
|
||||
}
|
||||
@@ -893,6 +907,22 @@ async function withdrawOfferAction(id, btn) {
|
||||
}
|
||||
}
|
||||
|
||||
async function sendOfferMailAction(id, btn) {
|
||||
const o = offerState.offers.get(id)
|
||||
if (!o) return
|
||||
const again = o.notifiedAt ? `\n\nZuletzt gesendet ${fmtDateTime(o.notifiedAt)}.` : ''
|
||||
if (!window.confirm(`E-Mail mit dem Angebot an ${o.customerName} (${o.customerEmail}) ${o.notifiedAt ? 'erneut ' : ''}senden?${again}`)) return
|
||||
btn.disabled = true
|
||||
try {
|
||||
await api(`/api/portal-admin/offers/${encodeURIComponent(id)}/notify`, { method: 'POST' })
|
||||
showOfferMessage(`Die E-Mail an ${o.customerEmail} ist raus, der Mailserver hat sie angenommen. Kommt sie nicht an: Spam-Ordner prüfen, bei Gmail auch den Tab „Werbung“.`)
|
||||
loadOffers()
|
||||
} catch (err) {
|
||||
btn.disabled = false
|
||||
alert(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const offerCustomerLabel = (c) =>
|
||||
[c.name || '(ohne Name)', c.email, c.code ? `Kd.-Nr. ${c.code}` : ''].filter(Boolean).join(' · ')
|
||||
|
||||
@@ -1001,7 +1031,11 @@ async function saveOffer(form) {
|
||||
showOfferMessage([
|
||||
`Angebot ${o.ref} für ${o.customerName} angelegt: ${o.text}${until}.`,
|
||||
`Stripe-Coupon ${o.stripeCouponId} ist eingerichtet${o.status === 'aktiv' ? ' und am laufenden Abo aktiv' : ''}.`,
|
||||
v.notifyCustomer ? (result.mailSent ? 'Die E-Mail an den Kunden ist raus.' : `E-Mail an den Kunden fehlgeschlagen (${result.mailError}).`) : '',
|
||||
v.notifyCustomer
|
||||
? (result.mailSent
|
||||
? `Die E-Mail an ${o.customerEmail} ist raus.`
|
||||
: `E-Mail an den Kunden fehlgeschlagen (${result.mailError})${o.mailSendable ? ', sie lässt sich in der Liste erneut senden' : ''}.`)
|
||||
: '',
|
||||
].filter(Boolean).join(' '))
|
||||
loadOffers()
|
||||
} catch (err) {
|
||||
|
||||
Reference in new issue
Block a user