diff --git a/public/shop-tab-bestellungen.js b/public/shop-tab-bestellungen.js
index 3432ae6..1822e1a 100644
--- a/public/shop-tab-bestellungen.js
+++ b/public/shop-tab-bestellungen.js
@@ -1,8 +1,478 @@
-/* Webshop, Reiter „Bestellungen“: Platzhalter, bis der Reiter gebaut ist (Plan Block G, Tasks G3 bis G6 ersetzen diese Datei).
- render(panel, ctx): ctx = { projectId, status, nurLesen, stripeRueckkehr, neuLaden(), zeigeMeldung(text, art), request } (siehe shop.js). */
+/* 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. 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 { h } from './shop-dom.js'
+import { abschnitt, allgemeinerFehler, datumText, euroEingabe, euroText, feld, frage, h, zeigeFehler } from './shop-dom.js'
-export function render(panel) {
- panel.replaceChildren(h('p', { class: 'sh-hinweis' }, 'Kommt gleich …'))
+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)
+ 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))],
+ zeichen(kaeufer.telefon) ? ['Telefon', kaeufer.telefon] : null,
+ ['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 (!ctx.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 && !ctx.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()
}
diff --git a/public/shop.css b/public/shop.css
index cdbd296..c7ac33d 100644
--- a/public/shop.css
+++ b/public/shop.css
@@ -114,6 +114,37 @@ a.kb-btn-outline { display: inline-flex; align-items: center; text-decoration: n
.sh-gewaehrleistung-bild { display: block; max-width: 100%; height: auto; margin: 0 0 12px; border-radius: 8px; background: #fff; }
.sh-rechtliches textarea { font-family: inherit; line-height: 1.5; }
+/* Reiter Bestellungen */
+.sh-umschalter { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
+.sh-umschalter [aria-pressed='true'] { background: var(--kb-purple-soft); border-color: var(--kb-purple-line); color: #fff; }
+.sh-werkzeug { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px 14px; }
+.sh-werkzeug .sh-feld { margin: 0; flex: 1 1 200px; }
+.sh-liste { margin: 12px 0 0; padding: 0; list-style: none; display: grid; gap: 8px; }
+.sh-zeile {
+ display: grid; grid-template-columns: minmax(80px, 110px) minmax(110px, 150px) minmax(0, 1fr) minmax(70px, 90px) minmax(80px, 100px) auto; align-items: center; gap: 6px 12px;
+ width: 100%; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.04);
+ color: inherit; font: inherit; font-size: 14px; text-align: left; cursor: pointer;
+}
+.sh-zeile:hover { background: rgba(255, 255, 255, 0.08); }
+.sh-zeile .sh-pille { margin: 0; justify-self: start; }
+.sh-zeile-name, .sh-zeile-art { overflow-wrap: anywhere; }
+.sh-zeile-summe { font-weight: 700; }
+.sh-zeile-warnung { grid-column: 1 / -1; font-size: 12.5px; color: #fcd34d; }
+.sh-seiten { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 14px; }
+.sh-zurueck { margin-bottom: 14px; }
+.sh-positionen, .sh-verlauf { margin: 0 0 12px; padding: 0; list-style: none; display: grid; gap: 6px; }
+.sh-positionen li { display: flex; justify-content: space-between; gap: 12px; line-height: 1.5; }
+.sh-positionen li > span:first-child { overflow-wrap: anywhere; }
+.sh-verlauf li { display: grid; grid-template-columns: minmax(130px, 170px) minmax(0, 1fr); gap: 4px 12px; line-height: 1.5; }
+.sh-verlauf-zeit { color: var(--kb-muted); font-size: 13px; }
+.sh-aktionen-bereich:empty, .sh-erstatten-frage:empty { display: none; }
+.sh-widerruf { padding: 14px 16px; border-radius: 12px; border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.04); }
+.sh-widerruf .sh-fakten { margin-top: 0; }
+@media (max-width: 700px) {
+ .sh-zeile { grid-template-columns: minmax(0, 1fr) auto; }
+ .sh-verlauf li { grid-template-columns: minmax(0, 1fr); }
+}
+
/* ----------------------------------------------------- Artikel (shop-tab-artikel.js) --- */
.sh-artikelliste { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 10px; }
.sh-artikel { display: grid; grid-template-columns: 56px minmax(0, 1fr) auto; gap: 8px 14px; align-items: center; padding: 12px; border-radius: 12px; border: 1px solid var(--kb-border); background: rgba(255, 255, 255, 0.03); }
diff --git a/test/shopTabBestellungen.test.js b/test/shopTabBestellungen.test.js
new file mode 100644
index 0000000..5513d66
--- /dev/null
+++ b/test/shopTabBestellungen.test.js
@@ -0,0 +1,514 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { installFakeDom } from './support/fakeDom.js'
+import { euroText, h, request } from '../public/shop-dom.js'
+import { render } from '../public/shop-tab-bestellungen.js'
+
+// Task G5: Reiter „Bestellungen“ (public/shop-tab-bestellungen.js) mit Fake-DOM und Fake-fetch. Die Antworten sind von Hand gebaut
+// (Form: server/routes/shop.js und server/services/shop/bestellungen.js listenZeile/bestellungKunde, widerrufe.js ansicht).
+
+const zeile = (nr, extra = {}) => ({
+ id: `o${nr}`, bestellnummer: `WK-${nr}`, status: 'bezahlt', erstelltAt: '2026-10-05T10:00:00.000Z', bezahltAt: '2026-10-05T10:05:00.000Z',
+ summeCent: 4590, summeText: '45,90 €', erstattetCent: 0, kaeuferName: `Kunde ${nr}`, kaeuferEmail: `k${nr}@example.com`, lieferart: 'versand',
+ anzahlArtikel: 2, ueberverkauft: false, spaet: false, ...extra,
+})
+const liste = (zeilen, extra = {}) => ({ bestellungen: zeilen, seite: 1, mehr: false, abgeschnitten: false, ...extra })
+const detail = (extra = {}) => ({
+ id: 'o1', bestellnummer: 'WK-1', status: 'bezahlt', erstelltAt: '2026-10-05T10:00:00.000Z', bezahltAt: '2026-10-05T10:05:00.000Z',
+ summeCent: 4590, summeText: '45,90 €', erstattetCent: 0, erstattetText: '0,00 €', restCent: 4590, restText: '45,90 €',
+ kaeufer: { name: 'Anna Beispiel', email: 'anna@example.com', telefon: '0151 123' },
+ lieferart: 'versand', land: 'DE',
+ lieferadresse: { name: 'Anna Beispiel', line1: 'Hauptstr. 1', line2: '', postal_code: '10115', city: 'Berlin', country: 'DE' },
+ rechnungsadresse: { name: 'Anna Beispiel', line1: 'Hauptstr. 1', line2: '', postal_code: '10115', city: 'Berlin', country: 'DE' },
+ abholung: null, zahlungsart: 'card', lieferzeit: '2–4 Werktage',
+ positionen: [
+ { name: 'Mütze', varianteText: 'Blau', menge: 2, summeCent: 3000, summeText: '30,00 €' },
+ { name: 'Schal', varianteText: '', menge: 1, summeCent: 1590, summeText: '15,90 €' },
+ ],
+ summen: { zwischensummeCent: 4590, versandCent: 0, gesamtCent: 4590, steuer: [], texte: { zwischensumme: '45,90 €', versand: 'kostenlos', gesamt: '45,90 €', steuerHinweis: 'Kein Steuerausweis nach § 19 UStG' } },
+ versand: null, ueberverkauft: false, spaet: false,
+ verlauf: [{ at: '2026-10-05T10:00:00.000Z', was: 'angelegt' }, { at: '2026-10-05T10:05:00.000Z', was: 'bezahlt' }],
+ erstattungen: [], aktionen: ['versendet', 'abgeschlossen'], kannErstatten: true, ...extra,
+})
+const widerruf = (nr, extra = {}) => ({
+ id: `w${nr}`, bestellnummer: `WK-${nr}`, name: `Wida ${nr}`, email: `w${nr}@example.com`, nachricht: 'Ich widerrufe ',
+ eingangAt: '2026-10-05T12:30:00.000Z', bestaetigungAt: '2026-10-05T12:30:05.000Z', bearbeitetAt: null, orderId: `o${nr}`, ...extra,
+})
+
+/** Fake-Server: jede Anfrage geht an route(aufruf) ⇒ { status, body }; Standard 200 {}. */
+const starte = (route) => installFakeDom({ antworten: Array.from({ length: 60 }, () => route) })
+
+function baueCtx({ nurLesen = false } = {}) {
+ const ctx = {
+ projectId: 'p1', status: { state: 'active' }, nurLesen, meldungen: [], aktuell: () => true, request,
+ zeigeMeldung: (text, art) => ctx.meldungen.push({ text, art }),
+ neuLaden: async () => ctx.status,
+ }
+ return ctx
+}
+
+async function zeige(fx, optionen = {}) {
+ const ctx = baueCtx(optionen)
+ const panel = h('div', { id: 'sh-panel' })
+ fx.document.body.append(panel)
+ await render(panel, ctx)
+ await fx.ruhe()
+ return { panel, ctx }
+}
+
+const mit = (route, fn) => async () => {
+ const fx = starte(route)
+ try {
+ await fn(fx)
+ } finally {
+ fx.restore()
+ }
+}
+const aktion = (fx, el, name) => fx.finde(el, `[data-aktion="${name}"]`)
+const gets = (fx) => fx.fetchLog.filter((a) => a.method === 'GET').map((a) => a.url)
+const posts = (fx) => fx.fetchLog.filter((a) => a.method !== 'GET')
+const letzteMeldung = (ctx) => ctx.meldungen.at(-1)
+const mailto = (fx, el) => fx.finde(el, (n) => n.tagName === 'A' && (n.getAttribute('href') || '').startsWith('mailto:'))
+
+/** Standard-Server: Liste mit zwei Zeilen, Detail, Aktionen und Erstattung. */
+const server = (ueberschreibe = {}) => (a) => {
+ const u = a.url
+ if (ueberschreibe[`${a.method} ${u.split('?')[0]}`]) return ueberschreibe[`${a.method} ${u.split('?')[0]}`](a)
+ if (a.method === 'GET' && u.startsWith('/api/shop/bestellungen?')) return { body: liste([zeile(1), zeile(2, { status: 'versendet', lieferart: 'abholung' })]) }
+ if (a.method === 'GET' && u.startsWith('/api/shop/bestellungen/')) return { body: { bestellung: detail() } }
+ if (a.method === 'GET' && u.startsWith('/api/shop/widerrufe?')) return { body: { widerrufe: [widerruf(1), widerruf(2, { bearbeitetAt: '2026-10-06T08:00:00.000Z' })], seite: 1, mehr: false } }
+ return { body: {} }
+}
+
+async function oeffneDetail(fx, optionen) {
+ const r = await zeige(fx, optionen)
+ await fx.klick(fx.finde(r.panel, '[data-bestellung="o1"]'))
+ await fx.ruhe()
+ return r
+}
+
+/* ----------------------------------------------------------------- Liste --- */
+test('Liste: Standard ist „bezahlt“, Zeilen zeigen Nummer, Datum, Name, Lieferart, Gesamt und Status', mit(server(), async (fx) => {
+ const { panel } = await zeige(fx)
+ assert.deepEqual(gets(fx), ['/api/shop/bestellungen?projectId=p1&status=bezahlt&seite=1'])
+ const zeilen = fx.alle(panel, '[data-bestellung]')
+ assert.equal(zeilen.length, 2)
+ const text = fx.text(zeilen[0])
+ assert.match(text, /WK-1/)
+ assert.match(text, /05\.10\.2026/)
+ assert.match(text, /Kunde 1/)
+ assert.match(text, /Versand/)
+ assert.match(text, /45,90 €/)
+ assert.match(text, /Bezahlt/)
+ assert.match(fx.text(zeilen[1]), /Abholung/)
+ assert.match(fx.text(zeilen[1]), /Versendet/)
+ assert.equal(aktion(fx, panel, 'weiter'), null, 'ohne weitere Seite keine Blätterleiste')
+}))
+
+test('Filter und Suche setzen die Abfrage (Status, Suche kodiert, zurück auf Seite 1)', mit(server(), async (fx) => {
+ const { panel } = await zeige(fx)
+ await fx.setze(fx.finde(panel, '#sh-b-filter'), 'versendet')
+ await fx.ruhe()
+ assert.equal(gets(fx).at(-1), '/api/shop/bestellungen?projectId=p1&status=versendet&seite=1')
+ await fx.setze(fx.finde(panel, '#sh-b-suche'), ' Anna Müller & Co ')
+ await fx.klick(aktion(fx, panel, 'suchen'))
+ await fx.ruhe()
+ assert.equal(gets(fx).at(-1), `/api/shop/bestellungen?projectId=p1&status=versendet&suche=${encodeURIComponent('Anna Müller & Co')}&seite=1`)
+ await fx.setze(fx.finde(panel, '#sh-b-filter'), 'alle')
+ await fx.ruhe()
+ assert.match(gets(fx).at(-1), /status=alle&suche=Anna/, 'die Suche bleibt beim Wechsel des Filters')
+}))
+
+test('Blättern: Weiter und Zurück fragen die nächste Seite, die Leiste folgt dem Server', mit((a) => {
+ if (a.url.startsWith('/api/shop/bestellungen?')) {
+ const seite = Number(/seite=(\d+)/.exec(a.url)[1])
+ return { body: liste([zeile(seite)], { seite, mehr: seite < 2 }) }
+ }
+ return { body: {} }
+}, async (fx) => {
+ const { panel } = await zeige(fx)
+ assert.equal(aktion(fx, panel, 'zurueck').hasAttribute('disabled'), true)
+ await fx.klick(aktion(fx, panel, 'weiter'))
+ await fx.ruhe()
+ assert.match(gets(fx).at(-1), /seite=2$/)
+ assert.match(fx.text(panel), /Seite 2/)
+ assert.equal(aktion(fx, panel, 'weiter').hasAttribute('disabled'), true)
+ await fx.klick(aktion(fx, panel, 'zurueck'))
+ await fx.ruhe()
+ assert.match(gets(fx).at(-1), /seite=1$/)
+ assert.match(fx.text(panel), /WK-1/)
+}))
+
+test('Leere Liste, Ladefehler und Hinweis bei abgeschnittener Suche', mit((a) => {
+ if (a.url.includes('status=erstattet')) return { status: 500, body: { error: 'Server kaputt.' } }
+ if (a.url.includes('status=alle')) return { body: liste([zeile(1)], { abgeschnitten: true }) }
+ return { body: liste([]) }
+}, async (fx) => {
+ const { panel } = await zeige(fx)
+ assert.ok(fx.finde(panel, '[data-leer]'))
+ await fx.setze(fx.finde(panel, '#sh-b-filter'), 'erstattet')
+ await fx.ruhe()
+ assert.match(fx.text(panel), /Server kaputt\./)
+ await fx.setze(fx.finde(panel, '#sh-b-filter'), 'alle')
+ await fx.ruhe()
+ assert.match(fx.text(panel), /nur die neuesten Bestellungen durchsucht/)
+}))
+
+/* ---------------------------------------------------------------- Detail --- */
+test('Detail: Positionen, Summen, Käufer, Adresse, Zahlung, Verlauf; Fremdtext bleibt Text', mit(server(), async (fx) => {
+ const { panel } = await oeffneDetail(fx)
+ assert.equal(gets(fx).at(-1), '/api/shop/bestellungen/o1?projectId=p1')
+ const text = fx.text(panel)
+ for (const erwartet of ['Bestellung WK-1', '2 × Mütze (Blau)', '30,00 €', '1 × Schal', '15,90 €', 'kostenlos', 'Kein Steuerausweis', 'Anna Beispiel', 'Hauptstr. 1', '10115 Berlin', 'Karte', '2–4 Werktage', 'Bestellung angelegt', 'Bezahlt']) {
+ assert.ok(text.includes(erwartet), erwartet)
+ }
+ assert.equal(fx.alle(panel, 'b').length, 0, 'Käufername ist Text, kein Markup')
+ const mail = mailto(fx, panel)
+ assert.equal(mail.getAttribute('href'), 'mailto:anna@example.com')
+ assert.equal(fx.finde(panel, '[data-hinweis="sepa"]'), null)
+ await fx.klick(aktion(fx, panel, 'liste'))
+ await fx.ruhe()
+ assert.ok(fx.finde(panel, '#sh-b-filter'), 'zurück in der Liste')
+}))
+
+test('Detail: Abholung zeigt Adresse und Hinweis, keine Lieferadresse; E-Mail mit Sonderzeichen ist kein Link', mit(server({
+ 'GET /api/shop/bestellungen/o1': () => ({ body: { bestellung: detail({ lieferart: 'abholung', lieferadresse: null, abholung: { adresse: 'Laden 5, 80331 München', hinweis: 'Mo–Fr 10–18 Uhr' }, kaeufer: { name: 'A', email: 'a@b.de"> {
+ const { panel } = await oeffneDetail(fx)
+ const text = fx.text(panel)
+ assert.ok(text.includes('Laden 5, 80331 München'))
+ assert.ok(text.includes('Mo–Fr 10–18 Uhr'))
+ assert.ok(!text.includes('Lieferadresse'))
+ assert.equal(mailto(fx, panel), null)
+ assert.ok(aktion(fx, panel, 'abholbereit'))
+ assert.equal(aktion(fx, panel, 'versendet'), null)
+}))
+
+test('Detail: SEPA-Hinweis nur bei Lastschrift und Status bezahlt', mit(server({
+ 'GET /api/shop/bestellungen/o1': () => ({ body: { bestellung: detail({ zahlungsart: 'sepa_debit' }) } }),
+}), async (fx) => {
+ const { panel } = await oeffneDetail(fx)
+ assert.equal(fx.text(fx.finde(panel, '[data-hinweis="sepa"]')), 'Lastschrift – versende erst, wenn das Geld sicher ist (nach einigen Tagen im Stripe-Dashboard sichtbar).')
+ assert.ok(fx.text(panel).includes('SEPA-Lastschrift'))
+}))
+
+test('Detail: SEPA ohne Hinweis, wenn schon versendet', mit(server({
+ 'GET /api/shop/bestellungen/o1': () => ({ body: { bestellung: detail({ zahlungsart: 'sepa_debit', status: 'versendet', aktionen: ['abgeschlossen'] }) } }),
+}), async (fx) => {
+ const { panel } = await oeffneDetail(fx)
+ assert.equal(fx.finde(panel, '[data-hinweis="sepa"]'), null)
+}))
+
+test('Detail: Ladefehler führt zurück in die Liste mit Meldung', mit(server({
+ 'GET /api/shop/bestellungen/o1': () => ({ status: 404, body: { error: 'Diese Bestellung gibt es nicht.' } }),
+}), async (fx) => {
+ const { panel, ctx } = await oeffneDetail(fx)
+ assert.ok(fx.finde(panel, '#sh-b-filter'))
+ assert.deepEqual(letzteMeldung(ctx), { text: 'Diese Bestellung gibt es nicht.', art: 'fehler' })
+}))
+
+/* -------------------------------------------------------------- Aktionen --- */
+test('Versendet: Formular mit Versanddienst und Sendungsnummer, Body, Meldung, Detail zeigt neuen Stand', mit(server({
+ 'POST /api/shop/bestellungen/o1/aktion': () => ({ body: { bestellung: detail({ status: 'versendet', aktionen: ['abgeschlossen'], kannErstatten: true, versand: { versanddienst: 'DHL', sendungsnummer: 'JJD123' } }), mailOk: true } }),
+}), async (fx) => {
+ const { panel, ctx } = await oeffneDetail(fx)
+ await fx.klick(aktion(fx, panel, 'versendet'))
+ assert.equal(posts(fx).length, 0, 'erst das Formular')
+ const form = fx.finde(panel, 'form[aria-label="Als versendet markieren"]')
+ await fx.setze(fx.finde(form, '[data-feld="versanddienst"]'), ' DHL ')
+ await fx.setze(fx.finde(form, '[data-feld="sendungsnummer"]'), 'JJD123')
+ await fx.klick(aktion(fx, form, 'versendet-speichern'))
+ await fx.ruhe()
+ assert.deepEqual(posts(fx).map((p) => [p.url, p.body]), [['/api/shop/bestellungen/o1/aktion', { projectId: 'p1', aktion: 'versendet', versanddienst: 'DHL', sendungsnummer: 'JJD123' }]])
+ assert.match(letzteMeldung(ctx).text, /Als versendet gespeichert/)
+ assert.equal(letzteMeldung(ctx).art, 'ok')
+ assert.equal(aktion(fx, panel, 'versendet'), null)
+ assert.ok(fx.text(panel).includes('DHL, JJD123'))
+}))
+
+test('Versendet ohne Angaben schickt leere Texte; Abholbereit und Abgeschlossen gehen direkt', mit(server({
+ 'POST /api/shop/bestellungen/o1/aktion': (a) => ({ body: { bestellung: detail({ aktionen: ['abholbereit', 'abgeschlossen', 'versendet'] }), mailOk: a.body.aktion !== 'abholbereit' || true } }),
+}), async (fx) => {
+ const { panel, ctx } = await oeffneDetail(fx)
+ await fx.klick(aktion(fx, panel, 'versendet'))
+ await fx.klick(aktion(fx, panel, 'versendet-speichern'))
+ await fx.ruhe()
+ assert.deepEqual(posts(fx)[0].body, { projectId: 'p1', aktion: 'versendet', versanddienst: '', sendungsnummer: '' })
+ await fx.klick(aktion(fx, panel, 'abholbereit'))
+ await fx.ruhe()
+ assert.deepEqual(posts(fx)[1].body, { projectId: 'p1', aktion: 'abholbereit' })
+ assert.match(letzteMeldung(ctx).text, /abholbereit/)
+ await fx.klick(aktion(fx, panel, 'abgeschlossen'))
+ await fx.ruhe()
+ assert.deepEqual(posts(fx)[2].body, { projectId: 'p1', aktion: 'abgeschlossen' })
+}))
+
+test('mailOk false: Meldung „Gespeichert, aber die Mail an den Käufer ging nicht raus.“', mit(server({
+ 'POST /api/shop/bestellungen/o1/aktion': () => ({ body: { bestellung: detail({ status: 'versendet', aktionen: ['abgeschlossen'] }), mailOk: false } }),
+}), async (fx) => {
+ const { panel, ctx } = await oeffneDetail(fx)
+ await fx.klick(aktion(fx, panel, 'abgeschlossen'))
+ await fx.ruhe()
+ assert.deepEqual(letzteMeldung(ctx), { text: 'Gespeichert, aber die Mail an den Käufer ging nicht raus.', art: 'fehler' })
+ assert.match(fx.text(panel), /Versendet/)
+}))
+
+test('Stornieren: nur wenn der Server es anbietet, erst nach Rückfrage', mit(server({
+ 'GET /api/shop/bestellungen/o1': () => ({ body: { bestellung: detail({ status: 'offen', aktionen: ['storniert'], kannErstatten: false }) } }),
+ 'POST /api/shop/bestellungen/o1/aktion': () => ({ body: { bestellung: detail({ status: 'storniert', aktionen: [], kannErstatten: false }), mailOk: true } }),
+}), async (fx) => {
+ const { panel, ctx } = await oeffneDetail(fx)
+ await fx.klick(aktion(fx, panel, 'storniert'))
+ assert.equal(posts(fx).length, 0)
+ assert.ok(fx.text(panel).includes('Bestellung wirklich stornieren?'))
+ await fx.klick(aktion(fx, panel, 'abbrechen'))
+ assert.equal(fx.finde(panel, 'form[aria-label="Bestellung wirklich stornieren?"]'), null)
+ await fx.klick(aktion(fx, panel, 'storniert'))
+ await fx.klick(aktion(fx, panel, 'stornieren-bestaetigen'))
+ await fx.ruhe()
+ assert.deepEqual(posts(fx).map((p) => p.body), [{ projectId: 'p1', aktion: 'storniert' }])
+ assert.match(letzteMeldung(ctx).text, /storniert/)
+ assert.equal(aktion(fx, panel, 'storniert'), null)
+}))
+
+test('Aktionsfehler (403 Kundenansicht) zeigt den Text des Servers', mit(server({
+ 'POST /api/shop/bestellungen/o1/aktion': () => ({ status: 403, body: { error: 'In der Kundenansicht kann das nicht geändert werden.' } }),
+}), async (fx) => {
+ const { panel, ctx } = await oeffneDetail(fx)
+ await fx.klick(aktion(fx, panel, 'abgeschlossen'))
+ await fx.ruhe()
+ assert.deepEqual(letzteMeldung(ctx), { text: 'In der Kundenansicht kann das nicht geändert werden.', art: 'fehler' })
+ assert.ok(aktion(fx, panel, 'abgeschlossen'), 'Detail bleibt stehen')
+}))
+
+/* -------------------------------------------------------------- Erstatten --- */
+test('Erstatten: Betrag vorbelegt mit dem Rest, Rückfrage mit Betrag, Body in Cent und Grund', mit(server({
+ 'POST /api/shop/bestellungen/o1/erstatten': () => ({ body: {
+ bestellung: detail({ erstattetCent: 1250, erstattetText: '12,50 €', restCent: 3340, restText: '33,40 €', erstattungen: [{ rid: 're_1', betragCent: 1250, grund: 'Kulanz', at: '2026-10-06T08:00:00.000Z' }] }),
+ erstattung: { rid: 're_1', betragCent: 1250, grund: 'Kulanz' }, voll: false, mailOk: true,
+ } }),
+}), async (fx) => {
+ const { panel, ctx } = await oeffneDetail(fx)
+ const form = fx.finde(panel, 'form[data-form="erstatten"]')
+ const betrag = fx.finde(form, '#sh-erstatten-betrag')
+ assert.equal(betrag.value, '45,90')
+ await fx.setze(betrag, '12,5')
+ await fx.setze(fx.finde(form, '[data-feld="grund"]'), ' Kulanz ')
+ await fx.klick(aktion(fx, form, 'erstatten'))
+ assert.equal(posts(fx).length, 0, 'erst die Rückfrage')
+ assert.ok(fx.text(panel).includes(`Wirklich ${euroText(1250)} an den Käufer zurückzahlen? Das geht nicht rückgängig.`))
+ await fx.klick(aktion(fx, panel, 'erstatten-bestaetigen'))
+ await fx.ruhe()
+ assert.deepEqual(posts(fx).map((p) => [p.url, p.body]), [['/api/shop/bestellungen/o1/erstatten', { projectId: 'p1', betragCent: 1250, grund: 'Kulanz' }]])
+ assert.ok(letzteMeldung(ctx).text.startsWith(`${euroText(1250)} erstattet`))
+ assert.ok(!letzteMeldung(ctx).text.includes('vollständig'))
+ const erst = fx.finde(panel, '[data-liste="erstattungen"]')
+ assert.ok(fx.text(erst).includes(`${euroText(1250)}: Kulanz`))
+ assert.equal(fx.finde(panel, '#sh-erstatten-betrag').value, '33,40', 'neuer Rest')
+}))
+
+test('Erstatten: ohne Grund wird kein Grund gesendet, Abbrechen sendet nichts', mit(server({
+ 'POST /api/shop/bestellungen/o1/erstatten': () => ({ body: { bestellung: detail({ status: 'erstattet', aktionen: [], kannErstatten: false }), erstattung: { betragCent: 4590 }, voll: true, mailOk: true } }),
+}), async (fx) => {
+ const { panel, ctx } = await oeffneDetail(fx)
+ await fx.klick(aktion(fx, panel, 'erstatten'))
+ await fx.klick(aktion(fx, panel, 'abbrechen'))
+ assert.equal(posts(fx).length, 0)
+ await fx.klick(aktion(fx, panel, 'erstatten'))
+ await fx.klick(aktion(fx, panel, 'erstatten-bestaetigen'))
+ await fx.ruhe()
+ assert.deepEqual(posts(fx)[0].body, { projectId: 'p1', betragCent: 4590 })
+ assert.match(letzteMeldung(ctx).text, /vollständig erstattet/)
+ assert.equal(fx.finde(panel, 'form[data-form="erstatten"]'), null, 'nichts mehr zu erstatten')
+}))
+
+test('Erstatten: unlesbarer, zu hoher und leerer Betrag werden vor der Rückfrage abgelehnt', mit(server(), async (fx) => {
+ const { panel } = await oeffneDetail(fx)
+ const form = fx.finde(panel, 'form[data-form="erstatten"]')
+ const betrag = fx.finde(form, '#sh-erstatten-betrag')
+ for (const [eingabe, muster] of [['zwölf', /Betrag in Euro an/], ['99,00', new RegExp(`höchstens ${euroText(4590)}`)], ['0', /größer als 0/], ['', /Betrag in Euro an/]]) {
+ await fx.setze(betrag, eingabe)
+ await fx.klick(aktion(fx, form, 'erstatten'))
+ assert.match(fx.text(fx.finde(form, '[data-fehler="betrag"]')), muster, eingabe)
+ }
+ assert.equal(fx.finde(panel, '[data-aktion="erstatten-bestaetigen"]'), null)
+ assert.equal(posts(fx).length, 0)
+}))
+
+test('Erstatten: Stripe-Fehler 502 zeigt den Text am Formular, nichts wird als erstattet gezeigt', mit(server({
+ 'POST /api/shop/bestellungen/o1/erstatten': () => ({ status: 502, body: { error: 'Die Erstattung ist bei Stripe fehlgeschlagen. Bitte versuch es später noch einmal.' } }),
+}), async (fx) => {
+ const { panel, ctx } = await oeffneDetail(fx)
+ await fx.klick(aktion(fx, panel, 'erstatten'))
+ await fx.klick(aktion(fx, panel, 'erstatten-bestaetigen'))
+ await fx.ruhe()
+ const form = fx.finde(panel, 'form[data-form="erstatten"]')
+ assert.match(fx.text(fx.finde(form, '[data-fehler="_"]')), /bei Stripe fehlgeschlagen/)
+ assert.equal(fx.finde(panel, '[data-aktion="erstatten-bestaetigen"]'), null, 'Rückfrage ist zu')
+ assert.equal(ctx.meldungen.filter((m) => m.art === 'ok' && m.text).length, 0)
+ assert.equal(fx.alle(panel, 'button').some((b) => b.hasAttribute('disabled')), false, 'Knöpfe sind wieder frei')
+}))
+
+test('Erstatten: Feldfehler des Servers (betrag) landet am Feld', mit(server({
+ 'POST /api/shop/bestellungen/o1/erstatten': () => ({ status: 400, body: { error: 'x', fehler: { betrag: 'Bitte gib einen Betrag zwischen 0,01 € und 10,00 € an.' } } }),
+}), async (fx) => {
+ const { panel } = await oeffneDetail(fx)
+ await fx.klick(aktion(fx, panel, 'erstatten'))
+ await fx.klick(aktion(fx, panel, 'erstatten-bestaetigen'))
+ await fx.ruhe()
+ assert.match(fx.text(fx.finde(panel, '[data-fehler="betrag"]')), /zwischen 0,01 €/)
+}))
+
+test('Erstatten mit mailOk false meldet die fehlende Mail', mit(server({
+ 'POST /api/shop/bestellungen/o1/erstatten': () => ({ body: { bestellung: detail(), erstattung: { betragCent: 4590 }, voll: true, mailOk: false } }),
+}), async (fx) => {
+ const { panel, ctx } = await oeffneDetail(fx)
+ await fx.klick(aktion(fx, panel, 'erstatten'))
+ await fx.klick(aktion(fx, panel, 'erstatten-bestaetigen'))
+ await fx.ruhe()
+ assert.deepEqual(letzteMeldung(ctx), { text: 'Gespeichert, aber die Mail an den Käufer ging nicht raus.', art: 'fehler' })
+}))
+
+test('Doppelklick auf eine Aktion schickt nur einen Aufruf', mit(server({
+ 'POST /api/shop/bestellungen/o1/aktion': () => ({ body: { bestellung: detail({ status: 'abgeschlossen', aktionen: [] }), mailOk: true } }),
+}), async (fx) => {
+ const { panel } = await oeffneDetail(fx)
+ const knopf = aktion(fx, panel, 'abgeschlossen')
+ await Promise.all([fx.klick(knopf), fx.klick(knopf)])
+ await fx.ruhe()
+ assert.equal(posts(fx).length, 1)
+}))
+
+/* ------------------------------------------------------------------- CSV --- */
+test('CSV: Link mit Projekt, Datum nach Eingabe, kodiert, nur Pfad auf dem eigenen Server', mit(server(), async (fx) => {
+ const { panel } = await zeige(fx)
+ const link = fx.finde(panel, '[data-link="csv"]')
+ assert.equal(link.getAttribute('href'), '/api/shop/bestellungen.csv?projectId=p1')
+ assert.ok(link.hasAttribute('download'))
+ await fx.setze(fx.finde(panel, '#sh-csv-von'), '2026-10-01')
+ assert.equal(link.getAttribute('href'), '/api/shop/bestellungen.csv?projectId=p1&von=2026-10-01')
+ await fx.setze(fx.finde(panel, '#sh-csv-bis'), '2026-10-31')
+ assert.equal(link.getAttribute('href'), '/api/shop/bestellungen.csv?projectId=p1&von=2026-10-01&bis=2026-10-31')
+ assert.ok(link.getAttribute('href').startsWith('/api/'))
+}))
+
+test('CSV: die Projekt-ID wird kodiert', async () => {
+ const fx = starte(server())
+ try {
+ const ctx = { ...baueCtx(), projectId: 'a&b=c' }
+ const panel = h('div')
+ fx.document.body.append(panel)
+ await render(panel, ctx)
+ await fx.ruhe()
+ assert.equal(fx.finde(panel, '[data-link="csv"]').getAttribute('href'), `/api/shop/bestellungen.csv?projectId=${encodeURIComponent('a&b=c')}`)
+ } finally {
+ fx.restore()
+ }
+})
+
+/* -------------------------------------------------------------- Widerrufe --- */
+test('Widerrufe: Liste mit Eingang, Name, E-Mail, Nummer, Erklärung und Stand; Hinweis', mit(server(), async (fx) => {
+ const { panel } = await zeige(fx)
+ await fx.klick(fx.finde(panel, '[data-ansicht="widerrufe"]'))
+ await fx.ruhe()
+ assert.equal(gets(fx).at(-1), '/api/shop/widerrufe?projectId=p1&seite=1')
+ assert.ok(fx.text(panel).includes('Ein Widerruf ist erst erledigt, wenn du die Ware zurückhast oder erstattet hast (spätestens 14 Tage nach Eingang).'))
+ const w1 = fx.finde(panel, '[data-widerruf="w1"]')
+ const text = fx.text(w1)
+ for (const erwartet of ['05.10.2026', '14:30', 'Wida 1', 'w1@example.com', 'WK-1', 'Ich widerrufe ', 'offen']) assert.ok(text.includes(erwartet), erwartet)
+ assert.equal(fx.alle(panel, 'script').length, 0)
+ assert.equal(mailto(fx, w1).getAttribute('href'), 'mailto:w1@example.com')
+ assert.match(fx.text(fx.finde(panel, '[data-widerruf="w2"]')), /erledigt am 06\.10\.2026/)
+ assert.ok(aktion(fx, w1, 'erledigt'))
+ assert.equal(aktion(fx, fx.finde(panel, '[data-widerruf="w2"]'), 'erledigt'), null)
+}))
+
+test('Widerruf erledigt: POST, Meldung, Liste wird neu geladen', async () => {
+ let erledigt = false
+ const fx = starte((a) => {
+ if (a.method === 'POST') { erledigt = true; return { body: { widerruf: widerruf(1, { bearbeitetAt: '2026-10-06T08:00:00.000Z' }) } } }
+ if (a.url.startsWith('/api/shop/widerrufe?')) return { body: { widerrufe: [widerruf(1, erledigt ? { bearbeitetAt: '2026-10-06T08:00:00.000Z' } : {})], seite: 1, mehr: false } }
+ return { body: liste([]) }
+ })
+ try {
+ const { panel, ctx } = await zeige(fx)
+ await fx.klick(fx.finde(panel, '[data-ansicht="widerrufe"]'))
+ await fx.ruhe()
+ await fx.klick(aktion(fx, panel, 'erledigt'))
+ await fx.ruhe()
+ assert.deepEqual(posts(fx).map((p) => [p.url, p.body]), [['/api/shop/widerrufe/w1/erledigt', { projectId: 'p1' }]])
+ assert.equal(letzteMeldung(ctx).text, 'Der Widerruf ist als erledigt markiert.')
+ assert.equal(gets(fx).filter((u) => u.startsWith('/api/shop/widerrufe?')).length, 2)
+ assert.match(fx.text(panel), /erledigt am 06\.10\.2026/)
+ assert.equal(aktion(fx, panel, 'erledigt'), null)
+ } finally {
+ fx.restore()
+ }
+})
+
+test('Widerruf: „Zur Bestellung“ öffnet das Detail; leere Liste und Blättern', mit((a) => {
+ if (a.url.startsWith('/api/shop/widerrufe?')) {
+ const seite = Number(/seite=(\d+)/.exec(a.url)[1])
+ return { body: { widerrufe: seite === 1 ? [widerruf(1)] : [], seite, mehr: seite === 1 } }
+ }
+ return server()(a)
+}, async (fx) => {
+ const { panel } = await zeige(fx)
+ await fx.klick(fx.finde(panel, '[data-ansicht="widerrufe"]'))
+ await fx.ruhe()
+ await fx.klick(aktion(fx, panel, 'weiter'))
+ await fx.ruhe()
+ assert.ok(fx.finde(panel, '[data-leer]'))
+ await fx.klick(aktion(fx, panel, 'zurueck'))
+ await fx.ruhe()
+ await fx.klick(aktion(fx, panel, 'zur-bestellung'))
+ await fx.ruhe()
+ assert.equal(gets(fx).at(-1), '/api/shop/bestellungen/o1?projectId=p1')
+ assert.ok(fx.text(panel).includes('Bestellung WK-1'))
+}))
+
+test('Wechsel zwischen den Ansichten: die Liste lädt neu und der Knopf zeigt die Ansicht', mit(server(), async (fx) => {
+ const { panel } = await zeige(fx)
+ await fx.klick(fx.finde(panel, '[data-ansicht="widerrufe"]'))
+ await fx.ruhe()
+ assert.ok(fx.finde(panel, '[data-widerruf="w1"]'))
+ await fx.klick(fx.finde(panel, '[data-ansicht="bestellungen"]'))
+ await fx.ruhe()
+ assert.equal(fx.finde(panel, '[data-widerruf="w1"]'), null)
+ assert.ok(fx.finde(panel, '[data-bestellung="o1"]'))
+ assert.equal(fx.finde(panel, '[data-ansicht="bestellungen"]').getAttribute('aria-pressed'), 'true')
+}))
+
+/* ------------------------------------------------------------- nur lesen --- */
+test('nurLesen: keine Aktionen, kein Erstatten, kein „erledigt“; Lesen und CSV bleiben', mit(server(), async (fx) => {
+ const { panel } = await oeffneDetail(fx, { nurLesen: true })
+ assert.ok(fx.text(panel).includes('Bestellung WK-1'))
+ for (const name of ['versendet', 'abgeschlossen', 'erstatten', 'abholbereit', 'storniert']) assert.equal(aktion(fx, panel, name), null, name)
+ assert.equal(fx.finde(panel, 'form[data-form="erstatten"]'), null)
+ await fx.klick(aktion(fx, panel, 'liste'))
+ await fx.ruhe()
+ assert.ok(fx.finde(panel, '[data-link="csv"]'))
+ await fx.klick(fx.finde(panel, '[data-ansicht="widerrufe"]'))
+ await fx.ruhe()
+ assert.equal(aktion(fx, panel, 'erledigt'), null)
+ assert.ok(fx.finde(panel, '[data-widerruf="w1"]'))
+ assert.equal(posts(fx).length, 0)
+}))
+
+test('Antwort nach dem Wechsel des Reiters zeichnet nicht in das fremde Panel', mit(server(), async (fx) => {
+ const ctx = baueCtx()
+ const panel = h('div')
+ fx.document.body.append(panel)
+ const fertig = render(panel, ctx)
+ panel.replaceChildren(h('p', null, 'anderer Reiter'))
+ await fertig
+ await fx.ruhe()
+ assert.equal(fx.text(panel), 'anderer Reiter')
+}))
+
+/* ---------------------------------------------------------------- Quelle --- */
+test('Quelltext: kein innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, confirm()', () => {
+ const quelle = readFileSync(new URL('../public/shop-tab-bestellungen.js', import.meta.url), 'utf8')
+ for (const verboten of ['innerHTML', 'outerHTML', 'insertAdjacentHTML', 'document.write', 'eval(', 'new Function', 'confirm(']) assert.ok(!quelle.includes(verboten), verboten)
+})