/* 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() }