Shop G5: Reiter Bestellungen (Liste mit Filter, Suche, Blättern, Detail, Aktionen, Erstatten mit Rückfrage, Widerrufe, CSV-Export) und Test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cc668d3765
commit
73820fb1e6
3 files changed
+1020
-5
No files matched your search
@@ -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()
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
Reference in new issue
Block a user