Files
Webklar-Kundenbereich/public/shop-tab-bestellungen.js
T
ClaudeandClaude Opus 5.5 43473c0879 Webshop: IP-Hash entfällt mit Zahlung oder Storno, keine Käufer-Telefonnummer mehr gespeichert
schliesseAb und verwirf entfernen ipHash aus dem Bestell-JSON, sobald die Bestellung nicht mehr offen ist
(Begrenzung offener Bestellungen je IP bleibt). customer_details.phone wird nicht mehr in kaeufer.telefon
übernommen; Kundenansicht und Bestelldetail geben auch in alten Datensätzen keine Telefonnummer aus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:26:56 +00:00

480 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Webshop, Reiter „Bestellungen“ (Plan Block G, Task G5): zwei Ansichten, umschaltbar: „Bestellungen“ und „Widerrufe“.
- Bestellungen: Filter (Standard: bezahlt, noch nicht versendet) und Suche, Liste mit Blättern (GET /api/shop/bestellungen?projectId=&status=&suche=&seite=),
Klick auf eine Zeile öffnet das Detail (GET /api/shop/bestellungen/:id?projectId=): Positionen, Summen, Käufer, Adresse oder Abholung, Zahlung,
Verlauf, Erstattungen. Aktionen kommen aus `aktionen` der Antwort (POST /bestellungen/:id/aktion { projectId, aktion, versanddienst?, sendungsnummer? }),
Erstatten (POST /bestellungen/:id/erstatten { projectId, betragCent, grund? }) nur nach Rückfrage in der Seite. Darunter der CSV-Export
(GET /api/shop/bestellungen.csv?projectId=&von=&bis= als Link zum Herunterladen).
- Widerrufe: Liste (GET /api/shop/widerrufe?projectId=&seite=) mit „Als erledigt markieren“ (POST /widerrufe/:id/erledigt { projectId }).
Mit ctx.nurLesen (Shop abgeschaltet) gibt es keine Knöpfe zum Ändern, ausser ctx.abwickeln ist gesetzt (shop.js: Bestellungen und Widerrufe bleiben bearbeitbar). In der Kundenansicht antwortet der Server beim Ändern mit 403: der Text wird gezeigt.
render(panel, ctx): ctx = { projectId, status, nurLesen, aktuell(), neuLaden(), zeigeMeldung(text, art), request } (shop.js).
Fremdtext (Käufer, Artikel, Erklärung) kommt nur über textContent in die Seite. */
import { abschnitt, allgemeinerFehler, datumText, euroEingabe, euroText, feld, frage, h, zeigeFehler } from './shop-dom.js'
const MAIL_FEHLT = 'Gespeichert, aber die Mail an den Käufer ging nicht raus.'
const SEPA_HINWEIS = 'Lastschrift – versende erst, wenn das Geld sicher ist (nach einigen Tagen im Stripe-Dashboard sichtbar).'
const WIDERRUF_HINWEIS = 'Ein Widerruf ist erst erledigt, wenn du die Ware zurückhast oder erstattet hast (spätestens 14 Tage nach Eingang).'
const FEHLER_LADEN = 'Das konnte nicht geladen werden. Bitte versuch es noch einmal.'
const FILTER = [
['bezahlt', 'Bezahlt, noch zu versenden'],
['alle', 'Alle bezahlten Bestellungen'],
['versendet', 'Versendet'],
['abholbereit', 'Abholbereit'],
['abgeschlossen', 'Abgeschlossen'],
['erstattet', 'Erstattet'],
['offen', 'Zahlung offen'],
['storniert', 'Storniert'],
]
const STATUS = {
bezahlt: ['Bezahlt', 'wait'],
versendet: ['Versendet', 'ok'],
abholbereit: ['Abholbereit', 'ok'],
abgeschlossen: ['Abgeschlossen', 'ok'],
erstattet: ['Erstattet', 'off'],
offen: ['Zahlung offen', 'off'],
storniert: ['Storniert', 'bad'],
}
const AKTION_KNOPF = { versendet: 'Als versendet markieren', abholbereit: 'Als abholbereit markieren', abgeschlossen: 'Als abgeschlossen markieren', storniert: 'Stornieren' }
const ZAHLUNG = { card: 'Karte', klarna: 'Klarna', sepa_debit: 'SEPA-Lastschrift' }
const VERLAUF = {
angelegt: 'Bestellung angelegt',
bezahlt: 'Bezahlt',
versendet: 'Als versendet markiert',
abholbereit: 'Als abholbereit markiert',
abgeschlossen: 'Abgeschlossen',
teilerstattet: 'Teilweise erstattet',
erstattet: 'Vollständig erstattet',
storniert: 'Storniert',
abgelaufen: 'Reservierung abgelaufen',
betrag_abweichend: 'Gezahlter Betrag weicht von der Bestellung ab',
}
const istObjekt = (x) => x !== null && typeof x === 'object' && !Array.isArray(x)
const zeichen = (x) => (typeof x === 'string' ? x : '')
const cent = (x) => (Number.isInteger(x) && x >= 0 ? x : null)
const enc = encodeURIComponent
const EINFACHE_MAIL = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}$/
/** E-Mail als mailto-Link, aber nur bei einer einfachen Adresse; sonst als Text. */
const mailLink = (email) => (EINFACHE_MAIL.test(email) ? h('a', { href: `mailto:${email}` }, email) : email)
const statusPille = (status) => {
const [text, art] = STATUS[status] || [zeichen(status) || '?', 'off']
return h('span', { class: `sh-pille sh-pille-${art}`, 'data-status': status }, text)
}
const lieferartText = (art) => (art === 'abholung' ? 'Abholung' : art === 'versand' ? 'Versand' : zeichen(art))
/** Adresse als mehrzeiliger Text. */
function adresseText(a) {
if (!istObjekt(a)) return ''
return [a.name, a.line1, a.line2, [a.postal_code, a.city].filter((t) => zeichen(t)).join(' '), a.country].filter((t) => zeichen(t) !== '').join('\n')
}
const fakten = (...zeilen) => h('dl', { class: 'sh-fakten' }, zeilen.filter(Boolean).map(([name, wert]) => h('div', null, h('dt', null, name), h('dd', null, wert))))
export function render(panel, ctx) {
const wurzel = h('div', { class: 'sh-bestellungen' })
panel.replaceChildren(wurzel)
// Bei abgeschaltetem Shop bleiben Bestellungen, Erstattungen und Widerrufe bearbeitbar (ctx.abwickeln), alles andere ist nur lesbar
const nurLesen = ctx.nurLesen === true && ctx.abwickeln !== true
const z = { ansicht: 'bestellungen', filter: 'bezahlt', suche: '', seite: 1, widerrufSeite: 1, detail: null }
let lauf = 0 // nur die zuletzt gestartete Abfrage zeichnet
let beschaeftigt = false
const hier = () => wurzel.parentNode === panel && ctx.aktuell?.() !== false
const melde = (text, art = 'ok') => { if (hier()) ctx.zeigeMeldung?.(text, art) }
const get = (pfad) => ctx.request(pfad)
const post = (pfad, body) => ctx.request(pfad, { method: 'POST', body: { projectId: ctx.projectId, ...body } })
const inhalt = h('div', { class: 'sh-bestellungen-inhalt' })
const umschalter = h('div', { class: 'sh-umschalter', role: 'group', 'aria-label': 'Ansicht' }, [['bestellungen', 'Bestellungen'], ['widerrufe', 'Widerrufe']].map(([key, label]) => h('button', {
type: 'button', class: 'kb-btn-outline', 'data-ansicht': key, 'aria-pressed': 'false',
onclick: () => { melde(''); z.ansicht = key; zeichne() },
}, label)))
wurzel.append(umschalter, inhalt)
function zeichne() {
for (const k of umschalter.querySelectorAll('button')) k.setAttribute('aria-pressed', String(k.dataset.ansicht === z.ansicht))
if (z.ansicht === 'widerrufe') zeichneWiderrufe()
else if (z.detail) zeichneDetail()
else zeichneBestellungen()
}
/** Eine Abfrage mit Platzhalter; die Antwort zählt nur, wenn inzwischen keine neuere gestartet wurde und die Ansicht noch da ist. */
async function lade(ziel, pfad, fertig) {
const nr = ++lauf
ziel.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
let antwort
try {
antwort = await get(pfad)
} catch (err) {
if (nr === lauf && hier()) ziel.replaceChildren(h('p', { class: 'sh-fehler', role: 'alert' }, err?.message || FEHLER_LADEN))
return
}
if (nr === lauf && hier()) fertig(antwort)
}
/** Aktion mit Sperre gegen Doppelklick; bei einem Fehler geht der Text an `form` (Feldfehler am Feld) oder als Meldung. Gibt die Antwort oder null. */
async function schreibe(pfad, body, form) {
if (beschaeftigt) return null
beschaeftigt = true
const knoepfe = [...inhalt.querySelectorAll('button')]
for (const k of knoepfe) k.setAttribute('disabled', '')
try {
return await post(pfad, body)
} catch (err) {
if (hier()) {
if (form) zeigeFehler(form, err)
else melde(err?.message || 'Das hat nicht geklappt. Bitte versuch es noch einmal.', 'fehler')
}
return null
} finally {
beschaeftigt = false
for (const k of knoepfe) k.removeAttribute('disabled')
}
}
/* ============================================================ Liste === */
function zeichneBestellungen() {
const select = h('select', { id: 'sh-b-filter' }, FILTER.map(([wert, label]) => {
const o = h('option', { value: wert }, label)
o.selected = wert === z.filter
return o
}))
select.addEventListener('change', () => { z.filter = select.value; z.seite = 1; ladeListe() })
const suchfeld = h('input', { type: 'search', id: 'sh-b-suche', maxlength: '100', autocomplete: 'off', placeholder: 'Nummer, Name oder E-Mail' })
suchfeld.value = z.suche
const werkzeug = h('form', {
class: 'sh-werkzeug',
novalidate: true,
role: 'search',
onsubmit: (ev) => {
ev.preventDefault()
z.suche = suchfeld.value.trim()
z.seite = 1
ladeListe()
},
},
feld('filter', 'Anzeigen', select),
feld('suche', 'Suche', suchfeld),
h('button', { type: 'submit', class: 'kb-btn-outline', 'data-aktion': 'suchen' }, 'Suchen'))
const box = h('div', { class: 'sh-liste-box', 'aria-live': 'polite' })
inhalt.replaceChildren(
abschnitt('Bestellungen', 'Ein Klick auf eine Bestellung zeigt alle Angaben und was du damit tun kannst.', werkzeug, box),
csvKarte(),
)
function ladeListe() {
const q = [`projectId=${enc(ctx.projectId)}`, `status=${enc(z.filter)}`]
if (z.suche) q.push(`suche=${enc(z.suche)}`)
q.push(`seite=${z.seite}`)
lade(box, `/api/shop/bestellungen?${q.join('&')}`, (antwort) => {
z.seite = Number.isInteger(antwort.seite) ? antwort.seite : z.seite
zeichneListe(box, antwort, ladeListe)
})
}
ladeListe()
}
function seitenLeiste(seite, mehr, setze) {
return h('div', { class: 'sh-seiten' },
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zurueck', disabled: seite <= 1, onclick: () => setze(seite - 1) }, 'Zurück'),
h('span', null, `Seite ${seite}`),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'weiter', disabled: !mehr, onclick: () => setze(seite + 1) }, 'Weiter'))
}
function zeichneListe(box, antwort, neuLaden) {
const liste = Array.isArray(antwort.bestellungen) ? antwort.bestellungen : []
const teile = []
if (!liste.length) {
teile.push(h('p', { class: 'kb-empty', 'data-leer': '' }, z.suche ? 'Zu dieser Suche gibt es keine Bestellung.' : 'In dieser Ansicht gibt es keine Bestellungen.'))
} else {
teile.push(h('ul', { class: 'sh-liste' }, liste.map((b) => h('li', null, h('button', {
type: 'button', class: 'sh-zeile', 'data-bestellung': b.id, onclick: () => oeffneDetail(b.id),
},
h('strong', { class: 'sh-zeile-nr' }, zeichen(b.bestellnummer)),
h('span', { class: 'sh-zeile-datum' }, datumText(b.bezahltAt || b.erstelltAt)),
h('span', { class: 'sh-zeile-name' }, zeichen(b.kaeuferName) || zeichen(b.kaeuferEmail)),
h('span', { class: 'sh-zeile-art' }, lieferartText(b.lieferart)),
h('span', { class: 'sh-zeile-summe' }, zeichen(b.summeText) || euroText(b.summeCent)),
statusPille(b.status),
b.ueberverkauft ? h('span', { class: 'sh-zeile-warnung' }, 'Bestand reichte nicht') : null)))))
}
if (antwort.abgeschnitten) teile.push(h('p', { class: 'sh-hinweis' }, 'Es werden nur die neuesten Bestellungen durchsucht. Für Älteres nimm den CSV-Export.'))
if (z.seite > 1 || antwort.mehr) teile.push(seitenLeiste(z.seite, antwort.mehr === true, (nr) => { z.seite = nr; neuLaden() }))
box.replaceChildren(...teile)
}
/* ============================================================ CSV === */
function csvKarte() {
const von = h('input', { type: 'date', id: 'sh-csv-von' })
const bis = h('input', { type: 'date', id: 'sh-csv-bis' })
const link = h('a', { class: 'kb-btn-outline', 'data-link': 'csv', download: '' }, 'CSV herunterladen')
const setzeLink = () => {
const q = [`projectId=${enc(ctx.projectId)}`]
if (von.value) q.push(`von=${enc(von.value)}`)
if (bis.value) q.push(`bis=${enc(bis.value)}`)
link.setAttribute('href', `/api/shop/bestellungen.csv?${q.join('&')}`)
}
for (const el of [von, bis]) for (const ereignis of ['input', 'change']) el.addEventListener(ereignis, setzeLink)
setzeLink()
return abschnitt('CSV-Export', 'Alle bezahlten Bestellungen eines Zeitraums für deine Buchhaltung. Ohne Datum bekommst du alle.',
h('div', { class: 'sh-werkzeug' }, feld('von', 'Von', von), feld('bis', 'Bis', bis), link))
}
/* ========================================================== Detail === */
async function oeffneDetail(id) {
melde('')
z.detail = { id, daten: null }
inhalt.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
const nr = ++lauf
try {
const antwort = await get(`/api/shop/bestellungen/${enc(id)}?projectId=${enc(ctx.projectId)}`)
if (nr !== lauf || !hier()) return
z.detail = { id, daten: antwort.bestellung }
} catch (err) {
if (nr !== lauf || !hier()) return
z.detail = null
zeichne()
melde(err?.message || FEHLER_LADEN, 'fehler')
return
}
zeichne()
}
const zurueckZurListe = () => { melde(''); z.detail = null; zeichneBestellungen() }
function zeichneDetail() {
const b = z.detail?.daten
if (!istObjekt(b)) { z.detail = null; zeichneBestellungen(); return }
const summen = istObjekt(b.summen) ? b.summen : {}
const texte = istObjekt(summen.texte) ? summen.texte : {}
const kaeufer = istObjekt(b.kaeufer) ? b.kaeufer : {}
const aktionen = Array.isArray(b.aktionen) ? b.aktionen.filter((a) => AKTION_KNOPF[a]) : []
const teile = []
teile.push(h('button', { type: 'button', class: 'kb-btn-outline sh-zurueck', 'data-aktion': 'liste', onclick: zurueckZurListe }, '← Zur Liste'))
// Kopf: Nummer, Status, Zeiten, Hinweise
const hinweise = []
if (b.zahlungsart === 'sepa_debit' && b.status === 'bezahlt') hinweise.push(h('div', { class: 'sh-box sh-box-warn', role: 'note', 'data-hinweis': 'sepa' }, SEPA_HINWEIS))
if (b.ueberverkauft) hinweise.push(h('div', { class: 'sh-box sh-box-warn', role: 'note', 'data-hinweis': 'ueberverkauft' }, 'Achtung: Der Bestand hat für diese Bestellung nicht gereicht. Melde dich bitte beim Käufer oder erstatte die Bestellung.'))
if (b.spaet) hinweise.push(h('div', { class: 'sh-box sh-box-warn', role: 'note', 'data-hinweis': 'spaet' }, 'Die Zahlung kam erst nach Ablauf der Reservierung an. Prüfe, ob du noch liefern kannst.'))
teile.push(abschnitt(`Bestellung ${zeichen(b.bestellnummer)}`, null,
statusPille(b.status),
...hinweise,
fakten(
['Bestellt am', datumText(b.erstelltAt)],
b.bezahltAt ? ['Bezahlt am', datumText(b.bezahltAt)] : null,
['Gesamt', zeichen(b.summeText) || euroText(b.summeCent)],
cent(b.erstattetCent) ? ['Erstattet', `${zeichen(b.erstattetText) || euroText(b.erstattetCent)} (noch erstattbar: ${zeichen(b.restText) || euroText(b.restCent)})`] : null,
)))
// Positionen und Summen
const positionen = Array.isArray(b.positionen) ? b.positionen : []
teile.push(abschnitt('Artikel', null,
h('ul', { class: 'sh-positionen' }, positionen.map((p) => h('li', null,
h('span', null, `${Number.isInteger(p?.menge) ? p.menge : '?'} × ${zeichen(p?.name)}${zeichen(p?.varianteText) ? ` (${p.varianteText})` : ''}`),
h('span', null, zeichen(p?.summeText) || euroText(p?.summeCent))))),
fakten(
['Zwischensumme', zeichen(texte.zwischensumme) || euroText(summen.zwischensummeCent)],
['Versand', zeichen(texte.versand) || euroText(summen.versandCent)],
['Gesamt', zeichen(texte.gesamt) || euroText(summen.gesamtCent)],
zeichen(texte.steuerHinweis) ? ['Steuer', texte.steuerHinweis] : null,
)))
// Käufer, Lieferung, Zahlung
const lieferung = b.lieferart === 'abholung'
? ['Abholung', [zeichen(b.abholung?.adresse), zeichen(b.abholung?.hinweis)].filter(Boolean).join('\n') || 'Der Käufer holt die Ware bei dir ab.']
: ['Lieferadresse', adresseText(b.lieferadresse) || 'Noch keine Adresse (die Zahlung ist noch nicht eingegangen).']
teile.push(abschnitt('Käufer und Lieferung', null, fakten(
['Name', zeichen(kaeufer.name)],
['E-Mail', mailLink(zeichen(kaeufer.email))],
['Lieferart', `${lieferartText(b.lieferart)}${b.lieferart === 'versand' && zeichen(b.land) ? ` (${b.land})` : ''}`],
lieferung,
b.rechnungsadresse && adresseText(b.rechnungsadresse) !== adresseText(b.lieferadresse) ? ['Rechnungsadresse', adresseText(b.rechnungsadresse)] : null,
zeichen(b.lieferzeit) ? ['Lieferzeit laut Shop', b.lieferzeit] : null,
['Zahlungsart', ZAHLUNG[b.zahlungsart] || zeichen(b.zahlungsart) || 'Nicht bekannt'],
istObjekt(b.versand) ? ['Versand', [zeichen(b.versand.versanddienst), zeichen(b.versand.sendungsnummer)].filter(Boolean).join(', ') || 'Versendet'] : null,
)))
// Aktionen
if (!nurLesen && (aktionen.length || b.kannErstatten)) teile.push(aktionenKarte(b, aktionen))
// Verlauf und Erstattungen
const verlauf = Array.isArray(b.verlauf) ? b.verlauf.filter(istObjekt) : []
teile.push(abschnitt('Verlauf', null, verlauf.length
? h('ul', { class: 'sh-verlauf' }, verlauf.map((v) => h('li', null,
h('span', { class: 'sh-verlauf-zeit' }, datumText(v.at)),
h('span', null, verlaufText(v))))) : h('p', { class: 'sh-hinweis' }, 'Noch nichts passiert.')))
const erstattungen = Array.isArray(b.erstattungen) ? b.erstattungen.filter(istObjekt) : []
if (erstattungen.length) {
teile.push(abschnitt('Erstattungen', null, h('ul', { class: 'sh-verlauf', 'data-liste': 'erstattungen' }, erstattungen.map((e) => h('li', null,
h('span', { class: 'sh-verlauf-zeit' }, datumText(e.at)),
h('span', null, `${euroText(e.betragCent)}${zeichen(e.grund) ? `: ${e.grund}` : ''}`))))))
}
inhalt.replaceChildren(...teile)
}
function verlaufText(v) {
const text = VERLAUF[v.was] || zeichen(v.was)
const extra = []
if (zeichen(v.versanddienst)) extra.push(v.versanddienst)
if (zeichen(v.sendungsnummer)) extra.push(v.sendungsnummer)
if (cent(v.betragCent) !== null && (v.was === 'teilerstattet' || v.was === 'erstattet')) extra.push(euroText(v.betragCent))
return extra.length ? `${text} (${extra.join(', ')})` : text
}
/* ======================================================== Aktionen === */
function aktionenKarte(b, aktionen) {
const bereich = h('div', { class: 'sh-aktionen-bereich' })
const knoepfe = h('div', { class: 'sh-aktionen' }, aktionen.map((aktion) => h('button', {
type: 'button',
class: aktion === 'storniert' ? 'kb-btn-outline' : 'kb-btn-primary',
'data-aktion': aktion,
onclick: () => {
if (aktion === 'versendet') zeigeVersandFrage(b, bereich)
else if (aktion === 'storniert') zeigeStornoFrage(b, bereich)
else fuehreAktionAus(b, aktion, {}, null)
},
}, AKTION_KNOPF[aktion])))
const teile = [knoepfe, bereich]
if (b.kannErstatten) teile.push(erstattenBereich(b))
return abschnitt('Aktionen', 'Der Käufer bekommt zu „versendet“, „abholbereit“ und zu Erstattungen eine Mail von deinem Shop.', ...teile)
}
const stelleFrage = (bereich, form) => { bereich.replaceChildren(form); form.focus() }
function zeigeVersandFrage(b, bereich) {
const dienst = h('input', { type: 'text', maxlength: '60', autocomplete: 'off', placeholder: 'z. B. DHL' })
const nummer = h('input', { type: 'text', maxlength: '80', autocomplete: 'off' })
const form = frage('Als versendet markieren',
feld('versanddienst', 'Versanddienst (freiwillig)', dienst),
feld('sendungsnummer', 'Sendungsnummer (freiwillig)', nummer, 'Beides steht in der Mail an den Käufer.'),
allgemeinerFehler(),
h('div', { class: 'sh-aktionen' },
h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'versendet-speichern' }, 'Als versendet speichern'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => bereich.replaceChildren() }, 'Abbrechen')))
form.addEventListener('submit', () => fuehreAktionAus(b, 'versendet', { versanddienst: dienst.value.trim(), sendungsnummer: nummer.value.trim() }, form))
stelleFrage(bereich, form)
}
function zeigeStornoFrage(b, bereich) {
const form = frage('Bestellung wirklich stornieren?',
h('p', { class: 'sh-hinweis' }, 'Die Bestellung ist noch nicht bezahlt. Nach dem Stornieren kann sie nicht mehr bezahlt werden.'),
allgemeinerFehler(),
h('div', { class: 'sh-aktionen' },
h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'stornieren-bestaetigen' }, 'Ja, stornieren'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => bereich.replaceChildren() }, 'Abbrechen')))
form.addEventListener('submit', () => fuehreAktionAus(b, 'storniert', {}, form))
stelleFrage(bereich, form)
}
async function fuehreAktionAus(b, aktion, extra, form) {
const antwort = await schreibe(`/api/shop/bestellungen/${enc(b.id)}/aktion`, { aktion, ...extra }, form)
if (!antwort || !hier()) return
if (istObjekt(antwort.bestellung)) z.detail = { id: b.id, daten: antwort.bestellung }
zeichne()
if (antwort.mailOk === false) { melde(MAIL_FEHLT, 'fehler'); return }
const text = {
versendet: 'Als versendet gespeichert. Der Käufer hat eine Mail bekommen.',
abholbereit: 'Als abholbereit gespeichert. Der Käufer hat eine Mail bekommen.',
abgeschlossen: 'Als abgeschlossen gespeichert.',
storniert: 'Die Bestellung ist storniert.',
}[aktion]
melde(text || 'Gespeichert.')
}
/** Erstatten: Formular (Betrag, Grund) und darunter die Rückfrage; beides in einem Container, weil Formulare sich nicht verschachteln lassen. */
function erstattenBereich(b) {
const rest = cent(b.restCent) ?? 0
const betrag = euroEingabe({ cent: rest, id: 'sh-erstatten-betrag', label: 'Betrag in Euro' })
const grund = h('input', { type: 'text', maxlength: '300', autocomplete: 'off' })
const bereich = h('div', { class: 'sh-erstatten-frage' })
const form = h('form', {
class: 'sh-form',
novalidate: true,
'data-form': 'erstatten',
onsubmit: (ev) => {
ev.preventDefault()
const c = betrag.inCent()
const fehler = {}
if (c === null) fehler.betrag = 'Bitte gib einen Betrag in Euro an, z. B. 12,50.'
else if (c < 1) fehler.betrag = 'Der Betrag muss größer als 0 sein.'
else if (c > rest) fehler.betrag = `Es sind höchstens ${euroText(rest)} erstattbar.`
if (Object.keys(fehler).length) { zeigeFehler(form, { fehler, message: '' }); return }
zeigeFehler(form, { fehler: {}, message: '' })
zeigeErstattenFrage(b, c, grund.value.trim(), bereich, form)
},
},
h('h4', null, 'Erstatten'),
h('p', { class: 'sh-hinweis' }, `Noch erstattbar: ${euroText(rest)}. Das Geld geht über dein Stripe-Konto an den Käufer zurück.`),
feld('betrag', 'Betrag in Euro', betrag),
feld('grund', 'Grund (freiwillig)', grund, 'Steht in der Mail an den Käufer.'),
allgemeinerFehler(),
h('div', { class: 'sh-aktionen' }, h('button', { type: 'submit', class: 'kb-btn-outline', 'data-aktion': 'erstatten' }, 'Erstatten …')))
return h('div', { class: 'sh-erstatten' }, form, bereich)
}
function zeigeErstattenFrage(b, betragCent, grund, bereich, form) {
const frageForm = frage(`Wirklich ${euroText(betragCent)} an den Käufer zurückzahlen? Das geht nicht rückgängig.`,
h('div', { class: 'sh-aktionen' },
h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'erstatten-bestaetigen' }, 'Ja, zurückzahlen'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => bereich.replaceChildren() }, 'Abbrechen')))
frageForm.addEventListener('submit', async () => {
const antwort = await schreibe(`/api/shop/bestellungen/${enc(b.id)}/erstatten`, { betragCent, ...(grund ? { grund } : {}) }, form)
if (!hier()) return
if (!antwort) { bereich.replaceChildren(); return }
if (istObjekt(antwort.bestellung)) z.detail = { id: b.id, daten: antwort.bestellung }
zeichne()
if (antwort.mailOk === false) { melde(MAIL_FEHLT, 'fehler'); return }
melde(`${euroText(cent(antwort.erstattung?.betragCent) ?? betragCent)} erstattet.${antwort.voll ? ' Die Bestellung ist damit vollständig erstattet.' : ''} Der Käufer hat eine Mail bekommen.`)
})
stelleFrage(bereich, frageForm)
}
/* ======================================================= Widerrufe === */
function zeichneWiderrufe() {
const box = h('div', { class: 'sh-liste-box', 'aria-live': 'polite' })
inhalt.replaceChildren(abschnitt('Widerrufe', WIDERRUF_HINWEIS, box))
const ladeW = () => lade(box, `/api/shop/widerrufe?projectId=${enc(ctx.projectId)}&seite=${z.widerrufSeite}`, (antwort) => {
z.widerrufSeite = Number.isInteger(antwort.seite) ? antwort.seite : z.widerrufSeite
const liste = Array.isArray(antwort.widerrufe) ? antwort.widerrufe : []
const teile = []
if (!liste.length) teile.push(h('p', { class: 'kb-empty', 'data-leer': '' }, 'Es ist noch kein Widerruf eingegangen.'))
else teile.push(h('ul', { class: 'sh-liste' }, liste.map((w) => widerrufZeile(w, ladeW))))
if (z.widerrufSeite > 1 || antwort.mehr) teile.push(seitenLeiste(z.widerrufSeite, antwort.mehr === true, (nr) => { z.widerrufSeite = nr; ladeW() }))
box.replaceChildren(...teile)
})
ladeW()
}
function widerrufZeile(w, neuLaden) {
const erledigt = Boolean(w.bearbeitetAt)
return h('li', { class: 'sh-widerruf', 'data-widerruf': w.id },
fakten(
['Eingang', datumText(w.eingangAt)],
['Name', zeichen(w.name)],
['E-Mail', mailLink(zeichen(w.email))],
['Bestellung', zeichen(w.bestellnummer) || 'Nicht zugeordnet'],
['Erklärung', zeichen(w.nachricht) || '–'],
['Stand', erledigt ? `erledigt am ${datumText(w.bearbeitetAt)}` : 'offen'],
),
h('div', { class: 'sh-aktionen' },
w.orderId ? h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zur-bestellung', onclick: () => { z.ansicht = 'bestellungen'; oeffneDetail(w.orderId) } }, 'Zur Bestellung') : null,
!erledigt && !nurLesen ? h('button', {
type: 'button',
class: 'kb-btn-primary',
'data-aktion': 'erledigt',
onclick: async () => {
const antwort = await schreibe(`/api/shop/widerrufe/${enc(w.id)}/erledigt`, {}, null)
if (!antwort || !hier()) return
melde('Der Widerruf ist als erledigt markiert.')
neuLaden()
},
}, 'Als erledigt markieren') : null))
}
zeichne()
}