/* 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, ordneNachIds, preisAnzeige, verschiebeEintrag, } from './termine-view.js' import { allgemeinerFehler, editorZeile, feld, gesperrt, h, hakenFeld, neuerEintrag, zeigeFehler } from './termine-dom.js' import { entfernenKnopf, nachSpeichern, neuerSpeicher, stelleVon } 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 } // Reihenfolge (Ruling R51): die Pfeile aendern nur diese gemerkte Reihenfolge (IDs), gespeichert wird sie mit „Reihenfolge speichern“ in einem // Speichern; null = wie gespeichert. Sie bleibt beim Neuzeichnen (z. B. nach dem Speichern einer Leistung) erhalten. let ordnung = null /** Anzeigereihenfolge (IDs): die gemerkte, auf den aktuellen Stand der Liste angewandt; ist sie die gespeicherte, gilt keine gemerkte mehr. */ function anzeigeIds() { const liste = ctx.daten.einstellung.leistungen || [] const gespeichert = liste.map((l) => l.id) const ids = ordnung ? ordneNachIds(liste, ordnung).map((l) => l.id) : gespeichert ordnung = ids.join('|') === gespeichert.join('|') ? null : ids return ids } function zeichne() { const e = ctx.daten.einstellung const leistungen = e.leistungen || [] const kopfFehler = h('div', null, allgemeinerFehler()) const neu = neuerEintrag({ nurLesen: ctx.nurLesen, text: '+ Leistung anlegen', werkzeug: 'leistung-neu', primaer: true, voll: leistungen.length >= LIMITS.leistungen, grenzText: `Mehr als ${LIMITS.leistungen} Leistungen sind nicht möglich.`, baue: (schliessen) => formular(t, { leistung: null, schliessen }), }) const zeilen = new Map(leistungen.map((l) => [l.id, zeile(t, l, verschiebe)])) const liste = h('ul', { class: 'tm-eintraege', 'aria-label': 'Leistungen in der Reihenfolge, in der Gäste sie sehen' }) const status = h('span', { role: 'status' }) const speichern = h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'reihenfolge-speichern' }, 'Reihenfolge speichern') const verwerfen = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'reihenfolge-verwerfen' }, 'Verwerfen') const leiste = h('div', { class: 'tm-box tm-box-info tm-reihenfolge', hidden: true }, status, h('div', { class: 'tm-frage-knoepfe' }, speichern, verwerfen)) /** Zeilen in Anzeigereihenfolge bringen (vorhandene Zeilen werden nur verschoben: offene Editoren und ihre Eingaben bleiben), Pfeile und Leiste nachziehen. */ function aktualisiere() { const ids = anzeigeIds() liste.replaceChildren(...ids.map((id) => zeilen.get(id))) ids.forEach((id, i) => { const li = zeilen.get(id) const hoch = li.querySelector('[data-aktion="hoch"]') if (hoch) hoch.disabled = i === 0 const runter = li.querySelector('[data-aktion="runter"]') if (runter) runter.disabled = i === ids.length - 1 }) leiste.hidden = !ordnung } function verschiebe(id, richtung, aktion) { const ids = anzeigeIds() ordnung = verschiebeEintrag(ids, ids.indexOf(id), richtung) aktualisiere() const stelle = anzeigeIds().indexOf(id) + 1 status.textContent = ordnung ? `„${leistungen.find((l) => l.id === id).name}“ steht jetzt an Stelle ${stelle} von ${ids.length}. Die neue Reihenfolge ist noch nicht gespeichert.` : 'Die Reihenfolge ist wieder wie gespeichert.' const li = zeilen.get(id) ;(li.querySelector(`[data-aktion="${aktion}"]:not(:disabled)`) || li.querySelector('[data-aktion="bearbeiten"]'))?.focus() } const fokusErsteZeile = () => liste.querySelector('li [data-aktion="bearbeiten"]')?.focus() verwerfen.addEventListener('click', () => { ordnung = null aktualisiere() fokusErsteZeile() ctx.meldung('Die gemerkte Reihenfolge ist verworfen.') }) speichern.addEventListener('click', async () => { const gewuenscht = [...(ordnung || [])] // die gemerkte Reihenfolge wirkt auf eine frische Kopie des gespeicherten Stands, nach IDs (nicht nach Stellen) const ok = await sp.speichere((entwurf) => { entwurf.leistungen = ordneNachIds(entwurf.leistungen, gewuenscht) }, { container: kopfFehler }) if (!ok || !sp.nochHier()) return ordnung = null zeichne() ctx.meldung('Reihenfolge gespeichert.') fokusErsteZeile() }) aktualisiere() // neu.leiste ist null, solange die Funktion nur lesbar ist: replaceChildren wuerde sonst den Text "null" in die Seite schreiben 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.')), neu.leiste, neu.box, leiste, kopfFehler, leistungen.length ? liste : h('p', { class: 'tm-leer' }, 'Noch keine Leistungen. Ohne Leistung kann niemand buchen.'), ].filter(Boolean)) } zeichne() } /** Zeile einer Leistung: Kurzfassung, Aktiv-Pille, Pfeile fuer die Reihenfolge und der Knopf, der das Formular unter der Zeile oeffnet. */ function zeile(t, l, verschiebe) { const { ctx } = t const e = ctx.daten.einstellung const namen = (l.ressourcen || []).map((id) => (e.ressourcen || []).find((r) => r.id === id)?.name).filter(Boolean) 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 pfeil = (aktion, richtung, zeichen, text) => h('button', { type: 'button', class: 'tm-pfeil', 'data-aktion': aktion, 'aria-label': `${text}: ${l.name}`, onclick: () => verschiebe(l.id, richtung, aktion), }, h('span', { 'aria-hidden': 'true' }, zeichen)) return editorZeile({ attrs: { 'data-id': l.id }, nurLesen: ctx.nurLesen, label: l.name, 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, ], pille: h('span', { class: `tm-pille tm-pille-${l.aktiv ? 'ok' : 'off'}` }, l.aktiv ? 'Aktiv' : 'Inaktiv'), vorKnopf: ctx.nurLesen ? [] : [pfeil('hoch', -1, '↑', 'Nach oben'), pfeil('runter', 1, '↓', 'Nach unten')], baue: (schliessen) => formular(t, { leistung: l, schliessen }), }) } /** * Formular einer neuen (leistung null) oder bestehenden Leistung. Die Pfade der Fehler des Servers nennen die Stelle in der gespeicherten * Liste (leistungen.2.dauer); die Felder heissen danach. Beim Absenden wird die Leistung per ID gesucht, nicht per Stelle. */ function formular(t, { leistung, schliessen }) { const { ctx, sp } = t const e = ctx.daten.einstellung const neu = leistung === null const index = neu ? (e.leistungen || []).length : (e.leistungen || []).findIndex((x) => x.id === leistung.id) 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 entfernen = neu || ctx.nurLesen ? null : entfernenKnopf(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: () => nachSpeichern(t, `Leistung „${l.name}“ entfernt.`, '[data-werkzeug="leistung-neu"]'), }) 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?.knopf)) /** 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) return true } const stelle = stelleVon(entwurf.leistungen, l.id, form, 'Diese Leistung') if (stelle < 0) return false entwurf.leistungen[stelle] = gelesen.leistung return true }, { container: form }) if (!ok || !sp.nochHier()) return nachSpeichern(t, neu ? 'Leistung angelegt.' : 'Leistung gespeichert.', `li[data-id="${l.id}"] [data-aktion="bearbeiten"]`) }) return h('div', { class: 'tm-editor' }, form, entfernen?.box) }