/* Terminplaner im Kundenbereich: Fragebogen vor der Bestellung (TP2). renderFragebogen(root, ctx) zeigt in der Huelle (termine.js) die acht Schritte und danach die Bestellseite (funktion-bestellen.js). ctx ist der Kontext der Reiter (baueCtx in termine.js, hinweise() bleibt leer); ctx.daten ist die Antwort von GET /api/termine/einstellungen im Zustand off (einstellung, version, fragebogen, branchen, betriebVorschlag, kundenansicht, offen, bestellung). Aufbau: Fortschrittsleiste (
    , aria-current="step"; am Handy dazu „Schritt n von 8“), darunter der Schritt. Der Start ist der erste offene Schritt (alles bestaetigt: die Bestellseite); die Leiste erlaubt freie Navigation, nur ohne Entwurf fuehrt sie allein in die Branchenwahl. Jeder Schritt steht in BAU: key ⇒ (t) ⇒ Element mit t = { ctx, key, nr, titel, gehe(key), neuZeichnen(), weiter(), zurueck }. Ein Schritt mit Feldern ist ein
    ; „Weiter“ (verdrahte) macht genau EINEN PATCH ueber den neuerSpeicher des Schritts (termine-speichern.js, der einzige PATCH-Weg; nur wenn der Schritt Felder hat) und danach PUT /fragebogen mit dem Schritt in bestaetigt (bestaetige, der einzige PUT). Fehler stehen am Feld (setzeFehler); beim Wechsel bekommt die Ueberschrift des Schritts den Fokus. Kundenansicht (daten.kundenansicht): PATCH bleibt moeglich, es gibt keinen PUT, denn Schritte bestaetigt nur der Kunde selbst. Die Schritte 4, 5, 6 und 8 traegt C9b in BAU nach. Texte der Kunden kommen nur ueber value/textContent in die Seite (h aus termine-dom.js), nie als HTML. */ import { FRAGEBOGEN_SCHRITTE, LIMITS, MAX, ersterOffenerSchritt, preisAnzeige, ressourcenAusNamen, schrittStatus } from './termine-view.js' import { allgemeinerFehler, feld, frage, h, hakenFeld, setzeFehler, zeigeFehler } from './termine-dom.js' import { neuerSpeicher } from './termine-speichern.js' import { render as renderLeistungen } from './termine-tab-leistungen.js' import { betrieb, darstellung, erinnerung } from './termine-tab-einstellungen.js' import { renderBestellen } from './funktion-bestellen.js' const BESTELLEN = 'bestellen' const TITEL = new Map([...FRAGEBOGEN_SCHRITTE, [BESTELLEN, 'Prüfen und bestellen']]) const AUFBEWAHRUNG = 'Wir speichern deinen Entwurf bis zu 90 Tage nach deiner letzten Änderung.' const KUNDENANSICHT = 'Du siehst den Fragebogen in der Kundenansicht. Angaben kannst du ändern, Schritte bestätigen und bestellen kann nur der Kunde selbst.' const GESUNDHEIT_TEXT = 'Für den Gesundheitsbereich ist der Terminplaner nicht verfügbar. Schon der Name einer Leistung oder ein Terminwunsch kann etwas über die Gesundheit deiner Gäste verraten, ' + 'und solche Angaben sind besonders geschützt. Gehört dein Angebot nicht zum Gesundheitsbereich, wähle die Branche, die am besten passt. Fragen dazu beantworten wir dir im Chat.' const WECHSEL_TEXT = 'Das ersetzt deine Leistungen, Zeiten und Regeln durch die Vorschläge der neuen Branche. Alle Bestätigungen gehen zurück, du bestätigst die Schritte danach noch einmal. ' + 'Deine Angaben zum Betrieb bleiben erhalten.' const NACH_VORLAGE_FEHLT = 'Deine Branche ist angelegt, aber nicht alle Angaben konnten gespeichert werden. Bitte prüfe sie und klick auf „Weiter“.' const NACH_VORLAGE_LADEN = 'Deine Branche ist angelegt. Wir konnten den Stand aber nicht laden. Bitte lade die Seite neu.' // Betriebsangaben von Schritt 1, ohne Entwurf und mit Entwurf const BETRIEB_NUR = ['name', 'adresse', 'kleinunternehmer'] // POST vorlage uebernimmt aus betrieb nur diese Felder (server/routes/termine.js) ... const KONTAKT_FELDER = ['name', 'adresse', 'telefon', 'email', 'websiteUrl'] // ... diese bleiben dabei auf der Strecke; der Client traegt sie mit einem PATCH nach (betriebNachtrag) const NACHTRAG_FELDER = ['kleinunternehmer', 'impressumUrl', 'datenschutzUrl', 'agbUrl'] const WER_ARTEN = [ ['einzel', 'Einzelperson', 'Eine Person wird gebucht, zum Beispiel du selbst.'], ['team', 'Team', 'Mehrere Personen können gebucht werden, jede mit eigenen Zeiten.'], ['plaetze', 'Plätze', 'Gebucht werden Plätze oder Räume, zum Beispiel Stühle oder Zimmer.'], ] const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) /* ---------------------------------------------------------------- Gerüst --- */ export function renderFragebogen(root, ctx) { const stand = h('p', { class: 'tm-schritt-stand' }) const liste = h('ol', { class: 'tm-schritte' }) const inhalt = h('div', { class: 'tm-fb-inhalt' }) root.replaceChildren(h('div', { class: 'tm-fragebogen' }, ctx.daten.kundenansicht === true ? h('div', { class: 'tm-box tm-box-info', role: 'note' }, h('span', null, KUNDENANSICHT)) : null, h('nav', { class: 'tm-fortschritt', 'aria-label': 'Fortschritt im Fragebogen' }, stand, liste), inhalt, h('p', { class: 'tm-hinweis tm-fb-aufbewahrung' }, AUFBEWAHRUNG))) let angezeigt = null // Schluessel des Schritts, der gerade steht // ohne Entwurf gibt es nur die Branchenwahl; alle anderen Schritte brauchen eine Einstellung const darfHin = (key) => key === 'branche' || Boolean(ctx.daten.einstellung) function zeichneLeiste() { const eintraege = [...schrittStatus(ctx.daten.fragebogen), { key: BESTELLEN, titel: TITEL.get(BESTELLEN), erledigt: false }] liste.replaceChildren(...eintraege.map(({ key, titel, erledigt }, i) => h('li', { class: 'tm-schritt', 'data-schritt': key, 'data-erledigt': erledigt ? 'true' : null, 'aria-current': key === angezeigt ? 'step' : null, }, h('button', { type: 'button', class: 'tm-schritt-knopf', disabled: !darfHin(key), onclick: () => { if (key !== angezeigt) zeige(key) } }, h('span', { class: 'tm-schritt-nr', 'aria-hidden': 'true' }, erledigt ? '✓' : String(i + 1)), h('span', { class: 'tm-schritt-titel' }, titel), erledigt ? h('span', { class: 'tm-sr' }, ' (erledigt)') : null)))) const nr = FRAGEBOGEN_SCHRITTE.findIndex(([key]) => key === angezeigt) stand.textContent = nr < 0 ? TITEL.get(BESTELLEN) : `Schritt ${nr + 1} von ${FRAGEBOGEN_SCHRITTE.length}` } function bestellseite() { const seite = h('div', { class: 'tm-fb-bestellen' }) renderBestellen(seite, { ctx, daten: ctx.daten, zumSchritt: (key) => zeige(key) }) return seite } /** Schritt zeigen (=> sein Element). fokus: die Ueberschrift bekommt den Fokus, so wie bei jedem Wechsel; der erste Aufruf nimmt keinen. */ function zeige(key, { fokus = true } = {}) { const ziel = darfHin(key) ? key : 'branche' angezeigt = ziel zeichneLeiste() const el = ziel === BESTELLEN ? bestellseite() : (BAU[ziel] ?? schrittOhneInhalt)(baueT(ziel)) inhalt.replaceChildren(el) const kopf = fokus ? el.querySelector('h3') : null if (kopf) { kopf.setAttribute('tabindex', '-1') kopf.focus() } return el } function baueT(key) { const nr = FRAGEBOGEN_SCHRITTE.findIndex(([k]) => k === key) return { ctx, key, nr, titel: TITEL.get(key), gehe: (ziel) => zeige(ziel), // nur solange der Schritt noch steht: eine spaete Antwort holt den Kunden nicht aus einem anderen Schritt zurueck neuZeichnen: () => (angezeigt === key ? zeige(key, { fokus: false }) : null), weiter: () => zeige(FRAGEBOGEN_SCHRITTE[nr + 1]?.[0] ?? BESTELLEN), zurueck: nr > 0 ? () => zeige(FRAGEBOGEN_SCHRITTE[nr - 1][0]) : null, } } zeige(ersterOffenerSchritt(ctx.daten.fragebogen), { fokus: false }) } /* ------------------------------------------------------------ Bausteine --- */ /** Rahmen eines Schritts: Ueberschrift (Fokusziel beim Wechsel) und ein erklaerender Satz; der Schritt haengt seinen Inhalt an. */ function rahmen(t, intro) { return h('section', { class: 'tm-fb-schritt', 'data-schritt': t.key }, h('h3', { id: `tm-fb-${t.key}`, tabindex: '-1' }, t.titel), intro ? h('p', { class: 'tm-hinweis tm-abschnitt-text' }, intro) : null) } const schrittForm = (t, ...kinder) => h('form', { class: 'tm-fb-form', novalidate: true, 'aria-labelledby': `tm-fb-${t.key}` }, ...kinder) /** Zurueck (ohne zu speichern) und Weiter (sendet das Formular). */ function knoepfe(t, { mitWeiter = true } = {}) { return h('div', { class: 'tm-frage-knoepfe' }, t.zurueck ? h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zurueck', onclick: () => t.zurueck() }, 'Zurück') : null, mitWeiter ? h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'weiter' }, 'Weiter') : null) } /** * PUT /fragebogen: der Schritt kommt in bestaetigt, aenderung(fragebogen) setzt weitere Angaben (z. B. preiseGeprueft). Gesendet wird der ganze Fragebogen * (PUT ersetzt ihn) auf dem Stand von ctx.daten; die Antwort gilt danach in ctx.daten (fragebogen und die offenen Punkte der Bestellung). * In der Kundenansicht passiert nichts: Schritte bestaetigt nur der Kunde selbst. */ async function bestaetige(t, aenderung) { const { ctx } = t if (ctx.daten.kundenansicht === true) return const fragebogen = structuredClone(istObjekt(ctx.daten.fragebogen) ? ctx.daten.fragebogen : {}) aenderung?.(fragebogen) fragebogen.bestaetigt = [...new Set([...(Array.isArray(fragebogen.bestaetigt) ? fragebogen.bestaetigt : []), t.key])] const antwort = await ctx.api.send('PUT', '/fragebogen', { fragebogen }) if (!ctx.aktuell()) return if (istObjekt(antwort?.fragebogen)) ctx.daten.fragebogen = antwort.fragebogen if (antwort?.offen !== undefined) ctx.daten.offen = antwort.offen } /** * „Weiter“ eines Schritts: pruefe() (Fehler ⇒ { feld: text } am Feld, nichts wird gesendet), aendere(entwurf) (ein PATCH ueber den Speicher des * Schritts; false = der Schritt hat selbst Fehler gezeigt; fehlt aendere, gibt es keinen PATCH), dann PUT mit dem Schritt (fragebogen(f) setzt weitere * Angaben) und der naechste Schritt. Nur ein Durchlauf zur Zeit; verlaesst der Kunde den Schritt oder die Ansicht, fasst nichts mehr die Seite an. */ function verdrahte(t, form, sp, { pruefe, aendere, fragebogen } = {}) { const weiter = form.querySelector('[data-aktion="weiter"]') let laeuft = false form.addEventListener('submit', async (ev) => { ev.preventDefault() if (laeuft) return laeuft = true const hatteFokus = document.activeElement === weiter weiter.disabled = true try { setzeFehler(form, {}) const fehler = pruefe?.() if (fehler) { zeigeFehler(form, { fehler }) return } if (aendere && !(await sp.speichere(aendere, { container: form }))) return if (!sp.nochHier()) return try { await bestaetige(t, fragebogen) } catch (err) { if (sp.nochHier()) zeigeFehler(form, err) return } if (sp.nochHier()) t.weiter() } finally { laeuft = false weiter.disabled = false // ein gesperrter Knopf verliert im Browser den Fokus: bleibt der Schritt stehen und hat ihn niemand sonst (ein Feld mit Fehler), kehrt er zurueck const jetzt = document.activeElement if (hatteFokus && weiter.isConnected && (!jetzt || jetzt === document.body || jetzt === weiter)) weiter.focus() } }) } /** Schritt neu zeichnen und im neuen Formular einen allgemeinen Fehler zeigen (nach einem Teilerfolg, den die alte Ansicht nicht kennt). */ function zeigeNeu(t, text) { const form = t.neuZeichnen()?.querySelector('form') if (form) setzeFehler(form, { _: text }) } /* ------------------------------------------------- Schritt 1: Branche und Betrieb --- */ /** * Radiogruppe der Branchen aus daten.branchen. Der Gesundheitsbereich zeigt den Erklaertext und KEINEN Knopf (es geht nie eine Anfrage raus), jede andere * Wahl den Knopf (type submit, data-aktion branche-starten). => { liste, hinweis, aktion, gewaehlt() }: der Aufrufer setzt die drei Elemente. */ function brancheWahl(daten, { name, knopf }) { let gewaehlt = null const hinweis = h('div', { class: 'tm-fb-branchenhinweis' }) const aktion = h('div', { class: 'tm-frage-knoepfe' }) const liste = h('fieldset', { class: 'tm-vorlagen-liste tm-fb-branchen' }, h('legend', { class: 'tm-sr' }, 'Branche'), (daten.branchen || []).map((b) => { const radio = h('input', { type: 'radio', name, value: b.key }) radio.addEventListener('change', () => { gewaehlt = b; zeichne() }) const beschreibung = (daten.vorlagen || []).find((v) => v.key === b.key)?.beschreibung return h('label', { class: 'tm-vorlage' }, radio, h('span', null, h('strong', null, b.name), beschreibung ? h('small', null, beschreibung) : null)) })) function zeichne() { if (gewaehlt?.gesundheit) { hinweis.replaceChildren(h('div', { class: 'tm-box tm-box-warn', role: 'note', 'data-hinweis': 'gesundheit' }, h('span', null, GESUNDHEIT_TEXT), h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Chat öffnen'))) aktion.replaceChildren() return } hinweis.replaceChildren() aktion.replaceChildren(h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'branche-starten', disabled: gewaehlt === null }, knopf)) } zeichne() return { liste, hinweis, aktion, gewaehlt: () => gewaehlt } } /** Nur die nichtleeren Texte der Felder, die POST vorlage aus betrieb uebernimmt. */ const kontaktVon = (angaben) => Object.fromEntries(KONTAKT_FELDER .filter((name) => typeof angaben?.[name] === 'string' && angaben[name].trim() !== '') .map((name) => [name, angaben[name]])) /** Was der Kunde angegeben hatte und POST vorlage nicht uebernommen hat (Haken gesetzt, Adresse eingetragen), im Vergleich zum neuen Entwurf. */ function betriebNachtrag(gewuenscht, neu) { const nachtrag = {} for (const name of NACHTRAG_FELDER) { const wunsch = gewuenscht?.[name] if (wunsch && wunsch !== neu?.[name]) nachtrag[name] = wunsch } return nachtrag } /** * Nach einem erfolgreichen POST vorlage: Daten neu laden (Einstellung, Version, Fragebogen) und nachtragen, was der Server aus betrieb nicht uebernimmt * (ein PATCH, nur wenn etwas fehlt). => true, wenn der Entwurf danach vollstaendig steht. */ async function nachVorlage(t, sp, container, gewuenscht) { const { ctx } = t try { await ctx.neuLaden() } catch { return false } if (!sp.nochHier() || !ctx.daten.einstellung) return false const nachtrag = betriebNachtrag(gewuenscht, ctx.daten.einstellung.betrieb) if (Object.keys(nachtrag).length === 0) return true return sp.speichere((entwurf) => { entwurf.betrieb = { ...entwurf.betrieb, ...nachtrag }; return true }, { container }) } function schrittBranche(t) { return t.ctx.daten.einstellung ? brancheMitEntwurf(t) : brancheStart(t) } /** Ohne Entwurf: Branche waehlen und die Betriebsangaben pruefen; „Mit dieser Branche starten“ legt den Entwurf an und bestaetigt den Schritt. */ function brancheStart(t) { const { ctx } = t const wurzel = rahmen(t, 'Such dir die Branche aus, die am besten zu deinem Betrieb passt. Daraus legen wir Leistungen, Zeiten und Regeln als Vorschlag an. Du kannst danach alles ändern.') const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen) const angaben = betrieb(ctx, { betrieb: ctx.daten.betriebVorschlag ?? {} }, { nur: BETRIEB_NUR }) const wahl = brancheWahl(ctx.daten, { name: 'termine-branche', knopf: 'Mit dieser Branche starten' }) const form = schrittForm(t, h('div', { class: 'kb-panel' }, wahl.liste, wahl.hinweis), angaben.el, allgemeinerFehler(), wahl.aktion) let laeuft = false form.addEventListener('submit', async (ev) => { ev.preventDefault() const branche = wahl.gewaehlt() if (laeuft || branche === null || branche.gesundheit) return // der Gesundheitsbereich sendet nie etwas laeuft = true const knopf = form.querySelector('[data-aktion="branche-starten"]') if (knopf) knopf.disabled = true try { setzeFehler(form, {}) const gewuenscht = angaben.lese() // die Website des Projekts kennt nur der Vorschlag (der Server nimmt Name, Anschrift, E-Mail und Telefon selbst aus dem Konto): sie geht mit const website = typeof ctx.daten.betriebVorschlag?.websiteUrl === 'string' ? ctx.daten.betriebVorschlag.websiteUrl.trim() : '' try { await ctx.api.send('POST', '/einstellungen/vorlage', { branche: branche.key, betrieb: website ? { ...gewuenscht, websiteUrl: website } : gewuenscht }) } catch (err) { if (sp.nochHier()) zeigeFehler(form, err) return } // ab hier gibt es den Entwurf: was schiefgeht, zeigt den Schritt neu (mit Entwurf) statt noch einmal anzulegen const vollstaendig = await nachVorlage(t, sp, form, gewuenscht) if (!sp.nochHier()) return if (!vollstaendig) { if (ctx.daten.einstellung) zeigeNeu(t, NACH_VORLAGE_FEHLT) else zeigeFehler(form, { fehler: { _: NACH_VORLAGE_LADEN } }) return } try { await bestaetige(t) } catch (err) { if (sp.nochHier()) zeigeNeu(t, err.message) return } if (!sp.nochHier()) return ctx.meldung(`Die Branche „${branche.name}“ ist angelegt. Alles darin ist ein Vorschlag, den du anpassen kannst.`) t.weiter() } finally { laeuft = false if (knopf) knopf.disabled = false } }) wurzel.append(sp.konflikt, form) return wurzel } /** Mit Entwurf: gewaehlte Branche (wechseln nur mit Rueckfrage), Betriebsangaben, „Weiter“ speichert sie und bestaetigt den Schritt. */ function brancheMitEntwurf(t) { const { ctx } = t const e = ctx.daten.einstellung const branche = (ctx.daten.branchen || []).find((b) => b.key === ctx.daten.fragebogen?.branche) const wurzel = rahmen(t, 'Prüfe die Angaben zu deinem Betrieb. Die Branche bestimmt, welche Vorschläge für Leistungen, Zeiten und Regeln du siehst.') const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen) const angaben = betrieb(ctx, e, { nur: BETRIEB_NUR }) const wechsel = wechselFrage(t, sp, angaben, branche !== undefined) const form = schrittForm(t, angaben.el, allgemeinerFehler(), knoepfe(t)) verdrahte(t, form, sp, { pruefe: () => (branche ? null : { _: 'Bitte wähle zuerst deine Branche.' }), aendere: (entwurf) => { angaben.lese(entwurf); return true }, }) wurzel.append(sp.konflikt, h('div', { class: 'tm-fb-branche' }, h('p', null, 'Deine Branche: ', h('strong', null, branche?.name ?? 'noch nicht gewählt')), wechsel.knopf), wechsel.box, form) return wurzel } /** * „Branche wechseln“ mit Rueckfrage in der Seite (kein confirm()): ersetzt Leistungen, Zeiten und Regeln und setzt alle Bestaetigungen zurueck * (POST vorlage mit ersetzen: true und der gelesenen version). Die Betriebsangaben gehen mit; was der Server dabei nicht uebernimmt, wird nachgetragen. * Danach steht Schritt 1 neu da, ein PUT gibt es nicht. => { knopf, box }: der Aufrufer setzt beide NEBEN das Formular des Schritts. */ function wechselFrage(t, sp, angaben, hatBranche) { const { ctx } = t const box = h('div', { class: 'tm-frage' }) const knopf = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'branche-wechseln' }, hatBranche ? 'Branche wechseln' : 'Branche wählen') const zu = () => { box.replaceChildren(); knopf.focus() } knopf.addEventListener('click', () => { const wahl = brancheWahl(ctx.daten, { name: 'termine-branche-neu', knopf: 'Ja, Branche wechseln' }) const abbrechen = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: zu }, 'Abbrechen') const rueckfrage = frage('Branche wechseln?', h('p', null, WECHSEL_TEXT), wahl.liste, wahl.hinweis, allgemeinerFehler(), h('div', { class: 'tm-frage-knoepfe' }, wahl.aktion, abbrechen)) let laeuft = false rueckfrage.addEventListener('submit', async () => { const neu = wahl.gewaehlt() if (laeuft || neu === null || neu.gesundheit) return laeuft = true const ok = rueckfrage.querySelector('[data-aktion="branche-starten"]') if (ok) ok.disabled = true try { setzeFehler(rueckfrage, {}) const gewuenscht = { ...ctx.daten.einstellung.betrieb, ...angaben.lese() } try { await ctx.api.send('POST', '/einstellungen/vorlage', { branche: neu.key, betrieb: kontaktVon(gewuenscht), ersetzen: true, version: ctx.daten.version }) } catch (err) { if (!sp.nochHier()) return if (err.code === 'EINSTELLUNG_VERALTET') { box.replaceChildren() sp.konflikt.hidden = false // „Neu laden“ des Speichers sp.konflikt.scrollIntoView({ block: 'nearest' }) } else zeigeFehler(rueckfrage, err) return } const vollstaendig = await nachVorlage(t, sp, rueckfrage, gewuenscht) if (!sp.nochHier()) return if (vollstaendig) t.gehe('branche') else zeigeNeu(t, NACH_VORLAGE_FEHLT) } finally { laeuft = false if (ok) ok.disabled = false } }) box.replaceChildren(rueckfrage) rueckfrage.focus() }) return { knopf, box } } /* ------------------------------------------------------ Schritt 2: Wer wird gebucht --- */ /** Vorbelegung der Art: nur Plaetze ⇒ Plaetze, mehr als ein Eintrag ⇒ Team, sonst Einzelperson. */ function vorgabeArt(ressourcen) { if (ressourcen.length > 0 && ressourcen.every((r) => r.typ === 'platz')) return 'plaetze' return ressourcen.length > 1 ? 'team' : 'einzel' } function schrittWer(t) { const { ctx } = t const wurzel = rahmen(t, 'Trag ein, wer oder was bei dir gebucht werden kann. Neue Personen oder Plätze können zunächst alle Leistungen anbieten, das änderst du im nächsten Schritt.') const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen) const ressourcen = ctx.daten.einstellung.ressourcen || [] const vorgabe = vorgabeArt(ressourcen) const radios = WER_ARTEN.map(([wert, name, beschreibung]) => h('label', { class: 'tm-vorlage' }, h('input', { type: 'radio', name: 'termine-wer-art', value: wert, checked: wert === vorgabe }), h('span', null, h('strong', null, name), h('small', null, beschreibung)))) const namen = h('textarea', { rows: '4', maxlength: String(LIMITS.ressourcen * (MAX.ressourcenName + 1)), autocomplete: 'off' }, ressourcen.map((r) => r.name).join('\n')) const form = schrittForm(t, h('div', { class: 'kb-panel' }, h('fieldset', { class: 'tm-vorlagen-liste tm-fb-branchen' }, h('legend', { class: 'tm-sr' }, 'Wer wird gebucht'), radios), feld('ressourcen', 'Namen (einer pro Zeile)', namen, `Höchstens ${LIMITS.ressourcen} Einträge mit je höchstens ${MAX.ressourcenName} Zeichen. Bei einer Einzelperson trägst du nur einen Namen ein.`)), allgemeinerFehler(), knoepfe(t)) let gelesen = null const art = () => form.querySelector('input[name="termine-wer-art"]:checked')?.getAttribute('value') ?? vorgabe verdrahte(t, form, sp, { pruefe: () => { const zeilen = namen.value.split(/\r?\n/) const gefuellt = zeilen.filter((zeile) => zeile.trim() !== '') let fehler = null if (gefuellt.length === 0) fehler = 'Bitte trag mindestens einen Namen ein.' else if (art() === 'einzel' && gefuellt.length > 1) fehler = 'Für eine Einzelperson trägst du nur einen Namen ein. Wähle „Team“, wenn mehrere Personen gebucht werden können.' else if (gefuellt.length > LIMITS.ressourcen) fehler = `Es sind höchstens ${LIMITS.ressourcen} Einträge möglich.` else if (gefuellt.some((zeile) => [...zeile.trim()].length > MAX.ressourcenName)) fehler = `Ein Name darf höchstens ${MAX.ressourcenName} Zeichen lang sein.` if (fehler) return { ressourcen: fehler } gelesen = ressourcenAusNamen({ bisher: ctx.daten.einstellung.ressourcen, typ: art() === 'plaetze' ? 'platz' : 'person', namen: zeilen }) return null }, // neue Eintraege bieten alle Leistungen an (der Validator verlangt eine aktive Ressource), entfernte verschwinden aus Leistungen und Abwesenheiten aendere: (entwurf) => { const { ressourcen: neu, entfernt, neu: hinzu } = gelesen entwurf.ressourcen = neu for (const leistung of entwurf.leistungen || []) leistung.ressourcen = [...(leistung.ressourcen || []).filter((id) => !entfernt.includes(id)), ...hinzu] entwurf.abwesenheiten = (entwurf.abwesenheiten || []).filter((a) => !entfernt.includes(a.ressourceId)) return true }, }) wurzel.append(sp.konflikt, form) return wurzel } /* ---------------------------------------------------------- Schritt 3: Leistungen --- */ /** Leistungen, die Gaeste sehen: aktiv und mit mindestens einer aktiven Person oder einem aktiven Platz (wie sichtbareLeistungen auf dem Server). */ function sichtbareLeistungen(einstellung) { const aktiv = new Set((einstellung?.ressourcen || []).filter((r) => r?.aktiv).map((r) => r.id)) return (einstellung?.leistungen || []).filter((l) => l?.aktiv && (l.ressourcen || []).some((id) => aktiv.has(id))) } const hatPreis = (leistung) => preisAnzeige(leistung) !== 'ohne Preis' function schrittLeistungen(t) { const { ctx } = t const wurzel = rahmen(t, 'Prüfe die Leistungen, die deine Gäste buchen können, und ändere sie, wie du sie brauchst. Änderungen an einer Leistung werden sofort gespeichert.') const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen) // der Reiter Leistungen speichert selbst; sein Platz ist ein eigenes Element, das Formular des Schritts steht daneben (kein verschachteltes Formular) const leistungen = h('div', { class: 'tm-fb-leistungen' }) renderLeistungen(leistungen, ctx) const haken = h('input', { type: 'checkbox', checked: ctx.daten.fragebogen?.preiseGeprueft === true }) const form = schrittForm(t, hakenFeld('preiseGeprueft', haken, 'Ich habe die Preise geprüft (die Preise der Vorlage sind Platzhalter)'), h('p', { class: 'tm-fehler', 'data-fehler': 'leistungen', role: 'alert' }), allgemeinerFehler(), knoepfe(t)) verdrahte(t, form, sp, { pruefe: () => { const sichtbar = sichtbareLeistungen(ctx.daten.einstellung) if (sichtbar.length === 0) return { leistungen: 'Bitte lege mindestens eine aktive Leistung an, die einer aktiven Person oder einem aktiven Platz zugeordnet ist. Nur solche Leistungen sehen Gäste.' } if (sichtbar.some(hatPreis) && !haken.checked) return { preiseGeprueft: 'Bitte bestätige, dass du die Preise geprüft hast.' } return null }, fragebogen: (f) => { f.preiseGeprueft = haken.checked }, }) wurzel.append(sp.konflikt, leistungen, form) return wurzel } /* ------------------------------------------------- Schritt 7: Benachrichtigung --- */ function schrittBenachrichtigung(t) { const { ctx } = t const wurzel = rahmen(t, 'Wohin sollen die Nachrichten zu Terminen gehen, wie sprichst du deine Gäste an und bekommen Gäste eine Erinnerung?') const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen) const e = ctx.daten.einstellung const abschnitte = [betrieb(ctx, e, { nur: ['email'], titel: 'Deine E-Mail-Adresse', hinweis: null }), darstellung(e), erinnerung(e)] const form = schrittForm(t, abschnitte.map((a) => a.el), allgemeinerFehler(), knoepfe(t)) verdrahte(t, form, sp, { aendere: (entwurf) => { const fehler = {} for (const a of abschnitte) a.lese(entwurf, fehler) if (Object.keys(fehler).length === 0) return true zeigeFehler(form, { fehler }) return false }, }) wurzel.append(sp.konflikt, form) return wurzel } /* ------------------------------------------------------ Schritte ohne Inhalt --- */ /** Platz fuer die Schritte, die C9b nachtraegt (4, 5, 6, 8): Ueberschrift, Hinweis und Zurueck, aber nichts zu bestaetigen. */ function schrittOhneInhalt(t) { const wurzel = rahmen(t, 'Dieser Schritt ist noch nicht verfügbar.') wurzel.append(knoepfe(t, { mitWeiter: false })) return wurzel } const BAU = { branche: schrittBranche, wer: schrittWer, leistungen: schrittLeistungen, benachrichtigung: schrittBenachrichtigung }