Files
Webklar-Kundenbereich/public/termine-fragebogen.js
T
ClaudeandClaude Sonnet 5.5 9bf5ec09f8 Terminplaner TP2: Fragebogen-Geruest und Schritte 1 bis 3
- public/termine-fragebogen.js: renderFragebogen(root, ctx) mit Fortschrittsleiste
  (ol, aria-current, Handy: Schritt n von 8), Start beim ersten offenen Schritt
  (alles bestaetigt: Bestellseite), freier Navigation, einem Formular je Schritt,
  Weiter = ein PATCH ueber neuerSpeicher, danach PUT /fragebogen; Fokus auf die
  Ueberschrift; Kundenansicht ohne PUT
- Schritt 1 Branche und Betrieb (Gesundheitsbereich: Erklaertext statt Weiter, keine
  Anfrage; Branche wechseln mit Rueckfrage), Schritt 2 Wer wird gebucht,
  Schritt 3 Leistungen mit Haken Preise geprueft; Schritt 7 als Beispiel eines
  Schritts mit Feldern, Schritte 4, 5, 6 und 8 folgen in C9b
- termine.css: Fortschrittsleiste und Handy-Ansicht
- Tests: termineView (Dateiliste, PUT nur im Fragebogen, nur ueber termine.js geladen),
  termineFragebogenView (Verhalten mit Fake-DOM)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 21:55:51 +00:00

503 lines
27 KiB
JavaScript

/* 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 (<ol>, 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 <form>; „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 }