From 8d628ef906eba138e961f43db5628a1d915ea6b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:18:44 +0000 Subject: [PATCH] Terminplaner TP2: Fragebogen-Schritte 4 bis 8 und Bestellseite - public/termine-fragebogen.js: Schritt 4 Zeiten (je Person/Platz wochenplanFeld, Bundesland ohne Vorauswahl mit leerer erster Option, "An Feiertagen geschlossen" im PATCH, danach PUT mit bundeslandGewaehlt; darunter abwesenheiten und sonderoeffnungen mit eigenem Container und eigenem zeichne, damit ungespeicherte Zeiten bleiben), Schritt 5 Regeln (Sammelknopf Alle sofort buchbar / Alle nur als Anfrage mit buchungsartFuerAlle und buchungsartLage, regeln, storno mit Muster nach Absagefrist, rechtliches, zahlung), Schritt 6 Gastfelder, Schritt 7 mit gemeinsamem leseAbschnitte, Schritt 8 Website (betrieb nur Telefon und vier Links, Einbauwuensche Modus/Knopftext/Stelle/Gestaltung/Bemerkung im PUT, Hinweis auf Mehrwuensche nach Stunden); danach renderBestellen. Platzhalter-Schritt entfernt. - termine.css: Klassen der Schritte 4 bis 8 - Tests: termineView (Importe, PATCH/PUT nur an erlaubten Stellen, Bundesland ohne Vorauswahl im Quelltext), termineFragebogenView (Verhalten mit Fake-DOM fuer die Schritte 4 bis 8, Schluesselmengen, Bestellseite nach Schritt 8) Co-Authored-By: Claude Sonnet 5.5 --- public/termine-fragebogen.js | 272 +++++++++++- public/termine.css | 10 + test/termineFragebogenView.test.js | 665 ++++++++++++++++++++++++++++- test/termineView.test.js | 26 ++ 4 files changed, 939 insertions(+), 34 deletions(-) diff --git a/public/termine-fragebogen.js b/public/termine-fragebogen.js index 4be3d52..47ddcd7 100644 --- a/public/termine-fragebogen.js +++ b/public/termine-fragebogen.js @@ -7,14 +7,20 @@ 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. */ + keinen PUT, denn Schritte bestaetigt nur der Kunde selbst. Die Schritte bauen auf den Abschnitten der Reiter auf (wochenplanFeld, abwesenheiten, + sonderoeffnungen, betrieb, darstellung, regeln, gastFelder, erinnerung, storno, rechtliches, zahlung); hier steht nur, was der Fragebogen daraus macht. + Nach Schritt 8 kommt die Bestellseite (renderBestellen). 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 { + BUNDESLAENDER, FRAGEBOGEN_SCHRITTE, LIMITS, MAX, buchungsartFuerAlle, buchungsartLage, ersterOffenerSchritt, preisAnzeige, ressourcenAusNamen, schrittStatus, +} from './termine-view.js' +import { abschnitt, allgemeinerFehler, feld, frage, h, hakenFeld, setzeFehler, zeigeFehler } from './termine-dom.js' +import { neuerSpeicher, stelleVon } from './termine-speichern.js' import { render as renderLeistungen } from './termine-tab-leistungen.js' -import { betrieb, darstellung, erinnerung } from './termine-tab-einstellungen.js' +import { wochenplanFeld } from './termine-tab-team.js' +import { abwesenheiten, sonderoeffnungen } from './termine-team-ausnahmen.js' +import { betrieb, darstellung, erinnerung, gastFelder, rechtliches, regeln, storno, zahlung } from './termine-tab-einstellungen.js' import { renderBestellen } from './funktion-bestellen.js' const BESTELLEN = 'bestellen' @@ -41,6 +47,28 @@ const WER_ARTEN = [ ['plaetze', 'Plätze', 'Gebucht werden Plätze oder Räume, zum Beispiel Stühle oder Zimmer.'], ] +// Schritt 4: ohne Wahl des Bundeslands geht es nicht weiter (der Serverstandard RP im Entwurf ist keine Wahl) +const LAND_FEHLT = 'Bitte wähle das Bundesland deines Betriebs. Danach richten sich die Feiertage.' + +// Schritt 5: Lage der Buchungsarten aller Leistungen (Schluessel wie buchungsartLage) und die zwei Sammelknoepfe +const LAGE_TEXT = { + sofort: 'Alle Leistungen sind sofort buchbar.', + anfrage: 'Alle Leistungen sind nur als Anfrage buchbar.', + gemischt: 'Die Leistungen sind gemischt: je Leistung sofort buchbar oder nur als Anfrage.', +} +const SAMMEL_ARTEN = [['sofort', 'Alle sofort buchbar'], ['anfrage', 'Alle nur als Anfrage']] + +// Schritt 8: Einbauwuensche (Werte und Grenzen wie normalisiereFragebogen auf dem Server) +const EINBAU_MODI = [ + ['inline', 'Direkt auf der Seite', 'Der Terminplaner steht offen an der Stelle, die du uns nennst.'], + ['knopf', 'Hinter einem Knopf', 'Gäste klicken auf einen Knopf, dann öffnet sich der Terminplaner.'], +] +const EINBAU_MAX = { knopfText: 40, stelle: 300, gestaltung: 1000, bemerkung: 1000 } +const MODUS_FEHLT = 'Bitte wähle, ob der Terminplaner direkt auf deiner Seite oder hinter einem Knopf erscheint.' +const KNOPFTEXT_FEHLT = 'Bitte gib an, was auf dem Knopf stehen soll, hinter dem sich der Terminplaner öffnet.' +const MEHRWUENSCHE = 'Der Einbau umfasst den Terminplaner an einer Stelle deiner Website. Ein zweites Widget, Knöpfe für einzelne Leistungen und ein Sonderdesign gehen nach Stunden.' +const WEBSITE_NUR = ['telefon', 'impressumUrl', 'datenschutzUrl', 'agbUrl', 'websiteUrl'] + const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) /* ---------------------------------------------------------------- Gerüst --- */ @@ -78,10 +106,10 @@ export function renderFragebogen(root, ctx) { /** 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' + const ziel = TITEL.has(key) && darfHin(key) ? key : 'branche' angezeigt = ziel zeichneLeiste() - const el = ziel === BESTELLEN ? bestellseite() : (BAU[ziel] ?? schrittOhneInhalt)(baueT(ziel)) + const el = ziel === BESTELLEN ? bestellseite() : BAU[ziel](baueT(ziel)) inhalt.replaceChildren(el) const kopf = fokus ? el.querySelector('h3') : null if (kopf) { @@ -189,6 +217,20 @@ function zeigeNeu(t, text) { if (form) setzeFehler(form, { _: text }) } +/** + * aendere() fuer Schritte aus Abschnitten der Einstellungen (jeder ist { el, lese(entwurf, fehler) }): jeder liest seine Felder in den Entwurf. + * Eingaben, die kein Feld versteht, stehen danach am Feld und nichts wird gesendet (false). Das Pruefen im Einzelnen bleibt Sache des Servers. + */ +function leseAbschnitte(form, abschnitte) { + return (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 + } +} + /* ------------------------------------------------- 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 @@ -470,33 +512,217 @@ function schrittLeistungen(t) { return wurzel } +/* ---------------------------------------------------------- Schritt 4: Zeiten --- */ +/** + * Je Person und Platz ein Wochenplan (wochenplanFeld), dazu Bundesland OHNE Vorauswahl (leere erste Option; gewaehlt ist nur, was der Kunde schon + * ausdruecklich gewaehlt hatte) und „An Feiertagen geschlossen“: alles im PATCH dieses Schritts, danach der PUT mit bundeslandGewaehlt. + * Darunter Abwesenheiten und Sonderoeffnungen aus dem Reiter „Team & Plaetze“: sie speichern selbst und haben deshalb einen eigenen Container mit + * eigenem zeichne, damit das Neuzeichnen nach dem Speichern die ungespeicherten Zeiten im Formular darueber nicht verliert. Der Speicher ist derselbe + * wie im Schritt (ein Speichern zur Zeit, eine Version). + */ +function schrittZeiten(t) { + const { ctx } = t + const e = ctx.daten.einstellung + const wurzel = rahmen(t, 'Wann kann bei dir gebucht werden? Trag für jede Person und jeden Platz die Zeiten ein und wähle dein Bundesland. Urlaub und besondere Öffnungstage trägst du weiter unten ein, sie werden sofort gespeichert.') + const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen) + const plaene = (e.ressourcen || []).map((r, i) => ({ r, plan: wochenplanFeld(r, `ressourcen.${i}`) })) + const vorgabe = ctx.daten.fragebogen?.bundeslandGewaehlt === true ? e.bundesland : '' + const land = h('select', null, + h('option', { value: '' }, 'Bitte wählen'), + BUNDESLAENDER.map(([key, name]) => h('option', { value: key, selected: key === vorgabe }, name))) + const geschlossen = h('input', { type: 'checkbox', checked: e.feiertageGeschlossen !== false }) + const form = schrittForm(t, + plaene.length > 0 + ? plaene.map(({ r, plan }) => h('div', { class: 'kb-panel tm-fb-person', 'data-ressource': r.id }, h('h4', null, `${r.name}${r.aktiv === false ? ' (nicht aktiv)' : ''}`), plan.el)) + : h('p', { class: 'tm-leer' }, 'Es gibt noch keine Person und keinen Platz. Lege sie im Schritt „Wer wird gebucht“ an.'), + abschnitt('Bundesland und Feiertage', null, + feld('bundesland', 'Bundesland', land, 'Danach richten sich die gesetzlichen Feiertage. Bitte wähle es ausdrücklich aus.'), + hakenFeld('feiertageGeschlossen', geschlossen, 'An Feiertagen geschlossen', 'Dann kann an gesetzlichen Feiertagen niemand buchen. Eine Sonderöffnung geht vor.')), + allgemeinerFehler(), + knoepfe(t)) + + let gelesen = [] + verdrahte(t, form, sp, { + pruefe: () => { + const fehler = {} + const plaeneGelesen = [] + for (const { r, plan } of plaene) { + const ergebnis = plan.lese() + if (ergebnis.fehler) Object.assign(fehler, ergebnis.fehler) + else plaeneGelesen.push([r.id, ergebnis.plan]) + } + if (land.value === '') fehler.bundesland = LAND_FEHLT + if (Object.keys(fehler).length > 0) return fehler + gelesen = plaeneGelesen + return null + }, + aendere: (entwurf) => { + for (const [id, plan] of gelesen) { + const stelle = stelleVon(entwurf.ressourcen, id, form, 'Diese Person oder dieser Platz') + if (stelle < 0) return false + entwurf.ressourcen[stelle].plan = plan + } + entwurf.bundesland = land.value + entwurf.feiertageGeschlossen = geschlossen.checked + return true + }, + fragebogen: (f) => { f.bundeslandGewaehlt = true }, + }) + + const ausnahmen = h('div', { class: 'tm-fb-ausnahmen' }) + const at = { ctx, sp, wurzel: ausnahmen, zeichne: () => ausnahmen.replaceChildren(abwesenheiten(at), sonderoeffnungen(at)) } + at.zeichne() + wurzel.append(sp.konflikt, form, ausnahmen) + return wurzel +} + +/* --------------------------------------------------------- Schritt 5: Regeln --- */ +/** + * Sammelknopf „Alle sofort buchbar“ / „Alle nur als Anfrage“: zeigt die Lage der Leistungen (buchungsartLage) und merkt sich die Wahl; geschrieben wird + * sie mit „Weiter“ in den PATCH des Schritts (buchungsartFuerAlle auf dem frischen Stand), ein Klick allein ruft nichts auf. => { el, lese(entwurf) }. + */ +function buchungsartSammel(e) { + const leistungen = e.leistungen || [] + let gewaehlt = null // null: der Kunde hat nichts gewaehlt, die Leistungen bleiben, wie sie sind + const lage = h('p', { class: 'tm-hinweis', role: 'status' }) + const tasten = SAMMEL_ARTEN.map(([art, text]) => h('button', { + type: 'button', class: 'kb-btn-outline', 'data-aktion': `alle-${art}`, onclick: () => { gewaehlt = art; zeige() }, + }, text)) + function zeige() { + const jetzt = buchungsartLage(gewaehlt === null ? leistungen : buchungsartFuerAlle(leistungen, gewaehlt)) + lage.setAttribute('data-lage', jetzt) + lage.textContent = `${LAGE_TEXT[jetzt]}${gewaehlt === null ? '' : ' Gespeichert wird mit „Weiter“.'}` + SAMMEL_ARTEN.forEach(([art], i) => tasten[i].setAttribute('aria-pressed', String(jetzt === art))) + } + zeige() + const el = abschnitt('Buchungsart', + 'Sollen deine Gäste sofort buchen können oder nur eine Anfrage stellen, die du bestätigst? Das gilt für alle Leistungen zugleich. Einzelne Leistungen änderst du im Schritt „Leistungen“.', + lage, + h('div', { class: 'tm-fb-sammel', role: 'group', 'aria-label': 'Buchungsart für alle Leistungen' }, tasten)) + return { el, lese(entwurf) { if (gewaehlt !== null) entwurf.leistungen = buchungsartFuerAlle(entwurf.leistungen, gewaehlt) } } +} + +function schrittRegeln(t) { + const { ctx } = t + const e = ctx.daten.einstellung + const wurzel = rahmen(t, 'Lege fest, wie deine Gäste buchen, was bei Absagen gilt und wie sie bezahlen.') + const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen) + const sammel = buchungsartSammel(e) + const regelnAbschnitt = regeln(e) + // das Muster der Stornobedingungen folgt der Frist, die im Feld „Selbst absagen bis“ steht (auch ungespeichert) + const abschnitte = [regelnAbschnitt, storno(ctx, e, regelnAbschnitt.absage), rechtliches(e), zahlung(e)] + const form = schrittForm(t, sammel.el, abschnitte.map((a) => a.el), allgemeinerFehler(), knoepfe(t)) + verdrahte(t, form, sp, { aendere: leseAbschnitte(form, [sammel, ...abschnitte]) }) + wurzel.append(sp.konflikt, form) + return wurzel +} + +/* ----------------------------------------------------- Schritt 6: Gastfelder --- */ +function schrittGastfelder(t) { + const { ctx } = t + const wurzel = rahmen(t, 'Was möchtest du von deinen Gästen wissen? Name und E-Mail-Adresse brauchen wir immer.') + const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen) + const gast = gastFelder(ctx, ctx.daten.einstellung) + const form = schrittForm(t, gast.el, allgemeinerFehler(), knoepfe(t)) + verdrahte(t, form, sp, { aendere: leseAbschnitte(form, [gast]) }) + wurzel.append(sp.konflikt, 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 + // aus dem Betrieb nur die E-Mail-Adresse: sie ist die einzige Adresse fuer Nachrichten und auch Reply-To 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 + verdrahte(t, form, sp, { aendere: leseAbschnitte(form, abschnitte) }) + wurzel.append(sp.konflikt, form) + return wurzel +} + +/* ---------------------------------------------------------- Schritt 8: Website --- */ +/** + * Einbauwuensche: Modus (direkt auf der Seite / hinter einem Knopf, ohne Vorauswahl), Knopftext (nur bei Knopf), Stelle, Gestaltung, Bemerkung und der + * Hinweis, was nach Stunden geht. einbau = fragebogen.einbau (darf fehlen). => { el, pruefe(), wert() }: pruefe() => null oder { feld: text }, wert() => + * das Objekt fuer fragebogen.einbau (bei „direkt auf der Seite“ ohne Knopftext). Die Texte kuerzt und bereinigt der Server. + */ +function einbauWuensche(einbau) { + const alt = istObjekt(einbau) ? einbau : {} + const textVon = (name) => (typeof alt[name] === 'string' ? alt[name] : '') + const eingaben = [] + const radios = EINBAU_MODI.map(([wert, name, beschreibung]) => { + const radio = h('input', { type: 'radio', name: 'termine-einbau-modus', value: wert, checked: alt.modus === wert }) + radio.addEventListener('change', zeigeKnopfFeld) + eingaben.push(radio) + return h('label', { class: 'tm-vorlage' }, radio, h('span', null, h('strong', null, name), h('small', null, beschreibung))) + }) + const gewaehlt = () => eingaben.find((radio) => radio.checked)?.value ?? null + const knopfText = h('input', { type: 'text', maxlength: String(EINBAU_MAX.knopfText), autocomplete: 'off', value: textVon('knopfText') }) + const knopfFeld = feld('einbau.knopfText', 'Text auf dem Knopf', knopfText, 'Zum Beispiel „Termin buchen“.') + const mehrzeilig = (name) => h('textarea', { rows: '3', maxlength: String(EINBAU_MAX[name]), autocomplete: 'off' }, textVon(name)) + const stelle = mehrzeilig('stelle') + const gestaltung = mehrzeilig('gestaltung') + const bemerkung = mehrzeilig('bemerkung') + function zeigeKnopfFeld() { knopfFeld.hidden = gewaehlt() !== 'knopf' } + zeigeKnopfFeld() + + const el = abschnitt('Einbau auf deiner Website', 'Wir bauen den Terminplaner für dich in deine Website ein. Sag uns, wie und wo er erscheinen soll.', + h('fieldset', { class: 'tm-gruppe-felder', 'data-feld': 'einbau.modus', tabindex: '-1' }, + h('legend', { class: 'tm-label' }, 'So erscheint der Terminplaner'), + h('div', { class: 'tm-vorlagen-liste' }, radios), + h('p', { class: 'tm-fehler', 'data-fehler': 'einbau.modus', role: 'alert' })), + knopfFeld, + feld('einbau.stelle', 'Wo auf deiner Website soll er stehen?', stelle, 'Beschreibe die Stelle in Worten, zum Beispiel „auf der Seite Kontakt unter dem Text“.'), + feld('einbau.gestaltung', 'Wünsche zur Gestaltung (optional)', gestaltung, 'Farben, Schrift, Stil: Wir passen den Terminplaner an deine Website an.'), + feld('einbau.bemerkung', 'Bemerkung (optional)', bemerkung), + h('p', { class: 'tm-hinweis', 'data-hinweis': 'einbau-mehr' }, MEHRWUENSCHE)) + el.classList.add('tm-fb-einbau') + + return { + el, + pruefe() { + const modus = gewaehlt() + if (modus === null) return { 'einbau.modus': MODUS_FEHLT } + if (modus === 'knopf' && knopfText.value.trim() === '') return { 'einbau.knopfText': KNOPFTEXT_FEHLT } + return null }, + wert() { + const modus = gewaehlt() + return { modus, knopfText: modus === 'knopf' ? knopfText.value.trim() : '', stelle: stelle.value, gestaltung: gestaltung.value, bemerkung: bemerkung.value } + }, + } +} + +function schrittWebsite(t) { + const { ctx } = t + const wurzel = rahmen(t, 'Zum Schluss: Wie erreichen dich Gäste, und wie soll der Terminplaner auf deiner Website erscheinen?') + const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen) + // aus dem Betrieb Telefon (Ausweichweg fuer Gaeste) und die Links; Name, Anschrift und E-Mail stehen in den Schritten 1 und 7 + const angaben = betrieb(ctx, ctx.daten.einstellung, { + nur: WEBSITE_NUR, + titel: 'Telefon und Links', + hinweis: 'Das Telefon ist der Ausweichweg für Gäste. Impressum und Datenschutzerklärung brauchen wir, weil der Terminplaner Gäste darauf verweist.', + }) + const einbau = einbauWuensche(ctx.daten.fragebogen?.einbau) + const form = schrittForm(t, angaben.el, einbau.el, allgemeinerFehler(), knoepfe(t)) + let gewuenscht = null + verdrahte(t, form, sp, { + pruefe: () => { + const fehler = einbau.pruefe() + if (fehler === null) gewuenscht = einbau.wert() + return fehler + }, + aendere: (entwurf) => { angaben.lese(entwurf); return true }, + fragebogen: (f) => { f.einbau = gewuenscht }, }) 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, zeiten: schrittZeiten, regeln: schrittRegeln, + gastfelder: schrittGastfelder, benachrichtigung: schrittBenachrichtigung, website: schrittWebsite, } - -const BAU = { branche: schrittBranche, wer: schrittWer, leistungen: schrittLeistungen, benachrichtigung: schrittBenachrichtigung } diff --git a/public/termine.css b/public/termine.css index 6aafb9b..d572a12 100644 --- a/public/termine.css +++ b/public/termine.css @@ -258,6 +258,15 @@ .tm-fb-branche { display: flex; align-items: center; justify-content: space-between; gap: 8px 16px; flex-wrap: wrap; } .tm-fb-branche p { margin: 0; } .tm-fb-aufbewahrung { margin-top: 18px; } +/* Schritte 4 bis 8: ein Wochenplan je Person, Urlaub und Sonderoeffnungen unter dem Formular, Sammelknopf, Einbauwuensche */ +.tm-fb-person > h4 { margin: 0 0 4px; font-size: 14.5px; overflow-wrap: anywhere; } +.tm-fb-person .tm-wochenplan { margin-top: 0; } +.tm-fb-ausnahmen { margin-top: 28px; } +.tm-fb-ausnahmen > * + * { margin-top: 16px; } +.tm-fb-sammel { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; } +.tm-fb-sammel [aria-pressed='true'] { color: #fff; background: var(--kb-purple-soft); border-color: var(--kb-purple-line); } +.tm-fb-einbau .tm-vorlagen-liste { margin-bottom: 4px; } +.tm-fb-einbau .tm-feld textarea { min-height: 76px; } /* ----------------------------------------------------------------- Handy --- */ @media (max-width: 880px), (pointer: coarse) { @@ -295,6 +304,7 @@ .tm-feed { padding: 12px; } .tm-frage-knoepfe > * { flex: 1 1 auto; } .tm-bestellen-aktion { justify-items: stretch; } + .tm-fb-sammel > * { flex: 1 1 auto; } .tm-rechtstext { max-height: 260px; } } @media (prefers-reduced-motion: reduce) { #view-termine * { transition: none !important; animation: none !important; } } diff --git a/test/termineFragebogenView.test.js b/test/termineFragebogenView.test.js index 37500e8..245fc5b 100644 --- a/test/termineFragebogenView.test.js +++ b/test/termineFragebogenView.test.js @@ -20,6 +20,7 @@ import * as fragebogenAnsicht from '../public/termine-fragebogen.js' import { FENSTER_FEHLER } from '../public/termine-view.js' import { h, request } from '../public/termine-dom.js' import { neuerSpeicher } from '../public/termine-speichern.js' +import { stornoMuster } from '../public/termine-gaestetexte.js' // Reine Helfer des Fragebogens in public/termine-view.js (TP2, Task C7a): Schrittstand, Namen der Personen/Plaetze, sofort/Anfrage fuer alle // und die Zusammenfassung auf der Bestellseite. Kein DOM; der Spiegel zum Server (SCHRITTE) steht in test/termineView.test.js. @@ -1674,16 +1675,6 @@ test('renderFragebogen: Kundenansicht zeigt den Hinweis, sonst nicht', mitDom(un assert.equal(fx.finde(fbSeite(fbDaten()).wurzel, '.tm-box[role="note"]'), null) })) -test('Schritte ohne Inhalt (4, 5, 6, 8 kommen mit C9b): Titel, Hinweis, Zurück, kein Weiter und kein Aufruf', mitDom(undefined, async (fx) => { - const { wurzel } = fbSeite(fbDaten({ fragebogen: fbBis(3) })) - assert.equal(fbSchritt(fx, wurzel), 'zeiten') - assert.equal(fx.text(fx.finde(wurzel, 'h3')), 'Zeiten') - assert.equal(fbWeiter(fx, wurzel), null) - await fx.klick(fx.finde(wurzel, '[data-aktion="zurueck"]')) - assert.equal(fbSchritt(fx, wurzel), 'leistungen') - assert.deepEqual(fx.fetchLog, []) -})) - // ------------------------------------------------------- Schritt 1: Branche und Betrieb, ohne Entwurf test('Schritt 1 ohne Entwurf: Radiogruppe aus daten.branchen, Betrieb mit Vorschlag, Knopf „Mit dieser Branche starten“ gesperrt bis zur Wahl', mitDom(undefined, async (fx) => { const { wurzel } = fbSeite(fbStart()) @@ -2295,12 +2286,658 @@ test('Texte der Kunden stehen nur als Text im Baum (Namen, Vorschlag)', mitDom(u assert.equal(fbFeld(fx, start, 'betrieb.name').value, boese) })) +// ------------------------------------------------------- Fragebogen-Oberfläche: Schritte 4 bis 8 und Bestellseite (TP2, Task C9b) +// Die Schritte bauen auf den Abschnitten der Reiter auf (wochenplanFeld aus termine-tab-team.js, abwesenheiten/sonderoeffnungen aus termine-team-ausnahmen.js, +// betrieb/darstellung/regeln/gastFelder/erinnerung/storno/rechtliches/zahlung aus termine-tab-einstellungen.js); hier steht, was der Fragebogen daraus macht. +const fbZeitenSeite = (aenderung = {}, optionen) => fbSeite(fbDaten({ fragebogen: fbBis(3), ...aenderung }), optionen) +const fbRegelnSeite = (aenderung = {}) => fbSeite(fbDaten({ fragebogen: fbBis(4, { bundeslandGewaehlt: true }), ...aenderung })) +const fbGastSeite = (aenderung = {}) => fbSeite(fbDaten({ fragebogen: fbBis(5, { bundeslandGewaehlt: true }), ...aenderung })) +const fbWebsiteSeite = (aenderung = {}) => fbSeite(fbDaten({ fragebogen: fbBis(7, { bundeslandGewaehlt: true }), ...aenderung })) +const EINBAU_LEER = { modus: null, knopfText: '', stelle: '', gestaltung: '', bemerkung: '' } + +/** Entwurf mit zwei Personen (Anna behält den Plan der Vorlage, Ben hat einen eigenen) und einem Platz. */ +function dreiEintraege() { + const e = salon() + const [anna] = e.ressourcen + e.ressourcen = [ + { ...anna, name: 'Anna' }, + { id: 'r_ben00002', typ: 'person', name: 'Ben', aktiv: true, plan: { 2: [['13:00', '17:00']] } }, + { id: 'r_stuhl003', typ: 'platz', name: 'Stuhl 3', aktiv: false, plan: {} }, + ] + return e +} + +test('alle acht Schritte haben Inhalt: ein Formular mit „Weiter“ (außer Schritt 1 ohne Entwurf), keinen Platzhalter', mitDom(undefined, async (fx) => { + const { wurzel } = fbSeite(fbDaten({ fragebogen: fbBis(0) })) + for (const [key, titel] of FRAGEBOGEN_SCHRITTE) { + await fbGehe(fx, wurzel, key) + assert.equal(fbSchritt(fx, wurzel), key) + assert.equal(fx.text(fx.finde(wurzel, 'h3')), titel) + assert.ok(fx.finde(wurzel, 'form'), `${key}: ein Formular`) + assert.ok(fbWeiter(fx, wurzel), `${key}: „Weiter“`) + assert.doesNotMatch(fx.text(wurzel), /noch nicht verfügbar/, `${key}: kein Platzhalter`) + } + assert.deepEqual(fx.fetchLog, [], 'das Anzeigen allein ruft nichts auf') +})) + +// ------------------------------------------------------- Schritt 4: Zeiten +test('Schritt 4: je Person und Platz ein Wochenplan (wochenplanFeld, Felder ressourcen.n.plan.1 bis 7), vorbelegt aus dem Plan', mitDom(undefined, (fx) => { + const { wurzel } = fbZeitenSeite({ einstellung: dreiEintraege() }) + assert.equal(fbSchritt(fx, wurzel), 'zeiten') + const gruppen = fx.alle(wurzel, '[data-ressource]') + assert.deepEqual(gruppen.map((g) => fx.text(fx.finde(g, 'h4'))), ['Anna', 'Ben', 'Stuhl 3 (nicht aktiv)']) + assert.equal(fx.alle(wurzel, 'fieldset.tm-wochenplan').length, 3, 'drei Wochenpläne') + const plaene = felderVon(fx, wurzel).filter((n) => n.includes('.plan.')) + assert.deepEqual(plaene, [0, 1, 2].flatMap((i) => [1, 2, 3, 4, 5, 6, 7].map((nr) => `ressourcen.${i}.plan.${nr}`))) + assert.equal(fbFeld(fx, wurzel, 'ressourcen.0.plan.2').value, '09:00–12:30, 13:30–18:00') + assert.equal(fbFeld(fx, wurzel, 'ressourcen.0.plan.1').value, '', 'Montag geschlossen') + assert.equal(fbFeld(fx, wurzel, 'ressourcen.1.plan.2').value, '13:00–17:00') + assert.deepEqual(fbKnoepfe(fx, wurzel).filter((t) => /Zurück|Weiter/.test(t)), ['Zurück', 'Weiter']) +})) + +test('Schritt 4 (a): Bundesland ohne Vorauswahl – die erste Option hat value "", keine Option ist gewählt, obwohl der Entwurf RP trägt', mitDom(undefined, (fx) => { + const { wurzel, ctx } = fbZeitenSeite() + assert.equal(ctx.daten.einstellung.bundesland, 'RP', 'der Serverstandard steht im Entwurf …') + const land = fbFeld(fx, wurzel, 'bundesland') + assert.equal(land.tagName, 'SELECT') + const optionen = fx.alle(land, 'option') + assert.equal(optionen.length, ansicht.BUNDESLAENDER.length + 1, 'alle 16 Länder und die leere Option') + assert.equal(optionen[0].value, '', 'die erste Option ist leer') + assert.ok(optionen.every((o) => !o.selected && !o.hasAttribute('selected')), 'keine Option ist gewählt') + assert.equal(land.value, '', '… die Auswahl zeigt trotzdem nichts') + assert.deepEqual(optionen.slice(1).map((o) => [o.value, fx.text(o)]), ansicht.BUNDESLAENDER) + const haken = fbFeld(fx, wurzel, 'feiertageGeschlossen') + assert.equal(haken.checked, true, '„An Feiertagen geschlossen“ steht für alte und neue Entwürfe auf ja') + assert.match(fx.text(wurzel), /An Feiertagen geschlossen/) +})) + +test('Schritt 4: hat der Kunde das Bundesland schon gewählt (bundeslandGewaehlt), steht seine Wahl da – und nur sie', mitDom(undefined, (fx) => { + const e = salon() + e.bundesland = 'BY' + e.feiertageGeschlossen = false + const { wurzel } = fbZeitenSeite({ einstellung: e, fragebogen: fbBis(3, { bundeslandGewaehlt: true }) }) + const land = fbFeld(fx, wurzel, 'bundesland') + assert.equal(land.value, 'BY') + assert.deepEqual(fx.alle(land, 'option').filter((o) => o.selected).map((o) => o.value), ['BY']) + assert.equal(fbFeld(fx, wurzel, 'feiertageGeschlossen').checked, false) +})) + +test('Schritt 4 (a): „Weiter“ ohne Bundesland ⇒ Fehler am Feld, kein Aufruf, der Schritt bleibt', mitDom(undefined, async (fx) => { + const { wurzel } = fbZeitenSeite() + await fx.klick(fbWeiter(fx, wurzel)) + assert.match(fbFehler(fx, wurzel, 'bundesland'), /Bundesland/) + assert.equal(fbFeld(fx, wurzel, 'bundesland').getAttribute('aria-invalid'), 'true') + assert.deepEqual(fx.fetchLog, [], 'weder PATCH noch PUT') + assert.equal(fbSchritt(fx, wurzel), 'zeiten') + assert.equal(fbWeiter(fx, wurzel).disabled, false, 'wieder frei') +})) + +test('Schritt 4 (a): mit Wahl RP ein PATCH (bundesland, Wochenplan aus wochenplanFeld), danach der PUT mit bundeslandGewaehlt und zeiten', mitDom({ + antworten: [fbPatchAntwort(4), fbAntwort(4, { bundeslandGewaehlt: true })], +}, async (fx) => { + const { wurzel, ctx } = fbZeitenSeite() + const plan = structuredClone(ctx.daten.einstellung.ressourcen[0].plan) + const ressourceId = ctx.daten.einstellung.ressourcen[0].id + await fx.setze(fbFeld(fx, wurzel, 'bundesland'), 'RP') + await fx.setze(fbFeld(fx, wurzel, 'ressourcen.0.plan.1'), '10:00–14:00') + await fx.setze(fbFeld(fx, wurzel, 'ressourcen.0.plan.6'), '') // Samstag zu + await fx.setze(fbFeld(fx, wurzel, 'feiertageGeschlossen'), false) + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT], 'genau ein PATCH, danach der PUT') + const [patch, put] = fx.fetchLog + assert.equal(patch.body.version, 3) + assert.equal(patch.body.einstellung.bundesland, 'RP') + assert.equal(patch.body.einstellung.feiertageGeschlossen, false) + const erwartet = { ...plan, 1: [['10:00', '14:00']] } + delete erwartet[6] + assert.deepEqual(patch.body.einstellung.ressourcen[0].plan, erwartet, 'der Plan kommt aus wochenplanFeld') + assert.equal(patch.body.einstellung.ressourcen[0].id, ressourceId, 'dieselbe Person') + assert.equal(put.body.projectId, 'p1') + assert.equal(put.body.fragebogen.bundeslandGewaehlt, true) + assert.deepEqual(put.body.fragebogen.bestaetigt, [...SCHRITTE.slice(0, 3), 'zeiten']) + assert.equal(fbSchritt(fx, wurzel), 'regeln') + assert.ok(document.activeElement === fx.finde(wurzel, 'h3'), 'Fokus auf der Überschrift des nächsten Schritts') +})) + +test('Schritt 4: mehrere Personen und ein Platz – jeder Plan wird einzeln gelesen, unberührte Pläne bleiben', mitDom({ + antworten: [fbPatchAntwort(4), fbAntwort(4, { bundeslandGewaehlt: true })], +}, async (fx) => { + const e = dreiEintraege() + const { wurzel } = fbZeitenSeite({ einstellung: e }) + await fx.setze(fbFeld(fx, wurzel, 'bundesland'), 'HE') + await fx.setze(fbFeld(fx, wurzel, 'ressourcen.1.plan.4'), '08:00–12:00, 14:00–16:00') + await fx.setze(fbFeld(fx, wurzel, 'ressourcen.2.plan.7'), '10:00–12:00') + await fx.klick(fbWeiter(fx, wurzel)) + const ressourcen = fx.fetchLog[0].body.einstellung.ressourcen + assert.deepEqual(ressourcen.map((r) => r.id), e.ressourcen.map((r) => r.id), 'Reihenfolge und IDs bleiben') + assert.deepEqual(ressourcen[0].plan, e.ressourcen[0].plan, 'Anna unberührt') + assert.deepEqual(ressourcen[1].plan, { 2: [['13:00', '17:00']], 4: [['08:00', '12:00'], ['14:00', '16:00']] }) + assert.deepEqual(ressourcen[2].plan, { 7: [['10:00', '12:00']] }) + assert.equal(ressourcen[2].aktiv, false, 'andere Angaben der Einträge bleiben') + assert.equal(fx.fetchLog[0].body.einstellung.bundesland, 'HE') +})) + +test('Schritt 4: ein Zeitfenster, das nicht zu lesen ist, steht am Feld seines Tages; es geht nichts raus', mitDom(undefined, async (fx) => { + const { wurzel } = fbZeitenSeite({ einstellung: dreiEintraege() }) + await fx.setze(fbFeld(fx, wurzel, 'bundesland'), 'RP') + await fx.setze(fbFeld(fx, wurzel, 'ressourcen.1.plan.3'), 'nachmittags') + await fx.setze(fbFeld(fx, wurzel, 'ressourcen.0.plan.5'), 'abends') + await fx.klick(fbWeiter(fx, wurzel)) + assert.equal(fbFehler(fx, wurzel, 'ressourcen.1.plan.3'), ansicht.FENSTER_FEHLER) + assert.equal(fbFehler(fx, wurzel, 'ressourcen.0.plan.5'), ansicht.FENSTER_FEHLER, 'alle Fehler auf einmal') + assert.equal(fbFehler(fx, wurzel, 'bundesland'), '', 'das Land ist gewählt') + assert.equal(fbFeld(fx, wurzel, 'ressourcen.1.plan.3').getAttribute('aria-invalid'), 'true') + assert.deepEqual(fx.fetchLog, []) +})) + +test('Schritt 4: ohne Wahl und mit unlesbarem Plan zeigt „Weiter“ beide Fehler zugleich', mitDom(undefined, async (fx) => { + const { wurzel } = fbZeitenSeite() + await fx.setze(fbFeld(fx, wurzel, 'ressourcen.0.plan.1'), 'xx') + await fx.klick(fbWeiter(fx, wurzel)) + assert.ok(fbFehler(fx, wurzel, 'bundesland') !== '') + assert.equal(fbFehler(fx, wurzel, 'ressourcen.0.plan.1'), ansicht.FENSTER_FEHLER) + assert.deepEqual(fx.fetchLog, []) +})) + +test('Schritt 4: antwortet der PATCH mit 400 und einem Fehler am Plan, steht er am Feld und der PUT bleibt aus', mitDom({ + antworten: [{ status: 400, body: { error: 'Zeiten überlappen.', fehler: { 'ressourcen.0.plan.2': 'Die Zeitfenster überlappen sich.' } } }], +}, async (fx) => { + const { wurzel } = fbZeitenSeite() + await fx.setze(fbFeld(fx, wurzel, 'bundesland'), 'RP') + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fbAufrufe(fx), [FB_PATCH]) + assert.equal(fbFehler(fx, wurzel, 'ressourcen.0.plan.2'), 'Die Zeitfenster überlappen sich.') + assert.equal(fbSchritt(fx, wurzel), 'zeiten') +})) + +test('Schritt 4: darunter Abwesenheiten und Sonderöffnungen mit eigenem Container; Bundesland und Feiertage gibt es nur im Formular des Schritts', mitDom(undefined, (fx) => { + const { wurzel } = fbZeitenSeite() + const ausnahmen = fx.finde(wurzel, '.tm-fb-ausnahmen') + assert.ok(ausnahmen, 'eigener Container') + const form = fx.finde(wurzel, 'form.tm-fb-form') + assert.ok(!form.contains(ausnahmen), 'nicht im Formular des Schritts (keine verschachtelten Formulare)') + assert.ok(fx.finde(ausnahmen, '[data-werkzeug="abwesenheit-neu"]')) + assert.ok(fx.finde(ausnahmen, '[data-werkzeug="sonder-neu"]')) + assert.deepEqual(fx.alle(ausnahmen, 'h3').map((el) => fx.text(el)), ['Abwesenheiten und Urlaub', 'Sonderöffnungen']) + assert.equal(fx.alle(wurzel, 'form[aria-label="Feiertage"]').length, 0, 'kein zweites Speichern für die Feiertage') + assert.equal(fx.alle(wurzel, '[data-feld="bundesland"]').length, 1) + const alle = fx.alle(wurzel, '*') + assert.ok(alle.indexOf(form) < alle.indexOf(ausnahmen), 'die Ausnahmen stehen unter dem Formular') +})) + +test('Schritt 4: eine Abwesenheit speichert sofort und lässt die noch nicht gespeicherten Zeiten oben stehen; „Weiter“ nimmt beides mit', mitDom({ + antworten: [ + (aufruf) => ({ body: { einstellung: { ...aufruf.body.einstellung, abwesenheiten: [{ id: 'a_urlaub01', ressourceId: null, von: '2099-12-24', bis: '2099-12-31', titel: 'Betriebsurlaub' }] }, version: 4, fehlt: [] } }), + fbPatchAntwort(5), + fbAntwort(4, { bundeslandGewaehlt: true }), + ], +}, async (fx) => { + const { wurzel, ctx } = fbZeitenSeite() + const plan = structuredClone(ctx.daten.einstellung.ressourcen[0].plan) + await fx.setze(fbFeld(fx, wurzel, 'ressourcen.0.plan.1'), '10:00–14:00') // nicht gespeichert + await fx.setze(fbFeld(fx, wurzel, 'bundesland'), 'RP') + await fx.klick(fx.finde(wurzel, '[data-werkzeug="abwesenheit-neu"]')) + await fx.setze(fbFeld(fx, wurzel, 'abwesenheiten.0.von'), '2099-12-24') + await fx.setze(fbFeld(fx, wurzel, 'abwesenheiten.0.bis'), '2099-12-31') + await fx.setze(fbFeld(fx, wurzel, 'abwesenheiten.0.titel'), 'Betriebsurlaub') + await fx.klick(fx.finde(wurzel, 'form[aria-label="Abwesenheit eintragen"] button[type="submit"]')) + assert.deepEqual(fbAufrufe(fx), [FB_PATCH], 'die Abwesenheit speichert selbst – ohne PUT') + const erster = fx.fetchLog[0].body + assert.equal(erster.einstellung.abwesenheiten.length, 1) + assert.deepEqual(erster.einstellung.abwesenheiten[0], { id: erster.einstellung.abwesenheiten[0].id, ressourceId: null, von: '2099-12-24', bis: '2099-12-31', titel: 'Betriebsurlaub' }) + assert.deepEqual(erster.einstellung.ressourcen[0].plan, plan, 'die noch ungespeicherten Zeiten gehen in diesen PATCH nicht mit') + assert.equal(erster.einstellung.bundesland, 'RP', 'und das Bundesland ist der Stand des Entwurfs') + assert.equal(fbFeld(fx, wurzel, 'ressourcen.0.plan.1').value, '10:00–14:00', 'die Eingabe oben bleibt stehen') + assert.equal(fbFeld(fx, wurzel, 'bundesland').value, 'RP') + assert.equal(fx.alle(wurzel, 'ul[aria-label="Abwesenheiten"] li').length, 1, 'der Container unten ist neu gezeichnet') + assert.equal(fbSchritt(fx, wurzel), 'zeiten') + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PATCH, FB_PUT]) + const zweiter = fx.fetchLog[1].body + assert.equal(zweiter.version, 4, 'der zweite PATCH baut auf der Antwort des ersten auf') + assert.equal(zweiter.einstellung.abwesenheiten.length, 1, 'die gespeicherte Abwesenheit bleibt') + assert.deepEqual(zweiter.einstellung.ressourcen[0].plan[1], [['10:00', '14:00']], 'jetzt sind die Zeiten dabei') +})) + +test('Schritt 4: eine Sonderöffnung speichert selbst und lässt das Formular des Schritts unberührt', mitDom({ + antworten: [(aufruf) => ({ body: { einstellung: aufruf.body.einstellung, version: 4, fehlt: [] } })], +}, async (fx) => { + const { wurzel } = fbZeitenSeite() + await fx.setze(fbFeld(fx, wurzel, 'ressourcen.0.plan.7'), '10:00–12:00') + await fx.klick(fx.finde(wurzel, '[data-werkzeug="sonder-neu"]')) + await fx.setze(fbFeld(fx, wurzel, 'datum'), '2099-12-24') + await fx.setze(fbFeld(fx, wurzel, 'zeiten'), '09:00–13:00') + await fx.klick(fx.finde(wurzel, 'form[aria-label="Sonderöffnung eintragen"] button[type="submit"]')) + assert.deepEqual(fbAufrufe(fx), [FB_PATCH]) + assert.deepEqual(fx.fetchLog[0].body.einstellung.sonderoeffnungen['2099-12-24'], [['09:00', '13:00']]) + assert.equal(fbFeld(fx, wurzel, 'ressourcen.0.plan.7').value, '10:00–12:00') + assert.equal(fx.alle(wurzel, 'ul[aria-label="Sonderöffnungen"] li').length, 1) +})) + +test('Schritt 4 (d): in der Kundenansicht geht der PATCH raus, ein PUT nicht, der Schritt führt trotzdem weiter', mitDom({ antworten: [fbPatchAntwort(4)] }, async (fx) => { + const { wurzel } = fbZeitenSeite({ kundenansicht: true }) + await fx.setze(fbFeld(fx, wurzel, 'bundesland'), 'RP') + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fbAufrufe(fx), [FB_PATCH]) + assert.equal(fbSchritt(fx, wurzel), 'regeln') +})) + +// ------------------------------------------------------- Schritt 5: Regeln +const lageVon = (fx, wurzel) => fx.finde(wurzel, '[data-lage]')?.dataset.lage +const sammel = (fx, wurzel, art) => fx.finde(wurzel, `[data-aktion="alle-${art}"]`) + +test('Schritt 5: Sammelknopf, Regeln, Stornobedingungen, Rechtliches und Bezahlung in dieser Reihenfolge; Zurück und Weiter', mitDom(undefined, (fx) => { + const { wurzel } = fbRegelnSeite() + assert.equal(fbSchritt(fx, wurzel), 'regeln') + assert.deepEqual(fx.alle(fx.finde(wurzel, 'form.tm-fb-form'), 'h3').map((el) => fx.text(el)), ['Buchungsart', 'Regeln fürs Buchen', 'Stornobedingungen', 'Rechtliches', 'Bezahlung']) + assert.deepEqual(felderVon(fx, wurzel), [ + 'regeln.raster', 'regeln.vorlaufMin', 'regeln.horizontTage', 'regeln.absageBisStd', 'regeln.maxOffeneJeEmail', 'regeln.personWaehlbar', + 'storno.text', 'recht.gaesteSind', 'recht.freizeit', 'zahlung.vorOrtArten', + ]) + assert.equal(fx.text(sammel(fx, wurzel, 'sofort')), 'Alle sofort buchbar') + assert.equal(fx.text(sammel(fx, wurzel, 'anfrage')), 'Alle nur als Anfrage') + assert.deepEqual(fbKnoepfe(fx, wurzel).filter((t) => /Zurück|Weiter/.test(t)), ['Zurück', 'Weiter']) +})) + +test('Schritt 5: der Sammelknopf zeigt die Lage (buchungsartLage) und ändert nichts, bis „Weiter“ gedrückt wird', mitDom({ + antworten: [fbPatchAntwort(5), fbAntwort(5, { bundeslandGewaehlt: true })], +}, async (fx) => { + const e = salon() + e.leistungen[1].buchungsart = 'anfrage' + assert.equal(buchungsartLage(e.leistungen), 'gemischt') + const { wurzel } = fbRegelnSeite({ einstellung: e }) + assert.equal(lageVon(fx, wurzel), 'gemischt') + assert.match(fx.text(fx.finde(wurzel, '[data-lage]')), /je Leistung/) + assert.equal(sammel(fx, wurzel, 'sofort').getAttribute('aria-pressed'), 'false') + await fx.klick(sammel(fx, wurzel, 'anfrage')) + assert.equal(lageVon(fx, wurzel), 'anfrage') + assert.equal(sammel(fx, wurzel, 'anfrage').getAttribute('aria-pressed'), 'true') + assert.equal(sammel(fx, wurzel, 'sofort').getAttribute('aria-pressed'), 'false') + await fx.klick(sammel(fx, wurzel, 'sofort')) + assert.equal(lageVon(fx, wurzel), 'sofort') + assert.equal(sammel(fx, wurzel, 'anfrage').getAttribute('aria-pressed'), 'false') + await fx.klick(sammel(fx, wurzel, 'anfrage')) + assert.deepEqual(fx.fetchLog, [], 'ein Klick auf den Sammelknopf ruft nichts auf') + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT]) + const leistungen = fx.fetchLog[0].body.einstellung.leistungen + assert.equal(leistungen.length, e.leistungen.length) + assert.ok(leistungen.every((l) => l.buchungsart === 'anfrage'), 'jede Leistung nur als Anfrage') + assert.deepEqual(leistungen.map((l) => l.id), e.leistungen.map((l) => l.id)) + assert.deepEqual(fx.fetchLog[1].body.fragebogen.bestaetigt, [...SCHRITTE.slice(0, 4), 'regeln']) +})) + +test('Schritt 5: ohne Klick auf den Sammelknopf bleiben die Buchungsarten der Leistungen, wie sie sind', mitDom({ + antworten: [fbPatchAntwort(5), fbAntwort(5)], +}, async (fx) => { + const e = salon() + e.leistungen[1].buchungsart = 'anfrage' + const { wurzel } = fbRegelnSeite({ einstellung: e }) + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fx.fetchLog[0].body.einstellung.leistungen.map((l) => l.buchungsart), e.leistungen.map((l) => l.buchungsart)) +})) + +test('Schritt 5: „Alle sofort buchbar“ setzt auch inaktive Leistungen und nimmt andere Angaben der Leistung nicht mit', mitDom({ + antworten: [fbPatchAntwort(5), fbAntwort(5)], +}, async (fx) => { + const e = salon() + e.leistungen[0].buchungsart = 'anfrage' + e.leistungen[2].aktiv = false + e.leistungen[2].buchungsart = 'anfrage' + const { wurzel } = fbRegelnSeite({ einstellung: e }) + await fx.klick(sammel(fx, wurzel, 'sofort')) + await fx.klick(fbWeiter(fx, wurzel)) + const leistungen = fx.fetchLog[0].body.einstellung.leistungen + assert.ok(leistungen.every((l) => l.buchungsart === 'sofort')) + assert.deepEqual(leistungen.map((l) => ({ ...l, buchungsart: null })), e.leistungen.map((l) => ({ ...l, buchungsart: null })), 'sonst ändert sich nichts') +})) + +test('Schritt 5: das Muster der Stornobedingungen folgt der Absagefrist, auch ungespeichert', mitDom(undefined, async (fx) => { + const { wurzel, ctx } = fbRegelnSeite() + const muster = fx.finde(wurzel, '.tm-muster-text') + assert.equal(fx.text(muster), stornoMuster(ctx.daten.einstellung.regeln.absageBisStd)) + await fx.setze(fbFeld(fx, wurzel, 'regeln.absageBisStd'), '48') + assert.equal(fx.text(muster), stornoMuster(48)) + assert.match(fx.text(muster), /bis 48 Stunden vorher/) + await fx.klick(fx.finde(wurzel, '[data-aktion="muster-uebernehmen"]')) + assert.equal(fbFeld(fx, wurzel, 'storno.text').value, stornoMuster(48), 'der Knopf übernimmt es ins Textfeld') + assert.deepEqual(fx.fetchLog, []) +})) + +test('Schritt 5: „Weiter“ liest Regeln, Storno, Rechtliches und Bezahlung in genau einen PATCH, danach der PUT', mitDom({ + antworten: [fbPatchAntwort(5), fbAntwort(5)], +}, async (fx) => { + const { wurzel } = fbRegelnSeite() + await fx.setze(fbFeld(fx, wurzel, 'regeln.vorlaufMin'), '180') + await fx.setze(fbFeld(fx, wurzel, 'regeln.absageBisStd'), '48') + await fx.setze(fbFeld(fx, wurzel, 'regeln.personWaehlbar'), false) + await fx.setze(fbFeld(fx, wurzel, 'storno.text'), 'Absagen bis 48 Stunden vorher.') + await fx.setze(fbFeld(fx, wurzel, 'recht.gaesteSind'), 'gemischt') + await fx.setze(fbFeld(fx, wurzel, 'recht.freizeit'), true) + const arten = fx.alle(fbFeld(fx, wurzel, 'zahlung.vorOrtArten'), 'input[type="checkbox"]') + await fx.setze(arten[1], false) // EC-Karte ab + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT]) + const e = fx.fetchLog[0].body.einstellung + assert.equal(e.regeln.vorlaufMin, 180) + assert.equal(e.regeln.absageBisStd, 48) + assert.equal(e.regeln.personWaehlbar, false) + assert.equal(e.regeln.raster, 15, 'unberührte Regeln bleiben') + assert.equal(e.storno.text, 'Absagen bis 48 Stunden vorher.') + assert.deepEqual(e.recht, { gaesteSind: 'gemischt', freizeit: true }) + assert.deepEqual(e.zahlung, { modus: 'vorort', vorOrtArten: ['bar', 'kreditkarte'] }) + assert.equal(fbSchritt(fx, wurzel), 'gastfelder') +})) + +test('Schritt 5: eine Zahl, die keine ist, steht am Feld; es geht nichts raus', mitDom(undefined, async (fx) => { + const { wurzel } = fbRegelnSeite() + await fx.setze(fbFeld(fx, wurzel, 'regeln.horizontTage'), 'viel') + await fx.klick(fbWeiter(fx, wurzel)) + assert.ok(fbFehler(fx, wurzel, 'regeln.horizontTage') !== '') + assert.deepEqual(fx.fetchLog, []) + assert.equal(fbSchritt(fx, wurzel), 'regeln') +})) + +test('Schritt 5: Fehler des Servers stehen am Feld, der PUT bleibt aus; die Stornofrist-Eingabe bleibt stehen', mitDom({ + antworten: [{ status: 400, body: { error: 'x', fehler: { 'regeln.vorlaufMin': 'Der Vorlauf darf höchstens 20160 Minuten betragen.', 'zahlung.vorOrtArten': 'Bitte wähle mindestens eine Zahlart.' } } }], +}, async (fx) => { + const { wurzel } = fbRegelnSeite() + await fx.setze(fbFeld(fx, wurzel, 'regeln.vorlaufMin'), '99999') + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fbAufrufe(fx), [FB_PATCH]) + assert.equal(fbFehler(fx, wurzel, 'regeln.vorlaufMin'), 'Der Vorlauf darf höchstens 20160 Minuten betragen.') + assert.equal(fbFehler(fx, wurzel, 'zahlung.vorOrtArten'), 'Bitte wähle mindestens eine Zahlart.') + assert.equal(fbFeld(fx, wurzel, 'regeln.vorlaufMin').value, '99999') + assert.equal(fbSchritt(fx, wurzel), 'regeln') +})) + +// ------------------------------------------------------- Schritt 6: Gastfelder +test('Schritt 6: gastFelder (Telefon, Nachricht, Zusatzfelder); „Weiter“ = ein PATCH, dann der PUT mit gastfelder', mitDom({ + antworten: [fbPatchAntwort(5), fbAntwort(6, { bundeslandGewaehlt: true })], +}, async (fx) => { + const { wurzel } = fbGastSeite() + assert.equal(fbSchritt(fx, wurzel), 'gastfelder') + assert.deepEqual(felderVon(fx, wurzel), ['gastFelder.telefon', 'gastFelder.nachricht']) + await fx.setze(fbFeld(fx, wurzel, 'gastFelder.telefon'), 'pflicht') + await fx.setze(fbFeld(fx, wurzel, 'gastFelder.nachricht'), 'optional') + await fx.klick(fx.finde(wurzel, '[data-aktion="zusatz-neu"]')) + await fx.setze(fbFeld(fx, wurzel, 'gastFelder.zusatz.0.label'), 'Haarlänge') + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT]) + const g = fx.fetchLog[0].body.einstellung.gastFelder + assert.equal(g.telefon, 'pflicht') + assert.equal(g.nachricht, 'optional') + assert.equal(g.zusatz.length, 1) + assert.deepEqual({ ...g.zusatz[0], key: typeof g.zusatz[0].key }, { key: 'string', label: 'Haarlänge', typ: 'text', optionen: [], pflicht: false }) + assert.ok(g.zusatz[0].key.length > 0) + assert.deepEqual(fx.fetchLog[1].body.fragebogen.bestaetigt, [...SCHRITTE.slice(0, 5), 'gastfelder']) + assert.equal(fbSchritt(fx, wurzel), 'benachrichtigung') +})) + +test('Schritt 6: Fehler des Servers am Zusatzfeld stehen am Feld, es folgt kein PUT', mitDom({ + antworten: [{ status: 400, body: { error: 'x', fehler: { 'gastFelder.zusatz.0.label': 'Bitte gib eine Beschriftung an.' } } }], +}, async (fx) => { + const { wurzel } = fbGastSeite() + await fx.klick(fx.finde(wurzel, '[data-aktion="zusatz-neu"]')) + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fbAufrufe(fx), [FB_PATCH]) + assert.equal(fbFehler(fx, wurzel, 'gastFelder.zusatz.0.label'), 'Bitte gib eine Beschriftung an.') + assert.equal(fbSchritt(fx, wurzel), 'gastfelder') +})) + +// ------------------------------------------------------- Schritt 7 (C9b): welche Angaben gelesen werden +const BETRIEB_ANDERS = { + name: 'Anderer Name', adresse: 'Neue Straße 2, 67655 Kaiserslautern', telefon: '0800 111', kleinunternehmer: true, + impressumUrl: 'https://neu.example.invalid/impressum', datenschutzUrl: 'https://neu.example.invalid/datenschutz', agbUrl: 'https://neu.example.invalid/agb', websiteUrl: 'https://neu.example.invalid', +} + +test('Schritt 7 (b): aus dem Betrieb wird nur die E-Mail-Adresse gelesen (Schlüsselmenge), alles andere bleibt, wie es zuletzt gespeichert war', mitDom({ + antworten: [fbPatchAntwort(4), fbAntwort(7)], +}, async (fx) => { + const { wurzel, ctx } = fbSiebenSeite() + assert.deepEqual(felderVon(fx, wurzel).filter((n) => n.startsWith('betrieb.')), ['betrieb.email']) + // inzwischen anderswo gespeichert (zweiter Tab, Schritt 8): die Felder dieses Schritts kennen die Werte nicht + Object.assign(ctx.daten.einstellung.betrieb, BETRIEB_ANDERS) + const vorher = structuredClone(ctx.daten.einstellung.betrieb) + await fx.setze(fbFeld(fx, wurzel, 'betrieb.email'), 'neu@example.invalid') + await fx.klick(fbWeiter(fx, wurzel)) + const betrieb = fx.fetchLog[0].body.einstellung.betrieb + assert.deepEqual(betrieb, { ...vorher, email: 'neu@example.invalid' }) + assert.deepEqual(Object.keys(betrieb).filter((k) => betrieb[k] !== vorher[k]), ['email']) +})) + +// ------------------------------------------------------- Schritt 8: Website +const WEBSITE_FELDER = ['betrieb.telefon', 'betrieb.impressumUrl', 'betrieb.datenschutzUrl', 'betrieb.agbUrl', 'betrieb.websiteUrl'] +const modusRadio = (fx, wurzel, wert) => fbRadio(fx, wurzel, wert) +const einbauFeld = (fx, wurzel, name) => fbFeld(fx, wurzel, `einbau.${name}`) + +test('Schritt 8: Telefon und Links aus dem Betrieb, dazu die Einbauwünsche; der Knopftext erscheint erst bei „hinter einem Knopf“', mitDom(undefined, async (fx) => { + const { wurzel } = fbWebsiteSeite() + assert.equal(fbSchritt(fx, wurzel), 'website') + assert.deepEqual(felderVon(fx, wurzel).filter((n) => n.startsWith('betrieb.')), WEBSITE_FELDER) + assert.equal(fbFeld(fx, wurzel, 'betrieb.telefon').value, '06301 12345') + const radios = fx.alle(wurzel, 'input[name="termine-einbau-modus"]') + assert.deepEqual(radios.map((r) => r.value), ['inline', 'knopf']) + assert.ok(radios.every((r) => !r.checked), 'keine Vorauswahl: der Kunde entscheidet') + assert.match(fx.text(wurzel), /direkt auf der Seite/i) + assert.match(fx.text(wurzel), /hinter einem Knopf/i) + const knopfFeld = einbauFeld(fx, wurzel, 'knopfText').closest('.tm-feld') + assert.equal(knopfFeld.hidden, true, 'ohne Knopf kein Knopftext') + await fx.klick(modusRadio(fx, wurzel, 'knopf')) + assert.equal(knopfFeld.hidden, false) + await fx.klick(modusRadio(fx, wurzel, 'inline')) + assert.equal(knopfFeld.hidden, true) + for (const name of ['stelle', 'gestaltung', 'bemerkung']) assert.ok(einbauFeld(fx, wurzel, name), `${name}: ein Feld`) + assert.deepEqual(fbKnoepfe(fx, wurzel).filter((t) => /Zurück|Weiter/.test(t)), ['Zurück', 'Weiter']) +})) + +test('Schritt 8: der Hinweis nennt, was nach Stunden geht – zweites Widget, Knöpfe je Leistung, Sonderdesign', mitDom(undefined, (fx) => { + const { wurzel } = fbWebsiteSeite() + const hinweis = fx.text(fx.finde(wurzel, '[data-hinweis="einbau-mehr"]')) + assert.match(hinweis, /zweites Widget/) + assert.match(hinweis, /Knöpfe für einzelne Leistungen/) + assert.match(hinweis, /Sonderdesign/) + assert.match(hinweis, /nach Stunden/) +})) + +test('Schritt 8: vorbelegt aus dem gespeicherten Fragebogen', mitDom(undefined, (fx) => { + const einbau = { modus: 'knopf', knopfText: 'Jetzt Termin buchen', stelle: 'Unter dem Text „Kontakt“', gestaltung: 'Farben wie das Logo', bemerkung: 'Bitte vorher anrufen' } + const { wurzel } = fbWebsiteSeite({ fragebogen: fbBis(7, { bundeslandGewaehlt: true, einbau }) }) + assert.equal(modusRadio(fx, wurzel, 'knopf').checked, true) + assert.equal(modusRadio(fx, wurzel, 'inline').checked, false) + assert.equal(einbauFeld(fx, wurzel, 'knopfText').closest('.tm-feld').hidden, false) + assert.equal(einbauFeld(fx, wurzel, 'knopfText').value, einbau.knopfText) + assert.equal(einbauFeld(fx, wurzel, 'stelle').value, einbau.stelle) + assert.equal(einbauFeld(fx, wurzel, 'gestaltung').value, einbau.gestaltung) + assert.equal(einbauFeld(fx, wurzel, 'bemerkung').value, einbau.bemerkung) +})) + +test('Schritt 8 (b): der PATCH enthält aus dem Betrieb nur Telefon und die vier Links (Schlüsselmenge); der PUT trägt einbau mit allen fünf Angaben', mitDom({ + antworten: [fbPatchAntwort(4), fbAntwort(8, { bundeslandGewaehlt: true })], +}, async (fx) => { + const { wurzel, ctx } = fbWebsiteSeite() + Object.assign(ctx.daten.einstellung.betrieb, { name: 'Anderer Name', adresse: 'Neue Straße 2', email: 'andere@example.invalid', kleinunternehmer: true }) + const vorher = structuredClone(ctx.daten.einstellung.betrieb) + const neu = { + telefon: '0800 222', impressumUrl: 'https://salon.example.invalid/impr', datenschutzUrl: 'https://salon.example.invalid/ds', agbUrl: 'https://salon.example.invalid/agb', websiteUrl: 'https://salon.example.invalid', + } + for (const [name, wert] of Object.entries(neu)) await fx.setze(fbFeld(fx, wurzel, `betrieb.${name}`), wert) + await fx.klick(modusRadio(fx, wurzel, 'inline')) + await fx.setze(einbauFeld(fx, wurzel, 'stelle'), 'Unter dem Text „Kontakt“') + await fx.setze(einbauFeld(fx, wurzel, 'gestaltung'), 'Farben wie das Logo') + await fx.setze(einbauFeld(fx, wurzel, 'bemerkung'), 'Bitte vorher anrufen') + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT]) + const [patch, put] = fx.fetchLog + assert.deepEqual(patch.body.einstellung.betrieb, { ...vorher, ...neu }) + assert.deepEqual(Object.keys(patch.body.einstellung.betrieb).filter((k) => patch.body.einstellung.betrieb[k] !== vorher[k]).sort(), Object.keys(neu).sort(), 'geändert sind genau diese fünf') + assert.deepEqual(put.body.fragebogen.einbau, { modus: 'inline', knopfText: '', stelle: 'Unter dem Text „Kontakt“', gestaltung: 'Farben wie das Logo', bemerkung: 'Bitte vorher anrufen' }) + assert.deepEqual(Object.keys(put.body.fragebogen.einbau).sort(), ['bemerkung', 'gestaltung', 'knopfText', 'modus', 'stelle']) + assert.deepEqual(put.body.fragebogen.bestaetigt, [...SCHRITTE]) + assert.equal(put.body.fragebogen.bundeslandGewaehlt, true, 'der übrige Fragebogen bleibt') +})) + +test('Schritt 8: hinter einem Knopf geht der Knopftext in den PUT', mitDom({ + antworten: [fbPatchAntwort(4), fbAntwort(8)], +}, async (fx) => { + const { wurzel } = fbWebsiteSeite() + await fx.klick(modusRadio(fx, wurzel, 'knopf')) + await fx.setze(einbauFeld(fx, wurzel, 'knopfText'), 'Termin buchen') + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fx.fetchLog[1].body.fragebogen.einbau, { modus: 'knopf', knopfText: 'Termin buchen', stelle: '', gestaltung: '', bemerkung: '' }) +})) + +test('Schritt 8: wer von „Knopf“ auf „direkt auf der Seite“ zurückwechselt, schickt keinen Knopftext mit', mitDom({ + antworten: [fbPatchAntwort(4), fbAntwort(8)], +}, async (fx) => { + const { wurzel } = fbWebsiteSeite() + await fx.klick(modusRadio(fx, wurzel, 'knopf')) + await fx.setze(einbauFeld(fx, wurzel, 'knopfText'), 'Termin buchen') + await fx.klick(modusRadio(fx, wurzel, 'inline')) + await fx.klick(fbWeiter(fx, wurzel)) + assert.equal(fx.fetchLog[1].body.fragebogen.einbau.modus, 'inline') + assert.equal(fx.fetchLog[1].body.fragebogen.einbau.knopfText, '') +})) + +test('Schritt 8 (b): „hinter einem Knopf“ ohne Knopftext ⇒ Fehler am Feld und kein Aufruf', mitDom(undefined, async (fx) => { + const { wurzel } = fbWebsiteSeite() + await fx.klick(modusRadio(fx, wurzel, 'knopf')) + await fx.klick(fbWeiter(fx, wurzel)) + assert.match(fbFehler(fx, wurzel, 'einbau.knopfText'), /Knopf/) + assert.equal(einbauFeld(fx, wurzel, 'knopfText').getAttribute('aria-invalid'), 'true') + assert.deepEqual(fx.fetchLog, [], 'weder PATCH noch PUT') + assert.equal(fbSchritt(fx, wurzel), 'website') + await fx.setze(einbauFeld(fx, wurzel, 'knopfText'), ' ') + await fx.klick(fbWeiter(fx, wurzel)) + assert.match(fbFehler(fx, wurzel, 'einbau.knopfText'), /Knopf/, 'Leerzeichen allein sind kein Text') + assert.deepEqual(fx.fetchLog, []) +})) + +test('Schritt 8: ohne Wahl des Modus ⇒ Fehler an der Gruppe und kein Aufruf', mitDom(undefined, async (fx) => { + const { wurzel } = fbWebsiteSeite() + await fx.klick(fbWeiter(fx, wurzel)) + assert.match(fbFehler(fx, wurzel, 'einbau.modus'), /direkt auf deiner Seite oder hinter einem Knopf/) + assert.equal(fbFeld(fx, wurzel, 'einbau.modus').getAttribute('aria-invalid'), 'true') + assert.deepEqual(fx.fetchLog, []) +})) + +test('Schritt 8: Fehler des Servers (PATCH am Link, PUT am Einbau) stehen am Feld; nach dem PATCH-Fehler gibt es keinen PUT', mitDom({ + antworten: [ + { status: 400, body: { error: 'x', fehler: { 'betrieb.impressumUrl': 'Bitte gib eine vollständige Adresse mit https:// an.' } } }, + fbPatchAntwort(4), + { status: 400, body: { error: 'x', fehler: { 'einbau.stelle': 'Der Text ist ungültig.' } } }, + ], +}, async (fx) => { + const { wurzel } = fbWebsiteSeite() + await fx.klick(modusRadio(fx, wurzel, 'inline')) + await fx.setze(fbFeld(fx, wurzel, 'betrieb.impressumUrl'), 'impressum') + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fbAufrufe(fx), [FB_PATCH]) + assert.equal(fbFehler(fx, wurzel, 'betrieb.impressumUrl'), 'Bitte gib eine vollständige Adresse mit https:// an.') + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PATCH, FB_PUT]) + assert.equal(fbFehler(fx, wurzel, 'einbau.stelle'), 'Der Text ist ungültig.') + assert.equal(fbSchritt(fx, wurzel), 'website') +})) + +test('Schritt 8: Texte des Kunden stehen nur als Text im Baum', mitDom(undefined, (fx) => { + const boese = '' + const { wurzel } = fbWebsiteSeite({ fragebogen: fbBis(7, { einbau: { ...EINBAU_LEER, modus: 'knopf', knopfText: boese, stelle: boese, gestaltung: boese, bemerkung: boese } }) }) + assert.equal(fx.alle(wurzel, 'img').length, 0) + for (const name of ['knopfText', 'stelle', 'gestaltung', 'bemerkung']) assert.equal(einbauFeld(fx, wurzel, name).value, boese) +})) + +test('Schritt 8 (d): in der Kundenansicht geht der PATCH raus, ein PUT nicht; die Bestellseite bleibt gesperrt', mitDom({ antworten: [fbPatchAntwort(4)] }, async (fx) => { + const bestellung = await bestellungTest() + const { wurzel } = fbWebsiteSeite({ kundenansicht: true, bestellung }) + await fx.klick(modusRadio(fx, wurzel, 'inline')) + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fbAufrufe(fx), [FB_PATCH]) + assert.equal(fbSchritt(fx, wurzel), 'bestellen') + assert.equal(bestellKnopf(fx, wurzel).disabled, true) +})) + +// ------------------------------------------------------- nach Schritt 8: die Bestellseite +test('Nach Schritt 8 (c) zeigt renderBestellen die Bestellseite; die Knopfbeschriftung ist bestellung.consent.knopf', mitDom({ + antworten: [fbPatchAntwort(4), fbAntwort(8, { bundeslandGewaehlt: true, einbau: { ...EINBAU_LEER, modus: 'inline' } })], +}, async (fx) => { + const bestellung = await bestellungTest() + const { wurzel } = fbWebsiteSeite({ bestellung }) + await fx.klick(modusRadio(fx, wurzel, 'inline')) + await fx.klick(fbWeiter(fx, wurzel)) + assert.equal(fbSchritt(fx, wurzel), 'bestellen') + assert.ok(fx.finde(wurzel, '.tm-fb-bestellen'), 'die Bestellseite steht in der Hülle des Fragebogens') + const knopf = bestellKnopf(fx, wurzel) + assert.ok(knopf) + assert.equal(fx.text(knopf), bestellung.consent.knopf) + assert.equal(fx.text(knopf), featureConfig('termine').consent.knopf) + assert.ok(document.activeElement === fx.finde(wurzel, 'h3'), 'Fokus auf der Überschrift') + assert.equal(fx.fetchLog.length, 2, 'das Zeigen der Bestellseite ruft nichts auf') +})) + +test('Nach Schritt 8: die offenen Punkte aus der Antwort des PUT stehen auf der Bestellseite, jeder mit „Zum Schritt“', mitDom({ + antworten: [fbPatchAntwort(4), { body: { fragebogen: fbBis(8), offen: [{ schritt: 'zeiten', text: 'Bitte wähle das Bundesland deines Betriebs. Danach richten sich die Feiertage.' }] } }], +}, async (fx) => { + const { wurzel } = fbWebsiteSeite({ bestellung: await bestellungTest() }) + await fx.klick(modusRadio(fx, wurzel, 'inline')) + await fx.klick(fbWeiter(fx, wurzel)) + assert.match(fx.text(fx.finde(wurzel, '.tm-bestellen-offen')), /Bundesland/) + assert.equal(bestellKnopf(fx, wurzel).disabled, true) + await fx.klick(fx.finde(wurzel, '[data-aktion="zum-schritt"][data-schritt="zeiten"]')) + assert.equal(fbSchritt(fx, wurzel), 'zeiten', 'zurück in den Schritt') +})) + +test('Durchlauf: von Schritt 4 bis zur Bestellseite, jeder Schritt mit genau einem PATCH und einem PUT', mitDom({ + antworten: [ + fbPatchAntwort(4), fbAntwort(4, { bundeslandGewaehlt: true }), + fbPatchAntwort(5), fbAntwort(5, { bundeslandGewaehlt: true }), + fbPatchAntwort(6), fbAntwort(6, { bundeslandGewaehlt: true }), + fbPatchAntwort(7), fbAntwort(7, { bundeslandGewaehlt: true }), + fbPatchAntwort(8), fbAntwort(8, { bundeslandGewaehlt: true, einbau: { ...EINBAU_LEER, modus: 'inline' } }), + ], +}, async (fx) => { + const { wurzel } = fbZeitenSeite({ bestellung: await bestellungTest() }) + await fx.setze(fbFeld(fx, wurzel, 'bundesland'), 'RP') + await fx.klick(fbWeiter(fx, wurzel)) + assert.equal(fbSchritt(fx, wurzel), 'regeln') + await fx.klick(fbWeiter(fx, wurzel)) + assert.equal(fbSchritt(fx, wurzel), 'gastfelder') + await fx.klick(fbWeiter(fx, wurzel)) + assert.equal(fbSchritt(fx, wurzel), 'benachrichtigung') + await fx.klick(fbWeiter(fx, wurzel)) + assert.equal(fbSchritt(fx, wurzel), 'website') + await fx.klick(modusRadio(fx, wurzel, 'inline')) + await fx.klick(fbWeiter(fx, wurzel)) + assert.equal(fbSchritt(fx, wurzel), 'bestellen') + assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT, FB_PATCH, FB_PUT, FB_PATCH, FB_PUT, FB_PATCH, FB_PUT, FB_PATCH, FB_PUT]) + assert.deepEqual(fx.fetchLog.filter((a) => a.method === 'PUT').map((a) => a.body.fragebogen.bestaetigt.at(-1)), ['zeiten', 'regeln', 'gastfelder', 'benachrichtigung', 'website']) + assert.equal(fx.text(bestellKnopf(fx, wurzel)), featureConfig('termine').consent.knopf) +})) + +test('Texte der Kunden in den Schritten 4 bis 6 stehen nur als Text im Baum (Namen, Storno, Zusatzfeld)', mitDom(undefined, (fx) => { + const boese = '' + const e = salon() + e.ressourcen[0].name = boese + e.storno = { ...e.storno, text: boese } + e.gastFelder = { ...e.gastFelder, zusatz: [{ key: 'haar', label: boese, typ: 'text', optionen: [], pflicht: false }] } + const zeiten = fbZeitenSeite({ einstellung: e }).wurzel + assert.equal(fx.alle(zeiten, 'img').length, 0) + assert.equal(fx.text(fx.finde(zeiten, '[data-ressource] h4')), boese) + const regelnSeite = fbRegelnSeite({ einstellung: e }).wurzel + assert.equal(fx.alle(regelnSeite, 'img').length, 0) + assert.equal(fbFeld(fx, regelnSeite, 'storno.text').value, boese) + const gast = fbGastSeite({ einstellung: e }).wurzel + assert.equal(fx.alle(gast, 'img').length, 0) + assert.equal(fbFeld(fx, gast, 'gastFelder.zusatz.0.label').value, boese) +})) + // ------------------------------------------------------- Quelltext und Styles test('termine-fragebogen.js statisch: nur diese Importe, ein einziger PUT, kein PATCH, kein Browserspeicher, Du-Form', () => { const code = ohneKommentare(fbQuelleText()) assert.ok(code.length > 3000, 'die Datei ist gefüllt') assert.deepEqual(code.match(/from '[^']+'/g), [ - "from './termine-view.js'", "from './termine-dom.js'", "from './termine-speichern.js'", "from './termine-tab-leistungen.js'", "from './termine-tab-einstellungen.js'", "from './funktion-bestellen.js'", + "from './termine-view.js'", "from './termine-dom.js'", "from './termine-speichern.js'", "from './termine-tab-leistungen.js'", "from './termine-tab-team.js'", + "from './termine-team-ausnahmen.js'", "from './termine-tab-einstellungen.js'", "from './funktion-bestellen.js'", ]) assert.equal(code.match(/'PUT'/g).length, 1, 'PUT /fragebogen an genau einer Stelle') assert.doesNotMatch(code, /'PATCH'/, 'PATCH nur über neuerSpeicher') @@ -2322,3 +2959,9 @@ test('termine.css: Fortschrittsleiste (tm-schritte, tm-schritt) und die Handy-An assert.match(handy, /\.tm-schritt-stand\s*\{[^}]*display:\s*block/, 'am Handy zeigt die Leiste den Zähler') assert.match(handy, /\.tm-schritt-titel\s*\{[^}]*(clip|position:\s*absolute)/, 'am Handy bleiben nur die Nummern sichtbar') }) + +test('termine.css: Klassen der Schritte 4 bis 8 (Person, Ausnahmen, Sammelknopf, Einbau)', () => { + const css = readFileSync(new URL('../public/termine.css', import.meta.url), 'utf8') + for (const klasse of ['tm-fb-person', 'tm-fb-ausnahmen', 'tm-fb-sammel', 'tm-fb-einbau']) assert.match(css, new RegExp(`\\.${klasse}[\\s{,.:\\[]`), `.${klasse} ist gestaltet`) + assert.match(css.match(/\.tm-fb-sammel\s*\{[^}]*\}/)?.[0] ?? '', /flex-wrap:\s*wrap/, 'die Knöpfe brechen am Handy um') +}) diff --git a/test/termineView.test.js b/test/termineView.test.js index 125743a..75b619e 100644 --- a/test/termineView.test.js +++ b/test/termineView.test.js @@ -373,6 +373,32 @@ test('Fragebogen (TP2 C9a): renderFragebogen wird nur über termine.js geladen, assert.match(lies('public/termine-fragebogen.js'), /export function renderFragebogen\(root, ctx\)/) }) +test('Fragebogen (TP2 C9b): Schritte 4 bis 8 nutzen die Bausteine der Reiter, PATCH und PUT nur an den erlaubten Stellen, Bundesland ohne Vorauswahl', () => { + const ohneKommentare = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '') + const quelle = ohneKommentare(lies('public/termine-fragebogen.js')) + const importAus = (datei) => [...quelle.matchAll(new RegExp(`import \\{([^}]*)\\} from '\\./${datei.replace('.', '\\.')}'`, 'g'))].flatMap((m) => m[1].split(',').map((x) => x.trim().split(/\s+as\s+/)[0])) + // Ausnahmen (Urlaub, Sonderöffnungen) und Wochenplan kommen aus den Reiter-Modulen, nicht aus einer zweiten Fassung im Fragebogen + assert.deepEqual(importAus('termine-team-ausnahmen.js').sort(), ['abwesenheiten', 'sonderoeffnungen']) + assert.deepEqual(importAus('termine-tab-team.js'), ['wochenplanFeld']) + for (const name of ['betrieb', 'darstellung', 'erinnerung', 'gastFelder', 'regeln', 'rechtliches', 'storno', 'zahlung']) { + assert.ok(importAus('termine-tab-einstellungen.js').includes(name), `${name} kommt aus termine-tab-einstellungen.js`) + } + assert.ok(importAus('termine-view.js').includes('buchungsartFuerAlle') && importAus('termine-view.js').includes('buchungsartLage'), 'der Sammelknopf nutzt die Helfer aus termine-view.js') + assert.ok(importAus('funktion-bestellen.js').includes('renderBestellen')) + assert.doesNotMatch(quelle, /\bfeiertage\b/, 'Bundesland und Feiertage stehen im Formular des Schritts, nicht im eigenen Speichern des Reiters') + // PATCH gibt es nur in termine-speichern.js, PUT nur in termine-fragebogen.js – und dort an genau einer Stelle + const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /^(termine.*|funktion-bestellen)\.js$/.test(f)) + assert.deepEqual(dateien.filter((f) => /'PATCH'/.test(ohneKommentare(lies(`public/${f}`)))), ['termine-speichern.js']) + assert.deepEqual(dateien.filter((f) => /'PUT'/.test(ohneKommentare(lies(`public/${f}`)))), ['termine-fragebogen.js']) + assert.equal(quelle.match(/'PUT'/g).length, 1) + assert.doesNotMatch(quelle, /'PATCH'|\bfetch\(/) + // Bundesland: leere erste Option, danach die Länder; gewählt ist nur, was der Kunde schon ausdrücklich gewählt hatte (nicht der Serverstandard im Entwurf) + const wahl = quelle.slice(quelle.indexOf('BUNDESLAENDER.map') - 200, quelle.indexOf('BUNDESLAENDER.map') + 200) + assert.match(wahl, /h\('option', \{ value: '' \}[^)]*\),\s*BUNDESLAENDER\.map/, 'die erste Option ist leer und kommt vor den Ländern') + assert.doesNotMatch(quelle, /selected:\s*(true|e\.bundesland|einstellung\.bundesland)/, 'keine Vorauswahl aus dem Entwurf') + assert.match(quelle, /bundeslandGewaehlt/) +}) + test('Reiter-Exporte für den Fragebogen (TP2 C7b): acht Abschnitte, wochenplanFeld, baueCtx, fehlt aus der Antwort', async () => { const ohneKommentare = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '') // Einstellungen: die acht Abschnitte sind Exporte; der Reiter baut sie weiter selbst (betrieb ohne nur = alle neun Felder, Verhalten in termineFragebogenView.test.js)