/* Terminplaner im Kundenbereich: Reiter "Leistungen" – Liste in der Reihenfolge, in der Gaeste sie sehen, Anlegen, Bearbeiten (Felder wie in einstellungen.js: Name, Beschreibung, Dauer, Pause danach, Preis, Buchungsart, wer sie durchfuehrt, aktiv), Entfernen, Reihenfolge. render(panel, ctx) ist der Einstieg (Registry REITER in termine.js). Jede Aktion ist ein Speichern der ganzen Einstellung (termine-speichern.js); Pruefen und Begrenzen bleibt Sache des Servers, hier steht nur, was ein Feld ohne Antwort des Servers wissen kann. Abgeschaltete Funktion (ctx.nurLesen): dieselben Felder, aber gesperrt und ohne Knoepfe zum Aendern. */ import { GESUNDHEIT_HINWEIS, LIMITS, MAX, centZuEuro, eindeutigeId, euroZuCent, ganzzahl, preisAnzeige, verschiebeEintrag } from './termine-view.js' import { allgemeinerFehler, feld, gesperrt, h, hakenFeld, zeigeFehler } from './termine-dom.js' import { entfernenFrage, neuerSpeicher } from './termine-speichern.js' const BUCHUNGSART = [['sofort', 'Sofort buchbar'], ['anfrage', 'Nur als Anfrage (du nimmst jeden Termin an)']] const PREISART = [['keiner', 'Ohne Preis'], ['fest', 'Festpreis'], ['ab', 'Preis ab']] const option = (wert, text, gewaehlt) => h('option', { value: wert, selected: gewaehlt }, text) export function render(panel, ctx) { const wurzel = h('div', { class: 'tm-tab' }) panel.replaceChildren(wurzel) const sp = neuerSpeicher(ctx, wurzel, zeichne) const t = { ctx, sp, zeichne, wurzel } function zeichne() { const e = ctx.daten.einstellung const leistungen = e.leistungen || [] const kopfFehler = h('div', null, allgemeinerFehler()) const neuBox = h('div', { class: 'tm-formular-box' }) const voll = leistungen.length >= LIMITS.leistungen const neuKnopf = ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn-primary', 'data-werkzeug': 'leistung-neu', 'aria-expanded': 'false', disabled: voll, onclick: (ev) => { if (neuBox.firstChild) { neuBox.replaceChildren(); ev.currentTarget.setAttribute('aria-expanded', 'false'); return } ev.currentTarget.setAttribute('aria-expanded', 'true') const form = formular(t, { index: leistungen.length, leistung: null, schliessen: () => { neuBox.replaceChildren(); neuKnopf.setAttribute('aria-expanded', 'false'); neuKnopf.focus() } }) neuBox.replaceChildren(form) form.querySelector('input')?.focus() }, }, '+ Leistung anlegen') wurzel.replaceChildren( sp.konflikt, h('div', { class: 'tm-box tm-box-info', role: 'note' }, h('span', null, h('strong', null, GESUNDHEIT_HINWEIS), ' Namen und Beschreibungen deiner Leistungen liest jeder, der auf deiner Website bucht. Schreib dort zum Beispiel keine Krankheiten oder Beschwerden hinein.')), ctx.nurLesen ? null : h('div', { class: 'tm-werkzeug-zeile' }, neuKnopf, voll ? h('p', { class: 'tm-hinweis' }, `Mehr als ${LIMITS.leistungen} Leistungen sind nicht möglich.`) : null), neuBox, kopfFehler, leistungen.length ? h('ul', { class: 'tm-eintraege', 'aria-label': 'Leistungen in der Reihenfolge, in der Gäste sie sehen' }, leistungen.map((l, i) => zeile(t, l, i, leistungen.length, kopfFehler))) : h('p', { class: 'tm-leer' }, 'Noch keine Leistungen. Ohne Leistung kann niemand buchen.')) } zeichne() } /** Zeile einer Leistung: Kurzfassung, Aktiv-Pille, Reihenfolge (hoch/runter) und der Knopf, der das Formular unter der Zeile oeffnet. */ function zeile(t, l, index, anzahl, kopfFehler) { const { ctx, sp } = t const e = ctx.daten.einstellung const namen = (l.ressourcen || []).map((id) => (e.ressourcen || []).find((r) => r.id === id)?.name).filter(Boolean) const li = h('li', { class: 'tm-eintrag', 'data-id': l.id }) const editor = h('div', { class: 'tm-editor-box' }) const meta = [`${l.dauer} Min.`, l.pufferNach ? `${l.pufferNach} Min. Pause danach` : '', preisAnzeige(l), l.buchungsart === 'anfrage' ? 'Nur als Anfrage' : 'Sofort buchbar', namen.join(', ') || 'niemand zugeordnet'] const oeffnen = h('button', { type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'bearbeiten', 'aria-expanded': 'false', 'aria-label': `${ctx.nurLesen ? 'Ansehen' : 'Bearbeiten'}: ${l.name}`, onclick: () => { if (editor.firstChild) { editor.replaceChildren(); oeffnen.setAttribute('aria-expanded', 'false'); return } oeffnen.setAttribute('aria-expanded', 'true') const form = formular(t, { index, leistung: l, schliessen: () => { editor.replaceChildren(); oeffnen.setAttribute('aria-expanded', 'false'); oeffnen.focus() } }) editor.replaceChildren(form) form.querySelector('input')?.focus() }, }, ctx.nurLesen ? 'Ansehen' : 'Bearbeiten') async function verschiebe(richtung, knopf) { const ok = await sp.speichere((entwurf) => { entwurf.leistungen = verschiebeEintrag(entwurf.leistungen, index, richtung) }, { container: kopfFehler, knopf }) if (!ok || !sp.nochHier()) return t.zeichne() ctx.meldung(`„${l.name}“ steht jetzt ${richtung < 0 ? 'weiter oben' : 'weiter unten'}.`) const neu = t.wurzel.querySelector(`li[data-id="${l.id}"]`) ;(neu?.querySelector(`[data-aktion="${richtung < 0 ? 'hoch' : 'runter'}"]:not([disabled])`) || neu?.querySelector('[data-aktion="bearbeiten"]'))?.focus() } const pfeil = (aktion, richtung, zeichen, text, aus) => h('button', { type: 'button', class: 'tm-pfeil', 'data-aktion': aktion, 'aria-label': `${text}: ${l.name}`, disabled: aus, onclick: (ev) => verschiebe(richtung, ev.currentTarget), }, h('span', { 'aria-hidden': 'true' }, zeichen)) li.append( h('div', { class: 'tm-kopf' }, h('div', { class: 'tm-haupt' }, h('strong', { class: 'tm-name' }, l.name), h('span', { class: 'tm-meta' }, meta.filter(Boolean).join(' · ')), l.beschreibung ? h('span', { class: 'tm-meta' }, l.beschreibung) : null), h('span', { class: `tm-pille tm-pille-${l.aktiv ? 'ok' : 'off'}` }, l.aktiv ? 'Aktiv' : 'Inaktiv')), h('div', { class: 'tm-aktionen' }, ctx.nurLesen ? null : [pfeil('hoch', -1, '↑', 'Nach oben', index === 0), pfeil('runter', 1, '↓', 'Nach unten', index === anzahl - 1)], oeffnen), editor) return li } /** Formular einer neuen (leistung null) oder bestehenden Leistung; index = ihre Stelle in der Liste (so heissen die Pfade der Fehler des Servers). */ function formular(t, { index, leistung, schliessen }) { const { ctx, sp, zeichne } = t const e = ctx.daten.einstellung const neu = leistung === null const l = leistung || { id: eindeutigeId('s', (e.leistungen || []).map((x) => x.id)), name: '', beschreibung: '', dauer: 30, pufferNach: 0, preisArt: 'keiner', preisCent: 0, buchungsart: 'sofort', ressourcen: [], aktiv: true } const p = `leistungen.${index}` const name = h('input', { type: 'text', maxlength: MAX.leistungName, autocomplete: 'off', value: l.name }) const beschreibung = h('textarea', { rows: '2', maxlength: MAX.beschreibung }, l.beschreibung) const dauer = h('input', { type: 'number', inputmode: 'numeric', min: '5', max: '480', step: '5', value: l.dauer }) const puffer = h('input', { type: 'number', inputmode: 'numeric', min: '0', max: '120', step: '5', value: l.pufferNach ?? 0 }) const preisArt = h('select', null, PREISART.map(([wert, text]) => option(wert, text, l.preisArt === wert))) const preis = h('input', { type: 'text', inputmode: 'decimal', autocomplete: 'off', value: l.preisArt === 'keiner' ? '' : centZuEuro(l.preisCent) }) const preisSteuern = () => { preis.disabled = preisArt.value === 'keiner'; if (preis.disabled) preis.value = '' } preisArt.addEventListener('change', preisSteuern) preisSteuern() const art = h('select', null, BUCHUNGSART.map(([wert, text]) => option(wert, text, l.buchungsart === wert))) const aktiv = h('input', { type: 'checkbox', checked: l.aktiv }) const boxen = (e.ressourcen || []).map((r) => ({ r, input: h('input', { type: 'checkbox', value: r.id, checked: (l.ressourcen || []).includes(r.id) }) })) const wer = h('fieldset', { class: 'tm-feld tm-gruppe-felder', 'data-feld': `${p}.ressourcen`, tabindex: '-1' }, h('legend', { class: 'tm-label' }, 'Wer führt die Leistung durch?'), boxen.length ? boxen.map(({ r, input }) => h('label', { class: 'tm-haken' }, input, h('span', null, `${r.name} · ${r.typ === 'platz' ? 'Platz' : 'Person'}${r.aktiv ? '' : ' (inaktiv)'}`))) : h('p', { class: 'tm-hinweis' }, 'Lege zuerst im Reiter „Team & Plätze“ eine Person oder einen Platz an.'), h('p', { class: 'tm-fehler', 'data-fehler': `${p}.ressourcen`, role: 'alert' })) const felder = [ feld(`${p}.name`, 'Name', name, 'So steht die Leistung beim Buchen.'), feld(`${p}.beschreibung`, 'Beschreibung (optional)', beschreibung, `Ein bis zwei Sätze für deine Gäste. ${GESUNDHEIT_HINWEIS}`), h('div', { class: 'tm-raster' }, feld(`${p}.dauer`, 'Dauer (Minuten)', dauer, '5 bis 480, in 5-Minuten-Schritten.'), feld(`${p}.pufferNach`, 'Pause danach (Minuten)', puffer, 'Zeit danach, in der niemand gebucht werden kann. Gäste sehen sie nicht. Dauer und Pause zusammen höchstens 500 Minuten.')), h('div', { class: 'tm-raster' }, feld(`${p}.preisArt`, 'Preis', preisArt), feld(`${p}.preisCent`, 'Betrag in Euro', preis, 'Bei „Preis ab“ ist es der Mindestpreis.')), feld(`${p}.buchungsart`, 'Buchung', art), wer, hakenFeld(`${p}.aktiv`, aktiv, 'Aktiv', 'Nur aktive Leistungen können Gäste buchen. Eine inaktive Leistung bleibt gespeichert.'), ] const speichern = h('button', { type: 'submit', class: 'kb-btn-primary' }, neu ? 'Leistung anlegen' : 'Speichern') const entfernenBox = h('div', { class: 'tm-frage' }) // die Rueckfrage steht neben dem Formular, nicht darin (ein submit steigt sonst ins aeussere Formular auf) const entfernen = neu || ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn-outline tm-gefahr', onclick: () => frageEntfernen() }, 'Entfernen') const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': neu ? 'Leistung anlegen' : `${ctx.nurLesen ? 'Leistung' : 'Leistung bearbeiten'}: ${l.name}` }, h('h3', null, neu ? 'Neue Leistung' : l.name), gesperrt(ctx.nurLesen, felder), allgemeinerFehler(), h('div', { class: 'tm-frage-knoepfe' }, ctx.nurLesen ? null : speichern, h('button', { type: 'button', class: 'kb-btn-outline', onclick: schliessen }, ctx.nurLesen ? 'Schließen' : 'Abbrechen'), entfernen)) /** Leistung aus den Feldern; Eingaben, die kein Feld verstehen kann, werden hier am Feld gemeldet. */ function lese() { const fehler = {} const minuten = ganzzahl(dauer.value) if (minuten === null) fehler[`${p}.dauer`] = 'Bitte gib die Dauer in ganzen Minuten an.' const pause = puffer.value.trim() === '' ? 0 : ganzzahl(puffer.value) if (pause === null) fehler[`${p}.pufferNach`] = 'Bitte gib die Pause in ganzen Minuten an (0 = keine Pause).' let cent = 0 if (preisArt.value !== 'keiner') { cent = euroZuCent(preis.value) if (cent === null) fehler[`${p}.preisCent`] = 'Bitte gib den Preis in Euro an, zum Beispiel 45,00.' } if (Object.keys(fehler).length) return { fehler } return { leistung: { ...l, name: name.value, beschreibung: beschreibung.value, dauer: minuten, pufferNach: pause, preisArt: preisArt.value, preisCent: cent, buchungsart: art.value, ressourcen: boxen.filter(({ input }) => input.checked).map(({ r }) => r.id), aktiv: aktiv.checked, }, } } form.addEventListener('submit', async (ev) => { ev.preventDefault() const ok = await sp.speichere((entwurf) => { const gelesen = lese() if (gelesen.fehler) { zeigeFehler(form, { fehler: gelesen.fehler }) return false } if (neu) entwurf.leistungen.push(gelesen.leistung) else entwurf.leistungen[index] = gelesen.leistung return true }, { container: form, knopf: speichern }) if (!ok || !sp.nochHier()) return zeichne() ctx.meldung(neu ? 'Leistung angelegt.' : 'Leistung gespeichert.') t.wurzel.querySelector(`li[data-id="${l.id}"] [data-aktion="bearbeiten"]`)?.focus() }) function frageEntfernen() { const box = entfernenFrage(sp, { titel: `Leistung „${l.name}“ entfernen?`, text: 'Das kann nicht rückgängig gemacht werden. Gibt es für die Leistung noch künftige Termine, geht das Entfernen nicht. Dann deaktiviere sie stattdessen.', fehlerPfad: `leistungen.${l.id}`, aendere: (entwurf) => { entwurf.leistungen = entwurf.leistungen.filter((x) => x.id !== l.id) }, nachher: () => { zeichne(); ctx.meldung(`Leistung „${l.name}“ entfernt.`) }, zurueck: () => { entfernenBox.replaceChildren(); entfernen.focus() }, }) entfernenBox.replaceChildren(box) box.focus() } return h('div', { class: 'tm-editor' }, form, entfernenBox) }